From e8d47a179ec0ffb13cffb7e920223da89d53f27e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 1 Oct 2026 18:48:24 +0000 Subject: [PATCH] 0.7.1: publish the Nim binding to Nimble The Nim package compiled against a shared library that only a CMake build in a checkout produced, so a Nimble install could not run anything. It now compiles the engine into the program from a vendored copy of ports/c, ports/cpp and ports/bindings (ports/nim/src/hqtui/engine, generated by ports/nim/tools/vendor.sh), so `nimble install` needs only Nim and a C/C++ compiler. - CI: a nim-package job (Linux + macOS) fails on a stale vendored engine, runs `nimble test`, and builds a program against an installed copy. - `nimble test` replays the demo-parity conformance group (240 reference frames) besides the API suite. - hqtui.nimble and hqtui.nim follow the library version; version.test.ts ties them, since Nimble resolves the package through the vX.Y.Z tags. - Bump 0.7.0 -> 0.7.1 everywhere it is written by hand. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 + .github/workflows/bindings.yml | 48 + README.md | 7 +- apps/benchmark/package.json | 2 +- apps/demo/package.json | 4 +- apps/demo/src/main.ts | 2 +- apps/web/app/docs/page.tsx | 5 +- apps/web/app/page.tsx | 2 +- apps/web/package.json | 2 +- bun.lock | 10 +- examples/package.json | 2 +- packages/hqtui/package.json | 2 +- packages/hqtui/src/cli.ts | 2 +- packages/hqtui/test/version.test.ts | 11 + ports/bindings/README.md | 11 +- ports/nim/README.md | 38 +- ports/nim/hqtui.nimble | 22 +- ports/nim/src/hqtui.nim | 54 +- ports/nim/src/hqtui/engine/README.md | 5 + ports/nim/src/hqtui/engine/VERSION | 1 + .../engine/bindings/include/hqtui_bindings.h | 50 + .../src/hqtui/engine/bindings/src/bridge.cpp | 688 +++ ports/nim/src/hqtui/engine/c/include/hqtui.h | 184 + ports/nim/src/hqtui/engine/c/src/buffer.c | 188 + ports/nim/src/hqtui/engine/c/src/color.c | 91 + ports/nim/src/hqtui/engine/c/src/diff.c | 126 + ports/nim/src/hqtui/engine/c/src/internal.h | 30 + ports/nim/src/hqtui/engine/c/src/layout.c | 163 + ports/nim/src/hqtui/engine/c/src/surface.c | 254 ++ ports/nim/src/hqtui/engine/c/src/theme.c | 32 + ports/nim/src/hqtui/engine/c/src/unicode.c | 132 + .../nim/src/hqtui/engine/cpp/demo/collect.cpp | 904 ++++ .../nim/src/hqtui/engine/cpp/demo/collect.hpp | 16 + .../src/hqtui/engine/cpp/demo/dashboard.cpp | 366 ++ .../engine/cpp/demo/generated/sample.hpp | 3697 +++++++++++++++++ ports/nim/src/hqtui/engine/cpp/demo/json.hpp | 293 ++ ports/nim/src/hqtui/engine/cpp/demo/main.cpp | 704 ++++ ports/nim/src/hqtui/engine/cpp/demo/model.hpp | 181 + .../src/hqtui/engine/cpp/demo/showcase.cpp | 586 +++ .../src/hqtui/engine/cpp/demo/telemetry.cpp | 545 +++ .../src/hqtui/engine/cpp/include/hqtui.hpp | 129 + .../engine/cpp/include/hqtui/widgets.hpp | 1206 ++++++ .../nim/src/hqtui/engine/cpp/src/calendar.cpp | 139 + ports/nim/src/hqtui/engine/cpp/src/canvas.cpp | 139 + ports/nim/src/hqtui/engine/cpp/src/chart.cpp | 364 ++ .../src/hqtui/engine/cpp/src/scrollbar.cpp | 80 + ports/nim/src/hqtui/engine/cpp/src/shadow.cpp | 74 + .../nim/src/hqtui/engine/cpp/src/widgets.cpp | 1314 ++++++ ports/nim/src/hqtui/engine/cpp/src/world.cpp | 169 + .../src/hqtui/engine/cpp/src/world_data.cpp | 505 +++ ports/nim/tests/parity.nim | 26 + ports/nim/tools/vendor.sh | 48 + 52 files changed, 13605 insertions(+), 50 deletions(-) create mode 100644 .gitattributes create mode 100644 ports/nim/src/hqtui/engine/README.md create mode 100644 ports/nim/src/hqtui/engine/VERSION create mode 100644 ports/nim/src/hqtui/engine/bindings/include/hqtui_bindings.h create mode 100644 ports/nim/src/hqtui/engine/bindings/src/bridge.cpp create mode 100644 ports/nim/src/hqtui/engine/c/include/hqtui.h create mode 100644 ports/nim/src/hqtui/engine/c/src/buffer.c create mode 100644 ports/nim/src/hqtui/engine/c/src/color.c create mode 100644 ports/nim/src/hqtui/engine/c/src/diff.c create mode 100644 ports/nim/src/hqtui/engine/c/src/internal.h create mode 100644 ports/nim/src/hqtui/engine/c/src/layout.c create mode 100644 ports/nim/src/hqtui/engine/c/src/surface.c create mode 100644 ports/nim/src/hqtui/engine/c/src/theme.c create mode 100644 ports/nim/src/hqtui/engine/c/src/unicode.c create mode 100644 ports/nim/src/hqtui/engine/cpp/demo/collect.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/demo/collect.hpp create mode 100644 ports/nim/src/hqtui/engine/cpp/demo/dashboard.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/demo/generated/sample.hpp create mode 100644 ports/nim/src/hqtui/engine/cpp/demo/json.hpp create mode 100644 ports/nim/src/hqtui/engine/cpp/demo/main.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/demo/model.hpp create mode 100644 ports/nim/src/hqtui/engine/cpp/demo/showcase.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/demo/telemetry.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/include/hqtui.hpp create mode 100644 ports/nim/src/hqtui/engine/cpp/include/hqtui/widgets.hpp create mode 100644 ports/nim/src/hqtui/engine/cpp/src/calendar.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/src/canvas.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/src/chart.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/src/scrollbar.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/src/shadow.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/src/widgets.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/src/world.cpp create mode 100644 ports/nim/src/hqtui/engine/cpp/src/world_data.cpp create mode 100644 ports/nim/tests/parity.nim create mode 100755 ports/nim/tools/vendor.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..55b2e8d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# A generated copy of ports/c, ports/cpp and ports/bindings (ports/nim/tools/vendor.sh). +ports/nim/src/hqtui/engine/** linguist-generated=true diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings.yml index aca8a44..116b5e6 100644 --- a/.github/workflows/bindings.yml +++ b/.github/workflows/bindings.yml @@ -9,6 +9,54 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # What `nimble install hqtui` gets: the vendored engine must match ports/c, + # ports/cpp and ports/bindings, `nimble test` must pass, and an installed copy + # must build a program with no checkout or shared library around it. + nim-package: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: jdx/mise-action@v5.0.0 + with: + install: false + - name: Install Nim + run: mise install nim@2.2.12 + - name: Vendored engine matches its sources + shell: bash + run: | + sh ports/nim/tools/vendor.sh + changes=$(git status --porcelain --untracked-files=all -- ports/nim/src/hqtui/engine) + if [ -n "$changes" ]; then + printf '%s\n' "$changes" + git --no-pager diff --stat -- ports/nim/src/hqtui/engine + echo "::error::ports/nim/src/hqtui/engine is stale. Run sh ports/nim/tools/vendor.sh and commit the result." + exit 1 + fi + - name: nimble test + working-directory: ports/nim + run: mise exec nim@2.2.12 -- nimble test + - name: nimble install, then build against the installed package + shell: bash + run: | + scratch="$RUNNER_TEMP/nimble-install" + mkdir -p "$scratch/app" + (cd ports/nim && mise exec nim@2.2.12 -- nimble install -y --nimbleDir:"$scratch/nimble") + cat > "$scratch/app/hello.nim" <<'EOF' + import std/strutils, hqtui + let ui = newUI() + ui.panel("Installed", proc(p: UI) = p.meter(0.5, %*{"label": "CPU"})) + var scene = newScene(30, 4) + scene.set(ui) + let frame = scene.render() + doAssert "Installed" in frame and "CPU" in frame, frame + echo frame + EOF + cd "$scratch/app" + mise exec nim@2.2.12 -- nim c -r --hints:off --nimblePath:"$scratch/nimble/pkgs2" hello.nim bindings: strategy: fail-fast: false diff --git a/README.md b/README.md index 4c3e3de..f5e5efc 100644 --- a/README.md +++ b/README.md @@ -213,8 +213,9 @@ cd ports/python && python examples/dashboard.py # Python cd ports/zig && zig build run-dashboard # Zig ``` -C++, Ruby, PHP, Perl and Nim build a native library first; their READMEs have the -CMake invocation. +C++, Ruby, PHP and Perl build a native library first; their READMEs have the +CMake invocation. Nim compiles the engine into the program itself: +`nim c -r ports/nim/examples/dashboard.nim`. ### Traffic — every protocol in and out of the host @@ -430,7 +431,7 @@ and the same escape bytes, byte for byte. | [C++](ports/cpp/) | native demo on the C core; library API experimental | none | [README](ports/cpp/README.md) | | [Ruby](ports/bindings/) | binding over the shared C ABI | the native engine | [README](ports/bindings/README.md) | | [PHP](ports/bindings/) | binding over the shared C ABI | the native engine | [README](ports/bindings/README.md) | -| [Nim](ports/nim/) | binding over the shared C ABI | the native engine | [README](ports/nim/README.md) | +| [Nim](ports/nim/) | binding over the shared C ABI, engine compiled in; `nimble install https://github.com/profullstack/hqtui?subdir=ports/nim` | none (vendored engine) | [README](ports/nim/README.md) | | [Perl](ports/bindings/) | binding over the shared C ABI | the native engine | [README](ports/bindings/README.md) | The four native ports are ports, not bindings: no Node is in the picture at diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json index 07ca64f..798ae86 100644 --- a/apps/benchmark/package.json +++ b/apps/benchmark/package.json @@ -7,6 +7,6 @@ "start": "bun src/main.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.7.0" + "@profullstack/hqtui": "^0.7.1" } } diff --git a/apps/demo/package.json b/apps/demo/package.json index 8077c9c..599bd45 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui-demo", - "version": "0.7.0", + "version": "0.7.1", "description": "The HQTUI reference dashboard: a btop-grade terminal system monitor. Runs on real system metrics or a deterministic simulation.", "license": "MIT", "type": "module", @@ -27,7 +27,7 @@ "audit:scroll": "bun scripts/scrollaudit.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.7.0" + "@profullstack/hqtui": "^0.7.1" }, "publishConfig": { "access": "public" diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index a8b2b24..1c86ea4 100755 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -52,7 +52,7 @@ function parseArgs(argv: string[]): Options { case "-h": case "--help": printHelp(); process.exit(0); case "-v": - case "--version": console.log("hqtui-demo 0.7.0"); process.exit(0); + case "--version": console.log("hqtui-demo 0.7.1"); process.exit(0); } } return options; diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index 490eb0f..669445c 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -103,11 +103,12 @@ export default async function Docs() {

Ruby, PHP, Perl and Nim are bindings, not independent renderer ports. Their widget APIs submit batched scene updates; the shared ten-screen demo, collectors and - terminal loop execute inside the calling runtime. They need GCC/Clang and CMake. + terminal loop execute inside the calling runtime. They need GCC/Clang, and Ruby, + PHP and Perl also need CMake. Ruby needs Fiddle; Perl needs FFI::Platypus (the launcher installs it into its private cache using cpanm if missing). PHP uses a small native adapter built with matching php-config/development headers, or FFI when available. - Nim uses its built-in C FFI and needs Nim 2.2.4 or newer. + Nim compiles the engine into the program from source and needs Nim 2.2.4 or newer. Mise supplies pinned Ruby/Perl/Nim, prebuilt PHP via its Conda backend, and CMake. No system packages or global gems/CPAN modules are installed by the launcher.

diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index c02f284..1dd0f56 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -158,7 +158,7 @@ export default async function Home() { High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++

- v0.7.0 · {COUNT} language demos · MIT + v0.7.1 · {COUNT} language demos · MIT

Terminal dashboards that diff --git a/apps/web/package.json b/apps/web/package.json index 9a9cec6..8301d81 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@base-ui/react": "1.7.0", - "@profullstack/hqtui": "^0.7.0", + "@profullstack/hqtui": "^0.7.1", "@profullstack/libsql-pg": "^0.1.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/bun.lock b/bun.lock index 44d7421..7837f40 100644 --- a/bun.lock +++ b/bun.lock @@ -21,12 +21,12 @@ }, "apps/demo": { "name": "@profullstack/hqtui-demo", - "version": "0.7.0", + "version": "0.7.1", "bin": { "hqtui-demo": "./bin/hqtui-demo.mjs", }, "dependencies": { - "@profullstack/hqtui": "^0.7.0", + "@profullstack/hqtui": "^0.7.1", }, }, "apps/web": { @@ -34,7 +34,7 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "1.7.0", - "@profullstack/hqtui": "^0.7.0", + "@profullstack/hqtui": "^0.7.1", "@profullstack/libsql-pg": "^0.1.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", @@ -59,12 +59,12 @@ "name": "@profullstack/hqtui-examples", "version": "0.1.0", "dependencies": { - "@profullstack/hqtui": "^0.7.0", + "@profullstack/hqtui": "^0.7.1", }, }, "packages/hqtui": { "name": "@profullstack/hqtui", - "version": "0.7.0", + "version": "0.7.1", "bin": { "hqtui": "./bin/hqtui.mjs", }, diff --git a/examples/package.json b/examples/package.json index e9777d8..d8fef15 100644 --- a/examples/package.json +++ b/examples/package.json @@ -4,6 +4,6 @@ "version": "0.1.0", "type": "module", "dependencies": { - "@profullstack/hqtui": "^0.7.0" + "@profullstack/hqtui": "^0.7.1" } } diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json index 1beb590..b238809 100644 --- a/packages/hqtui/package.json +++ b/packages/hqtui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui", - "version": "0.7.0", + "version": "0.7.1", "description": "High Quality Terminal UI for TypeScript. btop-grade dashboards with a one-import API, dark by default, zero runtime dependencies.", "license": "MIT", "type": "module", diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts index 79fa897..6def84c 100644 --- a/packages/hqtui/src/cli.ts +++ b/packages/hqtui/src/cli.ts @@ -14,7 +14,7 @@ import { detectCapabilities } from "./capabilities.ts"; import { themeList, themes } from "./theme.ts"; import { BrailleCanvas } from "./graphics/braille.ts"; -const VERSION = "0.7.0"; +const VERSION = "0.7.1"; function help(): void { console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript diff --git a/packages/hqtui/test/version.test.ts b/packages/hqtui/test/version.test.ts index 5f07404..c9ba17c 100644 --- a/packages/hqtui/test/version.test.ts +++ b/packages/hqtui/test/version.test.ts @@ -67,6 +67,17 @@ test("every version badge on the site matches the library", () => { for (const v of found) assert.equal(v, versionOf("packages", "hqtui"), `page.tsx has ${v}`); }); +test("the Nim package is versioned with the library", () => { + // Nimble resolves `nimble install hqtui` to the newest vX.Y.Z tag of this + // repository and reads ports/nim/hqtui.nimble there, so the two must agree + // or the registry installs a package that reports another version. + const library = versionOf("packages", "hqtui"); + const nimble = allMatches(read("ports", "nim", "hqtui.nimble"), /^version\s*=\s*"([^"]+)"/gm); + assert.deepEqual(nimble, [library], `hqtui.nimble has ${nimble.join(", ")}`); + const source = allMatches(read("ports", "nim", "src", "hqtui.nim"), /^const version\*\s*=\s*"([^"]+)"/gm); + assert.deepEqual(source, [library], `hqtui.nim has ${source.join(", ")}`); +}); + /** * Whether `version` satisfies `range`, for the range forms this repo uses. * Returns null for anything else so the caller fails loudly rather than diff --git a/ports/bindings/README.md b/ports/bindings/README.md index 25803e8..9c3a7c0 100644 --- a/ports/bindings/README.md +++ b/ports/bindings/README.md @@ -6,7 +6,8 @@ uses the C++ layout/widgets plus the C11 framebuffer/diff renderer. The JSON tre is retained and only reparsed on `set`, not on every render. No per-cell FFI calls, JSON subprocess pipe, JavaScript runtime or child demo executable is involved. -Nim uses its built-in C FFI and requires Nim 2.2.4+; see [Nim](../nim/README.md). +Nim compiles the engine from source into the program (no shared library) and +requires Nim 2.2.4+; it installs with Nimble, see [Nim](../nim/README.md). Ruby uses Fiddle. Perl uses FFI::Platypus. PHP prefers a tiny compiled Zend adapter to the same C ABI (so FFI need not be enabled); an FFI path is available otherwise. The full ten-screen example calls the shared C++ demo **inside the language VM**, @@ -57,10 +58,10 @@ matching the PHP binary, then build target `hqtui_php`. Run PHP with to the current invocation and does not edit php.ini. Without the adapter, PHP must provide its FFI extension and permit CLI FFI calls. -The loaders find this conventional checkout build or use `HQTUI_NATIVE_LIB` to -select an explicitly installed shared library. Copying a binding package alone -does not install the engine. RubyGems/Composer/CPAN metadata is included, but these -new packages have **not** been published to those registries. +The Ruby, PHP and Perl loaders find this conventional checkout build or use +`HQTUI_NATIVE_LIB` to select an explicitly installed shared library. Copying one of +those packages alone does not install the engine. RubyGems/Composer/CPAN metadata is +included, but those packages have **not** been published to their registries. ## API and tests diff --git a/ports/nim/README.md b/ports/nim/README.md index 07b8b49..2304e30 100644 --- a/ports/nim/README.md +++ b/ports/nim/README.md @@ -4,26 +4,36 @@ Nim 2.2.4+ bindings to the shared C/C++ engine, with no Nim package dependencies Build custom widget trees in Nim or run the full ten-screen dashboard in the same process. Linux and macOS are supported; live metrics require Linux. -## Build and run +## Install -From the repository root, with Nim, a C/C++17 compiler and CMake 3.20+ installed: +```sh +nimble install https://github.com/profullstack/hqtui?subdir=ports/nim +``` + +`nimble install hqtui` works the same way once the package is listed in +[nim-lang/packages](https://github.com/nim-lang/packages). Nimble picks the +newest `vX.Y.Z` tag, and the package version follows the hqtui release. + +The engine is compiled from source into your program: the package carries a +copy of `ports/c`, `ports/cpp` and `ports/bindings` in `src/hqtui/engine`, built +through Nim's `compile` pragma. You need Nim 2.2.4+ and a C11/C++17 compiler +(GCC or Clang); there is no CMake step, no shared library and nothing to set at +run time. The first build of a program compiles the engine (about 20 seconds); +later builds reuse the objects in its nimcache. + +## Build and run from a checkout ```sh -cmake -S ports/cpp -B ports/cpp/build-bindings \ - -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \ - -DBUILD_TESTING=OFF -DHQTUI_BUILD_BINDINGS=ON -cmake --build ports/cpp/build-bindings --target hqtui_bindings --parallel 2 nim c -r ports/nim/examples/hello.nim nim c -r -d:release ports/nim/examples/dashboard.nim --sim nim c -r ports/nim/examples/widgets.nim +cd ports/nim && nimble test ``` -The loader uses that checkout build by default. Set `HQTUI_NATIVE_LIB` to the -absolute path of `libhqtui_bindings.so` (Linux) or `libhqtui_bindings.dylib` -(macOS) when installing or moving the executable. The library must remain -available at runtime. Nimble metadata is included; this package is not published. +`src/hqtui/engine` is generated. After changing the C, C++ or binding sources, +run `sh ports/nim/tools/vendor.sh`; CI fails when the copy differs. -Once this change is on main, the latest-source launcher supports both forms: +The latest-source launcher runs the demo without a checkout: ```sh curl -fsSL https://hqtui.com/demo.sh | sh -s -- --system nim @@ -49,7 +59,7 @@ stdout.write scene.render() scene.close() ``` -Compile with `--path:ports/nim/src` when using the checkout. Each widget accepts +Compile with `--path:ports/nim/src` when using the checkout instead of Nimble. Each widget accepts an optional JSON object for options, with the same fields as the [shared protocol](../bindings/PROTOCOL.md). Arrays and records use Nim's `%*` JSON literal macro, exported by `hqtui`. Named procedures cover every widget in @@ -80,6 +90,10 @@ headless output and the full interactive demo. It does not implement the larger TypeScript API independently: modal content uses protocol fields rather than arbitrary nested widgets, and input is not a high-level event/callback API. +`nimble test` runs the API suite and replays the `demo-parity` conformance +group (240 TypeScript reference frames). The shared binding runner checks the +same frames across Ruby, PHP, Perl and Nim, plus interactive behaviour: + ```sh sh ports/nim/tests/build.sh export HQTUI_NATIVE_LIB="$PWD/ports/cpp/build-bindings/libhqtui_bindings.so" diff --git a/ports/nim/hqtui.nimble b/ports/nim/hqtui.nimble index 7975f76..4266df4 100644 --- a/ports/nim/hqtui.nimble +++ b/ports/nim/hqtui.nimble @@ -1,9 +1,17 @@ -version = "0.1.0" -author = "HQTUI contributors" -description = "HQTUI widgets and terminal applications over the shared native engine" -license = "MIT" -srcDir = "src" +# Package + +version = "0.7.1" +author = "HQTUI contributors" +description = "Terminal UI widgets and the ten-screen HQTUI dashboard for Nim, over HQTUI's shared native engine (compiled from source)" +license = "MIT" +srcDir = "src" + +# Dependencies + requires "nim >= 2.2.4" -task test, "Run binding API tests (requires native engine)": - exec "nim c -r --hints:off tests/core.nim" +# Tasks + +task test, "Run the API tests and replay the demo-parity conformance fixtures": + exec "nim c -r --hints:off --nimcache:build/nimcache --out:build/core tests/core.nim" + exec "nim c -r --hints:off --nimcache:build/nimcache --out:build/parity tests/parity.nim" diff --git a/ports/nim/src/hqtui.nim b/ports/nim/src/hqtui.nim index b89c776..8a198e1 100644 --- a/ports/nim/src/hqtui.nim +++ b/ports/nim/src/hqtui.nim @@ -1,14 +1,54 @@ ## Nim API over HQTUI's shared native rendering engine (ABI 1). ## Scenes own their handles, cannot be copied, and restore terminals on destruction. -import std/[json, os] +## The engine is compiled from source into the program (src/hqtui/engine, a +## generated copy of ports/c, ports/cpp and ports/bindings), so a Nimble install +## needs only Nim and a C/C++17 compiler: no CMake and no shared library. +import std/[json, os, strutils] export json -const version* = "0.1.0" -const checkoutLibrary = currentSourcePath().parentDir / "../../cpp/build-bindings" / - (when defined(macosx): "libhqtui_bindings.dylib" else: "libhqtui_bindings.so") -proc libraryPath(): string = getEnv("HQTUI_NATIVE_LIB", checkoutLibrary) - -{.push cdecl, dynlib: libraryPath(), importc.} +const version* = "0.7.1" + +const + engine = currentSourcePath().parentDir / "hqtui" / "engine" + engineVersion = staticRead(engine / "VERSION").strip() + includes = block: + var flags = "" + for dir in ["c/include", "cpp/include", "cpp/demo", "cpp/demo/generated", "bindings/include"]: + flags.add " -I" & quoteShell(engine / dir) + flags + # -ffp-contract=off keeps each operation rounded like the TypeScript reference; + # a fused multiply-add can move a glyph or colour boundary on ARM. + cFlags = "-std=c11 -ffp-contract=off" & includes + cppFlags = "-std=c++17 -ffp-contract=off -pthread" & includes & + " " & quoteShell("-DHQTUI_DEMO_VERSION=\"" & engineVersion & "\"") + +{.compile(engine / "c/src/color.c", cFlags).} +{.compile(engine / "c/src/unicode.c", cFlags).} +{.compile(engine / "c/src/buffer.c", cFlags).} +{.compile(engine / "c/src/layout.c", cFlags).} +{.compile(engine / "c/src/diff.c", cFlags).} +{.compile(engine / "c/src/theme.c", cFlags).} +{.compile(engine / "c/src/surface.c", cFlags).} +{.compile(engine / "cpp/src/widgets.cpp", cppFlags).} +{.compile(engine / "cpp/src/scrollbar.cpp", cppFlags).} +{.compile(engine / "cpp/src/chart.cpp", cppFlags).} +{.compile(engine / "cpp/src/calendar.cpp", cppFlags).} +{.compile(engine / "cpp/src/canvas.cpp", cppFlags).} +{.compile(engine / "cpp/src/shadow.cpp", cppFlags).} +{.compile(engine / "cpp/src/world.cpp", cppFlags).} +{.compile(engine / "cpp/src/world_data.cpp", cppFlags).} +{.compile(engine / "cpp/demo/collect.cpp", cppFlags).} +{.compile(engine / "cpp/demo/dashboard.cpp", cppFlags).} +{.compile(engine / "cpp/demo/telemetry.cpp", cppFlags).} +{.compile(engine / "cpp/demo/showcase.cpp", cppFlags).} +{.compile(engine / "bindings/src/bridge.cpp", cppFlags).} +when defined(macosx): + {.passL: "-lc++".} +else: + {.passL: "-lstdc++ -pthread".} +{.passL: "-lm".} + +{.push cdecl, importc.} proc hqb_abi_version(): cint proc hqb_error(): cstring proc hqb_create(width, height: cint, theme: cstring): pointer diff --git a/ports/nim/src/hqtui/engine/README.md b/ports/nim/src/hqtui/engine/README.md new file mode 100644 index 0000000..6f21feb --- /dev/null +++ b/ports/nim/src/hqtui/engine/README.md @@ -0,0 +1,5 @@ +# Generated: do not edit + +A copy of `ports/c`, `ports/cpp` and `ports/bindings`, made by +`ports/nim/tools/vendor.sh` so the Nim package builds the engine from source. +Change the originals and rerun the script. diff --git a/ports/nim/src/hqtui/engine/VERSION b/ports/nim/src/hqtui/engine/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/ports/nim/src/hqtui/engine/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/ports/nim/src/hqtui/engine/bindings/include/hqtui_bindings.h b/ports/nim/src/hqtui/engine/bindings/include/hqtui_bindings.h new file mode 100644 index 0000000..731151a --- /dev/null +++ b/ports/nim/src/hqtui/engine/bindings/include/hqtui_bindings.h @@ -0,0 +1,50 @@ +#ifndef HQTUI_BINDINGS_H +#define HQTUI_BINDINGS_H +#include +#ifdef __cplusplus +extern "C" { +#endif +/* Experimental ABI 1. Opaque handles, primitive arguments, no callbacks into a + * language runtime. Each scene belongs to one thread. All exceptions stop here. + * Output/error strings are borrowed until the next operation on that thread or + * scene. Copy them before calling again. NULL destroy is safe; other handles + * must be live values returned by create. Never destroy a handle twice. + */ +typedef struct hqb_scene hqb_scene; +int hqb_abi_version(void); +const char *hqb_error(void); +hqb_scene *hqb_create(int width, int height, const char *theme); +void hqb_destroy(hqb_scene *scene); +/* JSON scene is parsed once per update. At most 1 MiB, 4096 nodes, depth 32. + * A rejected update leaves the last valid scene intact. See PROTOCOL.md. */ +int hqb_set(hqb_scene *scene, const char *json, size_t length); +int hqb_resize(hqb_scene *scene, int width, int height); +/* Merge the borders of adjacent panels into shared lines, the way CSS collapses + * table borders. Applies to the next render or demo frame, and to every one + * after it until changed. */ +int hqb_collapse(hqb_scene *scene, int enabled); +/* text, ansi (full frame), diff (against last rendered frame), hashes (tests). + */ +const char *hqb_render(hqb_scene *scene, const char *format); +/* Render a shared deterministic demo body at the scene's size/theme. */ +const char *hqb_demo_frame(hqb_scene *scene, const char *screen, + const char *format); +/* Optional native terminal transport for custom language applications. + * One terminal/demo per process; close/destroy restores it. poll returns a + * copied raw UTF-8/escape byte chunk (possibly empty); NULL means failure. + * Applications handle keys in their own runtime. present updates terminal size. + */ +int hqb_open(hqb_scene *scene); +int hqb_present(hqb_scene *scene); +const char *hqb_poll(hqb_scene *scene, int timeout_ms); +int hqb_interrupted(void); +void hqb_close(hqb_scene *scene); +/* Shared ten-screen demo executes INSIDE the calling VM, never execs another + * demo. JSON is an array of CLI argument strings; returns the demo exit status. + * It owns the terminal for the duration and restores signal handlers on exit. + */ +int hqb_demo(const char *arguments, size_t length); +#ifdef __cplusplus +} +#endif +#endif diff --git a/ports/nim/src/hqtui/engine/bindings/src/bridge.cpp b/ports/nim/src/hqtui/engine/bindings/src/bridge.cpp new file mode 100644 index 0000000..f0cdd22 --- /dev/null +++ b/ports/nim/src/hqtui/engine/bindings/src/bridge.cpp @@ -0,0 +1,688 @@ +#define HQTUI_DEMO_LIBRARY +#include "../../cpp/demo/main.cpp" +#include "hqtui_bindings.h" +#include + +namespace { +using demo::Json; +using namespace hqtui; +thread_local std::string error; +std::atomic terminal_claimed{false}; +void dimensions(int w, int h) { + if (w < 1 || h < 1 || w > 500 || h > 200) + throw std::runtime_error("dimensions must be 1..500 by 1..200"); +} +template int checked(F action) noexcept { + try { + error.clear(); + action(); + return 1; + } catch (const std::exception &e) { + error = e.what(); + } catch (...) { + error = "native operation failed"; + } + return 0; +} +Json parse(const char *value, size_t length) { + if (!value || length > 1048576) + throw std::runtime_error("JSON must be at most 1 MiB"); + return Json::parse(std::string_view(value, length)); +} +int integer(const Json &v, int fallback, int lo, int hi) { + if (v.null()) + return fallback; + double n = v.n(std::numeric_limits::quiet_NaN()); + if (!std::isfinite(n) || n != std::floor(n) || n < lo || n > hi) + throw std::runtime_error("integer option out of range"); + return int(n); +} +Color color(const Json &v, const hq_theme &t, Color fallback) { + if (v.null()) + return fallback; + auto name = v.s(""); + if (name == "primary") + return t.primary; + if (name == "secondary") + return t.secondary; + if (name == "accent") + return t.accent; + if (name == "muted") + return t.muted; + if (name == "success") + return t.success; + if (name == "warning") + return t.warning; + if (name == "danger") + return t.danger; + if (name == "foreground") + return t.foreground; + if (name.size() == 7 && name[0] == '#' && + name.find_first_not_of("0123456789abcdefABCDEF", 1) == name.npos) + return hq_hex(name.c_str()); + throw std::runtime_error("color must be a theme role or #rrggbb"); +} +Constraint size(const Json &n, Constraint fallback = fr()) { + if (n["size"].null()) + return fallback; + return cells(integer(n["size"], 0, 0, 10000)); +} +void validate(const Json &n, int depth, int &count) { + if (depth > 32 || ++count > 4096 || + !std::holds_alternative(n.value)) + throw std::runtime_error( + "scene exceeds depth/node limits or node is not an object"); + auto type = n["type"].s(""); + static const std::vector types = { + "row", "col", "panel", "text", "spacer", "divider", + "meter", "graph", "gauge", "table", "keys", "log", + "badge", "progress", "sparkline", "heatbar", "columns", "donut", + "list", "tree", "button", "checkbox", "select", "input", + "tabs", "statusbar", "label", "heading", "meters", "modal", + "commandpalette", "tooltip", "scrollbar", "chart", "calendar"}; + if (std::find(types.begin(), types.end(), type) == types.end()) + throw std::runtime_error("unknown widget: " + type); + if (!n["children"].null() && + !std::holds_alternative(n["children"].value)) + throw std::runtime_error("children must be an array"); + for (auto &child : n["children"].array()) + validate(child, depth + 1, count); +} +/// An overlay drawn after layout, over the whole frame. Collected while the +/// tree is walked rather than drawn where it appears, because a modal centred +/// on the node that declared it would land inside that node's rectangle +/// instead of on the screen. +struct PendingOverlay { + enum Kind { kModal, kPalette, kTooltip } kind; + Modal modal; + CommandPalette palette; + Tooltip tooltip; +}; + +void node(UI &ui, const Json &n, std::vector &overlays) { + auto type = n["type"].s(); + auto body = [&n, &overlays](UI &p) { + for (auto &child : n["children"].array()) + node(p, child, overlays); + }; + int gap = integer(n["gap"], 0, 0, 100); + auto c = color(n["color"], ui.t(), ui.t().foreground); + if (type == "row" || type == "col") + ui.group(size(n), gap, type == "row", body); + else if (type == "panel") + ui.panel(n["title"].s(""), body, size(n), n["subtitle"].s("")); + else if (type == "text") + ui.text(n["text"].s(""), c, integer(n["align"], HQ_LEFT, 0, 2), + size(n, automatic(1)), integer(n["attrs"], 0, 0, 127)); + else if (type == "spacer") + ui.spacer(size(n)); + else if (type == "divider") + ui.divider(n["text"].s("")); + else if (type == "meter") { + Meter m; + m.value = n["value"].n(); + m.label = n["label"].s(""); + m.color = c; + ui.meter(m); + } else if (type == "gauge") { + auto v = n["value"].n(); + auto label = n["label"].s(""); + ui.draw([v, label](Surface s) { draw_gauge(s, v, label); }, size(n)); + } else if (type == "graph") { + Graph g; + g.series.push_back({demo::numbers(n["values"]), c, "", true}); + if (!n["max"].null()) + g.max = n["max"].n(); + g.min = n["min"].n(); + ui.graph(g, size(n)); + } else if (type == "keys") { + std::vector rows; + for (auto &r : n["rows"].array()) + rows.push_back({r.at(0).s(""), r.at(1).s(""), c}); + ui.keys(rows); + } else if (type == "table") { + Table t; + for (auto &column : n["columns"].array()) + t.columns.push_back({column.s(""), -1, 1, 0, HQ_LEFT}); + for (auto &r : n["rows"].array()) { + TableRow row; + for (auto &v : r.array()) + row.cells.push_back(v.s("")); + t.rows.push_back(std::move(row)); + } + if (t.columns.size() > 256 || t.rows.size() > 10000) + throw std::runtime_error("table too large"); + int selected = integer(n["selected"], 0, 0, 10000), + offset = integer(n["offset"], 0, 0, 10000); + ui.draw( + [t, selected, offset](Surface s) mutable { + Pane p; + p.selected = selected; + p.offset = offset; + t.pane = &p; + draw_table(s, t); + }, + size(n)); + } else if (type == "label") { + // Text in the theme's muted colour: captions, and the line under a number + // that says what the number is. + ui.text(n["text"].s(""), color(n["color"], ui.t(), ui.t().muted), + integer(n["align"], HQ_LEFT, 0, 2), size(n, automatic(1)), + integer(n["attrs"], 0, 0, 127)); + } else if (type == "heading") { + ui.text(n["text"].s(""), color(n["color"], ui.t(), ui.t().title), + integer(n["align"], HQ_LEFT, 0, 2), size(n, automatic(1)), + integer(n["attrs"], HQ_BOLD, 0, 127)); + } else if (type == "meters") { + Meters m; + for (auto &item : n["items"].array()) + m.items.push_back({item["label"].s(""), item["value"].n(), + item["max"].n(0), color(item["color"], ui.t(), 0), + item["text"].s("")}); + if (m.items.size() > 1000) + throw std::runtime_error("too many meters"); + m.columns = integer(n["columns"], 1, 1, 16); + m.label_width = integer(n["labelWidth"], -1, -1, 100); + m.value_width = integer(n["valueWidth"], -1, -1, 100); + m.style = integer(n["style"], HQ_BAR_SMOOTH, 0, 2); + m.gap = integer(n["gap"], 2, 0, 100); + int rows = (int(m.items.size()) + m.columns - 1) / m.columns; + ui.draw([m](Surface s) { draw_meters(s, m); }, size(n, cells(rows))); + } else if (type == "modal") { + PendingOverlay overlay; + overlay.kind = PendingOverlay::kModal; + overlay.modal.title = n["title"].s(""); + overlay.modal.message = n["message"].s(""); + overlay.modal.width = integer(n["width"], -1, -1, 1000); + overlay.modal.height = integer(n["height"], -1, -1, 1000); + overlay.modal.backdrop = n["backdrop"].b(true); + overlay.modal.align = integer(n["align"], HQ_CENTER, 0, 2); + for (auto &button : n["buttons"].array()) + overlay.modal.buttons.push_back( + {button["label"].s(""), + integer(button["variant"], HQ_BUTTON_PRIMARY, 0, 4), + button["focused"].b(false)}); + if (overlay.modal.buttons.size() > 16) + throw std::runtime_error("too many modal buttons"); + overlays.push_back(std::move(overlay)); + } else if (type == "commandpalette") { + PendingOverlay overlay; + overlay.kind = PendingOverlay::kPalette; + overlay.palette.query = n["query"].s(""); + overlay.palette.placeholder = n["placeholder"].s(""); + for (auto &item : n["items"].array()) + overlay.palette.items.push_back({item["label"].s(""), item["hint"].s("")}); + if (overlay.palette.items.size() > 1000) + throw std::runtime_error("too many palette items"); + overlay.palette.selected = integer(n["selected"], 0, 0, 1000); + overlay.palette.width = integer(n["width"], -1, -1, 1000); + overlay.palette.height = integer(n["height"], -1, -1, 1000); + overlays.push_back(std::move(overlay)); + } else if (type == "tooltip") { + PendingOverlay overlay; + overlay.kind = PendingOverlay::kTooltip; + overlay.tooltip.text = n["text"].s(""); + overlay.tooltip.x = integer(n["x"], 0, 0, 10000); + overlay.tooltip.y = integer(n["y"], 0, 0, 10000); + overlay.tooltip.color = color(n["color"], ui.t(), 0); + overlays.push_back(std::move(overlay)); + } else if (type == "badge") { + Badge b; + b.text = n["text"].s(""); + b.color = n["color"].null() ? 0 : c; + b.variant = integer(n["variant"], HQ_BADGE_FILLED, 0, 2); + b.align = integer(n["align"], HQ_LEFT, 0, 2); + ui.badge(b); + } else if (type == "progress") { + Progress p; + p.value = n["value"].n(); + p.max = n["max"].null() ? 1 : n["max"].n(); + p.label = n["label"].s(""); + p.color = n["color"].null() ? 0 : c; + p.show_count = n["count"].b(false); + ui.progress(p); + } else if (type == "sparkline") { + Sparkline sp; + sp.values = demo::numbers(n["values"]); + sp.label = n["label"].s(""); + sp.text = n["text"].s(""); + sp.color = n["color"].null() ? 0 : c; + if (!n["min"].null()) + sp.min = n["min"].n(); + if (!n["max"].null()) + sp.max = n["max"].n(); + ui.sparkline(sp); + } else if (type == "heatbar") { + HeatBar hb; + hb.value = n["value"].n(); + hb.color = n["color"].null() ? 0 : c; + ui.heat_bar(hb); + } else if (type == "columns") { + Columns cols; + cols.values = demo::numbers(n["values"]); + cols.color = n["color"].null() ? 0 : c; + if (!n["max"].null()) + cols.max = n["max"].n(); + ui.columns(cols, size(n)); + } else if (type == "donut") { + Donut d; + for (auto &seg : n["segments"].array()) + d.segments.push_back({seg["value"].n(), 0, seg["label"].s("")}); + if (d.segments.size() > 64) + throw std::runtime_error("too many donut segments"); + ui.donut(d, size(n)); + } else if (type == "calendar") { + Calendar cal; + cal.year = integer(n["year"], 1970, -9999, 9999); + cal.month = integer(n["month"], 1, 1, 12); + cal.selected = integer(n["selected"], 0, 0, 31); + cal.week_start = integer(n["weekStart"], 1, 0, 1); + if (!n["header"].null()) + cal.header = n["header"].b(true); + if (!n["weekdays"].null()) + cal.weekdays = n["weekdays"].b(true); + for (auto &mj : n["marks"].array()) { + CalendarMark mark; + mark.day = integer(mj["day"], 0, 0, 31); + mark.bold = mj["bold"].b(false); + cal.marks.push_back(mark); + } + if (cal.marks.size() > 64) + throw std::runtime_error("too many calendar marks"); + ui.calendar(cal); + } else if (type == "chart") { + Chart chart; + for (auto &sj : n["series"].array()) { + ChartSeries cs; + for (auto &pj : sj["points"].array()) + cs.points.push_back({pj["x"].n(), pj["y"].n()}); + if (cs.points.size() > 10000) + throw std::runtime_error("chart series too large"); + cs.label = sj["label"].s(""); + auto mark = sj["mark"].s("line"); + cs.mark = mark == "scatter" ? HQ_MARK_SCATTER + : mark == "bar" ? HQ_MARK_BAR + : HQ_MARK_LINE; + cs.fill = sj["fill"].b(false); + chart.series.push_back(std::move(cs)); + } + if (chart.series.size() > 64) + throw std::runtime_error("too many chart series"); + chart.axis = n["axis"].b(false); + chart.legend = n["legend"].b(false); + if (!n["mode"].null()) + chart.plot.mode = n["mode"].s("braille"); + auto read_axis = [](const Json &j) -> std::optional { + if (j.null()) + return std::nullopt; + Axis a; + if (!j["min"].null()) + a.min = j["min"].n(); + if (!j["max"].null()) + a.max = j["max"].n(); + a.ticks = integer(j["ticks"], 0, 0, 64); + return a; + }; + chart.plot.x = read_axis(n["x"]); + chart.plot.y = read_axis(n["y"]); + ui.chart(chart, size(n)); + } else if (type == "scrollbar") { + Scrollbar bar; + bar.total = integer(n["total"], 0, 0, 1000000); + bar.viewport = integer(n["viewport"], 0, 0, 1000000); + bar.offset = integer(n["offset"], 0, 0, 1000000); + auto edge = n["orientation"].s("right"); + bar.orientation = edge == "left" ? HQ_SCROLLBAR_LEFT + : edge == "bottom" ? HQ_SCROLLBAR_BOTTOM + : edge == "top" ? HQ_SCROLLBAR_TOP + : HQ_SCROLLBAR_RIGHT; + ui.scrollbar(bar, n["id"].s("")); + } else if (type == "list") { + List l; + for (auto &item : n["items"].array()) + l.items.push_back({item.s(""), 0}); + if (l.items.size() > 10000) + throw std::runtime_error("list too large"); + l.selected = integer(n["selected"], -1, -1, 10000); + l.offset = integer(n["offset"], 0, 0, 10000); + l.bullet = n["bullet"].s(""); + l.scrollbar = n["scrollbar"].b(false); + ui.list(l, n["id"].s("")); + } else if (type == "tree") { + Tree tree; + // One level of nesting, which is what a record-shaped scene can describe: + // a node with its children, not an arbitrary depth. + for (auto &node_json : n["nodes"].array()) { + TreeNode parent; + parent.label = node_json["label"].s(""); + for (auto &child : node_json["children"].array()) + parent.children.push_back(TreeNode{child["label"].s("")}); + tree.nodes.push_back(std::move(parent)); + } + tree.selected = integer(n["selected"], -1, -1, 10000); + ui.tree(tree, n["id"].s("")); + } else if (type == "button") { + Button b; + b.label = n["label"].s(""); + b.focused = n["focused"].b(false); + b.variant = integer(n["variant"], HQ_BUTTON_PRIMARY, 0, 4); + b.disabled = n["disabled"].b(false); + ui.button(b); + } else if (type == "checkbox") { + Checkbox cb; + cb.label = n["label"].s(""); + cb.checked = n["checked"].b(false); + cb.focused = n["focused"].b(false); + cb.variant = integer(n["variant"], HQ_CHECKBOX_BOX, 0, 2); + ui.checkbox(cb); + } else if (type == "select") { + Select sel; + sel.value = n["value"].s(""); + sel.open = n["open"].b(false); + for (auto &option : n["options"].array()) + sel.options.push_back(option.s("")); + if (sel.options.size() > 1000) + throw std::runtime_error("too many options"); + sel.selected_index = integer(n["selected"], 0, 0, 1000); + sel.focused = n["focused"].b(false); + ui.select(sel); + } else if (type == "input") { + TextInput input; + input.value = n["value"].s(""); + input.placeholder = n["placeholder"].s(""); + input.label = n["label"].s(""); + input.focused = n["focused"].b(false); + input.password = n["password"].b(false); + ui.text_input(input); + } else if (type == "tabs") { + Tabs tabs; + for (auto &tab : n["tabs"].array()) + tabs.tabs.push_back(tab.s("")); + if (tabs.tabs.size() > 100) + throw std::runtime_error("too many tabs"); + tabs.active = integer(n["active"], 0, 0, 100); + tabs.variant = integer(n["variant"], HQ_TAB_FILLED, 0, 1); + ui.tabs(tabs); + } else if (type == "statusbar") { + StatusBar bar; + for (auto &item : n["items"].array()) + bar.items.push_back({item["key"].s(""), item["label"].s(""), 0, + item["active"].b(false)}); + for (auto &item : n["right"].array()) + bar.right.push_back({item["key"].s(""), item["label"].s(""), 0, false}); + if (bar.items.size() + bar.right.size() > 100) + throw std::runtime_error("too many status items"); + ui.status_bar(bar); + } else if (type == "log") { + std::vector entries; + for (auto &v : n["entries"].array()) + entries.push_back({v["time"].s(""), v["level"].s("INFO"), + v["message"].s(""), v["meta"].s("")}); + int offset = integer(n["offset"], 0, 0, 10000); + ui.draw( + [entries, offset](Surface s) { + Pane p; + p.offset = offset; + draw_log(s, entries, &p, true); + }, + size(n)); + } +} +std::string hashes(const Buffer &frame) { + std::string out = "["; + for (int y = 0; y < frame.height(); y++) { + uint32_t h = 2166136261u; + auto byte = [&](unsigned char b) { h = (h ^ b) * 16777619u; }; + for (int x = 0; x < frame.width(); x++) { + auto cell = frame.cell(x, y); + char scratch[5]; + std::string_view glyph = + hq_buffer_cell_text(frame.native_handle(), x, y, scratch); + for (unsigned char b : glyph) + byte(b); + byte(0); + for (auto value : + {uint32_t(cell.fg), uint32_t(cell.bg), uint32_t(cell.attrs)}) + for (int i = 0; i < 4; i++) + byte((value >> (8 * i)) & 255); + } + if (y) + out += ','; + out += std::to_string(h); + } + return out + "]"; +} +} // namespace +struct hqb_scene { + Buffer previous, frame; + Encoder encoder; + const hq_theme *theme; + Json tree; + std::string output, input; + bool first = true, collapse = false; + std::unique_ptr terminal; + hqb_scene(int w, int h, const hq_theme *t) + : previous(w, h), frame(w, h), theme(t) {} + void paint(const Json &scene) { + frame.clear(theme->background, theme->foreground); + UI ui(frame.surface(theme), false, 0, nullptr, collapse); + std::vector overlays; + node(ui, scene, overlays); + ui.flush(); + // Overlays go on after the flush, so a modal covers the laid-out screen + // rather than being covered by it. + Surface root = frame.surface(theme); + for (const auto &overlay : overlays) { + switch (overlay.kind) { + case PendingOverlay::kModal: + draw_modal(root, overlay.modal); + break; + case PendingOverlay::kPalette: + draw_command_palette(root, overlay.palette); + break; + case PendingOverlay::kTooltip: + draw_tooltip(root, overlay.tooltip); + break; + } + } + } + const char *finish(const char *format) { + if (!format) + throw std::runtime_error("missing format"); + std::string f = format; + if (f == "text") { + output.clear(); + for (int y = 0; y < frame.height(); y++) + output += frame.row(y) + "\n"; + } else if (f == "hashes") + output = hashes(frame); + else if (f == "ansi" || f == "diff") + output = encoder.encode(previous, frame, first || f == "ansi").output; + else + throw std::runtime_error("format must be text, ansi, diff or hashes"); + previous.copy_from(frame); + first = false; + return output.c_str(); + } +}; +extern "C" { +int hqb_abi_version(void) { return 1; } +const char *hqb_error(void) { return error.c_str(); } +hqb_scene *hqb_create(int w, int h, const char *name) { + hqb_scene *s = nullptr; + checked([&] { + dimensions(w, h); + auto t = hq_theme_named(name ? name : "dark"); + if (!t) + throw std::runtime_error("unknown theme"); + auto candidate = std::make_unique(w, h, t); + candidate->tree = Json::Object{{"type", "col"}}; + s = candidate.release(); + }); + return s; +} +void hqb_close(hqb_scene *s) { + if (s && s->terminal) { + s->terminal.reset(); + terminal_claimed = false; + } +} +void hqb_destroy(hqb_scene *s) { + hqb_close(s); + delete s; +} +int hqb_set(hqb_scene *s, const char *json, size_t length) { + return checked([&] { + if (!s) + throw std::runtime_error("closed scene"); + auto tree = parse(json, length); + int count = 0; + validate(tree, 0, count); + // Validate widget options and allocations before committing the new tree. + s->paint(tree); + s->tree = std::move(tree); + }); +} +int hqb_resize(hqb_scene *s, int w, int h) { + return checked([&] { + if (!s) + throw std::runtime_error("closed scene"); + dimensions(w, h); + s->frame.resize(w, h); + s->previous.resize(w, h); + s->first = true; + }); +} +int hqb_collapse(hqb_scene *s, int enabled) { + return checked([&] { + if (!s) + throw std::runtime_error("closed scene"); + s->collapse = enabled != 0; + }); +} +const char *hqb_render(hqb_scene *s, const char *format) { + if (!checked([&] { + if (!s || !format) + throw std::runtime_error("closed scene or missing format"); + s->paint(s->tree); + s->finish(format); + })) + return nullptr; + return s->output.c_str(); +} +const char *hqb_demo_frame(hqb_scene *s, const char *screen, + const char *format) { + if (!checked([&] { + if (!s || !screen) + throw std::runtime_error("closed scene or missing screen"); + demo::State state; + state.data = Json::parse(demo::sample_json); + int i = 0; + for (; i < 10 && std::string(screen) != demo::screens[i]; i++) { + } + if (i == 10) + throw std::runtime_error("unknown demo screen"); + state.screen = i; + for (int t = 0; t < 9; t++) + if (std::string(s->theme->name) == demo::themes[t]) + state.theme_index = t; + // Collapsing changes the layout, not only the glyphs: the screens close + // the seams between panels so their borders have something to merge. + state.collapse = s->collapse; + s->frame.clear(s->theme->background, s->theme->foreground); + UI ui(s->frame.surface(s->theme), false, 0, nullptr, s->collapse); + demo::render(ui, state, true); + ui.flush(); + s->finish(format); + })) + return nullptr; + return s->output.c_str(); +} +int hqb_open(hqb_scene *s) { + return checked([&] { + if (!s) + throw std::runtime_error("closed scene"); + if (s->terminal) + return; + bool expected = false; + if (!terminal_claimed.compare_exchange_strong(expected, true)) + throw std::runtime_error("terminal already acquired"); + try { + demo::interrupted = 0; + s->terminal = std::make_unique(); + s->first = true; + } catch (...) { + terminal_claimed = false; + throw; + } + }); +} +int hqb_present(hqb_scene *s) { + return checked([&] { + if (!s || !s->terminal) + throw std::runtime_error("terminal not open"); + auto dimensions = s->terminal->size(); + if (dimensions.first != s->frame.width() || + dimensions.second != s->frame.height()) + if (!hqb_resize(s, dimensions.first, dimensions.second)) + throw std::runtime_error(error); + auto bytes = hqb_render(s, "diff"); + if (!bytes) + throw std::runtime_error(error); + if (!demo::write_all(s->output)) + throw std::runtime_error("terminal write failed"); + }); +} +const char *hqb_poll(hqb_scene *s, int timeout_ms) { + if (!checked([&] { + if (!s || !s->terminal || timeout_ms < 0 || timeout_ms > 1000) + throw std::runtime_error("invalid terminal poll"); + s->input.clear(); + struct pollfd fd{0, POLLIN, 0}; + auto ready = poll(&fd, 1, timeout_ms); + if (ready < 0 && errno != EINTR) + throw std::runtime_error("terminal poll failed"); + if (ready > 0 && fd.revents & (POLLHUP | POLLERR)) + demo::interrupted = SIGHUP; + if (ready > 0 && fd.revents & POLLIN) { + char b[4096]; + auto n = read(0, b, sizeof b); + if (n > 0) + s->input.assign(b, size_t(n)); + } + })) + return nullptr; + return s->input.c_str(); +} +int hqb_interrupted(void) { return demo::interrupted; } +int hqb_demo(const char *arguments, size_t length) { + int status = 2; + if (!checked([&] { + auto args = parse(arguments, length); + if (!std::holds_alternative(args.value) || + args.array().size() > 128) + throw std::runtime_error( + "arguments must be an array of at most 128 strings"); + std::vector strings{"hqtui-demo-binding"}; + for (auto &v : args.array()) { + if (!std::holds_alternative(v.value) || + v.s().find('\0') != std::string::npos) + throw std::runtime_error("arguments must be NUL-free strings"); + strings.push_back(v.s()); + } + std::vector argv; + for (auto &v : strings) + argv.push_back(v.data()); + argv.push_back(nullptr); + bool expected = false; + if (!terminal_claimed.compare_exchange_strong(expected, true)) + throw std::runtime_error("terminal already acquired"); + struct Release { + ~Release() { terminal_claimed = false; } + } release; + status = hqtui_demo_main(int(strings.size()), argv.data()); + })) + return -1; + return status; +} +} diff --git a/ports/nim/src/hqtui/engine/c/include/hqtui.h b/ports/nim/src/hqtui/engine/c/include/hqtui.h new file mode 100644 index 0000000..fd087eb --- /dev/null +++ b/ports/nim/src/hqtui/engine/c/include/hqtui.h @@ -0,0 +1,184 @@ +#ifndef HQTUI_H +#define HQTUI_H + +/* Native C11 renderer. No terminal state is changed by framebuffer operations. + * Objects are independent and may be used on separate threads, but an individual + * object requires external synchronization. Strings passed to *_n may contain NUL. + * Returned strings are borrowed until the next mutation of the owning object. + */ +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +typedef uint32_t hq_color; +enum { HQ_BOLD=1, HQ_DIM=2, HQ_ITALIC=4, HQ_UNDERLINE=8, + HQ_BLINK=16, HQ_REVERSE=32, HQ_STRIKE=64 }; +enum { HQ_STYLE_FG=1, HQ_STYLE_BG=2, HQ_STYLE_ATTRS=4, HQ_STYLE_ALL=7 }; +typedef struct { hq_color fg, bg; uint16_t attrs, mask; } hq_style; +typedef struct { int x, y, width, height; } hq_rect; +typedef struct hq_buffer hq_buffer; +typedef struct hq_encoder hq_encoder; +typedef struct { uint32_t value; hq_color fg, bg; uint16_t attrs; } hq_cell; +typedef struct { + const char *data; + size_t length, changed_cells, dirty_rows; +} hq_encoded; + +hq_color hq_rgb(int r, int g, int b); +hq_color hq_hex(const char *s); +hq_color hq_indexed(int index); +hq_color hq_from256(int index); +int hq_to256(hq_color c); +int hq_to16(hq_color c); +hq_color hq_mix(hq_color a, hq_color b, double t); +hq_color hq_grayscale(hq_color c); +double hq_luminance(hq_color c); +double hq_contrast(hq_color a, hq_color b); +hq_color hq_gradient(const hq_color *stops, size_t count, double t); +int hq_char_width(uint32_t cp); +size_t hq_text_width_n(const char *text, size_t length); +size_t hq_text_width(const char *text); + +/* create/resize reject negative or excessive dimensions. resize is transactional + * on allocation failure. Zero dimensions are valid. Each dimension <= 32768, + * total cells <= 16,777,216. Destroy accepts NULL. */ +hq_buffer *hq_buffer_create(int width, int height); +void hq_buffer_destroy(hq_buffer *buffer); +int hq_buffer_resize(hq_buffer *buffer, int width, int height); +int hq_buffer_width(const hq_buffer *buffer); +int hq_buffer_height(const hq_buffer *buffer); +size_t hq_buffer_allocations(const hq_buffer *buffer); +void hq_buffer_clear(hq_buffer *buffer, hq_color bg, hq_color fg); +int hq_buffer_cell(const hq_buffer *buffer, int x, int y, hq_cell *out); +const char *hq_buffer_cell_text(const hq_buffer *buffer, int x, int y, char scratch[5]); +int hq_buffer_set(hq_buffer *buffer, int x, int y, uint32_t cp, hq_style style); +size_t hq_buffer_write_n(hq_buffer *buffer, int x, int y, const char *text, + size_t length, hq_style style, size_t max_columns); +size_t hq_buffer_write(hq_buffer *buffer, int x, int y, const char *text, + hq_style style, size_t max_columns); +void hq_buffer_fill(hq_buffer *buffer, hq_rect rect, uint32_t cp, hq_style style); +void hq_buffer_style(hq_buffer *buffer, hq_rect rect, hq_style style); +int hq_buffer_copy(hq_buffer *destination, const hq_buffer *source); +/* Required byte count excluding NUL; safely truncates output to capacity-1. */ +size_t hq_buffer_row(const hq_buffer *buffer, int row, char *out, size_t capacity); + +typedef enum { HQ_CELLS, HQ_PERCENT, HQ_FR, HQ_AUTO, HQ_FILL } hq_size_kind; +typedef struct { + hq_size_kind kind; + double value; + int min, max, intrinsic; /* max=-1 means unbounded */ +} hq_constraint; +/* count <= 4096. Returns 0 on invalid input/allocation failure. */ +int hq_solve(int total, const hq_constraint *items, size_t count, int gap, int *out); +int hq_stack(hq_rect rect, const hq_constraint *items, size_t count, + int horizontal, int gap, hq_rect *out); +/* As above, with a gap per seam (count-1 of them) which may be negative. + * A negative seam is how collapsed borders work: two panels overlap by the + * column their borders share. `gaps` may be NULL when count < 2. */ +int hq_solve_gaps(int total, const hq_constraint *items, size_t count, + const int *gaps, int *out); +int hq_stack_gaps(hq_rect rect, const hq_constraint *items, size_t count, + int horizontal, const int *gaps, hq_rect *out); + +/* Where space the children leave over goes. It only applies when there is + * slack: a container holding any fr or fill child has none, because that child + * has already absorbed it. HQ_JUSTIFY_START is what every layout did before + * this existed. */ +typedef enum { + HQ_JUSTIFY_START = 0, + HQ_JUSTIFY_END, + HQ_JUSTIFY_CENTER, + HQ_JUSTIFY_SPACE_BETWEEN, + HQ_JUSTIFY_SPACE_AROUND, + HQ_JUSTIFY_SPACE_EVENLY +} hq_justify; + +/* The offset before the first child, and the extra added at each of the + * count-1 seams. `seams` may be NULL when count < 2. Returns 0 on bad input. */ +int hq_distribute(int slack, size_t count, hq_justify justify, int *lead, int *seams); +int hq_stack_justified(hq_rect rect, const hq_constraint *items, size_t count, + int horizontal, const int *gaps, hq_justify justify, hq_rect *out); + +/* colors: 0 (no color), 16, 256 or 16777216 (truecolor). + * encode reuses output capacity; allocation failure invalidates terminal state. + * It never writes to stdout. An unchanged frame produces zero bytes. */ +hq_encoder *hq_encoder_create(int colors, int monochrome); +void hq_encoder_destroy(hq_encoder *encoder); +void hq_encoder_invalidate(hq_encoder *encoder); +size_t hq_encoder_allocations(const hq_encoder *encoder); +int hq_encode(hq_encoder *encoder, const hq_buffer *previous, + const hq_buffer *next, int full, hq_encoded *out); + +typedef struct { + const char *name; + int dark; + hq_color background, surface, foreground, muted, primary, secondary, accent; + hq_color success, warning, danger, info, border, border_focused, title; + hq_color selection, selection_text, cursor; + hq_color graph[8], heat[8]; + size_t graph_count, heat_count; +} hq_theme; +size_t hq_theme_count(void); +const hq_theme *hq_theme_at(size_t index); +const hq_theme *hq_theme_named(const char *name); /* NULL if unknown */ +hq_color hq_heat(const hq_theme *theme, double value); +hq_color hq_series(const hq_theme *theme, size_t index); + +/* Surfaces borrow their buffer and theme; both must outlive all their surfaces. + * Surface creation, clipping, and text/box rendering do not allocate per frame + * except when interning a new Unicode cluster in the buffer's bounded pool. */ +typedef struct { hq_buffer *buffer; const hq_theme *theme; hq_rect rect, clip; } hq_surface; +typedef struct { hq_style style; int align, no_ellipsis, max_columns, has_max_columns; } hq_text_options; +enum { HQ_LEFT, HQ_CENTER, HQ_RIGHT }; +enum { HQ_ROUNDED, HQ_SINGLE, HQ_DOUBLE, HQ_THICK, HQ_DASHED, HQ_ASCII, HQ_NO_BORDER }; +/* Edge bits for a border glyph: 1 up, 2 right, 4 down, 8 left. + * + * Collapsing two panel borders is the union of their edges. A panel's + * top-right corner (down + left) landing on its neighbour's top-left + * (down + right) is down + left + right, which is the T that makes the two + * read as one frame. */ +enum { HQ_EDGE_UP = 1, HQ_EDGE_RIGHT = 2, HQ_EDGE_DOWN = 4, HQ_EDGE_LEFT = 8 }; +/* The edges of a border glyph, or -1 when it is not one. */ +int hq_border_bits(uint32_t cp); +/* The glyph in `border` with exactly these edges, or 0 if there is none. */ +uint32_t hq_border_glyph(int border,int bits); +/* Which edges of a box to draw, as a mask. Zero means all four, so a caller + * that has never heard of this gets the box it always got. HQ_SIDES_NONE draws + * no rule and insets nothing, the same as a border of HQ_NO_BORDER. */ +enum { + HQ_SIDE_TOP = 1, + HQ_SIDE_RIGHT = 2, + HQ_SIDE_BOTTOM = 4, + HQ_SIDE_LEFT = 8, + HQ_SIDES_ALL = 15, + HQ_SIDES_NONE = 16 +}; + +typedef struct { + int border, title_align, no_fill; + /* A mask of HQ_SIDE_*, or 0 for all four. */ + int sides; + /* Merge this border with one already in the same cell rather than + * overwriting it. Zero unless the caller asks for collapsed borders. */ + int collapse; + hq_style border_style, title_style, subtitle_style, footer_style; + hq_color background; + int has_background; + const char *title, *subtitle, *footer; +} hq_box_options; +hq_rect hq_intersect(hq_rect a,hq_rect b); +hq_rect hq_inset(hq_rect rect,int top,int right,int bottom,int left); +hq_surface hq_surface_root(hq_buffer *buffer,const hq_theme *theme); +hq_surface hq_surface_region(hq_surface parent,hq_rect absolute); +hq_surface hq_surface_sub(hq_surface parent,hq_rect local); +void hq_surface_set(hq_surface surface,int x,int y,uint32_t cp,hq_style style); +size_t hq_surface_text(hq_surface surface,int x,int y,const char *text,hq_text_options options); +void hq_surface_fill(hq_surface surface,uint32_t cp,hq_style style); +hq_surface hq_surface_box(hq_surface surface,hq_box_options options); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/ports/nim/src/hqtui/engine/c/src/buffer.c b/ports/nim/src/hqtui/engine/c/src/buffer.c new file mode 100644 index 0000000..1ba2a1f --- /dev/null +++ b/ports/nim/src/hqtui/engine/c/src/buffer.c @@ -0,0 +1,188 @@ +#include "internal.h" + +static int dimensions(int w,int h) { + return w>=0 && h>=0 && w<=32768 && h<=32768 && (size_t)w*(size_t)h<=16777216; +} +hq_buffer *hq_buffer_create(int w,int h) { + if(!dimensions(w,h)) return NULL; + hq_buffer *b=calloc(1,sizeof(*b)); + if(!b) return NULL; + b->allocations=1; + if(!hq_buffer_resize(b,w,h)) { hq_buffer_destroy(b); return NULL; } + return b; +} +void hq_buffer_destroy(hq_buffer *b) { + if(!b) return; + free(b->chars); free(b->fg); free(b->bg); free(b->attrs); + free(b->clusters); free(b->cluster_hash); free(b); +} +int hq_buffer_resize(hq_buffer *b,int w,int h) { + if(!b || !dimensions(w,h)) return 0; + if(w==b->width && h==b->height) return 1; + size_t n=(size_t)w*(size_t)h; + if(n>b->capacity) { + uint32_t *chars=malloc(n*sizeof(*chars)), *fg=malloc(n*sizeof(*fg)), *bg=malloc(n*sizeof(*bg)); + uint16_t *attrs=malloc(n*sizeof(*attrs)); + if(!chars || !fg || !bg || !attrs) { free(chars); free(fg); free(bg); free(attrs); return 0; } + free(b->chars); free(b->fg); free(b->bg); free(b->attrs); + b->chars=chars; b->fg=fg; b->bg=bg; b->attrs=attrs; + b->capacity=n; b->allocations+=4; + } + b->width=w; b->height=h; hq_buffer_clear(b,0,0); return 1; +} +int hq_buffer_width(const hq_buffer *b) { return b ? b->width:0; } +int hq_buffer_height(const hq_buffer *b) { return b ? b->height:0; } +size_t hq_buffer_allocations(const hq_buffer *b) { return b ? b->allocations:0; } +void hq_buffer_clear(hq_buffer *b,hq_color bg,hq_color fg) { + if(!b) return; + size_t n=(size_t)b->width*(size_t)b->height; + for(size_t i=0;ichars[i]=32; b->fg[i]=fg; b->bg[i]=bg; b->attrs[i]=0; } +} +static int bounds(const hq_buffer *b,int x,int y) { return b && x>=0 && y>=0 && xwidth && yheight; } +int hq_buffer_cell(const hq_buffer *b,int x,int y,hq_cell *out) { + if(!out || !bounds(b,x,y)) return 0; + size_t i=(size_t)y*(size_t)b->width+(size_t)x; + *out=(hq_cell){b->chars[i],b->fg[i],b->bg[i],b->attrs[i]}; return 1; +} +int hq_cell_width(const hq_buffer *b,uint32_t cp) { + if(cp==HQ_CONT) return 0; + if(cp>=HQ_CLUSTER) return cp-HQ_CLUSTERcluster_count ? b->clusters[cp-HQ_CLUSTER].width:1; + return hq_char_width(cp); +} +const char *hq_cell_text(const hq_buffer *b,uint32_t cp,char out[5]) { + if(cp==HQ_CONT || !cp) return " "; + if(cp>=HQ_CLUSTER) return cp-HQ_CLUSTERcluster_count ? b->clusters[cp-HQ_CLUSTER].text:" "; + hq_utf8(cp,out); return out; +} +const char *hq_buffer_cell_text(const hq_buffer *b,int x,int y,char out[5]) { + if(!out || !bounds(b,x,y)) return ""; + return hq_cell_text(b,b->chars[(size_t)y*(size_t)b->width+(size_t)x],out); +} +static void style_at(hq_buffer *b,size_t i,hq_style s) { + if(s.mask&HQ_STYLE_FG) b->fg[i]=s.fg; + if(s.mask&HQ_STYLE_BG) b->bg[i]=s.bg; + if(s.mask&HQ_STYLE_ATTRS) b->attrs[i]=s.attrs&127u; +} +static int set(hq_buffer *b,int x,int y,uint32_t cp,hq_style s) { + if(!bounds(b,x,y)) return 0; + int w=cp>=32 && cp<127 ? 1:hq_cell_width(b,cp); + size_t i=(size_t)y*(size_t)b->width+(size_t)x; + if(b->chars[i]==HQ_CONT && x>0) b->chars[i-1]=32; + b->chars[i]=cp; style_at(b,i,s); + if(w==2) { + if(x+1width) { b->chars[i+1]=HQ_CONT; style_at(b,i+1,s); } + else { b->chars[i]=32; return 1; } + } + return hq_max(w,1); +} +int hq_buffer_set(hq_buffer *b,int x,int y,uint32_t cp,hq_style s) { + if(hq_unsafe(cp) || cp==HQ_CONT) cp=32; + else if(cp>0x10ffff || (cp>=0xd800 && cp<=0xdfff)) cp=0xfffd; + return set(b,x,y,cp,s); +} +static uint32_t hash_text(const char *s,size_t n) { + uint32_t h=2166136261u; + for(size_t i=0;icp,single); + if(g->bytes==n && memcmp(single,g->start,n)==0) return g->cp; + /* The decoder replaces invalid UTF-8. Never intern its original bytes: + * a raw C1 byte could otherwise become a terminal control on output. */ + if(g->cp==0xfffd && (g->bytes<3 || memcmp(g->start,"\xef\xbf\xbd",3)!=0)) return 0xfffd; + if(g->bytes>=sizeof(b->clusters[0].text)) return g->cp; + if(!b->cluster_hash) { + b->cluster_hash=calloc(HQ_MAX_CLUSTERS*2,sizeof(uint32_t)); + if(!b->cluster_hash) return g->cp; + ++b->allocations; + } + size_t slot=hash_text(g->start,g->bytes)&(HQ_MAX_CLUSTERS*2-1); + while(b->cluster_hash[slot]) { + uint32_t id=b->cluster_hash[slot]-1; + if(strlen(b->clusters[id].text)==g->bytes && !memcmp(b->clusters[id].text,g->start,g->bytes)) return HQ_CLUSTER+id; + slot=(slot+1)&(HQ_MAX_CLUSTERS*2-1); + } + if(b->cluster_count>=HQ_MAX_CLUSTERS) return g->cp; + if(b->cluster_count==b->cluster_capacity) { + size_t capacity=b->cluster_capacity ? b->cluster_capacity*2:64; + hq_cluster *p=realloc(b->clusters,capacity*sizeof(*p)); + if(!p) return g->cp; + b->clusters=p; b->cluster_capacity=capacity; ++b->allocations; + } + size_t id=b->cluster_count++; + memcpy(b->clusters[id].text,g->start,g->bytes); b->clusters[id].text[g->bytes]=0; + b->clusters[id].width=(uint8_t)g->width; b->cluster_hash[slot]=(uint32_t)id+1; + return HQ_CLUSTER+(uint32_t)id; +} +size_t hq_buffer_write_n(hq_buffer *b,int x,int y,const char *text,size_t n,hq_style s,size_t max) { + if(!b || !text || y<0 || y>=b->height) return 0; + size_t off=0,used=0; int64_t cx=x; hq_grapheme g; + while(hq_next_grapheme(text,n,&off,&g)) { + if((size_t)g.width>max-used || cx+g.width>b->width) break; + if(cx>=0) set(b,(int)cx,y,intern(b,&g),s); + cx+=g.width; used+=(size_t)g.width; + } + return used; +} +size_t hq_buffer_write(hq_buffer *b,int x,int y,const char *s,hq_style style,size_t max) { + return s ? hq_buffer_write_n(b,x,y,s,strlen(s),style,max):0; +} +static hq_rect clip(const hq_buffer *b,hq_rect r) { + int64_t right=(int64_t)r.x+hq_max(r.width,0),bottom=(int64_t)r.y+hq_max(r.height,0); + int x=hq_min(hq_max(r.x,0),b->width),y=hq_min(hq_max(r.y,0),b->height); + int endx=(int)hq_clamp((double)right,0,b->width),endy=(int)hq_clamp((double)bottom,0,b->height); + return (hq_rect){x,y,hq_max(0,endx-x),hq_max(0,endy-y)}; +} +void hq_buffer_fill(hq_buffer *b,hq_rect r,uint32_t cp,hq_style s) { + if(!b) return; + r=clip(b,r); + for(int y=r.y;ywidth+(size_t)x,s); +} +int hq_buffer_copy(hq_buffer *dst,const hq_buffer *src) { + if(!dst || !src) return 0; + if(dst==src) return 1; + /* Retain pools between frames so copying ASCII frames never allocates. */ + if(src->cluster_count>dst->cluster_capacity) { + hq_cluster *p=realloc(dst->clusters,src->cluster_capacity*sizeof(*p)); + if(!p) return 0; + dst->clusters=p; dst->cluster_capacity=src->cluster_capacity; ++dst->allocations; + } + if(src->cluster_hash && !dst->cluster_hash) { + dst->cluster_hash=calloc(HQ_MAX_CLUSTERS*2,sizeof(uint32_t)); + if(!dst->cluster_hash) return 0; + ++dst->allocations; + } + if(!hq_buffer_resize(dst,src->width,src->height)) return 0; + size_t n=(size_t)src->width*(size_t)src->height; + if(n) { + memcpy(dst->chars,src->chars,n*sizeof(uint32_t)); memcpy(dst->fg,src->fg,n*sizeof(uint32_t)); + memcpy(dst->bg,src->bg,n*sizeof(uint32_t)); memcpy(dst->attrs,src->attrs,n*sizeof(uint16_t)); + } + dst->cluster_count=src->cluster_count; + if(src->cluster_count) memcpy(dst->clusters,src->clusters,src->cluster_count*sizeof(hq_cluster)); + if(src->cluster_hash) memcpy(dst->cluster_hash,src->cluster_hash,HQ_MAX_CLUSTERS*2*sizeof(uint32_t)); + else if(dst->cluster_hash) memset(dst->cluster_hash,0,HQ_MAX_CLUSTERS*2*sizeof(uint32_t)); + return 1; +} +size_t hq_buffer_row(const hq_buffer *b,int y,char *out,size_t cap) { + size_t used=0; + if(out && cap) out[0]=0; + if(!b || y<0 || y>=b->height) return 0; + for(int x=0;xwidth;++x) { + uint32_t cp=b->chars[(size_t)y*(size_t)b->width+(size_t)x]; + if(cp==HQ_CONT) continue; + char scratch[5]; const char *s=hq_cell_text(b,cp,scratch); size_t n=strlen(s); + if(out && used + +hq_color hq_rgb(int r,int g,int b) { + return 0x1000000u | ((uint32_t)r & 255u)<<16 | ((uint32_t)g & 255u)<<8 | ((uint32_t)b & 255u); +} +hq_color hq_indexed(int i) { return 0x2000000u | ((uint32_t)i & 255u); } +static int digit(char c) { + if(c>='0' && c<='9') return c-'0'; + if(c>='a' && c<='f') return c-'a'+10; + if(c>='A' && c<='F') return c-'A'+10; + return -1; +} +hq_color hq_hex(const char *s) { + if(!s) return hq_rgb(0,0,0); + while(isspace((unsigned char)*s)) ++s; + if(*s=='#') ++s; + size_t n=strlen(s); + while(n && isspace((unsigned char)s[n-1])) --n; + uint32_t v=0; + for(size_t i=0;i>shift)&255u); } +static int nearest(int v) { + int best=0, dist=INT_MAX; + for(int i=0;i<6;++i) if(abs(cube[i]-v)=232) { int v=8+(i-232)*10; return hq_rgb(v,v,v); } + i-=16; return hq_rgb(cube[i/36%6],cube[i/6%6],cube[i%6]); +} +int hq_to256(hq_color c) { + if(c&0x2000000u) return (int)(c&255u); + int r=channel(c,16),g=channel(c,8),b=channel(c,0); + if(abs(r-g)<8 && abs(g-b)<8) { + if(r<8) return 16; + if(r>248) return 231; + return 232+(int)floor((r-8)/247.0*24+0.5); + } + return 16+36*nearest(r)+6*nearest(g)+nearest(b); +} +int hq_to16(hq_color c) { + if(c&0x2000000u) { + int i=(int)(c&255u); if(i<16) return i; c=hq_from256(i); + } + int best=7,dist=INT_MAX; + for(int i=0;i<16;++i) { + int r=channel(c,16)-base[i][0],g=channel(c,8)-base[i][1],b=channel(c,0)-base[i][2]; + int d=r*r+g*g+b*b; + if(d=n-1) i=n-2; + return hq_mix(s[i],s[i+1],p-(double)i); +} diff --git a/ports/nim/src/hqtui/engine/c/src/diff.c b/ports/nim/src/hqtui/engine/c/src/diff.c new file mode 100644 index 0000000..aae045d --- /dev/null +++ b/ports/nim/src/hqtui/engine/c/src/diff.c @@ -0,0 +1,126 @@ +#include "internal.h" +#include +#include +struct hq_encoder { + char *data; + size_t length,capacity,allocations; + int colors,monochrome,x,y,known,failed,force; + hq_color fg,bg; + uint16_t attrs; +}; +static void append(hq_encoder *e,const char *s,size_t n) { + if(e->failed) return; + if(n>SIZE_MAX-e->length-1) { e->failed=1; return; } + size_t required=e->length+n+1; + if(required>e->capacity) { + size_t cap=e->capacity ? e->capacity:4096; + while(capSIZE_MAX/2) { cap=required; break; } cap*=2; } + char *p=realloc(e->data,cap); + if(!p) { e->failed=1; return; } + e->data=p; e->capacity=cap; ++e->allocations; + } + memcpy(e->data+e->length,s,n); e->length+=n; e->data[e->length]=0; +} +static void text(hq_encoder *e,const char *s) { append(e,s,strlen(s)); } +static void fmt(hq_encoder *e,const char *format,...) { + char s[96]; va_list args; va_start(args,format); + int n=vsnprintf(s,sizeof(s),format,args); va_end(args); + if(n<0 || (size_t)n>=sizeof(s)) { e->failed=1; return; } + append(e,s,(size_t)n); +} +hq_encoder *hq_encoder_create(int colors,int mono) { + if(colors!=0 && colors!=16 && colors!=256 && colors!=16777216) return NULL; + hq_encoder *e=calloc(1,sizeof(*e)); + if(e) { e->colors=colors; e->monochrome=!!mono; e->x=e->y=-1; e->allocations=1; } + return e; +} +void hq_encoder_destroy(hq_encoder *e) { if(e) { free(e->data); free(e); } } +void hq_encoder_invalidate(hq_encoder *e) { if(e) e->force=1; } +size_t hq_encoder_allocations(const hq_encoder *e) { return e ? e->allocations:0; } +static void color(hq_encoder *e,hq_color c,int background) { + if(!e->colors) return; + if(!c) { text(e,background ? "\033[49m":"\033[39m"); return; } + if(e->monochrome) c=hq_grayscale(c); + int code=background ? 48:38; + if(e->colors==16777216) fmt(e,"\033[%d;2;%u;%u;%um",code,(c>>16)&255u,(c>>8)&255u,c&255u); + else if(e->colors==256) fmt(e,"\033[%d;5;%dm",code,hq_to256(c)); + else { int idx=hq_to16(c); fmt(e,"\033[%dm",(background ? 40:30)+(idx>=8 ? 60:0)+idx%8); } +} +static void style(hq_encoder *e,hq_color fg,hq_color bg,uint16_t attrs) { + if(e->fg==fg && e->bg==bg && e->attrs==attrs) return; + if(e->attrs & (uint16_t)~attrs) { text(e,"\033[0m"); e->attrs=0; e->fg=e->bg=0; } + uint16_t added=attrs & (uint16_t)~e->attrs; + if(added) { + static const int codes[7]={1,2,3,4,5,7,9}; int first=1; + text(e,"\033["); + for(int i=0;i<7;++i) if(added&(1u<attrs=attrs; + } + if(e->fg!=fg) { color(e,fg,0); e->fg=fg; } + if(e->bg!=bg) { color(e,bg,1); e->bg=bg; } +} +static void move(hq_encoder *e,int x,int y) { + if(e->known && e->y==y) { + if(e->x==x) return; + if(x>e->x && x-e->x<=3) fmt(e,"\033[%dC",x-e->x); + else if(!x) text(e,"\r"); + else fmt(e,"\033[%dG",x+1); + } else fmt(e,"\033[%d;%dH",y+1,x+1); + e->x=x; e->y=y; e->known=1; +} +static int differs(const hq_buffer *a,const hq_buffer *b,size_t i,int same) { + if(!same) return 1; + if(a->fg[i]!=b->fg[i] || a->bg[i]!=b->bg[i] || a->attrs[i]!=b->attrs[i]) return 1; + uint32_t x=a->chars[i],y=b->chars[i]; + if(x>=HQ_CLUSTER && x!=HQ_CONT && y>=HQ_CLUSTER && y!=HQ_CONT) { + char t[5],u[5]; return strcmp(hq_cell_text(a,x,t),hq_cell_text(b,y,u))!=0; + } + return x!=y; +} +int hq_encode(hq_encoder *e,const hq_buffer *prev,const hq_buffer *next,int full,hq_encoded *out) { + if(!e || !next || !out) return 0; + *out=(hq_encoded){"",0,0,0}; e->length=0; e->failed=0; + if(e->data) e->data[0]=0; + int same=prev && prev->width==next->width && prev->height==next->height; + int repaint=full || !same || e->force; + if(repaint) { e->known=0; e->x=e->y=-1; e->fg=e->bg=0; e->attrs=0; e->force=0; text(e,"\033[0m"); } + int w=next->width,h=next->height; + for(int y=0;y0 && !repaint && same && !prev->cluster_count && !next->cluster_count && + !memcmp(prev->chars+row,next->chars+row,(size_t)w*sizeof(uint32_t)) && + !memcmp(prev->fg+row,next->fg+row,(size_t)w*sizeof(uint32_t)) && + !memcmp(prev->bg+row,next->bg+row,(size_t)w*sizeof(uint32_t)) && + !memcmp(prev->attrs+row,next->attrs+row,(size_t)w*sizeof(uint16_t))) continue; + while(x0 && next->chars[row+(size_t)start]==HQ_CONT) --start; + int end=start,clean=0; + for(int probe=start;probe5) break; + } + move(e,start,y); + for(int cx=start;cx<=end;++cx) { + size_t i=row+(size_t)cx; uint32_t cp=next->chars[i]; + if(cp==HQ_CONT) continue; + style(e,next->fg[i],next->bg[i],next->attrs[i]); + if(cp>=32 && cp<127) { + char ascii=(char)cp; append(e,&ascii,1); ++e->x; + } else { + char scratch[5]; text(e,hq_cell_text(next,cp,scratch)); + e->x+=hq_max(1,hq_cell_width(next,cp)); + } + if(differs(prev,next,i,same)) ++out->changed_cells; + } + if(e->x>=w) e->known=0; + dirty=1; x=end+1; + } + if(dirty) ++out->dirty_rows; + } + if(e->failed) { hq_encoder_invalidate(e); *out=(hq_encoded){"",0,0,0}; return 0; } + out->data=e->data ? e->data:""; out->length=e->length; return 1; +} diff --git a/ports/nim/src/hqtui/engine/c/src/internal.h b/ports/nim/src/hqtui/engine/c/src/internal.h new file mode 100644 index 0000000..86e28ab --- /dev/null +++ b/ports/nim/src/hqtui/engine/c/src/internal.h @@ -0,0 +1,30 @@ +#ifndef HQ_INTERNAL_H +#define HQ_INTERNAL_H +#include "hqtui.h" +#include +#include +#include +#include +#define HQ_CONT UINT32_MAX +#define HQ_CLUSTER 0x110000u +#define HQ_MAX_CLUSTERS 32768u +typedef struct { char text[69]; uint8_t width; } hq_cluster; +struct hq_buffer { + int width, height; + size_t capacity, allocations; + uint32_t *chars, *fg, *bg; + uint16_t *attrs; + hq_cluster *clusters; + uint32_t *cluster_hash; + size_t cluster_count, cluster_capacity; +}; +typedef struct { const char *start; size_t bytes; uint32_t cp; int width; } hq_grapheme; +int hq_next_grapheme(const char *s, size_t n, size_t *offset, hq_grapheme *g); +size_t hq_utf8(uint32_t cp, char out[5]); +int hq_unsafe(uint32_t cp); +const char *hq_cell_text(const hq_buffer *b, uint32_t cp, char out[5]); +int hq_cell_width(const hq_buffer *b, uint32_t cp); +static inline double hq_clamp(double v, double a, double b) { return fmax(a,fmin(b,v)); } +static inline int hq_min(int a, int b) { return a < b ? a : b; } +static inline int hq_max(int a, int b) { return a > b ? a : b; } +#endif diff --git a/ports/nim/src/hqtui/engine/c/src/layout.c b/ports/nim/src/hqtui/engine/c/src/layout.c new file mode 100644 index 0000000..df8bb00 --- /dev/null +++ b/ports/nim/src/hqtui/engine/c/src/layout.c @@ -0,0 +1,163 @@ +#include "internal.h" +typedef struct { double value,fr,min,max; } resolved; +int hq_solve(int total,const hq_constraint *items,size_t n,int gap,int *out) { + if(gap<0) return 0; + if(n<2) return hq_solve_gaps(total,items,n,NULL,out); + int local[64]; + int *gaps=n-1<=64 ? local:malloc((n-1)*sizeof(*gaps)); + if(!gaps) return 0; + for(size_t i=0;i+14096 || (n && (!items || !out)) || (n>1 && !gaps)) return 0; + if(!n) return 1; + resolved local[64]; + resolved *p=n<=64 ? local:malloc(n*sizeof(*p)); + if(!p) return 0; + int64_t gap_total=0; + for(size_t i=0;i+10 ? a:0),used=0,fr=0; + for(size_t i=0;i0 ? v:1; break; + case HQ_FILL: p[i].fr=1; break; + case HQ_AUTO: p[i].value=hq_max(0,c.intrinsic); break; + default: if(p!=local) free(p); return 0; + } + if(p[i].fr>0) { fr+=p[i].fr; out[i]=-1; } + else { double val=hq_clamp(floor(p[i].value+.5),p[i].min,fmin(p[i].max,available)); + out[i]=(int)hq_clamp(val,0,INT_MAX); used+=out[i]; } + } + double pool=fmax(0,available-used),remaining=fr; + if(fr>0) { + int changed=1; + while(changed) { + changed=0; + for(size_t i=0;i0 ? pool*p[i].fr/remaining:0; + double clamped=hq_clamp(share,p[i].min,p[i].max); + if(clamped!=share) { + out[i]=(int)hq_clamp(floor(clamped+.5),0,INT_MAX); + pool-=out[i]; remaining-=p[i].fr; changed=1; + } + } + } + size_t last=n; + for(size_t i=0;i0 ? pool*p[i].fr/remaining:0); + out[i]=(int)hq_clamp(v,0,INT_MAX); assigned+=out[i]; + } + } + int64_t sum=0; + for(size_t i=0;iavail;++pass) for(size_t i=n;i-->0 && sum>avail;) { + int64_t spare=(int64_t)out[i]-(pass ? 0:(int64_t)p[i].min); + int64_t shrink=spare0) { out[i]-=(int)shrink; sum-=shrink; } + } + if(p!=local) free(p); + return 1; +} +int hq_stack(hq_rect r,const hq_constraint *items,size_t n,int horizontal,int gap,hq_rect *out) { + if(gap<0) return 0; + if(n<2) return hq_stack_gaps(r,items,n,horizontal,NULL,out); + int local[64]; + int *gaps=n-1<=64 ? local:malloc((n-1)*sizeof(*gaps)); + if(!gaps) return 0; + for(size_t i=0;i+11 ? fi*slack/(fn-1.0):0.0; + case HQ_JUSTIFY_SPACE_EVENLY: return (fi+1.0)*slack/(fn+1.0); + case HQ_JUSTIFY_SPACE_AROUND: return (fi+0.5)*slack/fn; + default: return 0.0; + } +} + +/* Cells are whole, and rounding each gap on its own loses one here and gains + * one there. Rounding the cumulative offset and taking differences means the + * parts always add up to exactly the slack. */ +static int hq_at(size_t i,int slack,size_t n,hq_justify j) { + return (int)floor(hq_before(i,(double)slack,n,j)+0.5); +} + +int hq_distribute(int slack,size_t count,hq_justify justify,int *lead,int *seams) { + if(!lead || count>4096 || (count>1 && !seams)) return 0; + for(size_t i=0;i+10 ? d:0; + } + *lead=hq_at(0,slack,count,justify); + return 1; +} + +int hq_stack_justified(hq_rect r,const hq_constraint *items,size_t n,int horizontal,const int *gaps,hq_justify justify,hq_rect *out) { + if(n>4096 || (n && (!out || !items)) || (n>1 && !gaps) || r.width<0 || r.height<0) return 0; + if(!n) return 1; + int local[64]; + int *sizes=n<=64 ? local:malloc(n*sizeof(*sizes)); + if(!sizes) return 0; + int axis=horizontal ? r.width:r.height; + if(!hq_solve_gaps(axis,items,n,gaps,sizes)) { if(sizes!=local) free(sizes); return 0; } + + int64_t used=0; + for(size_t i=0;i0 ? (int)spare:0; + + int seam_local[64]; + int *extra=n-1<=64 ? seam_local:malloc((n?n-1:1)*sizeof(*extra)); + if(!extra) { if(sizes!=local) free(sizes); return 0; } + int lead=0; + if(!hq_distribute(slack,n,justify,&lead,extra)) { + if(sizes!=local) free(sizes); + if(extra!=seam_local) free(extra); + return 0; + } + + int64_t offset=(horizontal ? r.x:r.y)+lead; + for(size_t i=0;iINT_MAX) { + if(sizes!=local) free(sizes); + if(extra!=seam_local) free(extra); + return 0; + } + out[i]=horizontal ? (hq_rect){(int)offset,r.y,sizes[i],r.height} : (hq_rect){r.x,(int)offset,r.width,sizes[i]}; + offset+=(int64_t)sizes[i]+(i+1 +static int integer(int64_t n) { return nINT_MAX ? INT_MAX:(int)n; } +hq_rect hq_intersect(hq_rect a,hq_rect b) { + int x=hq_max(a.x,b.x),y=hq_max(a.y,b.y); + int64_t ax=(int64_t)a.x+hq_max(0,a.width),bx=(int64_t)b.x+hq_max(0,b.width); + int64_t ay=(int64_t)a.y+hq_max(0,a.height),by=(int64_t)b.y+hq_max(0,b.height); + int64_t w=(ax=s.clip.x && x+width<=(int64_t)s.clip.x+s.clip.width && + y>=s.clip.y && y<(int64_t)s.clip.y+s.clip.height; +} +void hq_surface_set(hq_surface s,int x,int y,uint32_t cp,hq_style style) { + int64_t ax=(int64_t)s.rect.x+x,ay=(int64_t)s.rect.y+y; + int width=hq_max(1,hq_char_width(cp)); + if(visible(s,ax,ay,width) && ax>=INT_MIN && ax<=INT_MAX && ay>=INT_MIN && ay<=INT_MAX) + hq_buffer_set(s.buffer,(int)ax,(int)ay,cp,style); +} +void hq_surface_fill(hq_surface s,uint32_t cp,hq_style style) { + hq_rect r=hq_intersect(s.clip,(hq_rect){0,0,hq_buffer_width(s.buffer),hq_buffer_height(s.buffer)}); + int w=hq_max(1,hq_char_width(cp)); + for(int y=r.y;y=(int64_t)s.clip.y+s.clip.height) return 0; + int room=hq_min(32768,hq_max(0,integer((int64_t)s.rect.width-x))); + size_t limit=(size_t)(o.has_max_columns ? hq_min(room,hq_max(0,o.max_columns)):room); + if(!limit) return 0; + size_t bytes=strlen(text),width=hq_text_width_n(text,bytes); + int ellipsis=!o.no_ellipsis && width>limit; + size_t content=width,off=0; + hq_grapheme g; + if(ellipsis) { + content=0; + while(hq_next_grapheme(text,bytes,&off,&g)) { + if(content+(size_t)g.width>limit-1) break; + content+=(size_t)g.width; + } + ++content; + } + size_t padding=contentcap) break; + if(visible(s,cx,ay,g.width)) hq_buffer_write_n(s.buffer,(int)cx,(int)ay,g.start,g.bytes,o.style,(size_t)g.width); + cx+=g.width; used+=(size_t)g.width; + } + if(ellipsis) { + if(visible(s,cx,ay,1)) hq_buffer_set(s.buffer,(int)cx,(int)ay,0x2026,o.style); + ++used; ++cx; + } + if(o.align!=HQ_LEFT) for(size_t i=left;i=6) return 0; + for(int i=0;i<11;++i) if(hq_part_bits[i]==bits) return hq_borders[border][i]; + return 0; +} + +/* Writes a border glyph, merging it with whatever border is already there. + * + * Only border glyphs merge. Anything else in the cell is overwritten, which + * keeps a panel drawn over a chart looking like a panel rather than growing + * junctions out of the data. */ +static void hq_put_border(hq_surface s,int border,int collapse,int x,int y,uint32_t cp,hq_style st) { + if(collapse) { + hq_cell cell; + if(hq_buffer_cell(s.buffer,s.rect.x+x,s.rect.y+y,&cell)) { + int before=hq_border_bits(cell.value),after=hq_border_bits(cp); + if(before>=0 && after>=0 && before!=after) { + uint32_t merged=hq_border_glyph(border,before|after); + if(merged) cp=merged; + } + } + } + hq_surface_set(s,x,y,cp,st); +} + +/* The glyph for a cell where two edges meet, given which of them are drawn. A + * single edge has no glyph of its own, so the plain rule stands in: that cell + * is part of a run, not a corner. */ +static uint32_t hq_side_glyph(int border,int bits) { + if(!bits) return 0; + uint32_t g=hq_border_glyph(border,bits); + if(g) return g; + return bits&(HQ_EDGE_LEFT|HQ_EDGE_RIGHT) ? hq_borders[border][4]:hq_borders[border][5]; +} + +hq_surface hq_surface_box(hq_surface s,hq_box_options o) { + const hq_theme *theme=s.theme ? s.theme:hq_theme_at(0); + /* Zero means all four, so a caller that predates this gets what it always + * got. HQ_SIDES_NONE is an explicit "no rule at all". */ + int sides=o.sides==0 ? HQ_SIDES_ALL:(o.sides&HQ_SIDES_NONE ? 0:o.sides&HQ_SIDES_ALL); + int s_top=(sides&HQ_SIDE_TOP)!=0, s_right=(sides&HQ_SIDE_RIGHT)!=0; + int s_bottom=(sides&HQ_SIDE_BOTTOM)!=0, s_left=(sides&HQ_SIDE_LEFT)!=0; + int draws_border=o.border!=HQ_NO_BORDER && sides && s.rect.width>=2 && s.rect.height>=1; + + if(o.has_background && !o.no_fill) { + hq_style bg={0,o.background,0,HQ_STYLE_BG}; + if(draws_border && o.collapse) { + /* The border ring is left to the border drawing below, which merges + * with whatever a neighbour already put in the shared column. + * Filling it here first would erase that border, and the merge + * would then find a blank cell and simply overwrite it -- which is + * why a panel with a background collapsed its seams into a corner + * rather than a junction. Every cell skipped here is painted by the + * border, in the same bg. */ + hq_surface_fill(hq_surface_region(s,hq_inset(s.rect,s_top,s_right, + (s.rect.height>1 && s_bottom)?1:0,s_left)),32,bg); + } else { + hq_surface_fill(s,32,bg); + } + } + + if(o.border==HQ_NO_BORDER || !sides) return s; + /* The interior follows the sides actually drawn, so a top-only box costs + * one row rather than two. */ + hq_surface inner=hq_surface_region(s,hq_inset(s.rect,s_top,s_right,s_bottom,s_left)); + if(s.rect.width<2 || s.rect.height<1) return inner; + int border=o.border>=0 && o.border<6 ? o.border:0; + const uint32_t *c=hq_borders[border]; + int w=s.rect.width,h=s.rect.height; + hq_style bs=default_style(o.border_style,theme->border,o); + /* With collapsing on a border glyph landing on another becomes the union + * of the two; without it this is the plain write it always was, so a + * screen that never asks for collapsing renders byte for byte as before. */ + /* A corner belongs to the two sides that meet there, so it exists only when + * both are drawn; where one is, the rule runs straight through the cell the + * corner would have occupied. */ + #define HQ_CORNER(a,abit,b,bbit) hq_side_glyph(border,((a)?(abit):0)|((b)?(bbit):0)) + uint32_t tl=HQ_CORNER(s_top,HQ_EDGE_RIGHT,s_left,HQ_EDGE_DOWN); + uint32_t tr=HQ_CORNER(s_top,HQ_EDGE_LEFT,s_right,HQ_EDGE_DOWN); + + if(s_top) for(int x=1;x1) { + uint32_t bl=HQ_CORNER(s_bottom,HQ_EDGE_RIGHT,s_left,HQ_EDGE_UP); + uint32_t br=HQ_CORNER(s_bottom,HQ_EDGE_LEFT,s_right,HQ_EDGE_UP); + if(s_bottom) for(int x=1;x=(size_t)w) sw=0; } + if(o.title && s_top) { + label(title,o.title); + int limit=sw ? w-1-(int)sw:w-2,room=hq_max(0,limit-2); + size_t tw=hq_text_width(title); + if(tw>(size_t)room) { + /* Measure the actual truncated width (wide glyphs can leave a gap). */ + size_t off=0,used=0; hq_grapheme g; + while(hq_next_grapheme(title,strlen(title),&off,&g)) { + if(room==0 || used+(size_t)g.width>(size_t)room-1) break; + used+=(size_t)g.width; + } + tw=room>0 ? used+1:0; + } + int tx=o.title_align==HQ_RIGHT ? hq_max(2,limit-(int)tw):o.title_align==HQ_CENTER ? hq_max(2,hq_min(limit-(int)tw,(w-(int)tw)/2)):2; + hq_text_options t={0}; t.style=default_style(o.title_style,theme->title,o); + t.style.attrs=HQ_BOLD; t.style.mask|=HQ_STYLE_ATTRS; t.has_max_columns=1; t.max_columns=room; + hq_surface_text(s,tx,0,title,t); + } + if(sw) { + hq_text_options t={0}; t.style=default_style(o.subtitle_style,theme->muted,o); + hq_surface_text(s,w-2-(int)sw,0,sub,t); + } + if(o.footer && s_bottom && h>2) { + label(foot,o.footer); + if(hq_text_width(foot)+4<(size_t)w) { + hq_text_options t={0}; t.style=default_style(o.footer_style,theme->muted,o); + hq_surface_text(s,2,h-1,foot,t); + } + } + return inner; +} diff --git a/ports/nim/src/hqtui/engine/c/src/theme.c b/ports/nim/src/hqtui/engine/c/src/theme.c new file mode 100644 index 0000000..f4d14ed --- /dev/null +++ b/ports/nim/src/hqtui/engine/c/src/theme.c @@ -0,0 +1,32 @@ +/* Palette data copied from the TypeScript theme conformance fixture. + * This is theme metadata, not prerendered UI. */ +#include "internal.h" +static const hq_theme themes[]={ + {"dark",1,17106698u,17436180u,29806811u,22702973u,22587135u,29201401u,22467805u,23068551u,33544031u,33516395u,22467805u,19148864u,22467805u,25092863u,18692690u,31912703u,22467805u, + {22587135u,23068551u,33520070u,33544031u,22467805u,33531479u}, {23068551u,27852640u,33544031u,33531479u,33516395u},6,5}, + {"dracula",1,18422305u,18948652u,33093874u,23229092u,29201401u,33520070u,25946621u,22084219u,32635532u,33510741u,25946621u,21251930u,29201401u,33520070u,21251930u,33093874u,22467805u, + {29201401u,22084219u,33520070u,32635532u,25946621u,33536108u}, {22084219u,32635532u,33536108u,33510741u},6,4}, + {"nord",1,19805248u,20134730u,31844848u,24873121u,25739472u,28610221u,26197179u,27508364u,32230283u,29319530u,25272769u,21187678u,25739472u,26197179u,21187678u,32305140u,22467805u, + {25739472u,27508364u,28610221u,32230283u,25272769u,30443376u}, {27508364u,32230283u,30443376u,29319530u},6,4}, + {"tokyo-night",1,18488102u,18817845u,29412085u,22437769u,24814327u,29072119u,25022463u,27184746u,31502184u,32994958u,25022463u,19871049u,24814327u,25022463u,19412055u,29412085u,22467805u, + {24814327u,27184746u,29072119u,31502184u,25022463u,33529444u}, {27184746u,31502184u,33529444u,32994958u},6,4}, + {"gruvbox",1,18685985u,19408936u,32234418u,26379124u,25404824u,30639771u,26132604u,28883750u,33209647u,33245492u,25404824u,20723766u,33209647u,33209647u,20723766u,33288647u,22467805u, + {25404824u,28883750u,30639771u,33209647u,26132604u,33456153u}, {28883750u,33209647u,33456153u,33245492u},6,4}, + {"matrix",1,16777216u,16910850u,27131805u,19884847u,16842561u,16828499u,24969084u,16842561u,30736128u,33504048u,16836016u,17973279u,16842561u,16842561u,17641236u,30015433u,22467805u, + {16842561u,16828499u,24969084u,16836016u,30736128u,19897924u}, {17791534u,16828499u,16842561u,30736128u},6,4}, + {"monochrome",1,16777216u,17500939u,30462160u,24014446u,33554431u,29409472u,31514848u,32041192u,28883128u,33554431u,27304096u,20593210u,30462160u,33554431u,19935280u,33554431u,22467805u, + {33554431u,29935816u,26251408u,23619688u,28356784u,25198720u}, {22567000u,26251408u,29935816u,33554431u},6,4}, + {"high-contrast",1,16777216u,16777216u,33554431u,29409472u,16842751u,33489151u,33554176u,16842496u,33554176u,33488896u,16842751u,33554431u,33554176u,33554431u,33554431u,16777216u,22467805u, + {16842751u,16842496u,33489151u,33554176u,33554431u,33521664u}, {16842496u,33554176u,33521664u,33488896u},6,4}, + {"light",0,33291517u,33554431u,18621744u,23820940u,17523408u,24918765u,17724560u,17992255u,27814400u,29763624u,17724560u,30661604u,17523408u,17513848u,30861051u,17507653u,17523408u, + {17523408u,17992255u,27473034u,27814400u,17724560u,29507852u}, {17992255u,24814336u,27814400u,29507852u,29763624u},6,5} +}; +size_t hq_theme_count(void) { return sizeof(themes)/sizeof(themes[0]); } +const hq_theme *hq_theme_at(size_t i) { return iheat,t->heat_count,value):0; } +hq_color hq_series(const hq_theme *t,size_t i) { return t && t->graph_count ? t->graph[i%t->graph_count]:0; } diff --git a/ports/nim/src/hqtui/engine/c/src/unicode.c b/ports/nim/src/hqtui/engine/c/src/unicode.c new file mode 100644 index 0000000..161ce6b --- /dev/null +++ b/ports/nim/src/hqtui/engine/c/src/unicode.c @@ -0,0 +1,132 @@ +#include "internal.h" +typedef struct { uint32_t lo,hi; } range; +static const range zero[]={ + {0x0300,0x036f},{0x0483,0x0489},{0x0591,0x05bd},{0x0610,0x061a}, + {0x064b,0x065f},{0x0670,0x0670},{0x06d6,0x06dc},{0x0730,0x074a}, + {0x07a6,0x07b0},{0x0816,0x0819},{0x08e3,0x0903},{0x093a,0x093c}, + {0x0951,0x0957},{0x0e31,0x0e31},{0x0e34,0x0e3a},{0x0eb1,0x0eb1}, + {0x1ab0,0x1aff},{0x1dc0,0x1dff},{0x200b,0x200f},{0x2028,0x202e}, + {0x2060,0x2064},{0x2066,0x2069},{0x20d0,0x20f0},{0xfe00,0xfe0f}, + {0xfe20,0xfe2f},{0xfeff,0xfeff},{0xe0100,0xe01ef} +}; +static const range wide[]={ + {0x1100,0x115f},{0x2e80,0x303e},{0x3041,0x33ff},{0x3400,0x4dbf}, + {0x4e00,0x9fff},{0xa000,0xa4cf},{0xa960,0xa97f},{0xac00,0xd7a3}, + {0xf900,0xfaff},{0xfe10,0xfe19},{0xfe30,0xfe6f},{0xff00,0xff60}, + {0xffe0,0xffe6},{0x1f004,0x1f004},{0x1f0cf,0x1f0cf},{0x1f18e,0x1f18e}, + {0x1f191,0x1f19a},{0x1f200,0x1f320},{0x1f32d,0x1f335},{0x1f337,0x1f37c}, + {0x1f37e,0x1f393},{0x1f3a0,0x1f3ca},{0x1f3cf,0x1f3d3},{0x1f3e0,0x1f3f0}, + {0x1f3f4,0x1f3f4},{0x1f3f8,0x1f43e},{0x1f440,0x1f440},{0x1f442,0x1f4fc}, + {0x1f4ff,0x1f53d},{0x1f54b,0x1f54e},{0x1f550,0x1f567},{0x1f57a,0x1f57a}, + {0x1f595,0x1f596},{0x1f5a4,0x1f5a4},{0x1f5fb,0x1f64f},{0x1f680,0x1f6c5}, + {0x1f6cc,0x1f6cc},{0x1f6d0,0x1f6d2},{0x1f6eb,0x1f6ec},{0x1f910,0x1f9ff}, + {0x20000,0x2fffd},{0x30000,0x3fffd} +}; +static const range pictographic[]={ + {0xa9,0xa9},{0xae,0xae},{0x203c,0x203c},{0x2049,0x2049},{0x2122,0x2122}, + {0x2139,0x2139},{0x2194,0x21aa},{0x231a,0x23fa},{0x24c2,0x24c2}, + {0x25aa,0x25fe},{0x2600,0x27bf},{0x2934,0x2935},{0x2b00,0x2bff}, + {0x3030,0x3030},{0x303d,0x303d},{0x3297,0x3299},{0x1f000,0x1faff},{0x1fc00,0x1fffd} +}; +static int in_ranges(uint32_t cp,const range *r,size_t n) { + size_t lo=0,hi=n; + while(lor[mid].hi) lo=mid+1; else return 1; + } + return 0; +} +#define IN(cp,r) in_ranges(cp,r,sizeof(r)/sizeof(r[0])) +int hq_unsafe(uint32_t cp) { + return cp<32 || (cp>=127 && cp<=159) || (cp>=0x202a && cp<=0x202e) || (cp>=0x2066 && cp<=0x2069); +} +int hq_char_width(uint32_t cp) { + if(cp<32 || (cp>=127 && cp<160)) return 0; + if(cp<0x300) return 1; + if(IN(cp,zero)) return 0; + return IN(cp,wide) ? 2:1; +} +/* Invalid encodings consume one byte, preventing reads past truncated input. */ +static uint32_t decode(const char *s,size_t n,size_t *used) { + *used=1; + unsigned char c=(unsigned char)s[0]; + if(c<128) return c; + size_t len=c>=0xc2 && c<=0xdf ? 2 : c>=0xe0 && c<=0xef ? 3 : c>=0xf0 && c<=0xf4 ? 4:0; + if(!len || n=0xd800 && cp<=0xdfff) || cp>0x10ffff) return 0xfffd; + *used=len; return cp; +} +size_t hq_utf8(uint32_t cp,char out[5]) { + if(cp>0x10ffff || (cp>=0xd800 && cp<=0xdfff)) cp=0xfffd; + size_t n; + if(cp<128) { out[0]=(char)cp; n=1; } + else if(cp<2048) { out[0]=(char)(0xc0|(cp>>6)); out[1]=(char)(0x80|(cp&63)); n=2; } + else if(cp<65536) { out[0]=(char)(0xe0|(cp>>12)); out[1]=(char)(0x80|(cp>>6&63)); out[2]=(char)(0x80|(cp&63)); n=3; } + else { out[0]=(char)(0xf0|(cp>>18)); out[1]=(char)(0x80|(cp>>12&63)); out[2]=(char)(0x80|(cp>>6&63)); out[3]=(char)(0x80|(cp&63)); n=4; } + out[n]=0; return n; +} +static int is_regional(uint32_t cp) { return cp>=0x1f1e6 && cp<=0x1f1ff; } +static int is_tone(uint32_t cp) { return cp>=0x1f3fb && cp<=0x1f3ff; } +static int is_tag(uint32_t cp) { return cp>=0xe0020 && cp<=0xe007f; } +static int is_keycap_base(uint32_t cp) { return (cp>='0' && cp<='9') || cp=='#' || cp=='*'; } +/* Columns a multi-codepoint cluster occupies. Emoji presentation makes it two + even when its base alone is one: a flag (two regional indicators), a keycap, + and a text-default pictograph with U+FE0F or a skin tone. */ +static int cluster_width(const char *s,size_t n,uint32_t first,int base) { + if(base==2) return 2; + size_t off,u; decode(s,n,&off); + if(is_regional(first)) return off=n) break; + size_t ab; uint32_t after=decode(s+start+bytes+nb,n-start-bytes-nb,&ab); + if(!IN(cp,pictographic) || !IN(after,pictographic) || parts+2>16) break; + bytes+=nb+ab; parts+=2; continue; + } + /* Emoji that are one picture but several codepoints: a skin tone + after a person or hand, the second half of a flag, and the tags + that spell a subdivision flag. Each belongs to the cell before it. */ + if((IN(cp,pictographic) && (is_tone(next) || is_tag(next))) || + (is_regional(cp) && is_regional(next) && parts==1)) { + bytes+=nb; ++parts; continue; + } + if(hq_char_width(next)!=0 || hq_unsafe(next)) break; + bytes+=nb; ++parts; + } + *offset=start+bytes; + if(!width) continue; + if(parts>1) width=cluster_width(s+start,bytes,cp,width); + g->start=s+start; g->bytes=bytes; g->cp=cp; g->width=width; + return 1; + } + return 0; +} +size_t hq_text_width_n(const char *s,size_t n) { + if(!s) return 0; + size_t off=0,w=0; hq_grapheme g; + while(hq_next_grapheme(s,n,&off,&g)) w+=(size_t)g.width; + return w; +} +size_t hq_text_width(const char *s) { return s ? hq_text_width_n(s,strlen(s)):0; } diff --git a/ports/nim/src/hqtui/engine/cpp/demo/collect.cpp b/ports/nim/src/hqtui/engine/cpp/demo/collect.cpp new file mode 100644 index 0000000..486d6e8 --- /dev/null +++ b/ports/nim/src/hqtui/engine/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/nim/src/hqtui/engine/cpp/demo/collect.hpp b/ports/nim/src/hqtui/engine/cpp/demo/collect.hpp new file mode 100644 index 0000000..4382782 --- /dev/null +++ b/ports/nim/src/hqtui/engine/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/nim/src/hqtui/engine/cpp/demo/dashboard.cpp b/ports/nim/src/hqtui/engine/cpp/demo/dashboard.cpp new file mode 100644 index 0000000..72b8bb9 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/demo/dashboard.cpp @@ -0,0 +1,366 @@ +#include "model.hpp" +namespace demo { +static void cpu_panel(UI &ui, State &s, int columns, Constraint size = fr()) { + 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; + m.segmented = true; + 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)}); + }, + size, percent(c["total"].n())); +} +static void memory_panel(UI &ui, State &s, Constraint size = fr()) { + 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)}); + }, size); +} +static void disks_panel(UI &ui, State &s, Constraint size = fr()) { + 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(); + } + }, size); +} +static void system_panel(UI &ui, State &s, Constraint size = fr()) { + const int gap = s.panel_gap(); + ui.panel("System", [&, gap](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), gap, [=, &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)}); + } + }, size); +} +static void processes_panel(UI &ui, State &s, Constraint size = fr()) { + 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)}); + }, + size, s.filter.empty() ? "" : "filter: " + s.filter, + ui.t().border_focused); +} +static void network_panel(UI &ui, State &s, Constraint size = fr()) { + 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); + } + }); + }, size); +} +static void disk_usage_panel(UI &ui, State &s, Constraint size = fr()) { + 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.segmented = true; + 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)); + }); + }); + }); + }, + size, 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, Constraint size = fr()) { + ui.panel("Logs", [&](UI &p) { + p.log(logs(s.data["logs"]), &s.panes["dashboard.logs"], "dashboard.logs"); + }, size); +} +// Panels go straight into their row rather than each inside a sizing column. A +// column is not a bordered child, so a row of them has no seam to merge and `c` +// could never change this screen; a size on the panel does the same job and +// leaves the borders adjacent to each other. +void dashboard(UI &ui, State &s) { + const int gap = s.panel_gap(); + if (ui.width() >= 150) { + ui.row(cells(ui.height() >= 44 ? 19 : 16), gap, [&](UI &r) { + cpu_panel(r, s, 2, fr()); + memory_panel(r, s, fr(.95)); + disks_panel(r, s, fr(.95)); + system_panel(r, s, fr(1.35)); + }); + ui.row(fr(), gap, [&](UI &r) { + processes_panel(r, s, fr(2)); + network_panel(r, s, fr(1.2)); + disk_usage_panel(r, s, fr(1.2)); + }); + ui.row(cells(12), gap, [&](UI &r) { + temperatures_panel(r, s); + sensors_panel(r, s); + logs_panel(r, s, fr(1.6)); + }); + } else if (ui.width() >= 100) { + ui.row(cells(14), gap, [&](UI &r) { + cpu_panel(r, s, 2); + memory_panel(r, s); + system_panel(r, s); + }); + ui.row(fr(), gap, [&](UI &r) { + processes_panel(r, s, fr(1.6)); + network_panel(r, s, fr()); + }); + ui.row(cells(10), gap, [&](UI &r) { + temperatures_panel(r, s); + logs_panel(r, s); + }); + } else { + ui.row(cells(10), gap, [&](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), gap, [&](UI &r) { network_panel(r, s); }); + } +} +} // namespace demo diff --git a/ports/nim/src/hqtui/engine/cpp/demo/generated/sample.hpp b/ports/nim/src/hqtui/engine/cpp/demo/generated/sample.hpp new file mode 100644 index 0000000..fcfdc82 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/demo/generated/sample.hpp @@ -0,0 +1,3697 @@ +#pragma once +namespace demo { inline constexpr const char* sample_json=R"hqtuisample({ + "time": 12.100000000000001, + "telemetry": { + "sessions": [ + { + "user": "anthony", + "tty": "pts/0", + "from": "10.0.4.17", + "loginAt": "09:14", + "idle": ".", + "what": "bun run dev" + }, + { + "user": "deploy", + "tty": "pts/1", + "from": "203.0.113.42", + "loginAt": "11:02", + "idle": "00:04", + "what": "tail -f app.log" + }, + { + "user": "anthony", + "tty": "pts/2", + "from": "10.0.4.17", + "loginAt": "11:40", + "idle": "00:12", + "what": "psql" + } + ], + "logins": [ + { + "user": "anthony", + "tty": "pts/0", + "from": "10.0.4.17", + "when": "Aug 20 07:00", + "status": "still" + }, + { + "user": "deploy", + "tty": "pts/1", + "from": "203.0.113.42", + "when": "Aug 21 08:07", + "status": "still" + }, + { + "user": "ci", + "tty": "pts/2", + "from": "198.51.100.9", + "when": "Aug 22 09:14", + "status": "still" + }, + { + "user": "root", + "tty": "pts/3", + "from": "192.168.1.42", + "when": "Aug 23 10:21", + "status": "ok" + }, + { + "user": "postgres", + "tty": "pts/0", + "from": "172.16.0.8", + "when": "Aug 24 11:28", + "status": "ok" + }, + { + "user": "anthony", + "tty": "pts/1", + "from": "10.0.4.17", + "when": "Aug 25 12:35", + "status": "ok" + }, + { + "user": "deploy", + "tty": "pts/2", + "from": "203.0.113.42", + "when": "Aug 26 13:42", + "status": "ok" + }, + { + "user": "ci", + "tty": "pts/3", + "from": "198.51.100.9", + "when": "Aug 27 14:49", + "status": "ok" + }, + { + "user": "root", + "tty": "pts/0", + "from": "192.168.1.42", + "when": "Aug 28 15:56", + "status": "ok" + }, + { + "user": "postgres", + "tty": "pts/1", + "from": "172.16.0.8", + "when": "Aug 20 16:03", + "status": "ok" + }, + { + "user": "anthony", + "tty": "pts/2", + "from": "10.0.4.17", + "when": "Aug 21 17:10", + "status": "ok" + }, + { + "user": "deploy", + "tty": "pts/3", + "from": "203.0.113.42", + "when": "Aug 22 18:17", + "status": "ok" + }, + { + "user": "ci", + "tty": "pts/0", + "from": "198.51.100.9", + "when": "Aug 23 07:24", + "status": "ok" + }, + { + "user": "root", + "tty": "pts/1", + "from": "192.168.1.42", + "when": "Aug 24 08:31", + "status": "ok" + }, + { + "user": "postgres", + "tty": "pts/2", + "from": "172.16.0.8", + "when": "Aug 25 09:38", + "status": "ok" + }, + { + "user": "anthony", + "tty": "pts/3", + "from": "10.0.4.17", + "when": "Aug 26 10:45", + "status": "ok" + }, + { + "user": "deploy", + "tty": "pts/0", + "from": "203.0.113.42", + "when": "Aug 27 11:52", + "status": "ok" + }, + { + "user": "ci", + "tty": "pts/1", + "from": "198.51.100.9", + "when": "Aug 28 12:59", + "status": "ok" + } + ], + "failedLogins": [ + { + "user": "root", + "tty": "ssh", + "from": "185.100.20.9", + "when": "Aug 30 00:00", + "status": "failed" + }, + { + "user": "admin", + "tty": "ssh", + "from": "185.101.23.10", + "when": "Aug 30 01:11", + "status": "failed" + }, + { + "user": "test", + "tty": "ssh", + "from": "185.102.26.11", + "when": "Aug 30 02:22", + "status": "failed" + }, + { + "user": "oracle", + "tty": "ssh", + "from": "185.103.29.12", + "when": "Aug 30 03:33", + "status": "failed" + }, + { + "user": "ubuntu", + "tty": "ssh", + "from": "185.104.32.13", + "when": "Aug 30 04:44", + "status": "failed" + }, + { + "user": "git", + "tty": "ssh", + "from": "185.105.35.14", + "when": "Aug 30 05:55", + "status": "failed" + } + ], + "connections": [ + { + "proto": "udp", + "local": "10.0.0.7:40000", + "remote": "10.0.4.17:443", + "state": "ESTAB", + "process": "bun/1261" + }, + { + "proto": "tcp", + "local": "10.0.0.7:40013", + "remote": "203.0.113.42:443", + "state": "ESTAB", + "process": "node/8437" + }, + { + "proto": "tcp", + "local": "10.0.0.7:40026", + "remote": "198.51.100.9:22", + "state": "ESTAB", + "process": "sshd/1024" + }, + { + "proto": "tcp", + "local": "10.0.0.7:40039", + "remote": "192.168.1.42:5432", + "state": "ESTAB", + "process": "postgres/2217" + }, + { + "proto": "tcp", + "local": "10.0.0.7:40052", + "remote": "172.16.0.8:6379", + "state": "ESTAB", + "process": "redis/1555" + } + ], + "listeners": [ + { + "proto": "tcp", + "address": "0.0.0.0", + "port": "443", + "process": "nginx/981" + }, + { + "proto": "tcp", + "address": "0.0.0.0", + "port": "80", + "process": "nginx/981" + }, + { + "proto": "tcp", + "address": "0.0.0.0", + "port": "22", + "process": "sshd/1024" + }, + { + "proto": "tcp", + "address": "127.0.0.1", + "port": "5432", + "process": "postgres/2217" + }, + { + "proto": "tcp", + "address": "127.0.0.1", + "port": "6379", + "process": "redis/1555" + }, + { + "proto": "tcp", + "address": "0.0.0.0", + "port": "3000", + "process": "bun/1261" + }, + { + "proto": "udp", + "address": "0.0.0.0", + "port": "53", + "process": "resolved/712" + } + ], + "services": [ + { + "name": "nginx", + "active": "active", + "sub": "running", + "description": "A high performance web server" + }, + { + "name": "postgresql", + "active": "active", + "sub": "running", + "description": "PostgreSQL RDBMS" + }, + { + "name": "redis-server", + "active": "active", + "sub": "running", + "description": "Advanced key-value store" + }, + { + "name": "ssh", + "active": "active", + "sub": "running", + "description": "OpenBSD Secure Shell server" + }, + { + "name": "cron", + "active": "active", + "sub": "running", + "description": "Regular background program processing" + }, + { + "name": "docker", + "active": "active", + "sub": "running", + "description": "Docker Application Container Engine" + }, + { + "name": "systemd-resolved", + "active": "active", + "sub": "running", + "description": "Network Name Resolution" + }, + { + "name": "backup", + "active": "failed", + "sub": "failed", + "description": "Nightly backup job" + }, + { + "name": "telemetry-agent", + "active": "inactive", + "sub": "dead", + "description": "Vendor telemetry collector" + } + ], + "containers": [ + { + "id": "9f2c1a4b7e33", + "name": "api", + "image": "hqtui/api:1.4.2", + "status": "Up 2 hours", + "cpu": "12.4%", + "memory": "184 MiB" + }, + { + "id": "3ab7f0912cd5", + "name": "worker", + "image": "hqtui/worker:1.4.2", + "status": "Up 2 hours", + "cpu": "6.1%", + "memory": "96 MiB" + }, + { + "id": "77e10cc4a9b2", + "name": "postgres", + "image": "postgres:16", + "status": "Up 5 days", + "cpu": "3.8%", + "memory": "412 MiB" + }, + { + "id": "1cd8e5b60f47", + "name": "redis", + "image": "redis:7-alpine", + "status": "Up 5 days", + "cpu": "0.7%", + "memory": "34 MiB" + } + ], + "interfaces": [ + { + "name": "eth0", + "ip": "10.0.0.7", + "mac": "ac:de:48:00:11:22", + "state": "up", + "mtu": 1500, + "rxRate": 6201653.797448864, + "txRate": 2392717.5349607365, + "rxTotal": 111298266.09595959, + "txTotal": 30485874.58796266, + "rxHistory": [ + 8942898.330648236, + 7819426.267671371, + 7152002.110760242, + 10160910.834934184, + 8613752.40919955, + 8412521.468347166, + 7661161.305374499, + 9672737.25724457, + 8407198.568981338, + 8067683.912462631, + 8028790.493543923, + 8232778.164100498, + 11148599.842076652, + 7368141.799049832, + 8354982.723369429, + 8376578.777089316, + 9037252.058889844, + 9966464.85838403, + 11106984.303399766, + 8266374.053213615, + 11116245.017054472, + 10862688.10564087, + 9118295.29047558, + 8850074.029993163, + 10351763.846631981, + 9761925.496383209, + 11811617.561797697, + 10674856.929594772, + 11359117.548118401, + 8721780.567627512, + 10735508.579914412, + 11321728.205507148, + 8989944.797068622, + 12081629.921554571, + 11238438.377412695, + 10362430.608474862, + 12172556.021251142, + 11439244.48583351, + 10095463.822241835, + 9341405.919821678, + 12550778.050317846, + 12588006.116090668, + 9107496.143747594, + 11321669.163912598, + 12356536.97051227, + 11247532.379102632, + 10234033.244196337, + 10001163.405604765, + 9411000.874807369, + 12166709.645224255, + 9338884.397517465, + 12343288.80133948, + 11330510.611898525, + 9331733.259774461, + 10195461.423021518, + 9179035.034397695, + 12539500.394477073, + 11013196.270436268, + 12386394.123113785, + 11515870.558950065, + 11452690.678991057, + 9752290.821706, + 11118393.51292545, + 9629225.633434977, + 9070298.262343401, + 11816689.350646047, + 9931139.505615719, + 11474138.081667783, + 11517220.594982445, + 11239270.779955266, + 8957322.658762204, + 10569101.895754693, + 10806247.56156728, + 11161741.1700801, + 11224246.467342712, + 10869534.766162924, + 9719257.0272072, + 8503589.520940784, + 8433683.211692097, + 8534932.7647757, + 8519063.841020243, + 7812720.921586106, + 8589409.384149145, + 10449224.636983464, + 7864211.659600998, + 7422245.456163474, + 8971490.62661733, + 7690825.0815702835, + 7911474.736168355, + 10321692.671227539, + 10044894.50434417, + 8219564.192396467, + 6411967.8325697305, + 9242446.016807366, + 8017615.685666224, + 6447482.184772285, + 9423329.752836764, + 6525378.726024627, + 6177580.807290457, + 9195550.648344994, + 6294429.508879637, + 6459944.920622017, + 6179395.187328516, + 7943329.750849214, + 5503781.385548246, + 5822751.926399035, + 7612304.483119585, + 5669287.4563652, + 5954866.025150514, + 6580717.289408894, + 7761504.26167937, + 7934963.268333027, + 5992343.5840513, + 6795409.301716011, + 4761154.42799202, + 6675596.415724957, + 5882715.62872173, + 6509177.373586752, + 7694075.972992238, + 6351319.821403307, + 6201653.797448864 + ], + "txHistory": [ + 2270632.9930338105, + 3150641.8510913076, + 3164730.761560943, + 2269910.7380544287, + 3326303.4198198216, + 3548078.4943135493, + 2940730.285526083, + 2226467.4652782707, + 3512532.751237719, + 2839177.894865218, + 3022547.442720748, + 2535888.041943365, + 2950196.0500888703, + 2989585.1002052985, + 3049225.4719804535, + 3317580.2297889846, + 3896097.5718226256, + 3132646.647683994, + 3025640.5216478026, + 2862570.3116606055, + 3496281.379721391, + 3003315.423898949, + 2765776.8839707216, + 3270525.0608555437, + 4175502.077289683, + 2801601.2096684305, + 2822761.80703398, + 2950021.4721664013, + 2963836.315814588, + 2834574.931927112, + 3680300.8242428917, + 3873467.8786266944, + 3942811.876660141, + 3686122.235908963, + 2884176.7133789025, + 3845648.6316185743, + 2966288.407341269, + 3452234.241673578, + 2797200.1837434014, + 3916631.448979782, + 2793521.162653678, + 4095819.926857433, + 2672287.9885559655, + 2779682.733267254, + 3988199.7003284725, + 3208497.4381271177, + 3534670.7697054716, + 3345404.047383772, + 3694682.5706425295, + 2519403.9286099123, + 2434724.161868878, + 2744270.5661096433, + 2942483.2277822364, + 3459163.076447485, + 3338458.2722381875, + 2784962.0161035913, + 3052168.500407206, + 2939557.668837717, + 2546121.318622807, + 3151917.2988432427, + 3371087.823512186, + 2615322.8481060355, + 2159863.789404909, + 3138629.563349888, + 2587527.795207422, + 2357852.9688226827, + 1775800.3229938517, + 2435711.9791235216, + 2115398.948020052, + 1681907.714551259, + 2421496.312632334, + 1490352.6366774892, + 2097650.480501896, + 2300950.948931084, + 1810919.2242014692, + 1470770.450868059, + 1732983.4829200034, + 1603862.7337686024, + 2521362.764177108, + 1130659.3384177552, + 1612502.267577083, + 1505268.458093749, + 2377356.753971408, + 1832337.626558193, + 1145606.2450167271, + 1911637.5800808626, + 1565833.7575490489, + 1034997.1268183442, + 1248533.3511318383, + 1925356.7925359532, + 2097216.1302972, + 2019900.31005462, + 1154721.856164508, + 1086611.2687048507, + 1947037.5739089497, + 1272180.6831931304, + 1605094.9402053093, + 2130099.1620423123, + 986006.5740852527, + 1796545.8806793436, + 1551106.861751629, + 1691539.575123638, + 1439703.8332848244, + 1674368.6627557043, + 2314919.4026194653, + 1958650.5971203498, + 1465769.6205513012, + 2385520.5891274, + 2170496.8736131117, + 1470967.8854481257, + 2600853.1853134623, + 2621933.7255653236, + 1495342.576051169, + 2569516.7226863867, + 1757817.2702807086, + 2397008.187269696, + 1522079.6459215325, + 1687269.6498447524, + 2544941.2104056985, + 1885006.382837907, + 2392717.5349607365 + ], + "errors": 0, + "drops": 0 + }, + { + "name": "wg0", + "ip": "100.84.2.19", + "mac": "00:00:00:00:00:00", + "state": "up", + "mtu": 1280, + "rxRate": 1038566.2146997491, + "txRate": 715799.391867375, + "rxTotal": 24243920.364585694, + "txTotal": 7725853.379705345, + "rxHistory": [ + 2667768.9212758443, + 2455714.341069919, + 2784825.1895196205, + 2300287.368675457, + 2687051.817838733, + 2873786.201611447, + 2918328.612344429, + 2408319.5832559876, + 2461854.5024702232, + 2283179.8470750744, + 2854034.898888191, + 2688878.8017943385, + 2952143.832915581, + 2324454.4325359324, + 2335906.4411002398, + 3163971.1231078766, + 2831709.153600971, + 2932039.014821898, + 2305975.5163223622, + 2773004.4907754017, + 2694651.025374314, + 2481944.200980716, + 2329529.414249416, + 2330390.3703185953, + 2889881.701412787, + 2463752.398359143, + 2367624.626638026, + 3101200.6368227853, + 3110976.5175578827, + 2821383.5512089874, + 2647328.727032909, + 2710225.499253036, + 2157691.4585936675, + 2225336.393423385, + 2884549.5089033227, + 2985120.3556182007, + 2390729.907005625, + 2853604.741775856, + 2106692.602407843, + 2883062.0482794642, + 2903712.305886638, + 2465138.8199044163, + 2425387.970254012, + 2204355.995864072, + 1996649.7502452973, + 2226044.1998604666, + 2401447.8671174212, + 2036249.1816660026, + 2705789.205562428, + 2549319.105354981, + 2571101.522638817, + 2593491.7586266124, + 2629918.7148034154, + 2333326.5856142067, + 2412584.70008663, + 1889703.598636361, + 2421804.89060073, + 2135934.8928439952, + 2386260.2835854813, + 2391462.4118644954, + 2230283.724838422, + 2142649.4837340126, + 1649396.476096648, + 2355761.096884561, + 1822904.2888754657, + 2012874.3972826977, + 1644994.8619036973, + 2185951.515932679, + 1867084.5818536934, + 2123228.7019580454, + 2211946.9053353756, + 1531556.6018708562, + 1425359.540073886, + 1401895.0872845717, + 1503886.2465451986, + 1387935.5744345146, + 1547807.4596142862, + 1394672.7461130102, + 1671762.9706302525, + 1528083.3818386872, + 1431159.964278196, + 1664943.1099912398, + 1088756.9305426301, + 1090094.9285018994, + 1353028.0517548854, + 1331388.541196561, + 1537397.4587435315, + 1366022.8491887844, + 1246584.0566985449, + 1013761.4209300235, + 1193107.3044634587, + 1387105.7706621275, + 934396.239157009, + 1284340.179984821, + 1602934.4758260618, + 1448240.61685318, + 1743490.6813573055, + 1639753.1207846862, + 1451965.2565857198, + 1361857.4758640593, + 1061941.8117883517, + 916236.1075587256, + 1287337.723423187, + 1421680.5833876175, + 1347016.030766543, + 1409139.9978568456, + 1639104.5845630178, + 1387928.5703410022, + 1292394.874038278, + 1207655.0334842189, + 1010497.6773004284, + 798626.274823086, + 1738711.4329253251, + 1606186.510462564, + 1352759.9765746193, + 1285168.103892331, + 865729.3899873159, + 1070403.0400552517, + 1267861.230181158, + 1505300.8640501588, + 1038566.2146997491 + ], + "txHistory": [ + 868287.9013950664, + 800860.8021900501, + 732391.4898260867, + 850944.171870533, + 946638.0365972437, + 809045.5377218283, + 732069.0130645413, + 974547.9069797129, + 955095.0547052072, + 730063.1101934921, + 873968.8156676167, + 818383.9108275803, + 937044.1173398688, + 800224.5471165162, + 967977.6455652406, + 814328.5955715068, + 1038295.8345309992, + 1054071.981728739, + 1031008.2776729891, + 884708.9011893407, + 812681.229277598, + 855946.6540847082, + 682923.5085077522, + 710165.1705241965, + 674876.7219150782, + 946309.9926975413, + 977205.416886843, + 665663.91466298, + 862564.8580206834, + 813623.180484486, + 728840.1628034097, + 686689.376703948, + 613407.3042399962, + 602045.8081837683, + 636845.190562522, + 832144.0866721765, + 627894.1347048567, + 571850.3561432003, + 688565.7657772158, + 679114.2506832482, + 757815.4269971802, + 815423.5541062079, + 586706.5708623684, + 634173.2666389161, + 754763.6617037272, + 494492.50942386745, + 638748.1466508954, + 583328.3159771794, + 715407.1300022146, + 697303.5935887462, + 694273.1936660401, + 645149.7324181945, + 562257.3660043026, + 601138.7465905382, + 606739.8046226591, + 393378.301634307, + 538835.3457179379, + 378914.7223176727, + 484911.19689382846, + 296203.1855085664, + 406308.32841092563, + 389746.81265029346, + 579534.7752999465, + 498833.7287095899, + 449142.93413982994, + 269313.912533217, + 355608.4468521852, + 479892.07667058526, + 571683.2275837968, + 501449.45155324973, + 368856.97992810444, + 504679.6879827076, + 451391.94112288405, + 417454.0401757929, + 241585.73441801837, + 304245.7064099978, + 544842.9471671628, + 430872.3899133466, + 567502.7655284597, + 304820.80365073046, + 230960.83959241168, + 462062.6711720694, + 497476.87402904016, + 392364.1454593164, + 527955.0325140858, + 518558.61427162157, + 466112.7687861542, + 608237.7349513604, + 369901.28109688876, + 395410.89068900177, + 641826.8014454951, + 294480.16552967625, + 564718.3558296042, + 526463.7726046571, + 424545.2897564762, + 520360.18512837985, + 480887.69400144473, + 371486.0017130025, + 525434.5381361857, + 546444.0370292907, + 586895.3065784939, + 547912.1479667891, + 611679.5786426535, + 540633.526924921, + 803820.9435367805, + 620742.3638842581, + 575321.9579580937, + 691880.2248260283, + 725086.2688669562, + 788829.0044416834, + 628496.4910794795, + 717080.1867580278, + 802035.7306501241, + 907685.2729565281, + 922062.6509600764, + 861889.2340430341, + 698095.8666233239, + 608756.972538148, + 821504.9289743097, + 909594.8559236407, + 715799.391867375 + ], + "errors": 0, + "drops": 0 + } + ], + "filesystems": [ + { + "mount": "/", + "device": "/dev/nvme0n1p2", + "type": "ext4", + "size": 549755813888, + "used": 146028888064, + "inodesUsed": 1240000, + "inodesTotal": 32000000 + }, + { + "mount": "/home", + "device": "/dev/nvme0n1p3", + "type": "ext4", + "size": 1052266987520, + "used": 667867414528, + "inodesUsed": 4100000, + "inodesTotal": 61000000 + }, + { + "mount": "/data", + "device": "/dev/sda1", + "type": "xfs", + "size": 2199023255552, + "used": 1121501860331.52, + "inodesUsed": 812000, + "inodesTotal": 210000000 + }, + { + "mount": "/boot", + "device": "/dev/nvme0n1p1", + "type": "vfat", + "size": 536870912, + "used": 155189248, + "inodesUsed": 0, + "inodesTotal": 0 + } + ], + "kernel": { + "contextSwitches": 116159.54467634177, + "contextSwitchRate": 11832.41169125606, + "interrupts": 65904.5548784161, + "interruptRate": 7065.480346110746, + "forks": 332.49485456582534, + "forkRate": 37.95832767054503, + "procsRunning": 7, + "procsBlocked": 0, + "entropy": 3607, + "openFiles": 3452, + "maxFiles": 9223372, + "bootTime": 0, + "pageIn": 22997, + "pageOut": 18076, + "swapIn": 0, + "swapOut": 0 + }, + "gpus": [ + { + "name": "NVIDIA RTX A2000", + "utilization": 0.4973001578383233, + "memoryUsed": 2254857830.4, + "memoryTotal": 6442450944, + "temperature": 56.92980410379641, + "power": 34.5 + } + ], + "power": { + "battery": 96, + "charging": false, + "timeRemaining": "Full", + "powerDraw": 18.645258292511343, + "acConnected": true + }, + "journal": [ + { + "time": "12:00:00", + "level": "DEBUG", + "unit": "cron", + "message": "(anthony) CMD (run-parts /etc/cron.hourly)" + }, + { + "time": "12:00:00", + "level": "DEBUG", + "unit": "sshd", + "message": "Received disconnect" + }, + { + "time": "12:00:00", + "level": "ERROR", + "unit": "nginx", + "message": "302 requests served" + }, + { + "time": "12:00:00", + "level": "INFO", + "unit": "kernel", + "message": "EXT4-fs: mounted filesystem with ordered data mode" + }, + { + "time": "12:00:00", + "level": "INFO", + "unit": "nginx", + "message": "client closed connection while waiting for request" + }, + { + "time": "12:00:00", + "level": "WARN", + "unit": "nginx", + "message": "upstream timed out" + }, + { + "time": "12:00:00", + "level": "ERROR", + "unit": "cron", + "message": "(anthony) CMD (run-parts /etc/cron.hourly)" + }, + { + "time": "12:00:00", + "level": "INFO", + "unit": "cron", + "message": "(anthony) CMD (run-parts /etc/cron.hourly)" + }, + { + "time": "12:00:00", + "level": "INFO", + "unit": "kernel", + "message": "TCP: request_sock_TCP: Possible SYN flooding" + }, + { + "time": "12:00:00", + "level": "INFO", + "unit": "sshd", + "message": "Connection closed by authenticating user root" + }, + { + "time": "12:00:00", + "level": "WARN", + "unit": "dockerd", + "message": "health check passed" + }, + { + "time": "12:00:00", + "level": "WARN", + "unit": "kernel", + "message": "EXT4-fs: mounted filesystem with ordered data mode" + }, + { + "time": "12:00:00", + "level": "INFO", + "unit": "dockerd", + "message": "image pull complete" + }, + { + "time": "12:00:00", + "level": "WARN", + "unit": "nginx", + "message": "upstream timed out" + }, + { + "time": "12:00:00", + "level": "WARN", + "unit": "systemd", + "message": "Reloading nginx configuration" + } + ], + "states": { + "running": 7, + "sleeping": 240, + "stopped": 0, + "zombie": 0, + "total": 247 + }, + "connectionHistory": [ + 63, + 58, + 67, + 75, + 66, + 76, + 67, + 72, + 60, + 62, + 69, + 57, + 75, + 72, + 69, + 66, + 78, + 57, + 76, + 61, + 54, + 59, + 61, + 66, + 71, + 63, + 62, + 60, + 72, + 69, + 69, + 73, + 62, + 55, + 60, + 60, + 67, + 82, + 61, + 66, + 67, + 50, + 69, + 65, + 67, + 56, + 63, + 65, + 66, + 60, + 61, + 68, + 63, + 61, + 59, + 59, + 60, + 57, + 65, + 70, + 61, + 61, + 75, + 76, + 63, + 56, + 64, + 65, + 51, + 63, + 61, + 65, + 67, + 62, + 79, + 64, + 55, + 70, + 66, + 77, + 71, + 63, + 64, + 57, + 69, + 77, + 57, + 59, + 77, + 70, + 58, + 66, + 57, + 79, + 81, + 56, + 64, + 69, + 68, + 75, + 80, + 68, + 63, + 62, + 75, + 90, + 59, + 84, + 71, + 72, + 72, + 68, + 64, + 71, + 78, + 75, + 93, + 69, + 83, + 61, + 86 + ], + "sessionHistory": [ + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 3, + 4, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 4, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 4, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 4, + 4, + 3, + 4, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 4, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3 + ], + "protocols": [ + { + "protocol": "HTTPS", + "inbound": 31, + "outbound": 10, + "total": 41 + }, + { + "protocol": "ephemeral", + "inbound": 2, + "outbound": 9, + "total": 11 + }, + { + "protocol": "Postgres", + "inbound": 1, + "outbound": 8, + "total": 9 + }, + { + "protocol": "HTTP", + "inbound": 5, + "outbound": 2, + "total": 7 + }, + { + "protocol": "DNS", + "inbound": 1, + "outbound": 6, + "total": 7 + }, + { + "protocol": "Redis", + "inbound": 1, + "outbound": 4, + "total": 5 + }, + { + "protocol": "SSH", + "inbound": 2, + "outbound": 1, + "total": 3 + }, + { + "protocol": "SMTP", + "inbound": 0, + "outbound": 3, + "total": 3 + } + ], + "remotes": [ + { + "host": "10.0.4.17", + "connections": 12, + "protocols": "HTTPS, Postgres" + }, + { + "host": "203.0.113.42", + "connections": 7, + "protocols": "HTTP, Redis" + }, + { + "host": "198.51.100.9", + "connections": 7, + "protocols": "SSH, DNS" + }, + { + "host": "192.168.1.42", + "connections": 7, + "protocols": "Postgres, SMTP" + }, + { + "host": "172.16.0.8", + "connections": 3, + "protocols": "Redis, ephemeral" + } + ], + "inboundConnections": 43, + "outboundConnections": 43, + "net": { + "tcpActiveOpens": 430, + "tcpPassiveOpens": 685, + "tcpEstablished": 86, + "tcpInSegs": 1090422.9482447472, + "tcpOutSegs": 997984.8902032655, + "tcpRetransSegs": 3071.5726900277377, + "tcpInErrs": 0, + "tcpOutRsts": 167, + "udpInDatagrams": 48917, + "udpOutDatagrams": 40561, + "udpInErrors": 0, + "icmpInMsgs": 136, + "icmpOutMsgs": 118, + "rates": { + "inSegs": 11762.248267226048, + "outSegs": 9003.644075129298, + "retrans": 38.27017551368568, + "passiveOpens": 9.385381579399109, + "activeOpens": 5.700383493676782, + "udpIn": 553.4054973162711, + "udpOut": 449.4523832644336 + }, + "retransRatio": 0.004250520699657499 + }, + "netInHistory": [ + 8513.949725526223, + 7082.263813203416, + 7803.283552943229, + 10083.774602565589, + 7927.104002165126, + 8115.231494965578, + 8505.98011425223, + 8781.134723621068, + 10154.700595394832, + 9149.046268201746, + 7958.992370103518, + 9275.687676134847, + 8397.87838804584, + 7940.3535318806935, + 8946.49550998372, + 7729.1750959533865, + 9877.702950824685, + 9343.689060383575, + 7898.750732088336, + 8315.659663781591, + 8960.249658224779, + 8801.192188731111, + 8398.715397593787, + 9091.515859529474, + 8989.345879663444, + 8493.159912042764, + 9046.97512690054, + 8498.35592688208, + 8466.881776651871, + 8887.307299811786, + 10143.443195547388, + 8724.078365225732, + 7803.627403548946, + 8925.0005090097, + 9613.322852131792, + 9020.54448570069, + 7869.7303791587365, + 9866.543423782407, + 8038.985480889235, + 7978.741708399206, + 10065.577677999796, + 8442.027033169572, + 7925.26595273135, + 8433.422581666782, + 8427.998195803675, + 8243.97953875085, + 7852.212954958952, + 8068.962295514224, + 9670.021696515316, + 7646.951427358372, + 8978.080040811592, + 8335.517214820073, + 7692.033247345793, + 8378.145113430188, + 9250.721600722478, + 7809.887730877562, + 7704.194459096992, + 7765.223036916876, + 9617.801814205388, + 8552.76303736575, + 7451.515124704309, + 9384.135499067877, + 7947.675603685098, + 8792.095104582822, + 9391.949253381046, + 7581.934404342237, + 9383.74098321656, + 8470.043850622445, + 9126.450003398633, + 8808.947808558116, + 7492.479678451535, + 8110.496657889973, + 7443.74911850715, + 8016.201837376379, + 7459.740824505678, + 8455.40641102529, + 9070.85117633491, + 9253.698281711555, + 8741.297418633172, + 9920.157152275995, + 9333.774367233866, + 8252.001128145661, + 8712.170069929403, + 8867.602209001785, + 9687.733264847247, + 9354.77863305014, + 9699.090181376254, + 10181.508380250405, + 8377.850825465413, + 10110.925112397574, + 8283.093018820451, + 9517.94502185437, + 10394.789071265255, + 10443.031877185138, + 8617.85771525142, + 10550.349849988035, + 10299.202907987357, + 10792.859694856485, + 8674.339733503872, + 8698.625493442407, + 8903.384245944908, + 10017.19652992057, + 9020.404883892661, + 9421.553058317586, + 9239.696480934395, + 10589.727680103282, + 9460.859088893838, + 9337.681849944214, + 11294.621028520267, + 9472.218084427739, + 11168.570779708187, + 9861.225781661045, + 9733.85944167115, + 10043.572413362173, + 10495.596567387809, + 11629.291074480148, + 11051.187123219088, + 10605.13721955931, + 11114.666365841713, + 10768.798180101949, + 11762.248267226048 + ], + "netOutHistory": [ + 7673.237749830516, + 7449.840342495632, + 9056.86788257181, + 7926.490850389637, + 8495.588608636932, + 7229.7012908366705, + 7956.765484391486, + 7607.287618031791, + 8235.360599561787, + 7089.134664152338, + 7844.936403758076, + 8421.719062561237, + 6995.501756956468, + 7688.4578407094505, + 7391.751518271244, + 8182.061903472681, + 8405.674208765498, + 8395.516972840309, + 8053.063037078247, + 8158.252948520457, + 9037.307191775504, + 8938.849020474176, + 7523.001216550793, + 8676.601468400888, + 7343.10906249361, + 8556.130695972453, + 7894.787652988373, + 7627.770147683901, + 7057.000577376064, + 7991.64311171685, + 7354.964565551829, + 7294.812465570524, + 8435.670246850026, + 6926.028749290081, + 7448.943432341919, + 8242.293641583792, + 8788.237963253418, + 7888.092210268027, + 8619.75413048095, + 7680.061334184591, + 8738.188032744301, + 8731.323688435046, + 7281.581920630177, + 6802.133204583735, + 7660.393884257292, + 6951.5333179327945, + 8269.193264567262, + 8869.2917092761, + 8409.729627754246, + 6832.305126063913, + 7712.826568598733, + 8003.134205921271, + 8489.776809551277, + 8444.396621942939, + 8729.34161727592, + 7659.50102244695, + 8360.674171698938, + 6956.1087826197345, + 6994.468698662971, + 8736.196258477066, + 8184.976023268693, + 7023.57998831277, + 6944.669329250661, + 7648.753215332867, + 6809.637519490116, + 6708.281671968651, + 7103.59544310001, + 8056.551604139821, + 8151.0657092622005, + 8443.190945619092, + 7404.969833931601, + 8163.581104006193, + 8126.569776116471, + 7368.243485666526, + 6942.682826097348, + 6936.90826175086, + 6824.423129747103, + 7494.6051481709155, + 8032.417829626273, + 7661.0578396622395, + 7505.489960530891, + 8263.437941341504, + 7973.664913582713, + 8579.078472787907, + 7694.595602410702, + 8571.61239807551, + 9219.926432516715, + 8541.127196312875, + 7422.086735728393, + 7625.955735352272, + 8418.484381911447, + 8907.301217049744, + 8994.269909937233, + 8544.416967367137, + 9484.191594203923, + 7862.832754480348, + 9077.12687452851, + 8162.818187108041, + 9556.470822298768, + 8466.069744926197, + 8387.013991355681, + 9477.215775693163, + 9977.775639981926, + 8727.981092063534, + 10019.264717638234, + 8600.6241288169, + 8263.45588187682, + 10047.729148872797, + 10356.408002306955, + 9939.840900976871, + 8734.396944637152, + 9757.597168371401, + 10535.37915378542, + 10724.998765760522, + 8764.576465936174, + 9386.136059995166, + 10398.138093038877, + 10888.461217383072, + 10797.301339374673, + 10081.864951218868, + 9003.644075129298 + ], + "retransHistory": [ + 12.696417306213089, + 30.875512576233568, + 30.414946289156653, + 34.08012407801684, + 35.25219997435574, + 23.191382446812554, + 27.09737855990577, + 9.401346986439512, + 41.12909363937521, + 10.636725988266168, + 12.7474421017658, + 25.018885125161514, + 29.60878931723247, + 8.566041240446257, + 9.837386481445924, + 25.57284494425814, + 14.107691762064361, + 12.338560157378357, + 10.647463219952549, + 37.56073519457399, + 18.118514164489557, + 31.704744356856995, + 34.3626840240305, + 31.934543949476044, + 29.531492116696665, + 29.244025575191, + 16.736459274853637, + 9.79616754197488, + 33.763519406883816, + 37.34687967781713, + 25.077945354350575, + 16.12935375846489, + 17.108857976484327, + 28.796961434410925, + 24.42794031909745, + 11.946009521442964, + 23.388901173608502, + 27.37094491299604, + 22.734946193123108, + 19.4023313826969, + 31.63049964484937, + 24.790607497523833, + 18.993693345466472, + 23.61698889453081, + 24.27955611113263, + 29.023589286705466, + 33.4750151629384, + 19.708746507565365, + 9.09043583961926, + 17.27891109256908, + 12.668706282719526, + 31.02959664943935, + 20.354670658731894, + 22.139594458677724, + 35.539457259086596, + 38.20596827405896, + 40.250630208720445, + 28.37532503523029, + 8.610006049869309, + 34.16599722444745, + 21.516767360653926, + 31.9350562331069, + 25.65418607123293, + 37.5378067370853, + 15.971053042410633, + 23.237996880646524, + 19.052647697225993, + 31.28133565101171, + 28.145105494573453, + 22.439193920560125, + 23.486117928922226, + 10.028637723262591, + 39.63269344304137, + 30.724981224995965, + 28.08927914158438, + 16.726710860609632, + 19.98910444072632, + 30.898745945991372, + 22.04060972033008, + 8.100027938244056, + 27.815863162752713, + 31.75957406914201, + 14.57434088392079, + 32.12804279250541, + 37.75784493478716, + 13.794524221126114, + 13.436083658300268, + 15.703016442485916, + 30.696754626422813, + 30.243018929814756, + 28.34203137716343, + 22.65891222251537, + 18.023172634001856, + 37.82736311159954, + 12.970401837401495, + 22.462678503398124, + 17.128413661441716, + 38.049155772552204, + 17.520489782554833, + 28.811223020790575, + 25.889587462202353, + 45.395919551429614, + 37.64791149992328, + 27.731116343635158, + 12.020120967948731, + 18.5471146629919, + 36.621613405402286, + 47.67376396836467, + 38.406555034390806, + 22.74126162960216, + 27.58555727512163, + 29.902470779490894, + 35.47709672652066, + 24.325005901754007, + 41.01602110167316, + 20.60614895396343, + 23.6462369194088, + 43.078228587194396, + 31.252659212840275, + 32.61497644141926, + 38.27017551368568 + ], + "ssh": [ + { + "time": "13:49:44", + "action": "failed", + "user": "admin", + "from": "185.172.15.70", + "method": "password" + }, + { + "time": "13:49:44", + "action": "failed", + "user": "oracle", + "from": "185.141.153.135", + "method": "password" + }, + { + "time": "13:49:44", + "action": "accepted", + "user": "anthony", + "from": "10.0.4.17", + "method": "publickey" + }, + { + "time": "13:49:44", + "action": "accepted", + "user": "ci", + "from": "198.51.100.9", + "method": "publickey" + } + ], + "http": { + "source": "/var/log/nginx/access.log", + "requestsPerSecond": 75.02351416898057, + "total": 7265, + "statusClasses": [ + { + "class": "2xx", + "count": 6248 + }, + { + "class": "3xx", + "count": 436 + }, + { + "class": "4xx", + "count": 436 + }, + { + "class": "5xx", + "count": 109 + }, + { + "class": "1xx", + "count": 17 + } + ], + "topPaths": [ + { + "path": "/", + "count": 2180 + }, + { + "path": "/api/health", + "count": 1308 + }, + { + "path": "/api/users", + "count": 1017 + }, + { + "path": "/assets/app.js", + "count": 872 + }, + { + "path": "/docs", + "count": 654 + }, + { + "path": "/api/ws", + "count": 509 + }, + { + "path": "/login", + "count": 436 + }, + { + "path": "/api/metrics", + "count": 291 + } + ], + "topClients": [ + { + "client": "10.0.4.17", + "count": 2180 + }, + { + "client": "203.0.113.42", + "count": 1816 + }, + { + "client": "198.51.100.9", + "count": 1453 + }, + { + "client": "192.168.1.42", + "count": 1090 + }, + { + "client": "172.16.0.8", + "count": 726 + } + ], + "methods": [ + { + "method": "GET", + "count": 5667 + }, + { + "method": "POST", + "count": 1162 + }, + { + "method": "PUT", + "count": 291 + }, + { + "method": "DELETE", + "count": 145 + } + ], + "upgrades": 17, + "recent": [ + { + "time": "13:49:44", + "method": "GET", + "path": "/api/users", + "status": "200", + "client": "192.168.1.42", + "bytes": 17331 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/metrics", + "status": "200", + "client": "198.51.100.9", + "bytes": 7480 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/login", + "status": "200", + "client": "192.168.1.42", + "bytes": 15526 + }, + { + "time": "13:49:44", + "method": "POST", + "path": "/api/users", + "status": "404", + "client": "172.16.0.8", + "bytes": 13914 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/assets/app.js", + "status": "200", + "client": "10.0.4.17", + "bytes": 19329 + }, + { + "time": "13:49:44", + "method": "POST", + "path": "/", + "status": "304", + "client": "172.16.0.8", + "bytes": 19902 + }, + { + "time": "13:49:44", + "method": "POST", + "path": "/", + "status": "304", + "client": "198.51.100.9", + "bytes": 880 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/metrics", + "status": "200", + "client": "198.51.100.9", + "bytes": 14377 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/login", + "status": "200", + "client": "172.16.0.8", + "bytes": 18870 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/users", + "status": "200", + "client": "198.51.100.9", + "bytes": 4587 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/health", + "status": "200", + "client": "198.51.100.9", + "bytes": 6939 + }, + { + "time": "13:49:44", + "method": "POST", + "path": "/api/health", + "status": "200", + "client": "198.51.100.9", + "bytes": 992 + }, + { + "time": "13:49:44", + "method": "DELETE", + "path": "/api/health", + "status": "404", + "client": "172.16.0.8", + "bytes": 13687 + }, + { + "time": "13:49:44", + "method": "POST", + "path": "/api/users", + "status": "304", + "client": "198.51.100.9", + "bytes": 5371 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/metrics", + "status": "200", + "client": "198.51.100.9", + "bytes": 21663 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/docs", + "status": "200", + "client": "192.168.1.42", + "bytes": 18577 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/users", + "status": "200", + "client": "203.0.113.42", + "bytes": 10584 + }, + { + "time": "13:49:44", + "method": "POST", + "path": "/api/ws", + "status": "200", + "client": "10.0.4.17", + "bytes": 21057 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/assets/app.js", + "status": "200", + "client": "192.168.1.42", + "bytes": 20649 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/login", + "status": "200", + "client": "198.51.100.9", + "bytes": 2779 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/users", + "status": "200", + "client": "172.16.0.8", + "bytes": 3793 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/", + "status": "200", + "client": "172.16.0.8", + "bytes": 16255 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/assets/app.js", + "status": "200", + "client": "10.0.4.17", + "bytes": 22680 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/users", + "status": "200", + "client": "198.51.100.9", + "bytes": 12774 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/", + "status": "200", + "client": "172.16.0.8", + "bytes": 2912 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/health", + "status": "200", + "client": "10.0.4.17", + "bytes": 17580 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/assets/app.js", + "status": "200", + "client": "172.16.0.8", + "bytes": 7604 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/metrics", + "status": "200", + "client": "10.0.4.17", + "bytes": 21344 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/ws", + "status": "200", + "client": "10.0.4.17", + "bytes": 6905 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/health", + "status": "200", + "client": "172.16.0.8", + "bytes": 5448 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/health", + "status": "200", + "client": "172.16.0.8", + "bytes": 14582 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/users", + "status": "200", + "client": "10.0.4.17", + "bytes": 7234 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/metrics", + "status": "200", + "client": "10.0.4.17", + "bytes": 7382 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/assets/app.js", + "status": "200", + "client": "172.16.0.8", + "bytes": 3633 + }, + { + "time": "13:49:44", + "method": "POST", + "path": "/api/users", + "status": "304", + "client": "192.168.1.42", + "bytes": 6589 + }, + { + "time": "13:49:44", + "method": "POST", + "path": "/docs", + "status": "304", + "client": "10.0.4.17", + "bytes": 5166 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/metrics", + "status": "200", + "client": "203.0.113.42", + "bytes": 18546 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/ws", + "status": "200", + "client": "198.51.100.9", + "bytes": 5823 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/ws", + "status": "200", + "client": "10.0.4.17", + "bytes": 13393 + }, + { + "time": "13:49:44", + "method": "GET", + "path": "/api/health", + "status": "200", + "client": "10.0.4.17", + "bytes": 14705 + } + ], + "history": [ + 40.436390021623765, + 47.70879070697048, + 50.841892100647996, + 53.04079305727723, + 52.11860104933719, + 49.50429850300951, + 54.38287076547219, + 47.11291615872612, + 48.75490062280885, + 64.92649399369957, + 51.276678174515496, + 61.8236723704777, + 50.61319901522146, + 48.36495593249391, + 45.9701177310622, + 48.50597379015082, + 64.35754323575189, + 66.492009325431, + 70.87250585314258, + 52.68691547158225, + 64.54027560097853, + 63.517682813650666, + 70.85903746333838, + 54.715656008881794, + 63.349372824281765, + 51.522365857070376, + 62.27363086217653, + 63.96117725169689, + 71.35048323756462, + 48.16528107667171, + 63.81596851007158, + 47.04819618832378, + 49.00981713402804, + 66.96883197037971, + 67.9610150526199, + 53.47055284003308, + 56.91773927227673, + 50.981768568935756, + 67.61023088883043, + 50.285427498815864, + 63.01600802618209, + 49.93120632421554, + 54.83862480262195, + 59.140809735411395, + 49.73554087300515, + 52.05814356024155, + 47.32096812805897, + 53.60007078408499, + 47.646535098396214, + 43.954878986122885, + 52.53705121004512, + 51.061524258921665, + 46.29803182789158, + 68.05977628343658, + 61.65761813621003, + 52.773693447148155, + 60.857947008182514, + 65.99496270111491, + 58.19588505457204, + 57.454333860060764, + 55.087333890238725, + 50.985320986881575, + 47.021977610905694, + 61.488571865900155, + 63.13366374151643, + 58.19468931027307, + 60.199830553232474, + 66.71264575359407, + 54.555522157885505, + 52.339882446676455, + 65.7500080059293, + 57.91865414739831, + 65.71576074932884, + 46.67950641637434, + 53.70941199383459, + 67.26631444234346, + 52.86779374840245, + 66.62847423734351, + 59.526086148757074, + 55.35002132372329, + 52.21357216985061, + 69.65207034228496, + 67.80084617162964, + 67.37533799983797, + 49.46310527673381, + 69.48435857339635, + 69.23129380686277, + 55.54287252195427, + 63.95291197557332, + 52.40938920723164, + 65.94200355226596, + 70.36515602298988, + 71.73516999982965, + 54.37300217453563, + 55.31650124755595, + 73.60092442999091, + 58.49817583263001, + 56.73028322104097, + 62.02400216779057, + 54.370674044278566, + 65.22404099486593, + 76.21024478432989, + 57.17902301007006, + 78.36178629554834, + 73.51587283683389, + 63.68508543118871, + 69.82875366712116, + 71.17245965031299, + 74.88571875078213, + 81.6725570250365, + 58.146924799030906, + 60.4663319107603, + 66.63799975167215, + 69.56295621662575, + 82.40708357975906, + 61.49180513071526, + 74.82437447196973, + 84.32502762268928, + 81.47078010423807, + 84.30236918734536, + 75.02351416898057 + ] + }, + "privileged": false + }, + "cpu": { + "total": 0.4838753769323337, + "cores": [ + 0.2859416989002501, + 0.2540791499403988, + 0.4893248734577541, + 0.5989851240754315, + 0.5081651587623439, + 0.4531481825782587, + 0.6001194081242874, + 0.5905314122711344, + 0.5903672491578601, + 0.5950011383356013, + 0.5804122560438653, + 0.2604288715408184 + ], + "history": [ + 23.095098489726517, + 30.85714228374777, + 30.613250708856704, + 29.47681393763425, + 33.077880359577534, + 32.336529266010956, + 31.916000969014295, + 31.824544826702013, + 31.265108637584188, + 31.18235297192225, + 30.66274609670781, + 30.42990500581233, + 30.859403984709783, + 30.683124637174803, + 30.632109882188686, + 31.01423345776802, + 31.44284106740654, + 31.343138969921398, + 31.094460916178246, + 31.060435066214225, + 31.27425356052269, + 31.711180494704266, + 31.675317870383978, + 32.04482670909656, + 31.75858168672984, + 31.92708607096238, + 32.28245918915744, + 31.909983645538958, + 31.42935468969263, + 31.48793245725832, + 31.007862542922116, + 30.909864812880055, + 31.010232985097325, + 31.01362671547005, + 31.182584952713615, + 30.83621649968836, + 30.693341697933512, + 30.423228167032544, + 30.69464883554758, + 30.625318091395204, + 30.011937289792144, + 29.772130496146914, + 29.790222223007422, + 30.24458811991652, + 30.48155348869829, + 30.142153586641683, + 29.60495821586554, + 28.867789489146706, + 28.636413753662733, + 28.51444880488976, + 28.251733604502753, + 28.218372250741925, + 28.489723965224627, + 28.711744148563223, + 27.827503752488752, + 27.961720653976453, + 27.794594863281784, + 27.739983153726616, + 28.327183083793155, + 28.75014556970441, + 28.397347735840935, + 28.820936821371152, + 28.453819450815438, + 28.464363562841914, + 28.36109206559127, + 27.919522182104036, + 27.987098893906055, + 27.862913332600563, + 28.188038347537276, + 28.288841957486273, + 28.001271903155395, + 28.05779115610647, + 27.695443021689737, + 28.535250159888452, + 29.331704607936455, + 29.705119896954802, + 29.452048146054395, + 29.804776245415166, + 30.208955108936813, + 30.584473777004657, + 31.01905517870079, + 31.36055861451667, + 31.043054914528856, + 31.14313022212249, + 31.401548270310624, + 32.495046560197984, + 32.93663975515989, + 33.39638510979044, + 33.68671057212768, + 33.28056204978639, + 33.247120004167705, + 34.51869302255119, + 34.95843510530667, + 35.270328118516545, + 35.527678847928215, + 36.556068678180445, + 37.64017307204415, + 38.050035874849186, + 38.196465195480656, + 38.7319297317396, + 39.48879952486901, + 40.142942994129946, + 40.55658085466287, + 40.65043288880554, + 41.70577419298845, + 41.673242332497985, + 42.5849335917411, + 42.90486903086699, + 43.78264969117185, + 44.14535591865401, + 44.71806569045531, + 45.46811258350189, + 45.76082021502144, + 45.82819526440872, + 46.57813932680086, + 47.15071992968501, + 47.46642882644589, + 47.93112964299766, + 48.15527013709106, + 48.38753769323337 + ], + "load": [ + 1.3860221806150874, + 0.9563440996589672, + 0.6748562964062538 + ], + "model": "Intel(R) Core(TM) i7-1260P 12th Gen", + "frequencyGhz": 2.7613009046376007 + }, + "memory": { + "total": 17179869184, + "used": 7587867338.071201, + "available": 9592001845.928799, + "cached": 4253902857.0494933, + "buffers": 1202590842.88, + "free": 4135508145.999305, + "history": [ + 41.86188151783568, + 41.855886761908046, + 41.849380492216696, + 41.837032963960674, + 41.84756119759001, + 41.853766825955304, + 41.85779082981364, + 41.86171662471107, + 41.86274112988743, + 41.863869369724256, + 41.86247491467038, + 41.860404346302445, + 41.861665350520916, + 41.86245650624669, + 41.863552708664606, + 41.867537338327956, + 41.87454466868664, + 41.881253484778206, + 41.88678462260501, + 41.8924846330454, + 41.89983187999726, + 41.910082381462644, + 41.920253954283794, + 41.93278247832593, + 41.94361527525785, + 41.95556530801821, + 41.96969759511632, + 41.98153558752096, + 41.9905447229161, + 42.00010131568822, + 42.006945725852454, + 42.01350560240738, + 42.02098496200391, + 42.02875556619794, + 42.03779580996987, + 42.044949809186456, + 42.05153238379681, + 42.05680820630521, + 42.06409134100603, + 42.07123642315936, + 42.07498555225584, + 42.077749455075754, + 42.08112425701715, + 42.087696518996864, + 42.09600141359847, + 42.10249355423215, + 42.10607623037296, + 42.1056944876325, + 42.10458070536947, + 42.10342733870356, + 42.10139124173609, + 42.0998920384321, + 42.10073051487949, + 42.10349313826904, + 42.10144546615849, + 42.10093807351832, + 42.100085194673746, + 42.09957848092849, + 42.10325077444024, + 42.109906968837954, + 42.11474256391109, + 42.12250615600436, + 42.12830633951634, + 42.13450647489616, + 42.14040288017256, + 42.14398014897922, + 42.14840830334063, + 42.1524933991684, + 42.15894761488899, + 42.16630571635032, + 42.17219140579843, + 42.178741966458915, + 42.18341006550727, + 42.19350186101584, + 42.20848514368948, + 42.22557618421441, + 42.24090974080447, + 42.25820754346879, + 42.27767912958213, + 42.29904288229362, + 42.32255759158844, + 42.347556252192994, + 42.37000962253406, + 42.39254942845256, + 42.416120380389735, + 42.44568036678506, + 42.47701745816955, + 42.51015065362825, + 42.54397255855325, + 42.574268812106, + 42.60345084287201, + 42.63940334816555, + 42.676795703092694, + 42.71478769387701, + 42.75302094684303, + 42.796108484026064, + 42.84414071787716, + 42.89282379205962, + 42.940543380491896, + 42.98968067439065, + 43.041492014489116, + 43.095226123852136, + 43.149342602557894, + 43.201902378273594, + 43.258750971784025, + 43.31314544217243, + 43.37087256224414, + 43.42821388151898, + 43.48853446730668, + 43.548593604670046, + 43.60966307134885, + 43.67275504674954, + 43.735023032128844, + 43.79515488289948, + 43.85735146044237, + 43.920443977427986, + 43.982845025633175, + 44.045481738247105, + 44.10689756761803, + 44.167200907082304 + ], + "swapTotal": 2147483648, + "swapUsed": 1338470030.7394922 + }, + "disks": [ + { + "device": "nvme0n1", + "mount": "/", + "type": "SSD", + "total": 549755813888, + "used": 146029056585.75214, + "readRate": 23305208.008736372, + "writeRate": 19794400.075301528, + "readHistory": [ + 28869695.98196447, + 13362792.564556003, + 22719380.484893918, + 17069921.342656016, + 91499910.088256, + 23618737.29340732, + 30048890.83094895, + 24288185.9857589, + 35695892.66739786, + 22435194.605961442, + 20199541.6469872, + 39973528.660833836, + 72560199.18061793, + 17041769.798845053, + 22948257.867246866, + 23717344.2710191, + 21299508.785828948, + 12704510.025680065, + 21572422.60314524, + 22689556.15349114, + 24638596.47139907, + 24422331.37227595, + 28958423.81939292, + 28140594.005584717, + 15944885.166361928, + 14987958.306446671, + 16841543.627902865, + 35983507.23087788, + 25569952.59784162, + 14175096.00520134, + 33291235.774755478, + 22001957.43329823, + 21666150.173172355, + 29535685.64169109, + 25751973.04598987, + 28230966.88091755, + 19985428.99824679, + 18666883.371770382, + 12098906.988278031, + 34481793.46136749, + 47853174.03264344, + 20512241.37470126, + 27345057.545229793, + 26909619.34067309, + 26696795.43748498, + 13672864.09623921, + 33624552.100896835, + 19893938.56935203, + 35533191.38288498, + 61216614.25381899, + 29804540.628567338, + 32195289.311930537, + 17345825.085416436, + 40043575.309216976, + 26382363.24302852, + 34477390.36381245, + 25623704.804107547, + 30579144.563525915, + 22804549.416527152, + 20282232.267782092, + 19179446.060210466, + 33354239.13784325, + 13903092.423453927, + 27219364.27988112, + 16055028.783157468, + 27839101.625606418, + 21665714.459493756, + 23950607.75242746, + 14649621.37117982, + 32042099.744081497, + 16705571.042373776, + 22710687.613114715, + 21938065.97404182, + 28441713.966429234, + 34068911.09235585, + 97826367.69115925, + 35388278.625905514, + 29181614.097207785, + 16537280.49248457, + 14323678.320273757, + 20342040.656134486, + 12609044.808894396, + 17437913.14959526, + 35149797.862395644, + 35224389.51395452, + 32551489.94922638, + 19553782.83932805, + 20657640.846446157, + 25915009.260177612, + 12749502.338469028, + 26225748.784840107, + 29298179.380595684, + 31061406.517401338, + 24678601.764142513, + 25475731.45106435, + 34216326.24976337, + 17534348.448738456, + 16738128.600642085, + 15945892.268791795, + 31397054.839879274, + 28826030.066236854, + 20215046.06679082, + 28653945.269063115, + 31752308.847382665, + 26520371.289923787, + 32314673.960208893, + 30341154.400259256, + 19920520.981773734, + 33552886.364981532, + 27732255.801558495, + 25489380.70423901, + 34682024.27588403, + 32765921.467915177, + 12090093.176811934, + 35897784.12692249, + 33586839.973926544, + 15136616.170406342, + 23843460.481613874, + 31629719.372838736, + 23305208.008736372 + ], + "writeHistory": [ + 6443690.092340112, + 15706037.254482506, + 12562640.15905559, + 9860456.81744814, + 33635442.90497899, + 8012893.7213122845, + 8776167.962551117, + 14768775.37988126, + 12295753.207281232, + 18820515.87201655, + 16407962.019592522, + 9376837.721318007, + 38684423.234164715, + 14132024.374455214, + 11035102.75952518, + 7102480.72206974, + 11199828.336238861, + 9206720.710024238, + 19970185.881704092, + 18680202.64863968, + 7689268.912598491, + 17772577.62797177, + 11057206.867486238, + 7703328.797444701, + 15602776.435241101, + 17869653.491154313, + 17981688.808500767, + 18463852.51455009, + 10391966.245919466, + 14634004.84755635, + 7456274.407580495, + 10924875.831082463, + 19646951.408162713, + 18024177.28252709, + 16195269.251838325, + 7039010.195285082, + 11673018.76232028, + 18310244.06388402, + 15938933.949396012, + 15143369.877934454, + 13646195.569857955, + 19684450.500681996, + 9746749.67251718, + 17688411.977738142, + 14949244.262874125, + 17220318.01700592, + 6280004.954561591, + 9211970.098167658, + 7686373.1662184, + 29096119.877174497, + 11168808.531984687, + 14719382.195770739, + 7552774.05180037, + 29010397.97835052, + 7886539.578661323, + 15889711.70246601, + 8038710.120543838, + 19468726.943284273, + 10024006.450027227, + 16452584.891617296, + 12883128.134384751, + 6357466.4602428675, + 6392710.183784366, + 8497763.145938516, + 12229841.793179512, + 17340929.815024137, + 19803084.11590755, + 11535816.67676568, + 7910684.633105993, + 18342021.075859666, + 15028389.478325842, + 17565308.347195387, + 8464680.605605245, + 15754347.16813266, + 18223657.94800222, + 40471961.00823581, + 7683978.999629617, + 12968261.116594076, + 8924224.971458316, + 11953996.99755013, + 15260396.467745302, + 7007661.508619785, + 14798021.47619426, + 16944059.12183225, + 15768167.300373314, + 13159580.795690417, + 10733298.452049494, + 6065959.706380963, + 17519904.607012868, + 20035468.750298023, + 12046220.526695251, + 10578346.979096532, + 9304382.766634226, + 8366344.533339143, + 13411414.838433266, + 8078297.1853762865, + 16305817.347988484, + 7368181.416615844, + 10904528.338685632, + 17306023.77489209, + 13869042.56477952, + 15171984.397843478, + 13587797.151356936, + 9566670.350208879, + 16992331.290096045, + 12923892.678916454, + 19986661.022007465, + 7886778.550073504, + 14914828.76524329, + 15577478.822171686, + 10607557.931765914, + 17066265.12095332, + 15799412.534236906, + 18702072.884738445, + 8259341.23121202, + 19740547.926574945, + 8663759.761154652, + 13159337.254464626, + 14511837.784051893, + 19794400.075301528 + ], + "iops": [ + 1942, + 1414 + ], + "temperature": 47.09826967343322 + }, + { + "device": "sda1", + "mount": "/data", + "type": "HDD", + "total": 2199023255552, + "used": 1121501896681.3076, + "readRate": 3320122.175384313, + "writeRate": 1471346.5730007738, + "readHistory": [ + 4172369.8074929416, + 2213106.9749593735, + 2666545.920073986, + 3000225.519994274, + 1634262.756910175, + 4285552.43415758, + 3960137.808462605, + 2366481.3777431846, + 3589897.416997701, + 4390221.622539684, + 2695832.5000014156, + 3156009.918311611, + 3441200.4947662354, + 2170222.307788208, + 4108212.816528976, + 4236840.480705723, + 2875128.2386947423, + 4265962.106874213, + 2317225.9451821446, + 2593909.058952704, + 4487254.421459511, + 3970185.521990061, + 1709992.2085180879, + 3825550.532899797, + 1886426.4548756182, + 1983999.928459525, + 3865454.4688761234, + 3223383.929114789, + 3649666.568264365, + 4353497.713571414, + 13881243.347655982, + 4406689.315102994, + 4374768.860405311, + 1844096.039654687, + 2490424.299845472, + 3417932.8023456037, + 1838589.1886427999, + 4088669.146411121, + 1612034.3003422022, + 3028927.3287635297, + 1728531.5457265824, + 2879093.8882157207, + 3638516.603037715, + 2140546.1893882602, + 1577768.1225445122, + 3076687.9809089005, + 3635670.737363398, + 2608161.6247538477, + 1930496.0095323622, + 2492791.507858783, + 4385996.903060004, + 2258253.008360043, + 2324697.8782117367, + 1701120.7721661776, + 2026773.5843081027, + 2186734.2637851834, + 3996485.902927816, + 1591723.0392806232, + 3233560.698805377, + 3979589.986614883, + 3564374.8878501356, + 1520838.7174643576, + 1926713.5122790933, + 46123802.3492042, + 2734802.4405073375, + 3628001.1855997145, + 2339093.9575619996, + 2584670.529467985, + 4397087.176563218, + 2444535.034010187, + 3089763.3600980043, + 2922544.758534059, + 4201969.333458692, + 1698731.8159081042, + 4055744.7778992355, + 3992670.9921564907, + 2933705.937350169, + 2906388.262985274, + 4202868.013875559, + 2632843.6047770083, + 4125825.4456333816, + 2067006.4617879689, + 2495727.497851476, + 4150002.052774653, + 3304394.1906653345, + 4032050.2433460206, + 48168768.30323599, + 1969486.6645149887, + 1768327.6939205825, + 2533307.40169622, + 3259481.380227953, + 3966343.9630530775, + 3787144.0169401467, + 3789230.8838199824, + 2589099.7988171875, + 3088802.519487217, + 2238039.400661364, + 3142371.8815203756, + 49633732.71538876, + 2761022.607330233, + 32487696.06789574, + 4445264.191366732, + 3069095.633458346, + 1734054.5793995261, + 3158187.7707503736, + 2255877.8424281627, + 3209260.7945669442, + 3061645.3906986862, + 61131227.1701172, + 3391135.9480116516, + 1804772.6363874972, + 1623668.8869539648, + 51900581.40270412, + 73432014.07091692, + 83404163.0977299, + 1932496.8941509724, + 3193310.7369113714, + 2411281.619919464, + 4312619.989039376, + 3320122.175384313 + ], + "writeHistory": [ + 1434945.0026918203, + 2051942.7704438565, + 784657.0057142526, + 2230215.3446059674, + 1675908.2031715661, + 1368661.5716479719, + 1369686.5568682551, + 1050834.1791853309, + 2228949.5359919965, + 1218162.748804316, + 1908318.741833791, + 2147763.5257691145, + 962209.7311355174, + 1868831.846611574, + 1619444.2341383547, + 1052363.2457759231, + 1653492.6584735513, + 1015477.4981830269, + 2150103.279137984, + 2095602.7327850459, + 2321998.876640573, + 2042752.9666386542, + 1443651.5239812434, + 938202.3942936212, + 2000773.321073502, + 1018023.0934917927, + 1548783.9362863451, + 855309.6982464194, + 1603271.5112250298, + 1572987.9750218242, + 6360406.506508589, + 1631632.2476416826, + 859104.40960899, + 1260920.3946590424, + 2063272.4016252903, + 2262382.9272855073, + 752092.2201499343, + 1855114.769870415, + 980444.8615573347, + 769833.0077156425, + 770047.3924912512, + 2263693.0736526847, + 1447082.5780928135, + 736193.736763671, + 2463992.159049958, + 1954787.6165527848, + 924973.099892959, + 940539.3799580634, + 1840577.0708620546, + 852370.2737689018, + 1195527.6053678244, + 1533755.7417154312, + 849644.9071727693, + 2235301.6691096127, + 1400958.870984614, + 2303255.2823703736, + 966616.9869527221, + 2114395.8645220846, + 730913.8577710837, + 1376662.1820442379, + 1193970.2946972102, + 1142677.6313502342, + 2294462.4040089548, + 18783173.6791227, + 2243990.282882005, + 1458700.1540511847, + 1909376.1310726402, + 1272045.0623240322, + 785006.2695611268, + 909436.1212849617, + 1836104.021715, + 2060353.6569792775, + 2483969.936594367, + 1322317.7959956229, + 1396062.2000321746, + 1343578.2436095178, + 1568537.9304271191, + 1117930.6422639638, + 1025709.483101964, + 2412415.759898722, + 2308808.144442737, + 762469.2916218191, + 1455019.283182919, + 1640308.823743835, + 1757490.9292906523, + 2348466.3460683078, + 19547462.541358545, + 2434537.0909944177, + 1077820.3459270298, + 1996583.1422992048, + 837105.0251461565, + 1664759.9609848112, + 1261935.8010403812, + 1813884.7166486082, + 889256.3977092505, + 2439955.1473371685, + 1986208.185162395, + 2230393.429873511, + 20870435.016816482, + 2109309.903373942, + 12940428.07652615, + 2354791.385103017, + 1691842.2786332667, + 1093815.9185741097, + 1599732.975549996, + 1327941.6565597057, + 934004.6743210405, + 1688899.8115248978, + 24880991.20415747, + 1603056.4118921757, + 1800547.8652473537, + 1241758.686332032, + 21978064.360776916, + 30519908.01010281, + 33617383.18827003, + 874529.267270118, + 2356521.72004804, + 1854921.265672892, + 1663832.9380005598, + 1471346.5730007738 + ], + "iops": [ + 277, + 105 + ], + "temperature": 41.38989277686558 + } + ], + "network": { + "interface": "en0", + "speed": "1 Gb/s", + "ip": "192.168.1.42", + "mac": "ac:de:48:00:11:22", + "downRate": 7891399.331520743, + "upRate": 3248038.5673410497, + "downHistory": [ + 7025253.717903149, + 8155774.037052375, + 7077672.981340021, + 8100006.869896598, + 7178801.001805875, + 9118217.44097301, + 7336827.835871135, + 10629400.035984848, + 9402230.679452717, + 10604927.69364883, + 7554219.486367154, + 7786050.838824329, + 9075807.419296775, + 7504893.414104206, + 9633481.245961636, + 9081911.511520736, + 9470692.244284954, + 10708701.146187054, + 10903544.980920086, + 8159343.435012636, + 8241027.903482816, + 11208660.91671394, + 11520415.951379733, + 10336300.083616331, + 9435290.22621462, + 8306328.5546635445, + 9191239.75899348, + 9210980.01701122, + 8922021.966883596, + 11334766.275318665, + 9763709.55025767, + 8839104.254333105, + 8995500.09486488, + 61275985.19275969, + 9661484.16623016, + 9902805.477272812, + 8779635.341230098, + 11689816.741633104, + 12180333.171237372, + 8570329.986898027, + 12308887.915022532, + 10270312.506237656, + 11715656.904563095, + 10584688.393093796, + 10790465.392442677, + 10960420.085194243, + 10524032.65295292, + 9999135.596075293, + 10244528.891895374, + 23195724.39004582, + 10135335.077177696, + 10587181.47859768, + 9819249.134377208, + 12063709.328501428, + 25973251.67396511, + 12340900.472933654, + 11971353.284190957, + 10161713.368705535, + 10674605.354705155, + 65657597.84591789, + 9225177.461000903, + 11977100.277427755, + 10184919.426456198, + 12427949.597814318, + 11336809.97304783, + 9431676.394310217, + 9985457.48564931, + 11990432.725512583, + 11488888.15395568, + 10837185.294258606, + 11826946.66407229, + 11800569.75395126, + 11020519.165718934, + 10350266.44095236, + 12374875.111146394, + 25945999.676867336, + 10305429.829687182, + 11378173.398592405, + 10691787.166441737, + 8924958.513920365, + 9517088.055364864, + 11374894.855767742, + 12323214.063903634, + 10390262.21847989, + 10249545.499129333, + 8728546.957897695, + 12072986.260060279, + 9935770.770613454, + 12001704.260609899, + 11859235.898630638, + 9784533.576170743, + 8852142.773003206, + 8324099.301974392, + 8584526.87829082, + 8937788.906124888, + 9151376.123102702, + 10271945.045942586, + 10519436.632021576, + 8475911.351731159, + 55847897.6871839, + 9917657.190729752, + 10881740.493898587, + 9612312.451714877, + 11202555.37705558, + 10044539.37910955, + 7486665.742287551, + 10061132.703376489, + 8806366.1228141, + 7345961.154687926, + 8190537.94160879, + 9896787.380893119, + 9123573.737345172, + 7797309.682702398, + 9600980.429275515, + 9186893.659106448, + 8179873.387415978, + 9817668.646690449, + 10012912.269399926, + 8381661.138098026, + 7891399.331520743 + ], + "upHistory": [ + 2749792.7730349796, + 2186300.857132024, + 3057963.64488638, + 2421436.9057445377, + 2491596.670040411, + 2066083.8601513624, + 2297383.608328673, + 3144847.262150008, + 2766078.045952854, + 2333988.0830698097, + 1843592.739101265, + 3093005.0027952446, + 3124760.581045733, + 2908529.5735856267, + 3100017.8871882902, + 2124555.550576704, + 2404679.753356058, + 2545417.250388899, + 2627775.40745775, + 2600887.9436392495, + 3116221.2322626607, + 2626975.888694112, + 2067914.1516513627, + 2330019.7956408244, + 2566237.791263652, + 2917312.1470946134, + 2939425.624946569, + 2767340.125979031, + 3386571.5958579066, + 2959477.6212119283, + 2405716.5463273637, + 2586109.112225018, + 2174212.2419287567, + 17833274.66139427, + 3081420.8116531903, + 3095753.7893293775, + 2545029.9772268888, + 3577001.3476070194, + 2812649.437603187, + 2437035.620953594, + 2551814.9722578563, + 3074901.9483456463, + 3442916.35615718, + 3590510.7719791615, + 3352271.758653491, + 3542968.546025512, + 2481832.8734486955, + 2566595.527208781, + 3265339.077993569, + 6673105.600709838, + 2588680.2895890586, + 3392794.1591002485, + 3575257.2554691667, + 2567207.2081094906, + 8148081.8757468, + 2984303.7481721705, + 3546581.88751497, + 2977759.6857150425, + 2761521.856040421, + 18690242.818096157, + 2667260.0442900476, + 2956203.1711258767, + 3524088.362399021, + 3321552.3206192674, + 2624095.30638679, + 3712145.181335554, + 3773140.014013581, + 2795115.6645983076, + 3401187.1511969427, + 3318057.892552937, + 2819894.5586157427, + 2599513.667494444, + 3789237.715055664, + 2813642.6507586734, + 2842081.212625161, + 7592921.088959839, + 2782749.200533132, + 3283249.225408309, + 3785472.1723586093, + 3317053.002284096, + 3622695.740139251, + 3705396.132069232, + 2871498.597262536, + 3890779.0809019, + 3452716.7341161235, + 4083659.118372198, + 2925251.868703734, + 2973120.715147059, + 2609702.820654114, + 3528338.3304564413, + 3442459.350587403, + 3949627.6095428187, + 2640368.0368714696, + 3586193.378196718, + 2607304.5878690085, + 3037891.8189263665, + 3077417.1461991533, + 3909454.8573102085, + 3960419.1649290686, + 17537447.533883784, + 2668105.652692586, + 3807241.2757928986, + 3644745.22653316, + 2732608.0717954673, + 2693841.885261945, + 4068870.2211978775, + 3380415.210851684, + 3507453.997554162, + 2931495.4173989464, + 3279611.798197654, + 2724816.0998861874, + 3300110.6583366864, + 3339605.0779659715, + 2968813.317305172, + 3007268.7916498743, + 3446088.94347751, + 2535654.7330304594, + 2673510.563665864, + 2885576.378473373, + 3248038.5673410497 + ], + "downTotal": 13671914280.978863, + "upTotal": 3479195536.054852, + "downPeak": 65657597.84591789, + "upPeak": 18690242.818096157 + }, + "processes": [ + { + "pid": 40344, + "name": "ssh", + "cpu": 50.021494071464986, + "mem": 0.5036076683551074, + "rss": 13401679.599609375, + "threads": 15, + "user": "dev", + "command": "ssh deploy@prod", + "state": "S" + }, + { + "pid": 26848, + "name": "docker", + "cpu": 31.865783848799765, + "mem": 5.059937346726653, + "rss": 248470269.2890625, + "threads": 2, + "user": "root", + "command": "dockerd -H unix:///var/run/docker.sock", + "state": "S" + }, + { + "pid": 13725, + "name": "nginx", + "cpu": 28.34055506531149, + "mem": 3.420652775745839, + "rss": 172437430.24804688, + "threads": 16, + "user": "www-data", + "command": "nginx: worker process", + "state": "R" + }, + { + "pid": 15055, + "name": "node", + "cpu": 27.762788597960025, + "mem": 3.6113079253584126, + "rss": 194242417.17773438, + "threads": 26, + "user": "dev", + "command": "node index.js", + "state": "R" + }, + { + "pid": 28963, + "name": "chrome", + "cpu": 23.395272554364055, + "mem": 2.132857508305461, + "rss": 11438956.31640625, + "threads": 12, + "user": "dev", + "command": "chrome --type=renderer", + "state": "S" + }, + { + "pid": 29751, + "name": "python", + "cpu": 17.49790712306276, + "mem": 6.910427920706572, + "rss": 73352427.52929688, + "threads": 27, + "user": "dev", + "command": "python worker.py", + "state": "R" + }, + { + "pid": 13458, + "name": "redis-server", + "cpu": 10.037278552539647, + "mem": 3.124368785973639, + "rss": 28196620.291015625, + "threads": 5, + "user": "redis", + "command": "redis-server *:6379", + "state": "R" + }, + { + "pid": 27222, + "name": "rustc", + "cpu": 6.947565608192235, + "mem": 4.191203259397298, + "rss": 256446053.38867188, + "threads": 14, + "user": "dev", + "command": "rustc --edition 2021 src/main.rs", + "state": "R" + }, + { + "pid": 16055, + "name": "systemd", + "cpu": 4.42256407532841, + "mem": 5.446574072260411, + "rss": 331806683.28125, + "threads": 30, + "user": "root", + "command": "/sbin/init", + "state": "R" + }, + { + "pid": 44220, + "name": "code", + "cpu": 3.7363769290968776, + "mem": 6.352349482849241, + "rss": 37123376.146484375, + "threads": 6, + "user": "dev", + "command": "code --unity-launch", + "state": "R" + }, + { + "pid": 33905, + "name": "postgres", + "cpu": 2.37702462868765, + "mem": 0.6176048647612333, + "rss": 131736989.80078125, + "threads": 4, + "user": "postgres", + "command": "postgres -D /var/lib/postgresql/data", + "state": "R" + }, + { + "pid": 14107, + "name": "bun", + "cpu": 2.3552859532646835, + "mem": 0.33871206119656566, + "rss": 106033327.1015625, + "threads": 10, + "user": "dev", + "command": "bun server.ts", + "state": "S" + } + ], + "temperatures": [ + { + "label": "CPU Package", + "value": 55.01816847949359, + "max": 100 + }, + { + "label": "CPU Core #1", + "value": 46.463604184123504, + "max": 100 + }, + { + "label": "CPU Core #2", + "value": 46.172469935128184, + "max": 100 + }, + { + "label": "CPU Core #3", + "value": 53.43258825254411, + "max": 100 + }, + { + "label": "CPU Core #4", + "value": 54.80641006451574, + "max": 100 + }, + { + "label": "CPU Core #5", + "value": 50.654604234103296, + "max": 100 + }, + { + "label": "CPU Core #6", + "value": 49.25966466210429, + "max": 100 + }, + { + "label": "GPU Package", + "value": 48.07515626086947, + "max": 100 + }, + { + "label": "SSD (nvme0n1)", + "value": 47.05614891347823, + "max": 85 + } + ], + "sensors": [ + { + "label": "Fan Speed 1", + "value": "2477 RPM" + }, + { + "label": "Fan Speed 2", + "value": "2329 RPM" + }, + { + "label": "Battery", + "value": "97%" + }, + { + "label": "CPU Voltage", + "value": "0.97 V" + }, + { + "label": "CPU Power", + "value": "16.6 W" + }, + { + "label": "GPU Power", + "value": "8.4 W" + } + ], + "system": { + "os": "Ubuntu 24.04 LTS", + "kernel": "6.8.0-31-generic", + "hostname": "devbox", + "shell": "bash 5.2.21", + "terminal": "hqtui", + "uptime": 9266.100000000044, + "processCount": 247, + "threadCount": 998, + "contextSwitches": 57678 + }, + "logs": [ + { + "time": "12:00:00", + "level": "INFO", + "message": "User authenticated successfully", + "meta": "service: auth" + }, + { + "time": "12:00:00", + "level": "DEBUG", + "message": "Response time: 142ms", + "meta": "service: api" + }, + { + "time": "12:00:00", + "level": "ERROR", + "message": "Failed to fetch user profile", + "meta": "service: api" + }, + { + "time": "12:00:00", + "level": "INFO", + "message": "Background job \"cleanup\" completed in 120ms", + "meta": "service: job" + }, + { + "time": "12:00:00", + "level": "DEBUG", + "message": "Response time: 142ms", + "meta": "service: api" + }, + { + "time": "12:00:00", + "level": "WARN", + "message": "Retrying in 2 seconds (attempt 2/3)", + "meta": "service: api" + }, + { + "time": "12:00:00", + "level": "WARN", + "message": "Cache miss for key: user:48231", + "meta": "service: cache" + }, + { + "time": "12:00:00", + "level": "ERROR", + "message": "Failed to fetch user profile", + "meta": "service: api" + }, + { + "time": "12:00:00", + "level": "INFO", + "message": "Database connection established", + "meta": "service: db" + }, + { + "time": "12:00:00", + "level": "INFO", + "message": "Migration 0042_add_index applied", + "meta": "service: db" + } + ] +} +)hqtuisample"; } diff --git a/ports/nim/src/hqtui/engine/cpp/demo/json.hpp b/ports/nim/src/hqtui/engine/cpp/demo/json.hpp new file mode 100644 index 0000000..9d4e7f5 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/demo/json.hpp @@ -0,0 +1,293 @@ +#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; + } + /// A JSON true, or a number the caller wrote instead. Anything else is the + /// fallback, so a missing key and an explicit false read the same. + bool b(bool fallback = false) const { + if (auto p = std::get_if(&value)) + return *p; + if (auto p = std::get_if(&value)) + return *p != 0; + 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/nim/src/hqtui/engine/cpp/demo/main.cpp b/ports/nim/src/hqtui/engine/cpp/demo/main.cpp new file mode 100644 index 0000000..ce9602c --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/demo/main.cpp @@ -0,0 +1,704 @@ +#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"}, + {"c", "Collapse"}, + {"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 == "c") + // Shows what collapsed borders do, live. Worth a key because the + // difference is only visible when panels sit next to each other. + s.collapse = !s.collapse; + 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 hqtui_demo_main(int argc, char **argv) { + demo::interrupted = 0; + 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, state.collapse); + 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, state.collapse); + 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; + } +} +#ifndef HQTUI_DEMO_LIBRARY +int main(int argc, char **argv) { return hqtui_demo_main(argc, argv); } +#endif +#endif diff --git a/ports/nim/src/hqtui/engine/cpp/demo/model.hpp b/ports/nim/src/hqtui/engine/cpp/demo/model.hpp new file mode 100644 index 0000000..9494728 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/demo/model.hpp @@ -0,0 +1,181 @@ +#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; + // The demo's bars are segmented, like btop's. The library default is smooth, + // matching every other port, so this asks rather than assuming. + m.segmented = true; + 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, collapse = false; + + // The seam between panels: zero while collapsed, so their borders merge. The + // library merges a seam only where two bordered siblings already touch, so + // toggling `collapse` without closing the gap moved nothing. + // Read it where the seam is declared, not into a local a nested body + // captures by reference: panel bodies are closures the UI runs at flush, + // long after the screen function returned. + int panel_gap() const { return collapse ? 0 : 1; } + 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/nim/src/hqtui/engine/cpp/demo/showcase.cpp b/ports/nim/src/hqtui/engine/cpp/demo/showcase.cpp new file mode 100644 index 0000000..425c531 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/demo/showcase.cpp @@ -0,0 +1,586 @@ +#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) { + const int gap = s.panel_gap(); + 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(), gap, [=](UI &r) { + r.col(fr(), gap, [=](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); + }); + }, true); + r.col(fr(), gap, [=](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); + }); + }); + }, true); + }); +} +static void theme_screen(UI &ui, State &s) { + const int gap = s.panel_gap(); + 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(), gap, [&, gap](UI &grid) { + for (int row = 0; row < 3; row++) + grid.row(fr(), gap, [&, 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); + } + }, true); + }); +} +static void input_screen(UI &ui, State &s) { + const int gap = s.panel_gap(); + auto t = ui.t(); + ui.row(fr(), gap, [&, 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) { + const int gap = s.panel_gap(); + auto t = ui.t(); + ui.row(cells(3), gap, [&, 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 int gap = s.panel_gap(); + const auto &c = s.data["cpu"], &m = s.data["memory"], &n = s.data["network"]; + ui.row(fr(), gap, [&, t](UI &r) { + r.col(fr(), s.panel_gap(), [&, 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)); + }, true); + r.col(fr(), s.panel_gap(), [&, 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); + }); + }, 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/nim/src/hqtui/engine/cpp/demo/telemetry.cpp b/ports/nim/src/hqtui/engine/cpp/demo/telemetry.cpp new file mode 100644 index 0000000..20ebefe --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/demo/telemetry.cpp @@ -0,0 +1,545 @@ +#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) { + const int gap = s.panel_gap(); + 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), gap, [&, 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(), gap, [&, t](UI &r) { + r.col(fr(), s.panel_gap(), [&, 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); + }, true); + r.col(fr(.85), s.panel_gap(), [&, 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); + }, true); + }); + 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) { + const int gap = s.panel_gap(); + auto t = ui.t(); + const auto &d = s.data["telemetry"]; + ui.row(cells(9), gap, [&, 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(), gap, [&, 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), s.panel_gap(), [&, 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); + }, true); + }); +} +static void network(UI &ui, State &s) { + const int gap = s.panel_gap(); + 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), gap, [&, 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(), gap, [&, 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), s.panel_gap(), [&, 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); + }, true); + }); +} +static void services(UI &ui, State &s) { + auto t = ui.t(); + const int gap = s.panel_gap(); + const auto &d = s.data["telemetry"]; + int failed = 0; + for (auto &v : d["services"].array()) + failed += v["active"].s() == "failed"; + ui.row(fr(), gap, [&, 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), s.panel_gap(), [&, 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); + }, true); + }); + 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/nim/src/hqtui/engine/cpp/include/hqtui.hpp b/ports/nim/src/hqtui/engine/cpp/include/hqtui.hpp new file mode 100644 index 0000000..15b21b6 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/include/hqtui.hpp @@ -0,0 +1,129 @@ +#ifndef HQTUI_HPP +#define HQTUI_HPP +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// C++17 ownership and value types over the same native C hot path. No virtual +// dispatch, callback erasure, GC, or per-cell C++ allocation. Static linking with +// LTO can inline across the C/C++ boundary; correctness never depends on LTO. +namespace hqtui { +using Color = hq_color; +using Rect = hq_rect; +using Cell = hq_cell; +using Constraint = hq_constraint; + +struct Style : hq_style { + constexpr Style() noexcept : hq_style{0,0,0,0} {} + constexpr Style(Color fg, Color bg, std::uint16_t attrs=0) noexcept + : hq_style{fg,bg,attrs,HQ_STYLE_ALL} {} + constexpr Style foreground(Color color) const noexcept { + auto s=*this; s.fg=color; s.mask|=HQ_STYLE_FG; return s; + } + constexpr Style background(Color color) const noexcept { + auto s=*this; s.bg=color; s.mask|=HQ_STYLE_BG; return s; + } + constexpr Style attributes(std::uint16_t flags) const noexcept { + auto s=*this; s.attrs=flags; s.mask|=HQ_STYLE_ATTRS; return s; + } +}; +constexpr Color rgb(int r,int g,int b) noexcept { + return 0x1000000u | (static_cast(r)&255u)<<16 | + (static_cast(g)&255u)<<8 | (static_cast(b)&255u); +} +inline std::size_t width(std::string_view text) noexcept { return hq_text_width_n(text.data(),text.size()); } + +class Surface { + hq_surface 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)); } + Surface box(hq_box_options options={}) const noexcept { return Surface(hq_surface_box(surface_,options)); } + void set(int x,int y,std::uint32_t cp,Style style={}) const noexcept { hq_surface_set(surface_,x,y,cp,style); } + void fill(std::uint32_t cp=' ',Style style={}) const noexcept { hq_surface_fill(surface_,cp,style); } + std::size_t text(int x,int y,const char *text,hq_text_options options={}) const noexcept { + return hq_surface_text(surface_,x,y,text,options); + } +}; + +class Buffer { + struct Destroy { void operator()(hq_buffer *p) const noexcept { hq_buffer_destroy(p); } }; + std::unique_ptr buffer_; +public: + Buffer(int width,int height) : buffer_(hq_buffer_create(width,height)) { + if(!buffer_) throw std::runtime_error("hqtui: invalid buffer dimensions or allocation failure"); + } + Buffer(Buffer&&) noexcept=default; + Buffer& operator=(Buffer&&) noexcept=default; + Buffer(const Buffer&)=delete; + Buffer& operator=(const Buffer&)=delete; + hq_buffer *native_handle() noexcept { return buffer_.get(); } + const hq_buffer *native_handle() const noexcept { return buffer_.get(); } + int width() const noexcept { return hq_buffer_width(buffer_.get()); } + int height() const noexcept { return hq_buffer_height(buffer_.get()); } + std::size_t allocations() const noexcept { return hq_buffer_allocations(buffer_.get()); } + Surface surface(const hq_theme *theme=nullptr) noexcept { return Surface(hq_surface_root(buffer_.get(),theme)); } + void resize(int width,int height) { + if(!hq_buffer_resize(buffer_.get(),width,height)) throw std::runtime_error("hqtui: resize failed"); + } + void clear(Color background=0,Color foreground=0) noexcept { hq_buffer_clear(buffer_.get(),background,foreground); } + int set(int x,int y,std::uint32_t cp,Style style={}) noexcept { return hq_buffer_set(buffer_.get(),x,y,cp,style); } + std::size_t write(int x,int y,std::string_view text,Style style={}, + std::size_t max_columns=std::numeric_limits::max()) noexcept { + return hq_buffer_write_n(buffer_.get(),x,y,text.data(),text.size(),style,max_columns); + } + void fill(Rect rect,std::uint32_t cp=' ',Style style={}) noexcept { hq_buffer_fill(buffer_.get(),rect,cp,style); } + void restyle(Rect rect,Style style) noexcept { hq_buffer_style(buffer_.get(),rect,style); } + Cell cell(int x,int y) const { + Cell c{}; + if(!hq_buffer_cell(buffer_.get(),x,y,&c)) throw std::out_of_range("hqtui: cell outside buffer"); + return c; + } + void copy_from(const Buffer &other) { + if(!hq_buffer_copy(buffer_.get(),other.buffer_.get())) throw std::bad_alloc(); + } + std::string row(int y) const { + std::size_t n=hq_buffer_row(buffer_.get(),y,nullptr,0); + std::string result(n,'\0'); + hq_buffer_row(buffer_.get(),y,result.data(),n+1); + return result; + } +}; + +struct Encoded { + // Borrowed until the next encode() call or destruction of the encoder. + std::string_view output; + std::size_t changed_cells,dirty_rows; +}; +class Encoder { + struct Destroy { void operator()(hq_encoder *p) const noexcept { hq_encoder_destroy(p); } }; + std::unique_ptr encoder_; +public: + explicit Encoder(int colors=16777216,bool monochrome=false) + : encoder_(hq_encoder_create(colors,monochrome)) { + if(!encoder_) throw std::runtime_error("hqtui: invalid color mode or allocation failure"); + } + Encoder(Encoder&&) noexcept=default; + Encoder& operator=(Encoder&&) noexcept=default; + Encoder(const Encoder&)=delete; + Encoder& operator=(const Encoder&)=delete; + void invalidate() noexcept { hq_encoder_invalidate(encoder_.get()); } + std::size_t allocations() const noexcept { return hq_encoder_allocations(encoder_.get()); } + Encoded encode(const Buffer &previous,const Buffer &next,bool full=false) { + hq_encoded out{}; + if(!hq_encode(encoder_.get(),previous.native_handle(),next.native_handle(),full,&out)) throw std::bad_alloc(); + return {{out.data,out.length},out.changed_cells,out.dirty_rows}; + } +}; +} +#endif diff --git a/ports/nim/src/hqtui/engine/cpp/include/hqtui/widgets.hpp b/ports/nim/src/hqtui/engine/cpp/include/hqtui/widgets.hpp new file mode 100644 index 0000000..21ff4ad --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/include/hqtui/widgets.hpp @@ -0,0 +1,1206 @@ +#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; +} +/// `s` with its first `columns` display columns removed. +/// +/// For scrolling a line sideways. Slicing by bytes would cut inside a grapheme +/// and corrupt it, and a scroll that lands in the middle of a wide character +/// cannot draw half of it -- what is left of that character is a space, which +/// is what a terminal shows when a double-width cell is clipped. +inline std::string drop_columns(std::string_view s, int columns) { + if (columns <= 0) + return std::string(s); + std::string out; + int skipped = 0; + std::size_t i = 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); + auto cell = s.substr(i, len); + int cw = int(width(cell)); + if (skipped >= columns) { + out.append(cell); + } else { + skipped += cw; + // A wide character straddling the cut leaves its trailing half behind. + if (skipped > columns) + out.append(std::size_t(skipped - columns), ' '); + } + i += len; + } + return out; +} +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; } +/// `4` rather than `4.0`, `4.2` rather than `4.20`. +std::string number(double); +/// Returns the columns written, so a caller laying items out in a row can +/// advance by what was actually drawn rather than by what it hoped to draw. +inline std::size_t 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); + return 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}; +} +/// Aligned, styled, optionally wrapped copy. Every other widget is built on it. +struct TextStyle { + Color fg = 0; + std::optional bg; + int align = HQ_LEFT; + int attrs = 0; + /// Wrap on the surface width rather than truncating at the edge. + bool wrap = false; + /// First line to show, counted after wrapping. + /// + /// After wrapping is the only place this can be correct: the caller does not + /// know how many lines their text became, and pre-slicing the string means + /// re-deciding every time the width changes. + int scroll = 0; + /// Columns to shift the text left by, for lines wider than the surface. + int scroll_x = 0; +}; +void draw_text(Surface, std::string_view content, const TextStyle & = {}); + +struct Clear { + /// What to leave behind. Zero means the theme's background. + Color background = 0; +}; +/// Reset a region to empty, so an overlay can draw over what was there. +/// +/// Without this an overlay is drawn *into* whatever it lands on: the cells it +/// does not touch keep the widget underneath, and a dialog ends up with someone +/// else's table showing through the gaps between its words. +void draw_clear(Surface, const Clear & = {}); + +struct Fill { + /// The symbol to repeat. A wide one is stepped over rather than written per + /// column, since each glyph owns a continuation cell. + std::string symbol = " "; + Color fg = 0; + std::optional bg; + int attrs = 0; +}; +/// Flood a region with one repeated symbol and style. +void draw_fill(Surface, const Fill & = {}); + +enum BadgeVariant { HQ_BADGE_FILLED, HQ_BADGE_OUTLINE, HQ_BADGE_SUBTLE }; +struct Badge { + std::string text; + Color color = 0; + int variant = HQ_BADGE_FILLED; + int align = HQ_LEFT; +}; +/// Returns the columns the chip occupies. +int draw_badge(Surface, const Badge &); + +struct Divider { + std::string label; + Color color = 0; + /// The rule character. Empty means the theme's horizontal line. + std::string ch; + int align = HQ_LEFT; +}; +void draw_divider(Surface, const Divider & = {}); + +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; + /// Labels along the bottom, e.g. {"60s", "30s", "0s"}. Costs a row. + std::vector time_axis; + 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; + // Round the tie robustly: plot geometry runs through cos and sin, and libm + // implementations differ by an ULP at the exact angles where a coordinate + // lands on .5 -- cos(120 degrees) is -0.5, and V8 returns a hair under it + // where others return a hair over. Rounding the raw value lets that ULP + // decide a pixel, so the same widget at the same size differs between + // ports. The tolerance is far wider than an ULP and far narrower than + // anything geometric. + int x = iround(dx >= 0 ? dx + 1e-9 : dx - 1e-9); + int y = iround(dy >= 0 ? dy + 1e-9 : dy - 1e-9); + 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; + } + } + } + /// The inclusive row/column span an axis-aligned loop should cover, clipped + /// to the canvas. Nothing outside it can draw, so clipping here is what makes + /// every loop below finite for any input -- infinite, enormous, or NaN. + std::pair span(double a, double b, int limit) const { + double lo = std::min(a, b), hi = std::max(a, b); + if (!(lo <= hi)) + return {0, -1}; + return {std::max(0, int(std::ceil(lo))), std::min(limit - 1, int(std::floor(hi)))}; + } + void vline(double x, double y0, double y1) { + auto [a, b] = span(y0, y1, h); + for (int y = a; y <= b; y++) + pixel(x, y); + } + void hline(double y, double x0, double x1) { + auto [a, b] = span(x0, x1, w); + for (int x = a; x <= b; x++) + pixel(x, y); + } + void rect(double x0, double y0, double x1, double y1) { + hline(y0, x0, x1); + hline(y1, x0, x1); + vline(x0, y0, y1); + vline(x1, y0, y1); + } + void fill_rect(double x0, double y0, double x1, double y1) { + auto [a, b] = span(y0, y1, h); + for (int y = a; y <= b; y++) + hline(y, x0, x1); + } + void circle(double cx, double cy, double radius) { + // A radius larger than the canvas draws the same arc as one exactly its + // size, and an unbounded one never finishes the `x >= y` walk. + if (std::isnan(radius)) + return; + int x = int(std::floor(std::min(std::abs(radius), double(w + h)) + .5)); + int y = 0, err = 1 - x; + while (x >= y) { + pixel(cx + x, cy + y); + pixel(cx + y, cy + x); + pixel(cx - y, cy + x); + pixel(cx - x, cy + y); + pixel(cx - x, cy - y); + pixel(cx - y, cy - x); + pixel(cx + y, cy - x); + pixel(cx + x, cy - y); + y++; + if (err < 0) { + err += 2 * y + 1; + } else { + x--; + err += 2 * (y - x) + 1; + } + } + } + void blit(Surface s, Color color, std::optional bg = {}) const { + blit(s, [color](int, int) { return color; }, bg); + } + /// Blit with a colour chosen per cell, for a canvas whose parts differ. + void blit(Surface s, const std::function &color_at, + 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_at(x, y)); + if (bg) + st = st.background(*bg); + s.set(x, y, 0x2800 + d, st); + } + } +}; +/// How a bar's fill is drawn. Smooth uses partial blocks; segmented draws +/// discrete ticks, which is the btop look and keeps stacked bars separable; +/// ascii is the fallback for a terminal without the glyphs. +enum BarStyle { HQ_BAR_SMOOTH, HQ_BAR_SEGMENTED, HQ_BAR_ASCII }; + +struct Bar { + /// Already a 0-1 ratio. + double value = 0; + Color color = 0; + /// Colour along the theme's heat ramp by fill level rather than one colour. + bool heat = false; + int style = HQ_BAR_SMOOTH; + std::optional background; +}; +void draw_bar(Surface, const Bar &); + +struct Meter { + double value = 0; + /// When set, `value` is absolute and this is its maximum. + double max = 0; + std::string label, readout; + Color color = 0; + int label_width = -1, value_width = -1; + bool show_value = true; + /// Discrete ticks rather than partial blocks. Smooth is the default in every + /// port; this one defaulted the other way and quietly drew a different meter + /// from the rest of the library. + bool segmented = false; + /// Ascii fill, for a terminal without block glyphs. Wins over `segmented`. + bool ascii = false; + /// Green-to-red by fill level. Defaults to on when no colour was given, so + /// the readout and the bar agree about what "hot" means. + std::optional heat; + std::optional background; +}; +void draw_meter(Surface, const Meter &); + +struct MeterItem { + std::string label; + double value = 0; + double max = 0; + Color color = 0; + std::string text; +}; +struct Meters { + std::vector items; + int label_width = -1, value_width = -1; + std::optional heat; + int style = HQ_BAR_SMOOTH; + /// Lay out in N columns when there is room, like btop's core grid. + int columns = 1; + int gap = 2; + std::optional background; +}; +void draw_meters(Surface, const Meters &); + +/// One row, one glyph per sample, scaled to the window it is given. +struct Sparkline { + std::vector values; + Color color = 0; + std::optional min, max; + std::string label, text; + std::optional background; +}; +/// The bare row of glyphs; `draw_sparkline` adds the label and readout. +void draw_spark(Surface, const std::vector &, const Sparkline & = {}); +void draw_sparkline(Surface, const Sparkline &); + +/// A segmented bar coloured along the theme's heat ramp, like btop's +/// temperatures. No label, no readout: the colour is the information. +struct HeatBar { + double value = 0; + int width = -1; + Color color = 0; + std::string ch; + std::optional background; +}; +void draw_heat_bar(Surface, const HeatBar &); + +/// Block columns. Cheaper than Braille and reads well when short. +struct Columns { + std::vector values; + Color color = 0; + std::optional max; + std::optional background; +}; +void draw_columns(Surface, const Columns &); + +struct Progress { + double value = 0; + double max = 1; + std::string label; + Color color = 0; + /// Show `37/120` rather than a percentage. + bool show_count = false; + std::optional background; +}; +void draw_progress(Surface, const Progress &); +void draw_graph(Surface, const Graph &); + + +extern const char *const MONTH_NAMES[12]; +/// Two letters each, so a week is exactly as wide as its days. +extern const char *const WEEKDAY_NAMES[7]; +/// The width a calendar wants, which is the same whatever month it shows. +constexpr int CALENDAR_WIDTH = 7 * 3 - 1; +/// The Gregorian leap rule in full: every four years, except centuries, except +/// every fourth century. +bool is_leap_year(long year); +/// How many days a month has. Months are 1-12, as people write them. +long days_in_month(long year, long month); +/// Day of the week, 0 = Sunday. Sakamoto's method, so no host calendar is +/// consulted and every port agrees. +long day_of_week(long year, long month, long day); +struct CalendarMark { + /// Day of the month, 1-31. + long day = 0; + Color color = 0; + Color background = 0; + bool bold = false; +}; +struct Calendar { + long year = 1970; + /// 1-12, as people write months. + long month = 1; + /// Days worth pointing at: holidays, deadlines, days with something on. + std::vector marks; + /// Drawn in the accent colour, as the day the view is about. 0 for none. + long selected = 0; + /// The month and year above the grid. + bool header = true; + int header_align = HQ_CENTER; + /// The weekday initials above the days. + bool weekdays = true; + /// 0 for Sunday, 1 for Monday. Most of the world starts on Monday. + long week_start = 1; + Color color = 0; + std::optional background; +}; +/// How many rows a month needs, so a caller can size the panel around it. +int calendar_height(const Calendar &); +/// A month as a grid, with per-day styling. +void draw_calendar(Surface, const Calendar &); + +/// A point in a chart's own coordinates, not the grid's. +struct ChartPoint { + double x = 0, y = 0; +}; +/// How a series is marked: joined, dotted, or dropped to the baseline. +enum MarkType { HQ_MARK_LINE, HQ_MARK_SCATTER, HQ_MARK_BAR }; +struct ChartSeries { + std::vector points; + Color color = 0; + std::string label; + MarkType mark = HQ_MARK_LINE; + /// Shade between the line and the baseline. Ignored for a scatter. + bool fill = false; +}; +/// One axis: what it spans and how its numbers read. +struct Axis { + std::optional min, max; + std::function format; + /// How many labels to place. Default 2 -- the ends. + int ticks = 0; +}; +struct Domain { + double min = 0, max = 1; +}; +struct ChartPlot { + std::string mode = "braille"; + std::optional x, y; + std::optional background; + bool grid = false; + std::optional grid_color; + /// 0-1 opacity of the area fill against the background. + std::optional fill_alpha; + /// Where a bar or an area is measured from. Defaults to the y minimum. + std::optional baseline; +}; +struct Chart { + std::vector series; + ChartPlot plot; + /// Numbers down the left edge. + bool axis = false; + Color axis_color = 0; + bool legend = false; + int legend_align = HQ_LEFT; +}; +/// The span an axis covers, from the caller where they said and from the data +/// where they did not. +Domain domain_of(const std::vector &, const Axis *, int which); +/// Draw point series across the whole surface. +void plot_points(Surface, const std::vector &, const ChartPlot &); +/// A chart of arbitrary (x, y) data, with a domain on both axes. +void draw_chart(Surface, const Chart &); + +struct Bounds { + double min = 0, max = 1; +}; +/// Which of the shapes a Shape is. +enum ShapeKind { + HQ_SHAPE_LINE, + HQ_SHAPE_POLYLINE, + HQ_SHAPE_POINTS, + HQ_SHAPE_CIRCLE, + HQ_SHAPE_RECT, +}; +struct Shape { + ShapeKind kind = HQ_SHAPE_LINE; + /// Line: the two ends. Circle and rect: the centre or corner. + double x1 = 0, y1 = 0, x2 = 0, y2 = 0; + /// points and polyline. + std::vector points; + /// circle. + double radius = 0; + /// rect. + double width = 0, height = 0; + bool fill = false; + Color color = 0; +}; +struct Canvas { + std::vector shapes; + /// The span the drawing is in. Unset means 0-1. + std::optional x, y; + /// Colour for shapes that do not name their own. + Color color = 0; + std::optional background; + /// A faint dotted grid behind the shapes. + bool grid = false; + std::optional grid_color; +}; +/// A projection from the caller's coordinates onto the canvas's pixels. +/// +/// Handed out so a caller can place their own labels against the same drawing. +struct Projection { + double width = 0, height = 0; + Bounds x_bounds, y_bounds; + double x(double value) const; + /// Flipped: the caller's y goes up, the canvas's goes down. + double y(double value) const; +}; +Projection canvas_projection(const Braille &, Bounds x, Bounds y); +/// A canvas drawn in the caller's own coordinates rather than in pixels. +void draw_canvas(Surface, const Canvas &); + +struct CountryOutline { + std::string name; + /// ISO 3166-1 alpha-2, where Natural Earth has one. + std::string iso; + /// Longitude and latitude, interleaved. More than one ring means more than + /// one landmass. + std::vector> rings; +}; +/// The country outlines, built once on first use. +const std::vector &world_countries(); +struct WorldShapeOptions { + /// Colour for countries with nothing special about them. + Color color = 0; + /// Countries to pick out, by name or ISO code. + std::vector highlight; + Color highlight_color = 0; +}; +/// The world as canvas shapes, one closed polyline per landmass. +std::vector world_shapes(const WorldShapeOptions & = {}); +/// The country containing a point, or null for open water. +const CountryOutline *country_at(double lon, double lat); +/// Look a country up by name or ISO code. +const CountryOutline *find_country(std::string_view key); +/// The window a country fills, with a little room around it. +void country_bounds(const CountryOutline &, double margin, Bounds *x, Bounds *y); +/// The degrees under a terminal cell, given the window the map was drawn with. +bool degrees_at(int column, int row, int width, int height, Bounds x, Bounds y, double *lon, + double *lat); +struct WorldMap { + /// The window on the globe. Unset means all of it. + std::optional x, y; + /// Coastline colour. + Color color = 0; + /// Countries to pick out, by name or ISO code. + std::vector highlight; + Color highlight_color = 0; + std::optional background; + bool grid = false; +}; +/// A world map drawn in degrees. +void draw_world_map(Surface, const WorldMap & = {}); +/// The country under a cell of a map drawn with these bounds. +const CountryOutline *country_at_cell(int column, int row, int width, int height, + const WorldMap & = {}); + +struct Shadow { + /// How far the shadow falls. + int offset_x = 1, offset_y = 1; + /// 0-1: how much light the covered cells lose. + double amount = .55; + /// Paint this colour instead of dimming what is underneath. + std::optional color; +}; +/// Darken every cell in a region, keeping its character and its hue. +void dim_rect(Surface, int x, int y, int width, int height, double amount); +/// Cast a shadow from `rect` onto the surface, behind whatever sits there. +void draw_shadow(Surface, Rect rect, const Shadow & = {}); +void draw_gauge(Surface, double, std::string_view); +void draw_keys(Surface, const std::vector &, bool spread = true); +/// Which edge a scrollbar sits on, and therefore which way it runs. +enum ScrollbarOrientation { + HQ_SCROLLBAR_RIGHT, + HQ_SCROLLBAR_LEFT, + HQ_SCROLLBAR_BOTTOM, + HQ_SCROLLBAR_TOP, +}; +inline bool is_vertical(ScrollbarOrientation o) { + return o == HQ_SCROLLBAR_RIGHT || o == HQ_SCROLLBAR_LEFT; +} +/// How much there is, how much of it is visible, and how far through it we are +/// — the state the caller owns. +struct Scrollbar { + int total = 0; + int viewport = 0; + int offset = 0; + ScrollbarOrientation orientation = HQ_SCROLLBAR_RIGHT; +}; +/// Where the thumb starts and how long it is, in cells along the track. +struct Thumb { + int start = 0; + int size = 0; +}; +/// The thumb is as long as the visible fraction, so it needs the viewport as +/// well as the track. For a table those are the same number, which is why +/// `viewport` defaults to the track. +Thumb thumb(int track, int total, int offset, int viewport = 0); +void draw_scrollbar(Surface, int, int, int, int, int); +/// A scrollbar filling the surface it is given, on whichever edge. +void draw_scrollbar(Surface, const Scrollbar &); +/// Which offset a click at `position` along the track means. +int offset_for_position(int position, int track, int total, int viewport); +/// Surface tinted one step above the theme's surface, for a control's chrome. +inline Color elevate(const hq_theme &t, double amount = .06) { + return hq_mix(t.surface, t.dark ? 0xffffffu : 0x000000u, amount); +} + +enum ButtonVariant { + HQ_BUTTON_PRIMARY, + HQ_BUTTON_SUCCESS, + HQ_BUTTON_WARNING, + HQ_BUTTON_DANGER, + HQ_BUTTON_GHOST +}; +struct Button { + std::string label; + bool focused = false; + Color color = 0; + int variant = HQ_BUTTON_PRIMARY; + bool disabled = false; + int width = -1; + int align = HQ_CENTER; +}; +/// Returns the width it drew, so callers can lay buttons out in a row. +int draw_button(Surface, const Button &); + +enum CheckboxVariant { HQ_CHECKBOX_BOX, HQ_CHECKBOX_TOGGLE, HQ_CHECKBOX_RADIO }; +struct Checkbox { + std::string label; + bool checked = false; + bool focused = false; + Color color = 0; + int variant = HQ_CHECKBOX_BOX; +}; +int draw_checkbox(Surface, const Checkbox &); + +/// A closed dropdown, or an open one with its option list underneath. +struct Select { + std::string value; + bool focused = false; + bool open = false; + std::vector options; + int selected_index = 0; + int width = -1; + Color color = 0; +}; +void draw_select(Surface, const Select &); + +struct TextInput { + std::string value, placeholder, label; + bool focused = false; + /// Caret index. Negative means the end of the value. + int cursor = -1; + int width = -1; + bool password = false; + Color color = 0; +}; +void draw_text_input(Surface, const TextInput &); + +enum TabVariant { HQ_TAB_FILLED, HQ_TAB_UNDERLINE }; +struct Tabs { + std::vector tabs; + int active = 0; + Color color = 0; + int align = HQ_LEFT; + int variant = HQ_TAB_FILLED; +}; +void draw_tabs(Surface, const Tabs &); + +/// The F1/F2/F10 bar along the bottom of every serious TUI. +struct StatusItem { + std::string key, label; + Color color = 0; + /// Highlight this entry, e.g. the active mode. + bool active = false; +}; +struct StatusBar { + std::vector items, right; + std::optional background; + Color key_color = 0; + /// Reverse-video key caps, like a function-key bar. False draws them plain. + bool caps = true; +}; +void draw_status_bar(Surface, const StatusBar &); + +/// A centred dialog drawn over everything. Returns its interior, so a caller +/// can draw its own content instead of `message`. +struct ModalButton { + std::string label; + int variant = HQ_BUTTON_PRIMARY; + bool focused = false; +}; +struct Modal { + std::string title, message; + int width = -1, height = -1; + /// Dim the screen behind the dialog. + bool backdrop = true; + std::vector buttons; + Color color = 0; + int align = HQ_CENTER; +}; +Surface draw_modal(Surface root, const Modal &); + +/// Ctrl+K style palette: a query line above a filtered list. +struct PaletteItem { + std::string label, hint; +}; +struct CommandPalette { + std::string query, placeholder; + std::vector items; + int selected = 0; + int width = -1, height = -1; +}; +void draw_command_palette(Surface root, const CommandPalette &); + +/// A small floating box anchored at a cell, clamped to stay on screen. +struct Tooltip { + std::string text; + int x = 0, y = 0; + Color color = 0; +}; +void draw_tooltip(Surface root, const Tooltip &); + +/// A ring split into labelled, coloured segments. Reads well from about 12x6. +struct DonutSegment { + double value = 0; + Color color = 0; + std::string label; +}; +struct Donut { + std::vector segments; + std::optional background; +}; +void draw_donut(Surface, const Donut &); + +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 &); +/// A selectable list. One column, less ceremony than a table. +struct ListItem { + std::string label; + Color color = 0; +}; +struct List { + std::vector items; + /// -1 for no selection. + int selected = -1; + int offset = 0; + /// Scroll so `selected` stays visible. + bool follow_selection = false; + std::string bullet; + bool scrollbar = false; + std::optional background; +}; +void draw_list(Surface, const List &); + +/// An indented tree with box-drawing connectors, like `pstree`. +struct TreeValue { + std::string text; + int width = 0; + Color color = 0; + int align = HQ_RIGHT; +}; +struct TreeNode { + std::string label; + Color color = 0; + std::vector values; + std::vector children; + /// Children are shown unless this is explicitly false. + bool expanded = true; +}; +struct Tree { + std::vector nodes; + int selected = -1; + int offset = 0; + bool follow_selection = false; + /// Draw the ├─ └─ connectors. + bool guides = true; + Color guide_color = 0; + std::optional background; +}; +void draw_tree(Surface, const Tree &); + +struct LogEntry { + std::string time, level, message, meta; +}; +/// `scrollbar` reserves the rightmost column, as the reference does when a log +/// is asked for one. It changes where the meta field lands, so it is a +/// parameter rather than an assumption. +void draw_log(Surface, const std::vector &, Pane *, bool scrollbar = false); + +// 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; + /// True when this child draws a border of its own. Only bordered siblings + /// collapse into each other: a table pressed against a panel edge should + /// not grow junctions out of its rows. + bool bordered = false; + }; + Surface surface_; + bool horizontal_; + int gap_; + std::vector nodes_; + bool collapse_ = false; + hq_justify justify_ = HQ_JUSTIFY_START; + +public: + std::vector *regions; + UI(Surface s, bool horizontal = false, int gap = 0, + std::vector *regions = nullptr, bool collapse = false) + : surface_(s), horizontal_(horizontal), gap_(gap), collapse_(collapse), + regions(regions) {} + + /// Where space the children leave over goes. Only ever applies when there is + /// slack: a container holding any fr or fill child has none. + void justify(hq_justify value) { justify_ = value; } + hq_justify justify() const { return justify_; } + + /// Merge the borders of adjacent panels into shared lines, the way CSS + /// collapses table borders. Inherited by nested containers. + void collapse_borders(bool value) { collapse_ = value; } + bool collapse_borders() const { return collapse_; } + 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), false}); + } + + /// As `draw`, for a child that draws its own border. + void draw_bordered(Paint fn, Constraint size, bool bordered) { + nodes_.push_back({size, std::move(fn), bordered}); + } + 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()); + // The gap at each seam. Ordinarily `gap_` repeated, but where collapsing is + // on and two bordered siblings meet with no gap between them, the seam is + // minus one so their borders land in the same column and merge. + std::vector seams(nodes_.empty() ? 0 : nodes_.size() - 1, gap_); + if (collapse_ && gap_ == 0) + for (std::size_t i = 0; i + 1 < nodes_.size(); i++) + if (nodes_[i].bordered && nodes_[i + 1].bordered) + seams[i] = -1; + if (!hq_stack_justified(surface_.rect(), sizes.data(), sizes.size(), + horizontal_, seams.empty() ? nullptr : seams.data(), + justify_, 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])); + } + /// `bordered` treats this container's own edges as panel borders when + /// collapsing. + /// + /// Collapsing merges a seam only where two bordered siblings meet, and a row + /// or column is not itself bordered -- so wrapping a stack of panels in a + /// column, which is the only way to put a stack beside one tall panel, used + /// to make the seam down the middle of the screen the one seam that could + /// never merge. Set it on such a wrapper and its edges join in. + /// + /// It is a declaration rather than something inferred, because the children + /// are built only once the layout has been solved and the seam has to be + /// known before that. True only if every child is a panel filling the + /// container across the seam; otherwise a neighbour's border lands on + /// content. + void group(Constraint size, int gap, bool horizontal, + std::function body, bool bordered = false) { + auto regions_ = regions; + auto collapse = collapse_; + draw_bordered( + [=](Surface s) { + UI p(s, horizontal, gap, regions_, collapse); + body(p); + p.flush(); + }, + size, bordered); + } + void row(Constraint size, int gap, std::function body, + bool bordered = false) { + group(size, gap, true, std::move(body), bordered); + } + void col(Constraint size, int gap, std::function body, + bool bordered = false) { + group(size, gap, false, std::move(body), bordered); + } + /// `sides` is a mask of HQ_SIDE_*, or 0 for all four. Use it for chrome that + /// is not a box: a header rule, a sidebar rail, a footer that should not look + /// boxed in. + void panel(std::string title, std::function body, + Constraint size = fr(), std::string subtitle = {}, + Color border = 0, std::optional bg = {}, + Color subtitle_color = 0, int sides = 0) { + auto regions_ = regions; + auto collapse = collapse_; + draw_bordered( + [=](Surface s) { + hq_box_options o{}; + o.collapse = collapse ? 1 : 0; + o.sides = sides; + 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_, collapse); + body(p); + p.flush(); + }, + size, true); + } + 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, true); + if (regions_) + regions_->push_back({s.rect(), id, 0}); + }); + } + + // The rest of the library, in the same shape: one row unless the widget + // genuinely wants the space. + void badge(Badge o) { + draw([=](Surface s) { draw_badge(s, o); }, cells(1)); + } + void progress(Progress o) { + draw([=](Surface s) { draw_progress(s, o); }, cells(1)); + } + /// A chart of arbitrary (x, y) data, with a domain on both axes. + /// + /// `graph` plots a history buffer, one sample per column. Use this when the + /// data has its own x values: two series of different lengths then line up, + /// and a point lands where its x says it does. + void chart(Chart o, Constraint size = fr()) { + draw([=](Surface s) { draw_chart(s, o); }, size); + } + /// Reset a region so an overlay can own it. + void clear(Clear o = {}, Constraint size = fr()) { + draw([=](Surface s) { draw_clear(s, o); }, size); + } + /// Flood a region with one repeated symbol and style. + void fill(Fill o = {}, Constraint size = fr()) { + draw([=](Surface s) { draw_fill(s, o); }, size); + } + /// A month as a grid, with per-day styling. Sized to the month it shows. + void calendar(Calendar o) { + int height = calendar_height(o); + draw([=](Surface s) { draw_calendar(s, o); }, cells(height)); + } + /// A canvas drawn in your own coordinates rather than in pixels. + void shapes(Canvas o, Constraint size = fr()) { + draw([=](Surface s) { draw_canvas(s, o); }, size); + } + /// A world map, and the country under whatever gets clicked. + void world_map(WorldMap o, Constraint size = fr()) { + draw([=](Surface s) { draw_world_map(s, o); }, size); + } + void sparkline(Sparkline o) { + draw([=](Surface s) { draw_sparkline(s, o); }, cells(1)); + } + void heat_bar(HeatBar o) { + draw([=](Surface s) { draw_heat_bar(s, o); }, cells(1)); + } + void columns(Columns o, Constraint size = fr()) { + draw([=](Surface s) { draw_columns(s, o); }, size); + } + void donut(Donut o, Constraint size = fr()) { + draw([=](Surface s) { draw_donut(s, o); }, size); + } + /// A scrollbar over state you own, for anything that scrolls and is not a + /// table. A vertical bar fills the space it is given; a horizontal one is a + /// single row. + void scrollbar(Scrollbar o, std::string id = {}) { + auto regions_ = regions; + draw( + [=](Surface s) { + draw_scrollbar(s, o); + if (regions_ && !id.empty()) + regions_->push_back({s.rect(), id, 0}); + }, + is_vertical(o.orientation) ? fr() : cells(1)); + } + void list(List o, std::string id = {}) { + auto regions_ = regions; + draw([=](Surface s) { + draw_list(s, o); + if (regions_ && !id.empty()) + regions_->push_back({s.rect(), id, 0}); + }); + } + void tree(Tree o, std::string id = {}) { + auto regions_ = regions; + draw([=](Surface s) { + draw_tree(s, o); + if (regions_ && !id.empty()) + regions_->push_back({s.rect(), id, 0}); + }); + } + void button(Button o) { + draw([=](Surface s) { draw_button(s, o); }, cells(1)); + } + void checkbox(Checkbox o) { + draw([=](Surface s) { draw_checkbox(s, o); }, cells(1)); + } + void select(Select o) { + // An open dropdown needs room for its list; a closed one is one row. + int rows = o.open ? 1 + int(o.options.size()) : 1; + draw([=](Surface s) { draw_select(s, o); }, cells(rows)); + } + void text_input(TextInput o) { + draw([=](Surface s) { draw_text_input(s, o); }, cells(1)); + } + void tabs(Tabs o) { + draw([=](Surface s) { draw_tabs(s, o); }, cells(1)); + } + void status_bar(StatusBar o) { + draw([=](Surface s) { draw_status_bar(s, o); }, cells(1)); + } +}; +} // namespace hqtui +#endif diff --git a/ports/nim/src/hqtui/engine/cpp/src/calendar.cpp b/ports/nim/src/hqtui/engine/cpp/src/calendar.cpp new file mode 100644 index 0000000..93a8ac7 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/src/calendar.cpp @@ -0,0 +1,139 @@ +/// A month, as a grid, with per-day styling. +/// +/// The dates are computed rather than read from a host calendar. Every language +/// this library is ported to has a different date type -- different epochs, +/// different month numbering, different opinions about time zones -- and a +/// widget whose output depends on any of that cannot be held to a fixture. So +/// the arithmetic is here, in terms every port already has: integers. +#include + +namespace hqtui { +namespace { + +/// Floor division. The leap count rounds towards negative infinity, and C++ +/// integer division truncates towards zero -- which only differs before year 1, +/// but differs there every time. +long floor_div(long a, long b) { + long q = a / b; + if ((a % b != 0) && ((a < 0) != (b < 0))) + q--; + return q; +} + +long positive_mod(long a, long b) { return ((a % b) + b) % b; } + +} // namespace + +const char *const MONTH_NAMES[12] = { + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"}; + +const char *const WEEKDAY_NAMES[7] = {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}; + +bool is_leap_year(long year) { + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); +} + +long days_in_month(long year, long month) { + if (month == 2) + return is_leap_year(year) ? 29 : 28; + // April, June, September, November. + if (month == 4 || month == 6 || month == 9 || month == 11) + return 30; + return 31; +} + +long day_of_week(long year, long month, long day) { + static const long offsets[12] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}; + // January and February belong to the previous year for leap-counting, since + // the leap day falls after them. + long y = month < 3 ? year - 1 : year; + long leaps = floor_div(y, 4) - floor_div(y, 100) + floor_div(y, 400); + return positive_mod(y + leaps + offsets[month - 1] + day, 7); +} + +/// Three cells a day, minus the separator the last column does not need. +static constexpr int DAY_WIDTH = 3; + +static bool calendar_valid(const Calendar &o) { return o.month >= 1 && o.month <= 12; } + +static long calendar_week_start(const Calendar &o) { return o.week_start == 0 ? 0 : 1; } + +static long calendar_start_column(const Calendar &o) { + return positive_mod(day_of_week(o.year, o.month, 1) - calendar_week_start(o), 7); +} + +int calendar_height(const Calendar &o) { + if (!calendar_valid(o)) + return 0; + long cells = calendar_start_column(o) + days_in_month(o.year, o.month); + int weeks = int((cells + 6) / 7); + return weeks + int(o.header) + int(o.weekdays); +} + +void draw_calendar(Surface s, const Calendar &o) { + int sw = s.rect().width, sh = s.rect().height; + if (sw <= 0 || sh <= 0 || !calendar_valid(o)) + return; + auto &t = theme(s); + Color base = o.color ? o.color : t.foreground; + long week_start = calendar_week_start(o); + + int row = 0; + if (o.header) { + std::string label = std::string(MONTH_NAMES[o.month - 1]) + " " + std::to_string(o.year); + int lw = std::min(sw, CALENDAR_WIDTH); + hqtui::text(s, 0, row, fit(label, lw, o.header_align), t.title, HQ_BOLD, o.background); + row++; + } + + if (o.weekdays) { + for (int i = 0; i < 7; i++) { + const char *name = WEEKDAY_NAMES[positive_mod(i + week_start, 7)]; + hqtui::text(s, i * DAY_WIDTH, row, name, t.muted, 0, o.background); + } + row++; + } + + long first = calendar_start_column(o); + long total = days_in_month(o.year, o.month); + + for (long day = 1; day <= total; day++) { + long cell = first + day - 1; + int y = row + int(cell / 7); + if (y >= sh) + break; + int x = int(cell % 7) * DAY_WIDTH; + + const CalendarMark *mark = nullptr; + for (auto &m : o.marks) + if (m.day == day) { + mark = &m; + break; + } + bool selected = o.selected == day; + + Color fg = base; + std::optional bg = o.background; + int attrs = 0; + if (mark) { + if (mark->color) + fg = mark->color; + if (mark->background) + bg = mark->background; + if (mark->bold) + attrs = HQ_BOLD; + } + if (selected) { + fg = t.background; + bg = t.accent; + attrs = HQ_BOLD; + } + // Right-aligned in two cells, so the units column lines up down the week + // and a calendar reads as a table rather than as a paragraph of numbers. + hqtui::text(s, x, y, fit(std::to_string(day), 2, HQ_RIGHT, false), fg, + std::uint16_t(attrs), bg); + } +} + +} // namespace hqtui diff --git a/ports/nim/src/hqtui/engine/cpp/src/canvas.cpp b/ports/nim/src/hqtui/engine/cpp/src/canvas.cpp new file mode 100644 index 0000000..7217994 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/src/canvas.cpp @@ -0,0 +1,139 @@ +/// A canvas you can draw on in your own coordinates. +/// +/// `Braille` works in pixels: good primitives, but the caller does every unit +/// conversion, and a drawing written for one panel size is wrong in the next. +/// This wraps it with a domain per axis and a list of shapes placed in that +/// domain, so the same drawing fits whatever region it is given. +/// +/// Y increases upwards, as it does on paper and in every plot, rather than +/// downwards as it does in a terminal. A canvas is for drawing things that have +/// their own geometry; making the caller flip every y would be handing them +/// back the conversion this exists to take away. +#include + +namespace hqtui { +namespace { + +/// A usable span: a zero-width one cannot be mapped onto anything. +Bounds span(const std::optional &bounds) { + if (!bounds || !std::isfinite(bounds->min) || !std::isfinite(bounds->max) || + !(bounds->max > bounds->min)) + return Bounds{0, 1}; + return *bounds; +} + +bool all_finite(std::initializer_list values) { + for (double v : values) + if (!std::isfinite(v)) + return false; + return true; +} + +/// Whether a shape has anything finite to draw. +bool usable(const Shape &s) { + switch (s.kind) { + case HQ_SHAPE_LINE: + return all_finite({s.x1, s.y1, s.x2, s.y2}); + case HQ_SHAPE_CIRCLE: + return all_finite({s.x1, s.y1, s.radius}); + case HQ_SHAPE_RECT: + return all_finite({s.x1, s.y1, s.width, s.height}); + default: + for (auto &p : s.points) + if (all_finite({p.x, p.y})) + return true; + return false; + } +} + +} // namespace + +Projection canvas_projection(const Braille &canvas, Bounds x, Bounds y) { + Projection p; + p.width = double(std::max(2, canvas.w) - 1); + p.height = double(std::max(2, canvas.h) - 1); + p.x_bounds = x; + p.y_bounds = y; + return p; +} + +double Projection::x(double value) const { + return (value - x_bounds.min) / (x_bounds.max - x_bounds.min) * width; +} + +double Projection::y(double value) const { + double at = (value - y_bounds.min) / (y_bounds.max - y_bounds.min); + return (1 - at) * height; +} + +void draw_canvas(Surface s, const Canvas &o) { + int sw = s.rect().width, sh = s.rect().height; + if (sw <= 0 || sh <= 0 || o.shapes.empty()) + return; + auto &t = theme(s); + Bounds xb = span(o.x), yb = span(o.y); + + if (o.grid) { + Color color = o.grid_color ? *o.grid_color : hq_mix(t.border, t.background, .4); + for (int y = 0; y < sh; y += std::max(2, sh / 4)) + for (int x = 0; x < sw; x += 2) + s.set(x, y, 0xb7, Style().foreground(color)); + } + + Braille canvas(sw, sh); + Projection at = canvas_projection(canvas, xb, yb); + Color base = o.color ? o.color : t.accent; + + // One shape at a time, blitted before the next is drawn, so each keeps its + // own colour. A shared canvas would make the last colour win everywhere the + // shapes overlap. + for (auto &shape : o.shapes) { + if (!usable(shape)) + continue; + std::fill(canvas.dots.begin(), canvas.dots.end(), 0); + switch (shape.kind) { + case HQ_SHAPE_LINE: + canvas.line(at.x(shape.x1), at.y(shape.y1), at.x(shape.x2), at.y(shape.y2)); + break; + case HQ_SHAPE_POLYLINE: { + std::vector pixels; + for (auto &p : shape.points) + if (all_finite({p.x, p.y})) + pixels.push_back({at.x(p.x), at.y(p.y)}); + if (pixels.size() == 1) + canvas.pixel(pixels[0].x, pixels[0].y); + else + for (std::size_t i = 0; i + 1 < pixels.size(); i++) + canvas.line(pixels[i].x, pixels[i].y, pixels[i + 1].x, pixels[i + 1].y); + break; + } + case HQ_SHAPE_POINTS: + for (auto &p : shape.points) + if (all_finite({p.x, p.y})) + canvas.pixel(at.x(p.x), at.y(p.y)); + break; + case HQ_SHAPE_CIRCLE: { + // A radius is a distance, not a position, so it is scaled by the span + // rather than projected. The two axes rarely scale alike in a terminal + // cell, and the x one is what a circle is measured against. + double scale = double(std::max(2, canvas.w) - 1) / (xb.max - xb.min); + canvas.circle(at.x(shape.x1), at.y(shape.y1), std::abs(shape.radius) * scale); + break; + } + case HQ_SHAPE_RECT: { + // Given as a corner and a size, in the caller's own direction: a positive + // height goes up, because their y does. + double x0 = at.x(shape.x1), x1 = at.x(shape.x1 + shape.width); + double y0 = at.y(shape.y1), y1 = at.y(shape.y1 + shape.height); + if (shape.fill) + canvas.fill_rect(x0, std::min(y0, y1), x1, std::max(y0, y1)); + else + canvas.rect(x0, std::min(y0, y1), x1, std::max(y0, y1)); + break; + } + } + canvas.blit(s, shape.color ? shape.color : base, o.background); + } +} + +} // namespace hqtui diff --git a/ports/nim/src/hqtui/engine/cpp/src/chart.cpp b/ports/nim/src/hqtui/engine/cpp/src/chart.cpp new file mode 100644 index 0000000..37e3e0d --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/src/chart.cpp @@ -0,0 +1,364 @@ +/// Charts of arbitrary (x, y) data. +/// +/// `draw_graph` takes a vector of doubles and puts one sample per column: the x +/// axis is the vector index. That is the right model for a history buffer and +/// the wrong one for everything else -- two series of different lengths +/// silently render at different horizontal scales, a gap in the data is +/// indistinguishable from a shorter series, and there is no way at all to say +/// where on the x axis a point belongs. +/// +/// This takes points and a domain for each axis, so a series is placed rather +/// than appended. `draw_graph` is untouched and still means what it meant. +#include + +namespace hqtui { +namespace { + +/// A finite number, or nothing: a caller's bound is data, and data can be NaN. +std::optional bound(std::optional value) { + if (value && std::isfinite(*value)) + return value; + return std::nullopt; +} + +double clamp01(double v) { return std::clamp(v, 0.0, 1.0); } + +/// Where a value sits in its domain, 0 at the minimum and 1 at the maximum. +double ratio(double value, Domain d) { return (value - d.min) / (d.max - d.min); } + +/// The eighth-block for a partial cell, as the reference spells it. +uint32_t partial(double r, const std::string &mode) { + int n = std::clamp(iround(clamp01(r) * 8), 0, 8); + if (mode == "ascii") + return r <= 0 ? ' ' : r < .4 ? '.' : r < .7 ? '=' : '#'; + return n ? 0x2580 + n : ' '; +} + +} // namespace + +Domain domain_of(const std::vector &series, const Axis *axis, int which) { + auto min = axis ? bound(axis->min) : std::nullopt; + auto max = axis ? bound(axis->max) : std::nullopt; + if (!min || !max) { + double lo = INFINITY, hi = -INFINITY; + for (auto &s : series) + for (auto &p : s.points) { + double v = which == 0 ? p.x : p.y; + if (!std::isfinite(v)) + continue; + lo = std::min(lo, v); + hi = std::max(hi, v); + } + if (!std::isfinite(lo)) { + lo = 0; + hi = 1; + } + if (!min) + min = lo; + if (!max) + max = hi; + } + if (!(*max > *min)) { + // A flat series still has to be drawn somewhere sensible. + double pad = std::abs(*min) > 0 ? std::abs(*min) * .5 : .5; + return {*min - pad, *min + pad}; + } + return {*min, *max}; +} + +/// The area between a series and its baseline, in block elements. +/// +/// Braille would give eight scattered dots per cell, which reads as noise where +/// an area should read as an area. The line itself stays Braille, so it keeps +/// the sub-cell resolution. +/// +/// The height of each column is interpolated along the line rather than sampled +/// from the points that happen to land in it. Sampling leaves a gap wherever a +/// column has no point of its own, which with arbitrary x values is most of +/// them -- the area comes out striped instead of solid. +static void fill_under(Surface s, const std::vector &points, Domain xd, + Domain yd, double baseline, Color color, + std::optional bg, double alpha) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0 || points.empty()) + return; + auto &t = theme(s); + Color base = bg.value_or(t.background); + double floor_at = clamp01(ratio(baseline, yd)); + auto column = [&](double x) { return ratio(x, xd) * (w - 1); }; + + std::vector tops(std::size_t(w), std::numeric_limits::quiet_NaN()); + auto record = [&](int col, double value) { + if (col < 0 || col >= w) + return; + // A path that doubles back covers a column twice; the outer edge is the one + // that bounds the area. + double previous = tops[std::size_t(col)]; + if (std::isnan(previous) || + std::abs(value - floor_at) > std::abs(previous - floor_at)) + tops[std::size_t(col)] = value; + }; + + if (points.size() == 1) + record(iround(column(points[0].x)), clamp01(ratio(points[0].y, yd))); + for (std::size_t i = 0; i + 1 < points.size(); i++) { + double x0 = points[i].x, y0 = points[i].y; + double x1 = points[i + 1].x, y1 = points[i + 1].y; + double c0 = column(x0), c1 = column(x1); + int from = int(std::max(0.0, std::floor(std::min(c0, c1)))); + int to = int(std::min(double(w - 1), std::max(0.0, std::ceil(std::max(c0, c1))))); + for (int col = from; col <= to; col++) { + double u = c1 == c0 ? 0 : (col - c0) / (c1 - c0); + if (u < -.5 || u > 1.5) + continue; + record(col, clamp01(ratio(y0 + (y1 - y0) * clamp01(u), yd))); + } + } + + for (int x = 0; x < w; x++) { + double top = tops[std::size_t(x)]; + if (std::isnan(top)) + continue; + double from01 = std::min(floor_at, top); + double filled = (std::max(floor_at, top) - from01) * h; + int bottom = int(std::floor(from01 * h)); + int full = int(std::floor(filled)); + for (int k = 0; k < full && k < h; k++) { + int row = h - 1 - bottom - k; + if (row < 0 || row >= h) + continue; + double depth = h <= 1 ? 0 : double(row) / (h - 1); + auto st = Style().foreground(hq_mix(base, color, alpha * (1 - depth * .3))); + if (bg) + st = st.background(*bg); + s.set(x, row, 0x2588, st); + } + if (full < h) { + uint32_t glyph = partial(filled - full, "block"); + int row = h - 1 - bottom - full; + if (glyph != ' ' && row >= 0 && row < h) { + double depth = h <= 1 ? 0 : double(row) / (h - 1); + auto st = + Style().foreground(hq_mix(base, color, alpha * (1 - depth * .3) + .12)); + if (bg) + st = st.background(*bg); + s.set(x, row, glyph, st); + } + } + } +} + +/// The block and ascii degradations: one column per cell, tallest point wins. +/// +/// A scatter keeps its dots rather than growing columns, because a scatter that +/// fills to the baseline is a bar chart wearing the wrong name. +static void plot_cells(Surface s, const std::vector &series, + const std::string &mode, Domain xd, Domain yd, + double baseline, std::optional bg) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + auto &t = theme(s); + double floor_ratio = clamp01(ratio(baseline, yd)); + + for (std::size_t si = 0; si < series.size(); si++) { + auto &cs = series[si]; + Color color = cs.color ? cs.color : hq_series(&t, si); + // Highest value per column, so a column shows the peak that fell in it + // rather than whichever point happened to be last. + std::vector tops(std::size_t(w), std::numeric_limits::quiet_NaN()); + for (auto &p : cs.points) { + if (!std::isfinite(p.x) || !std::isfinite(p.y)) + continue; + int col = std::clamp(iround(ratio(p.x, xd) * (w - 1)), 0, w - 1); + double value = clamp01(ratio(p.y, yd)); + if (std::isnan(tops[std::size_t(col)]) || value > tops[std::size_t(col)]) + tops[std::size_t(col)] = value; + } + + for (int x = 0; x < w; x++) { + double top = tops[std::size_t(x)]; + if (std::isnan(top)) + continue; + auto st = Style().foreground(color); + if (bg) + st = st.background(*bg); + if (cs.mark == HQ_MARK_SCATTER) { + int row = h - 1 - std::min(int(std::floor(top * h)), h - 1); + s.set(x, row, mode == "ascii" ? '*' : 0x2022, st); + continue; + } + double from = std::min(floor_ratio, top) * h; + double filled = (std::max(floor_ratio, top) - std::min(floor_ratio, top)) * h; + int full = int(std::floor(filled)); + for (int k = 0; k < full; k++) { + int row = h - 1 - int(std::floor(from)) - k; + if (row >= 0 && row < h) + s.set(x, row, 0x2588, st); + } + uint32_t glyph = partial(filled - full, mode); + int row = h - 1 - int(std::floor(from)) - full; + if (glyph != ' ' && row >= 0 && row < h) + s.set(x, row, glyph, st); + } + } +} + +void plot_points(Surface s, const std::vector &series, + const ChartPlot &o) { + if (s.rect().width <= 0 || s.rect().height <= 0 || series.empty()) + return; + auto &t = theme(s); + int w = s.rect().width, h = s.rect().height; + + Domain xd = domain_of(series, o.x ? &*o.x : nullptr, 0); + Domain yd = domain_of(series, o.y ? &*o.y : nullptr, 1); + double baseline = bound(o.baseline).value_or(yd.min); + + if (o.grid) { + Color color = o.grid_color.value_or(hq_mix(t.border, t.background, .4)); + 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(color)); + } + + if (o.mode != "braille") { + plot_cells(s, series, o.mode, xd, yd, baseline, o.background); + return; + } + + for (std::size_t si = 0; si < series.size(); si++) { + auto &cs = series[si]; + Color color = cs.color ? cs.color : hq_series(&t, si); + std::vector finite; + for (auto &p : cs.points) + if (std::isfinite(p.x) && std::isfinite(p.y)) + finite.push_back(p); + if (finite.empty()) + continue; + + Braille canvas(w, h); + double px = canvas.w, py = canvas.h; + std::vector pixels; + pixels.reserve(finite.size()); + for (auto &p : finite) + pixels.push_back({double(iround(clamp01(ratio(p.x, xd)) * (px - 1))), + double(iround((1 - clamp01(ratio(p.y, yd))) * (py - 1)))}); + + if (cs.mark == HQ_MARK_SCATTER) { + for (auto &p : pixels) + canvas.pixel(p.x, p.y); + } else if (cs.mark == HQ_MARK_BAR) { + double floor_px = iround((1 - clamp01(ratio(baseline, yd))) * (py - 1)); + for (auto &p : pixels) + canvas.line(p.x, std::min(p.y, floor_px), p.x, std::max(p.y, floor_px)); + } else if (pixels.size() == 1) { + canvas.pixel(pixels[0].x, pixels[0].y); + } else { + for (std::size_t i = 0; i + 1 < pixels.size(); i++) + canvas.line(pixels[i].x, pixels[i].y, pixels[i + 1].x, pixels[i + 1].y); + } + + if (cs.fill && cs.mark != HQ_MARK_SCATTER) + fill_under(s, finite, xd, yd, baseline, color, o.background, + o.fill_alpha.value_or(.5)); + canvas.blit(s, color, o.background); + } +} + +/// Evenly spaced values across a domain, ends included. +/// +/// Two ticks means the ends and nothing else, which is what an axis wants when +/// there is no room to say more. +static std::vector ticks_for(double min, double max, int count) { + int n = std::max(2, count); + std::vector out; + out.reserve(std::size_t(n)); + for (int i = 0; i < n; i++) + out.push_back(min + (max - min) * i / (n - 1)); + return out; +} + +void draw_chart(Surface surface, const Chart &o) { + if (surface.rect().width <= 0 || surface.rect().height <= 0) + return; + auto &t = theme(surface); + Color axis_color = o.axis_color ? o.axis_color : t.muted; + + Domain xd = domain_of(o.series, o.plot.x ? &*o.plot.x : nullptr, 0); + Domain yd = domain_of(o.series, o.plot.y ? &*o.plot.y : nullptr, 1); + + auto label_of = [](const std::optional &axis, double v) { + if (axis && axis->format) + return axis->format(v); + return std::abs(v) >= 1000 ? number(std::floor(v / 100 + .5) / 10) + "k" + : number(v); + }; + + // The x labels take a row, and they can only take one when there is a row to + // spare -- a two-row chart is all plot. + int x_ticks = o.axis ? 2 : 0; + if (o.plot.x && o.plot.x->ticks > 0) + x_ticks = o.plot.x->ticks; + const bool want_x_axis = o.axis && x_ticks >= 2 && surface.rect().height > 2; + + Surface s = surface; + if (o.axis) { + std::string hi = label_of(o.plot.y, yd.max), lo = label_of(o.plot.y, yd.min); + int lw = std::max(int(width(hi)), int(width(lo))) + 1; + text(s, 0, 0, fit(hi, lw, HQ_RIGHT), axis_color); + if (s.rect().height > 1) { + // The minimum marks the bottom of the plot, which is a row higher when + // the x labels have taken the last one. + int bottom = want_x_axis ? s.rect().height - 2 : s.rect().height - 1; + text(s, 0, bottom, fit(lo, lw, HQ_RIGHT), axis_color); + } + s = s.sub({lw, 0, std::max(0, s.rect().width - lw), s.rect().height}); + } + + Surface area = s; + if (want_x_axis && s.rect().height > 1 && s.rect().width > 0) { + area = s.sub({0, 0, s.rect().width, s.rect().height - 1}); + int row = s.rect().height - 1; + auto values = ticks_for(xd.min, xd.max, x_ticks); + double step = values.size() > 1 ? double(s.rect().width - 1) / (values.size() - 1) : 0; + for (std::size_t i = 0; i < values.size(); i++) { + std::string label = label_of(o.plot.x, values[i]); + // The last label is right-aligned to the edge, so it cannot run off it. + int x = std::min(s.rect().width - int(width(label)), iround(double(i) * step)); + text(s, std::max(0, x), row, label, axis_color); + } + } + + // The domain is resolved once and handed down, so the labels and the marks + // cannot disagree about what the axis spans. + ChartPlot plot = o.plot; + Axis x_axis = plot.x.value_or(Axis{}); + x_axis.min = xd.min; + x_axis.max = xd.max; + Axis y_axis = plot.y.value_or(Axis{}); + y_axis.min = yd.min; + y_axis.max = yd.max; + plot.x = x_axis; + plot.y = y_axis; + plot_points(area, o.series, plot); + + if (o.legend) { + int total = 0; + for (auto &cs : o.series) + if (!cs.label.empty()) + total += int(width(cs.label)) + 3; + int x = o.legend_align == HQ_RIGHT ? std::max(0, area.rect().width - total) : 0; + int y = area.rect().height > 3 ? area.rect().height - 1 : 0; + for (std::size_t i = 0; i < o.series.size(); i++) { + auto &cs = o.series[i]; + if (cs.label.empty()) + continue; + Color color = cs.color ? cs.color : hq_series(&t, i); + x += int(text(area, x, y, "■ ", color)); + x += int(text(area, x, y, cs.label + " ", t.muted)); + } + } +} + +} // namespace hqtui diff --git a/ports/nim/src/hqtui/engine/cpp/src/scrollbar.cpp b/ports/nim/src/hqtui/engine/cpp/src/scrollbar.cpp new file mode 100644 index 0000000..45ca4d8 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/src/scrollbar.cpp @@ -0,0 +1,80 @@ +/// A scrollbar, on its own. +/// +/// The renderer used to live inside the table and was reachable only by being a +/// table, list, tree or log. Anything else that scrolls — a wrapped paragraph, a +/// canvas, a `draw()` somebody wrote themselves — could not show one. +/// +/// This is the same drawing, lifted out and given the four edges plus state the +/// caller owns. The dense widgets route through it, so there is one +/// implementation and one appearance. +#include + +namespace hqtui { + +Thumb thumb(int track, int total, int offset, int viewport) { + if (track <= 0 || total <= 0) + return {0, 0}; + int visible = viewport > 0 ? viewport : track; + if (total <= visible) + return {0, track}; + int size = std::min(track, std::max(1, iround(double(visible) / total * track))); + int max_offset = std::max(1, total - visible); + int clamped = std::clamp(offset, 0, max_offset); + int start = iround(double(clamped) / max_offset * (track - size)); + return {std::clamp(start, 0, track - size), size}; +} + +void draw_scrollbar(Surface s, int x, int y, int h, int total, int offset) { + // The table draws its bar unconditionally and leans on this guard to keep it + // off screen when everything already fits, so the early return has to stay. + if (h <= 0 || total <= h) + return; + auto &t = theme(s); + Thumb bar = thumb(h, total, offset, h); + for (int i = 0; i < h; i++) { + bool in_thumb = i >= bar.start && i < bar.start + bar.size; + s.set(x, y + i, in_thumb ? 0x2588 : 0x2502, + Style().foreground(in_thumb ? t.accent : hq_mix(t.background, t.border, .7))); + } +} + +void draw_scrollbar(Surface s, const Scrollbar &o) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + bool vertical = is_vertical(o.orientation); + auto &t = theme(s); + Color track_color = hq_mix(t.background, t.border, .7); + + int length = vertical ? h : w; + int viewport = o.viewport > 0 ? o.viewport : length; + Thumb bar = thumb(length, o.total, o.offset, viewport); + + int line = vertical ? (o.orientation == HQ_SCROLLBAR_RIGHT ? w - 1 : 0) + : (o.orientation == HQ_SCROLLBAR_BOTTOM ? h - 1 : 0); + + for (int i = 0; i < length; i++) { + bool in_thumb = i >= bar.start && i < bar.start + bar.size; + // A horizontal bar uses the half-height glyphs rather than the full block: + // a run of full blocks across a row reads as a solid rule, which is not + // what a thumb is meant to look like. + uint32_t glyph = vertical ? (in_thumb ? 0x2588 : 0x2502) : (in_thumb ? 0x2501 : 0x2500); + Style style = Style().foreground(in_thumb ? t.accent : track_color); + if (vertical) + s.set(line, i, glyph, style); + else + s.set(i, line, glyph, style); + } +} + +int offset_for_position(int position, int track, int total, int viewport) { + int visible = viewport > 0 ? viewport : track; + if (track <= 0 || total <= visible) + return 0; + int size = thumb(track, total, 0, visible).size; + int usable = std::max(1, track - size); + int at = std::clamp(position - size / 2, 0, usable); + return iround(double(at) / usable * (total - visible)); +} + +} // namespace hqtui diff --git a/ports/nim/src/hqtui/engine/cpp/src/shadow.cpp b/ports/nim/src/hqtui/engine/cpp/src/shadow.cpp new file mode 100644 index 0000000..33cec9f --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/src/shadow.cpp @@ -0,0 +1,74 @@ +/// A drop shadow cast by a region onto whatever is behind it. +/// +/// The point is that it dims what it covers rather than painting over it: a +/// shadow that filled its band with a flat colour would erase the dashboard +/// underneath, which is the opposite of what a shadow is for. Every covered cell +/// keeps its own character and its own hue, and only loses some of its light. +#include + +namespace hqtui { + +void dim_rect(Surface s, int x, int y, int width, int height, double amount) { + double t = std::clamp(amount, 0.0, 1.0); + // `hq_rgb(0, 0, 0)`, not a bare 0: zero is the sentinel for "the terminal's + // own colour", so mixing towards it drains a cell's colour rather than + // darkening it. + Color black = hq_rgb(0, 0, 0); + auto native = s.native(); + for (int row = 0; row < height; row++) { + for (int col = 0; col < width; col++) { + int ax = native.rect.x + x + col; + int ay = native.rect.y + y + row; + if (ax < native.clip.x || ay < native.clip.y || + ax >= native.clip.x + native.clip.width || ay >= native.clip.y + native.clip.height) + continue; + hq_cell cell{}; + if (!hq_buffer_cell(native.buffer, ax, ay, &cell)) + continue; + // Written through the absolute-coordinate surface so the cell keeps its + // own character: only the colours change. + Surface whole(hq_surface{native.buffer, native.theme, native.clip, native.clip}); + whole.set(ax - native.clip.x, ay - native.clip.y, cell.value, + Style(hq_mix(cell.fg, black, t), hq_mix(cell.bg, black, t), cell.attrs)); + } + } +} + +void draw_shadow(Surface s, Rect rect, const Shadow &o) { + if (s.rect().width <= 0 || s.rect().height <= 0) + return; + int dx = o.offset_x, dy = o.offset_y; + if (dx == 0 && dy == 0) + return; + + auto paint = [&](int x, int y, int w, int h) { + if (w <= 0 || h <= 0) + return; + if (o.color) + s.sub({x, y, w, h}).fill(' ', Style().background(*o.color)); + else + dim_rect(s, x, y, w, h, o.amount); + }; + + // The shadow is the moved region minus the original, which splits into two + // rectangles that do not touch: the rows the move added, at the moved + // region's full width, and then the columns it added over the rows the two + // still share. Cutting it any other way overlaps at the corner, and a corner + // dimmed twice reads as a smudge rather than an edge. + int tx = rect.x + dx, ty = rect.y + dy; + if (dy > 0) + paint(tx, rect.y + rect.height, rect.width, dy); + else if (dy < 0) + paint(tx, ty, rect.width, -dy); + + int y0 = std::max(rect.y, ty); + int shared = std::min(rect.y + rect.height, ty + rect.height) - y0; + if (shared > 0) { + if (dx > 0) + paint(rect.x + rect.width, y0, dx, shared); + else if (dx < 0) + paint(tx, y0, -dx, shared); + } +} + +} // namespace hqtui diff --git a/ports/nim/src/hqtui/engine/cpp/src/widgets.cpp b/ports/nim/src/hqtui/engine/cpp/src/widgets.cpp new file mode 100644 index 0000000..48a6f74 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/src/widgets.cpp @@ -0,0 +1,1314 @@ +#include +#include +#include +namespace hqtui { +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 : ' '; +} +/// The reference's word wrap: break before a word that would overflow, and +/// hard-split a single word longer than the line. Trailing space is trimmed +/// from each produced line. +static std::vector wrap_text(std::string_view content, int columns) { + std::vector lines; + if (columns <= 0) + return lines; + + std::size_t para_start = 0; + while (para_start <= content.size()) { + std::size_t brk = content.find('\n', para_start); + std::string_view paragraph = content.substr( + para_start, brk == std::string_view::npos ? std::string_view::npos : brk - para_start); + + std::string line; + int line_w = 0; + // Split on runs of whitespace, keeping them as their own tokens, exactly as + // the reference's /(\s+)/ split does. + std::size_t i = 0; + while (i <= paragraph.size()) { + bool space = i < paragraph.size() && std::isspace((unsigned char)paragraph[i]); + std::size_t j = i; + while (j < paragraph.size() && + bool(std::isspace((unsigned char)paragraph[j])) == space) + j++; + if (j == i) + break; + std::string_view word = paragraph.substr(i, j - i); + i = j; + + int w = int(width(word)); + if (line_w + w > columns && line_w > 0) { + while (!line.empty() && line.back() == ' ') + line.pop_back(); + lines.push_back(line); + line.clear(); + line_w = 0; + if (space) + continue; + } + if (w > columns) { + // A single word longer than the line: hard-split it by grapheme. + std::size_t k = 0; + while (k < word.size()) { + unsigned char lead = word[k]; + std::size_t len = lead < 128 ? 1 : lead < 224 ? 2 : lead < 240 ? 3 : 4; + len = std::min(len, word.size() - k); + std::string_view g = word.substr(k, len); + int gw = int(width(g)); + if (line_w + gw > columns) { + lines.push_back(line); + line.clear(); + line_w = 0; + } + line += g; + line_w += gw; + k += len; + } + continue; + } + line += word; + line_w += w; + } + while (!line.empty() && line.back() == ' ') + line.pop_back(); + lines.push_back(line); + + if (brk == std::string_view::npos) + break; + para_start = brk + 1; + } + return lines; +} + +/// Splits on newlines, or wraps, depending on what the caller asked for. +static std::vector lines_of(std::string_view content, int columns, + bool wrap) { + if (wrap) + return wrap_text(content, columns); + std::vector out; + std::size_t start = 0; + while (start <= content.size()) { + std::size_t brk = content.find('\n', start); + out.emplace_back(content.substr( + start, brk == std::string_view::npos ? std::string_view::npos : brk - start)); + if (brk == std::string_view::npos) + break; + start = brk + 1; + } + return out; +} + +void draw_text(Surface s, std::string_view content, const TextStyle &o) { + auto &t = theme(s); + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + Color fg = o.fg ? o.fg : t.foreground; + auto lines = lines_of(content, w, o.wrap); + const int scroll = std::max(0, o.scroll); + const int scroll_x = std::max(0, o.scroll_x); + for (int i = 0; i + scroll < int(lines.size()) && i < h; i++) { + std::string line = lines[std::size_t(i + scroll)]; + if (scroll_x > 0) + line = drop_columns(line, scroll_x); + text(s, 0, i, fit(line, w, o.align), fg, o.attrs, o.bg); + } +} + +/// The first codepoint of a symbol, which is the whole of it for a fill. +static std::uint32_t codepoint_of(std::string_view s) { + if (s.empty()) + return ' '; + unsigned char c = s[0]; + std::size_t len = c < 128 ? 1 : c < 224 ? 2 : c < 240 ? 3 : 4; + if (len == 1) + return c; + if (s.size() < len) + return ' '; + std::uint32_t cp = c & (0xff >> (len + 1)); + for (std::size_t i = 1; i < len; i++) + cp = (cp << 6) | (static_cast(s[i]) & 0x3f); + return cp; +} + +void draw_clear(Surface s, const Clear &o) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + auto &t = theme(s); + s.fill(' ', Style().foreground(t.foreground).background(o.background ? o.background + : t.background)); +} + +void draw_fill(Surface s, const Fill &o) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + auto style = Style().foreground(o.fg ? o.fg : theme(s).foreground).attributes(o.attrs); + if (o.bg) + style = style.background(*o.bg); + const int glyph_width = std::max(1, int(width(o.symbol))); + // A one-cell symbol is what `fill` is for. Anything wider has to be stepped + // over rather than written per column: each glyph owns a continuation cell, + // and writing the next one on top of it leaves a row of half-characters. + if (glyph_width == 1) { + s.fill(codepoint_of(o.symbol), style); + return; + } + for (int y = 0; y < h; y++) + // The last glyph is dropped rather than clipped when the region does not + // divide evenly: half a wide character is not a fill, it is damage. + for (int x = 0; x + glyph_width <= w; x += glyph_width) + s.set(x, y, codepoint_of(o.symbol), style); +} + +int draw_badge(Surface s, const Badge &o) { + auto &t = theme(s); + int surface_width = s.rect().width; + if (surface_width <= 0 || s.rect().height <= 0) + return 0; + Color color = o.color ? o.color : t.primary; + std::string label = " " + o.text + " "; + int w = std::min(int(width(label)), surface_width); + + Color fg = color; + std::optional bg; + int attrs = HQ_BOLD; + if (o.variant == HQ_BADGE_FILLED) { + fg = t.dark ? t.background : t.surface; + bg = color; + } else if (o.variant == HQ_BADGE_SUBTLE) { + bg = hq_mix(t.surface, color, .18); + attrs = 0; + } + + int x = o.align == HQ_RIGHT ? surface_width - w + : o.align == HQ_CENTER ? (surface_width - w) / 2 + : 0; + text(s, std::max(0, x), 0, fit(label, w, HQ_LEFT), fg, attrs, bg); + return w; +} + +void draw_divider(Surface s, const Divider &o) { + auto &t = theme(s); + int w = s.rect().width; + if (w <= 0 || s.rect().height <= 0) + return; + Color color = o.color ? o.color : t.border; + uint32_t ch = 0x2500; + if (!o.ch.empty()) { + // First code point of the caller's rule character. + unsigned char lead = o.ch[0]; + std::size_t len = lead < 128 ? 1 : lead < 224 ? 2 : lead < 240 ? 3 : 4; + len = std::min(len, o.ch.size()); + ch = 0; + if (len == 1) { + ch = lead; + } else { + ch = lead & (0xffu >> (len + 1)); + for (std::size_t i = 1; i < len; i++) + ch = (ch << 6) | (uint32_t(o.ch[i]) & 0x3f); + } + } + auto st = Style().foreground(color); + for (int x = 0; x < w; x++) + s.set(x, 0, ch, st); + + if (!o.label.empty()) { + std::string label = " " + o.label + " "; + int lw = int(width(label)); + int x = o.align == HQ_LEFT ? 1 + : o.align == HQ_RIGHT ? w - lw - 1 + : (w - lw) / 2; + text(s, std::max(0, x), 0, fit(label, std::min(lw, w), HQ_LEFT), t.muted, 0, {}); + } +} + +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); + } +} +/// Eighth-width blocks, for the partial cell at the end of a smooth bar. +static uint32_t horizontal_glyph(double ratio, bool ascii) { + double r = ratio <= 0 ? 0 : ratio >= 1 ? 1 : ratio; + if (ascii) + return r == 0 ? ' ' : r < .5 ? '-' : '#'; + static const uint32_t eighths[9] = {' ', 0x258f, 0x258e, 0x258d, 0x258c, + 0x258b, 0x258a, 0x2589, 0x2588}; + return eighths[iround(r * 8)]; +} + +void draw_bar(Surface s, const Bar &o) { + auto &t = theme(s); + int w = s.rect().width; + if (w <= 0 || s.rect().height <= 0) + return; + double r = ratio(o.value); + Color track = hq_mix(t.background, t.border, .8); + Color color = o.color ? o.color : t.primary; + uint32_t track_char = o.style == HQ_BAR_ASCII ? '-' + : o.style == HQ_BAR_SEGMENTED ? 0x25ae + : 0x2500; + uint32_t fill_char = o.style == HQ_BAR_ASCII ? '#' + : o.style == HQ_BAR_SEGMENTED ? 0x25ae + : 0x2588; + + double filled = r * w; + int full = int(std::floor(filled)); + for (int x = 0; x < w; x++) { + uint32_t ch; + Color fg; + if (x < full) { + ch = fill_char; + fg = o.heat ? hq_heat(&t, w <= 1 ? r : double(x) / (w - 1)) : color; + } else if (x == full && o.style != HQ_BAR_SEGMENTED) { + uint32_t glyph = horizontal_glyph(filled - full, o.style == HQ_BAR_ASCII); + ch = glyph == ' ' ? track_char : glyph; + fg = glyph == ' ' ? track : (o.heat ? hq_heat(&t, r) : color); + } else { + ch = track_char; + fg = track; + } + auto st = Style().foreground(fg); + if (o.background) + st = st.background(*o.background); + s.set(x, 0, ch, st); + } +} + +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; + // Ordered so a non-finite value falls through to 0 rather than rendering + // "nan%" in black on black. + double r = ratio(o.max ? o.value / o.max : o.value); + bool heat = o.heat ? *o.heat : o.color == 0; + + int lw = o.label.empty() ? 0 + : o.label_width < 0 ? int(width(o.label)) + 1 + : o.label_width; + std::string value = o.show_value + ? (o.readout.empty() ? number(iround(r * 100)) + "%" + : o.readout) + : ""; + int vw = value.empty() ? 0 + : o.value_width < 0 ? int(width(value)) + 1 + : o.value_width; + int bw = std::max(0, w - lw - vw); + + if (lw) + text(s, 0, 0, fit(o.label, lw), t.muted, 0, o.background); + if (bw > 0) { + Bar bar; + bar.value = r; + bar.color = o.color; + bar.heat = heat; + bar.style = o.ascii ? HQ_BAR_ASCII + : o.segmented ? HQ_BAR_SEGMENTED + : HQ_BAR_SMOOTH; + bar.background = o.background; + draw_bar(s.sub({lw, 0, bw, 1}), bar); + } + if (vw) + text(s, w - vw, 0, fit(value, vw, HQ_RIGHT), + o.color ? o.color : (o.heat && !*o.heat ? t.foreground : hq_heat(&t, r)), + HQ_BOLD, o.background); +} + +void draw_meters(Surface s, const Meters &o) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0 || o.items.empty()) + return; + int columns = std::max(1, o.columns); + int gap = o.gap; + int col_width = (w - gap * (columns - 1)) / columns; + int per_column = int((o.items.size() + std::size_t(columns) - 1) / std::size_t(columns)); + if (per_column <= 0 || col_width <= 0) + return; + + for (std::size_t i = 0; i < o.items.size(); i++) { + int col = int(i) / per_column, row = int(i) % per_column; + if (row >= h || col >= columns) + continue; + const auto &item = o.items[i]; + Meter m; + m.value = item.value; + m.max = item.max; + m.label = item.label; + m.readout = item.text; + m.color = item.color; + m.label_width = o.label_width; + m.value_width = o.value_width; + m.heat = o.heat; + m.segmented = o.style == HQ_BAR_SEGMENTED; + m.ascii = o.style == HQ_BAR_ASCII; + m.background = o.background; + draw_meter(s.sub({col * (col_width + gap), row, col_width, 1}), m); + } +} + +/// Bottom-up eighths, for one cell of a sparkline or column chart. +static uint32_t vertical_glyph(double ratio) { + double r = ratio <= 0 ? 0 : ratio >= 1 ? 1 : ratio; + static const uint32_t eighths[9] = {' ', 0x2581, 0x2582, 0x2583, 0x2584, + 0x2585, 0x2586, 0x2587, 0x2588}; + return eighths[iround(r * 8)]; +} + +/// The reference's axis extent: a caller's bound wins, otherwise the data's, +/// and a bound that is not finite falls back to the computed one so every +/// plotted coordinate stays finite. +static void extent(const std::vector &values, std::optional min_in, + std::optional max_in, double *min_out, double *max_out) { + std::optional min = min_in && std::isfinite(*min_in) ? min_in : std::nullopt; + std::optional max = max_in && std::isfinite(*max_in) ? max_in : std::nullopt; + if (!min || !max) { + double lo = INFINITY, hi = -INFINITY; + for (double v : values) { + if (!std::isfinite(v)) + continue; + lo = std::min(lo, v); + hi = std::max(hi, v); + } + if (!std::isfinite(lo)) { + lo = 0; + hi = 1; + } + if (!min) + min = std::min(0., lo); + if (!max) + max = hi; + } + if (*max <= *min) + max = *min + 1; + *min_out = *min; + *max_out = *max; +} + +void draw_spark(Surface s, const std::vector &values, const Sparkline &o) { + auto &t = theme(s); + int w = s.rect().width; + if (w <= 0 || s.rect().height <= 0) + return; + // The scale comes from the window that is actually shown, not the whole + // series, so a long history does not flatten the visible part. + std::size_t window = std::size_t(std::max(1, w)); + std::vector shown(values.end() - std::min(window, values.size()), values.end()); + double min = 0, max = 1; + extent(shown, o.min, o.max, &min, &max); + double span = max - min; + Color color = o.color ? o.color : t.accent; + + int count = int(std::min(values.size(), std::size_t(w))); + int start = int(values.size()) - count, offset = w - count; + for (int i = 0; i < count; i++) { + double v = values[std::size_t(start + i)]; + if (!std::isfinite(v)) + continue; + double r = ratio((v - min) / span); + auto st = Style().foreground(color); + if (o.background) + st = st.background(*o.background); + s.set(offset + i, 0, vertical_glyph(r), st); + } +} + +void draw_sparkline(Surface s, const Sparkline &o) { + auto &t = theme(s); + int w = s.rect().width; + if (w <= 0 || s.rect().height <= 0) + return; + int lw = o.label.empty() ? 0 : int(width(o.label)) + 1; + int vw = o.text.empty() ? 0 : int(width(o.text)) + 1; + if (lw) + text(s, 0, 0, o.label, t.muted, 0, o.background); + int bw = w - lw - vw; + if (bw > 0) + draw_spark(s.sub({lw, 0, bw, 1}), o.values, o); + if (vw) + text(s, w - vw, 0, fit(o.text, vw, HQ_RIGHT), o.color ? o.color : t.accent, + HQ_BOLD, o.background); +} + +void draw_heat_bar(Surface s, const HeatBar &o) { + auto &t = theme(s); + int surface_width = s.rect().width; + if (surface_width <= 0 || s.rect().height <= 0) + return; + double r = ratio(o.value); + int w = std::min(o.width < 0 ? surface_width : o.width, surface_width); + int filled = iround(r * w); + uint32_t ch = 0x25ae; + if (!o.ch.empty()) { + unsigned char lead = o.ch[0]; + std::size_t len = lead < 128 ? 1 : lead < 224 ? 2 : lead < 240 ? 3 : 4; + len = std::min(len, o.ch.size()); + if (len == 1) { + ch = lead; + } else { + ch = lead & (0xffu >> (len + 1)); + for (std::size_t i = 1; i < len; i++) + ch = (ch << 6) | (uint32_t(o.ch[i]) & 0x3f); + } + } + Color off = hq_mix(t.background, t.border, .75); + for (int x = 0; x < w; x++) { + bool on = x < filled; + Color fg = on ? (o.color ? o.color : hq_heat(&t, w <= 1 ? r : double(x) / (w - 1))) + : off; + auto st = Style().foreground(fg); + if (o.background) + st = st.background(*o.background); + s.set(x, 0, ch, st); + } +} + +void draw_columns(Surface s, const Columns &o) { + auto &t = theme(s); + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0 || o.values.empty()) + return; + double max = 1; + if (o.max) { + max = *o.max; + } else { + for (double v : o.values) + max = std::max(max, v); + } + Color color = o.color ? o.color : t.primary; + + int count = int(std::min(o.values.size(), std::size_t(w))); + int start = int(o.values.size()) - count; + for (int i = 0; i < count; i++) { + double r = ratio(o.values[std::size_t(start + i)] / max); + double filled = r * h; + int full = int(std::floor(filled)); + auto st = Style().foreground(color); + if (o.background) + st = st.background(*o.background); + for (int k = 0; k < full; k++) + s.set(i, h - 1 - k, 0x2588, st); + if (full < h) { + uint32_t glyph = vertical_glyph(filled - full); + if (glyph != ' ') + s.set(i, h - 1 - full, glyph, st); + } + } +} + +void draw_progress(Surface s, const Progress &o) { + auto &t = theme(s); + Meter m; + m.value = o.value; + m.max = o.max ? o.max : 1; + m.label = o.label; + m.color = o.color ? o.color : t.primary; + m.heat = false; + m.background = o.background; + if (o.show_count) + m.readout = number(iround(o.value)) + "/" + number(iround(m.max)); + draw_meter(s, m); +} + +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); + }; + // Whether the bottom row belongs to the time axis rather than the plot. + // Decided before the y-axis labels are written: the minimum marks the bottom + // of the *plot*, and the time axis takes that row away. Writing it at + // height - 1 regardless put it against the first time label, so "$0" and + // "08-10" rendered as "$008-10". + const bool time_axis_row = !o.time_axis.empty() && s.rect().height > 2; + 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) { + const int bottom = time_axis_row ? s.rect().height - 2 : s.rect().height - 1; + text(s, 0, bottom, fit(label(o.min), lw, HQ_RIGHT), t.muted); + } + s = s.sub({lw, 0, std::max(0, s.rect().width - lw), s.rect().height}); + } + // The time axis costs the bottom row, and the plot gets what is left. + if (!o.time_axis.empty() && s.rect().height > 1) { + Surface plot_surface = s; + int pw = plot_surface.rect().width, ph = plot_surface.rect().height; + double step = o.time_axis.size() > 1 ? double(pw - 1) / double(o.time_axis.size() - 1) : 0; + for (std::size_t i = 0; i < o.time_axis.size(); i++) { + const auto &label = o.time_axis[i]; + int x = std::min(pw - int(width(label)), iround(double(i) * step)); + text(plot_surface, std::max(0, x), ph - 1, label, t.muted, 0, {}); + } + s = s.sub({0, 0, pw, ph - 1}); + } + + 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_donut(Surface s, const Donut &o) { + auto &t = theme(s); + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + double total = 0; + for (const auto &seg : o.segments) + total += std::max(0., seg.value); + if (total <= 0) + total = 1; + + Braille canvas(w, h); + // Colour is per cell rather than per segment, because one cell can hold dots + // from two segments and the last one written wins, as in the reference. + std::vector colors(std::size_t(w) * std::size_t(h), 0); + double cx = canvas.w / 2., cy = canvas.h / 2.; + double outer = std::min(canvas.w / 2., canvas.h / 2.) - 1, inner = outer * .55; + + double angle = -std::acos(-1.) / 2; + for (std::size_t i = 0; i < o.segments.size(); i++) { + const auto &seg = o.segments[i]; + double sweep = (std::max(0., seg.value) / total) * std::acos(-1.) * 2; + Color color = seg.color ? seg.color : hq_series(&t, i); + 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); + int y = iround(cy + std::sin(a) * r * .9); + canvas.pixel(x, y); + int col = x >> 1, row = y >> 2; + if (col >= 0 && col < w && row >= 0 && row < h) + colors[std::size_t(row) * std::size_t(w) + std::size_t(col)] = color; + } + } + angle += sweep; + } + + canvas.blit( + s, + [&](int col, int row) { + Color c = colors[std::size_t(row) * std::size_t(w) + std::size_t(col)]; + return c ? c : t.muted; + }, + o.background); +} + +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_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); +} +/// Where the visible window starts, given an explicit offset, a selection to +/// follow, and how much fits. +static int resolve_offset(int offset, int selected, int capacity, int total, bool follow) { + int max_offset = std::max(0, total - capacity); + int start = std::clamp(offset, 0, max_offset); + if (follow && selected >= 0 && capacity > 0) { + if (selected < start) + start = selected; + else if (selected >= start + capacity) + start = selected - capacity + 1; + } + return std::clamp(start, 0, max_offset); +} + +int draw_button(Surface s, const Button &o) { + auto &t = theme(s); + int surface_width = s.rect().width; + if (surface_width <= 0 || s.rect().height <= 0) + return 0; + Color color = o.color ? o.color + : o.variant == HQ_BUTTON_SUCCESS ? t.success + : o.variant == HQ_BUTTON_WARNING ? t.warning + : o.variant == HQ_BUTTON_DANGER ? t.danger + : o.variant == HQ_BUTTON_GHOST ? t.muted + : t.primary; + std::string label = " " + o.label + " "; + int w = std::min(o.width < 0 ? int(width(label)) : o.width, surface_width); + + Color fg = color; + std::optional bg; + int attrs = 0; + if (o.disabled) { + fg = t.muted; + bg = elevate(t, .05); + } else if (o.focused) { + fg = t.dark ? t.background : t.surface; + bg = color; + attrs = HQ_BOLD; + } else if (o.variant != HQ_BUTTON_GHOST) { + bg = hq_mix(t.surface, color, .16); + attrs = HQ_BOLD; + } + text(s, 0, 0, fit(label, w, o.align), fg, attrs, bg); + return w; +} + +int draw_checkbox(Surface s, const Checkbox &o) { + auto &t = theme(s); + if (s.rect().width <= 0 || s.rect().height <= 0) + return 0; + Color color = o.color ? o.color : (o.checked ? t.success : t.muted); + const char *glyph = o.variant == HQ_CHECKBOX_TOGGLE + ? (o.checked ? "[▮ ]" : "[ ▮]") + : o.variant == HQ_CHECKBOX_RADIO + ? (o.checked ? "(●)" : "( )") + : (o.checked ? "[✓]" : "[ ]"); + int x = int(text(s, 0, 0, glyph, color, o.focused ? HQ_BOLD : 0, {})); + if (!o.label.empty()) + x += int(text(s, x, 0, " " + o.label, o.focused ? t.foreground : t.muted, + o.focused ? HQ_BOLD : 0, {})); + return x; +} + +void draw_select(Surface s, const Select &o) { + auto &t = theme(s); + int surface_width = s.rect().width, h = s.rect().height; + if (surface_width <= 0 || h <= 0) + return; + int w = std::min(o.width < 0 ? surface_width : o.width, surface_width); + Color color = o.color ? o.color : (o.focused ? t.border_focused : t.border); + Color chrome = elevate(t, .05); + + std::string label = " " + std::string(fit(o.value, std::max(0, w - 4), HQ_LEFT)); + text(s, 0, 0, fit(label, w - 2), t.foreground, o.focused ? HQ_BOLD : 0, chrome); + text(s, w - 2, 0, o.open ? " ▴" : " ▾", color, 0, chrome); + + if (o.open && !o.options.empty()) { + int rows = std::min(int(o.options.size()), h - 1); + Color open_bg = elevate(t, .08); + for (int i = 0; i < rows; i++) { + bool selected = i == o.selected_index; + text(s, 0, i + 1, fit(" " + std::string(fit(o.options[std::size_t(i)], w - 2, HQ_LEFT)), w), + selected ? t.selection_text : t.foreground, 0, + selected ? t.selection : open_bg); + } + } +} + +void draw_text_input(Surface s, const TextInput &o) { + auto &t = theme(s); + int surface_width = s.rect().width; + if (surface_width <= 0 || s.rect().height <= 0) + return; + int w = std::min(o.width < 0 ? surface_width : o.width, surface_width); + int lw = o.label.empty() ? 0 : int(width(o.label)) + 1; + if (!o.label.empty()) + text(s, 0, 0, o.label, t.muted, 0, {}); + + int field = std::max(0, w - lw); + Color bg = elevate(t, o.focused ? .1 : .05); + s.sub({lw, 0, field, 1}).fill(' ', Style().background(bg)); + + std::string shown = o.value; + if (o.password) { + shown.clear(); + // One bullet per character, not per byte. + for (std::size_t i = 0; i < o.value.size();) { + unsigned char lead = o.value[i]; + i += lead < 128 ? 1 : lead < 224 ? 2 : lead < 240 ? 3 : 4; + shown += "•"; + } + } + bool empty = shown.empty(); + std::string content = empty ? o.placeholder : shown; + text(s, lw + 1, 0, fit(content, std::max(0, field - 2), HQ_LEFT, false), + empty ? t.muted : t.foreground, 0, bg); + + if (o.focused) { + int caret = o.cursor < 0 ? int(width(shown)) : o.cursor; + int x = std::min(lw + 1 + caret, lw + field - 1); + auto native = s.native(); + hq_rect cell{native.rect.x + x, native.rect.y, 1, 1}; + hq_style style{t.background, o.color ? o.color : t.cursor, 0, HQ_STYLE_FG | HQ_STYLE_BG}; + hq_buffer_style(native.buffer, hq_intersect(cell, native.clip), style); + } +} + +void draw_tabs(Surface s, const Tabs &o) { + auto &t = theme(s); + int w = s.rect().width; + if (w <= 0 || s.rect().height <= 0) + return; + Color color = o.color ? o.color : t.accent; + int total = 0; + for (const auto &tab : o.tabs) + total += int(width(tab)) + 4; + int x = o.align == HQ_CENTER ? std::max(0, (w - total) / 2) + : o.align == HQ_RIGHT ? std::max(0, w - total) + : 0; + + for (std::size_t i = 0; i < o.tabs.size(); i++) { + bool active = int(i) == o.active; + std::string label = " " + o.tabs[i] + " "; + Color fg = active ? (o.variant == HQ_TAB_FILLED ? (t.dark ? t.background : t.surface) : color) + : t.muted; + std::optional bg; + int attrs = 0; + if (active) { + attrs = o.variant == HQ_TAB_FILLED ? HQ_BOLD : (HQ_BOLD | HQ_UNDERLINE); + if (o.variant == HQ_TAB_FILLED) + bg = color; + } + x += int(text(s, x, 0, label, fg, attrs, bg)); + } +} + +void draw_status_bar(Surface s, const StatusBar &o) { + auto &t = theme(s); + int w = s.rect().width; + if (w <= 0 || s.rect().height <= 0) + return; + Color bg = o.background ? *o.background : elevate(t, .04); + s.fill(' ', Style().background(bg)); + + auto draw_items = [&](const std::vector &items, int start) { + int cx = start; + for (const auto &item : items) { + if (cx >= w) + break; + if (!item.key.empty()) { + Color cap_fg = o.caps ? (t.dark ? t.background : t.surface) + : (o.key_color ? o.key_color : t.accent); + Color cap_bg = o.caps ? (o.key_color ? o.key_color : t.accent) : bg; + cx += int(text(s, cx, 0, item.key, cap_fg, HQ_BOLD, cap_bg)); + cx += int(text(s, cx, 0, " ", t.foreground, 0, bg)); + } + cx += int(text(s, cx, 0, item.label, + item.active ? t.foreground : (item.color ? item.color : t.muted), + item.active ? HQ_BOLD : 0, bg)); + cx += int(text(s, cx, 0, " ", t.foreground, 0, bg)); + } + return cx; + }; + + int x = draw_items(o.items, 1); + if (!o.right.empty()) { + int right_width = 0; + for (const auto &item : o.right) + right_width += int(width(item.label)) + (item.key.empty() ? 0 : int(width(item.key)) + 1) + 2; + draw_items(o.right, std::max(x, w - right_width - 1)); + } +} + +/// Restyles a rectangle in place, for the backdrop dim and the caret. +static void restyle(Surface s, hq_rect local, hq_style style) { + auto native = s.native(); + hq_rect absolute{native.rect.x + local.x, native.rect.y + local.y, local.width, + local.height}; + hq_buffer_style(native.buffer, hq_intersect(absolute, native.clip), style); +} + +Surface draw_modal(Surface root, const Modal &o) { + auto &t = theme(root); + int rw = root.rect().width, rh = root.rect().height; + if (o.backdrop) + // Dim rather than blank: the dashboard stays legible behind the dialog. + restyle(root, {0, 0, rw, rh}, + hq_style{hq_mix(t.foreground, t.background, .72), 0, 0, HQ_STYLE_FG}); + + int w = std::min(o.width < 0 ? 48 : o.width, rw - 2); + int message_lines = o.message.empty() ? 0 : int(wrap_text(o.message, w - 4).size()); + int h = std::min(o.height < 0 ? message_lines + (o.buttons.empty() ? 4 : 5) : o.height, + rh - 2); + int x = std::max(0, (rw - w) / 2), y = std::max(0, (rh - h) / 2); + + Surface surface = root.sub({x, y, w, h}); + hq_box_options box{}; + box.title = o.title.empty() ? nullptr : o.title.c_str(); + box.title_align = o.align; + box.border = HQ_ROUNDED; + box.border_style = Style().foreground(o.color ? o.color : t.border_focused); + box.has_background = 1; + box.background = elevate(t, .08); + Surface inner = surface.box(box); + + if (!o.message.empty()) { + auto lines = wrap_text(o.message, inner.rect().width - 2); + for (std::size_t i = 0; i < lines.size(); i++) { + if (int(i) + 1 >= inner.rect().height) + break; + text(inner, 1, int(i) + 1, fit(lines[i], inner.rect().width - 2, o.align), + t.foreground, 0, {}); + } + } + + if (!o.buttons.empty()) { + std::vector widths; + int total = -2; + for (const auto &b : o.buttons) { + widths.push_back(int(width(b.label)) + 4); + total += widths.back() + 2; + } + int bx = std::max(0, (inner.rect().width - total) / 2); + int by = inner.rect().height - 2; + for (std::size_t i = 0; i < o.buttons.size(); i++) { + Button button; + button.label = o.buttons[i].label; + button.variant = o.buttons[i].variant; + button.focused = o.buttons[i].focused; + button.width = widths[i]; + draw_button(inner.sub({bx, by, widths[i], 1}), button); + bx += widths[i] + 2; + } + } + return inner; +} + +void draw_command_palette(Surface root, const CommandPalette &o) { + auto &t = theme(root); + int rw = root.rect().width, rh = root.rect().height; + int w = std::min(o.width < 0 ? 60 : o.width, rw - 2); + int h = std::min(o.height < 0 ? std::min(int(o.items.size()) + 4, 14) : o.height, rh - 2); + int x = std::max(0, (rw - w) / 2), y = std::max(1, rh / 5); + + restyle(root, {0, 0, rw, rh}, + hq_style{hq_mix(t.foreground, t.background, .7), 0, 0, HQ_STYLE_FG}); + + Surface surface = root.sub({x, y, w, h}); + hq_box_options box{}; + box.border = HQ_ROUNDED; + box.border_style = Style().foreground(t.border_focused); + box.has_background = 1; + box.background = elevate(t, .1); + box.title = "Command Palette"; + Surface inner = surface.box(box); + + int iw = inner.rect().width, ih = inner.rect().height; + text(inner, 0, 0, "› ", t.accent, HQ_BOLD, {}); + std::string prompt = !o.query.empty() ? o.query + : !o.placeholder.empty() ? o.placeholder + : "Type a command…"; + text(inner, 2, 0, prompt, o.query.empty() ? t.muted : t.foreground, 0, {}); + for (int i = 0; i < iw; i++) + inner.set(i, 1, 0x2500, Style().foreground(t.border)); + + int rows = ih - 2; + for (int i = 0; i < rows; i++) { + if (i >= int(o.items.size())) + break; + const auto &item = o.items[std::size_t(i)]; + bool selected = i == o.selected; + int yy = i + 2; + std::optional bg; + if (selected) { + inner.sub({0, yy, iw, 1}).fill(' ', Style().background(t.selection)); + bg = t.selection; + } + text(inner, 1, yy, fit(item.label, iw - 2, HQ_LEFT, false), + selected ? t.selection_text : t.foreground, selected ? HQ_BOLD : 0, bg); + if (!item.hint.empty()) { + int hw = int(width(item.hint)); + if (hw + 3 < iw) + text(inner, iw - hw - 1, yy, item.hint, t.muted, 0, bg); + } + } +} + +void draw_tooltip(Surface root, const Tooltip &o) { + auto &t = theme(root); + int rw = root.rect().width, rh = root.rect().height; + int w = std::min(int(width(o.text)) + 4, rw); + int x = std::clamp(o.x, 0, std::max(0, rw - w)); + int y = std::clamp(o.y, 0, std::max(0, rh - 3)); + + Surface surface = root.sub({x, y, w, 3}); + hq_box_options box{}; + box.border = HQ_ROUNDED; + box.border_style = Style().foreground(o.color ? o.color : t.border_focused); + box.has_background = 1; + box.background = elevate(t, .12); + Surface inner = surface.box(box); + text(inner, 0, 0, fit(o.text, inner.rect().width, HQ_LEFT, false), t.foreground, 0, {}); +} + +void draw_list(Surface s, const List &o) { + auto &t = theme(s); + int h = s.rect().height; + if (s.rect().width <= 0 || h <= 0) + return; + int w = s.rect().width - (o.scrollbar ? 1 : 0); + int offset = resolve_offset(o.offset, o.selected, h, int(o.items.size()), o.follow_selection); + + for (int i = 0; i < h; i++) { + int index = offset + i; + if (index >= int(o.items.size())) + break; + const auto &item = o.items[std::size_t(index)]; + bool selected = o.selected == index; + std::string label = (o.bullet.empty() ? "" : o.bullet + " ") + item.label; + std::optional bg = selected ? std::optional(t.selection) : o.background; + if (selected) + s.sub({0, i, w, 1}).fill(' ', Style().background(t.selection)); + text(s, 0, i, fit(label, w), selected ? t.selection_text : (item.color ? item.color : t.foreground), + selected ? HQ_BOLD : 0, bg); + } + + if (o.scrollbar && int(o.items.size()) > h) + draw_scrollbar(s, s.rect().width - 1, 0, h, int(o.items.size()), offset); +} + +namespace { +struct FlatNode { + const TreeNode *node; + int depth; + std::vector last; +}; + +void flatten(const std::vector &nodes, int depth, std::vector trail, + std::vector &out) { + for (std::size_t i = 0; i < nodes.size(); i++) { + bool last = i + 1 == nodes.size(); + std::vector here = trail; + here.push_back(last); + out.push_back({&nodes[i], depth, here}); + if (!nodes[i].children.empty() && nodes[i].expanded) + flatten(nodes[i].children, depth + 1, here, out); + } +} +} // namespace + +void draw_tree(Surface s, const Tree &o) { + auto &t = theme(s); + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + std::vector flat; + flatten(o.nodes, 0, {}, flat); + + int offset = resolve_offset(o.offset, o.selected, h, int(flat.size()), o.follow_selection); + Color guide_color = o.guide_color ? o.guide_color : hq_mix(t.border, t.foreground, .15); + + for (int i = 0; i < h; i++) { + int index = offset + i; + if (index >= int(flat.size())) + break; + const auto &entry = flat[std::size_t(index)]; + bool selected = o.selected == index; + std::optional bg = selected ? std::optional(t.selection) : o.background; + if (selected) + s.sub({0, i, w, 1}).fill(' ', Style().background(t.selection)); + + std::string prefix; + if (o.guides) { + for (int d = 0; d < entry.depth; d++) + prefix += entry.last[std::size_t(d)] ? " " : "│ "; + prefix += entry.last[std::size_t(entry.depth)] ? "└─ " : "├─ "; + } else { + for (int d = 0; d < entry.depth; d++) + prefix += " "; + } + + int values_width = 0; + for (const auto &v : entry.node->values) + values_width += v.width + 1; + int label_width = std::max(0, w - values_width); + + text(s, 0, i, fit(prefix, label_width, HQ_LEFT, false), guide_color, 0, bg); + int px = std::min(int(width(prefix)), label_width); + text(s, px, i, fit(entry.node->label, std::max(0, label_width - px), HQ_LEFT, false), + selected ? t.selection_text : (entry.node->color ? entry.node->color : t.foreground), + selected ? HQ_BOLD : 0, bg); + + int vx = label_width; + for (const auto &v : entry.node->values) { + text(s, vx, i, fit(v.text, v.width, v.align), + selected ? t.selection_text : (v.color ? v.color : t.foreground), 0, bg); + vx += v.width + 1; + } + } +} + +void draw_log(Surface s, const std::vector &entries, Pane *pane, + bool scrollbar) { + auto &t = theme(s); + int h = s.rect().height, w = s.rect().width - (scrollbar ? 1 : 0), + 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); + } + // With `scrollbar`, the rightmost column is reserved and left unpainted, as + // the reference does. +} +} // namespace hqtui diff --git a/ports/nim/src/hqtui/engine/cpp/src/world.cpp b/ports/nim/src/hqtui/engine/cpp/src/world.cpp new file mode 100644 index 0000000..f2b1f36 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/src/world.cpp @@ -0,0 +1,169 @@ +/// The world, as shapes for the canvas, and the lookup that makes it clickable. +/// +/// The canvas already draws in the caller's own coordinates, and longitude and +/// latitude are just another pair of axes -- so a map is a list of polylines in +/// degrees, and nothing here needs a projection of its own beyond deciding +/// which window on the globe to show. +/// +/// The interesting half is the other direction. A click arrives as a terminal +/// cell, and a country is a polygon, so answering "what did they click" means +/// turning the cell back into degrees and testing it against the outlines. +/// Doing it that way rather than with bounding boxes is what makes the answer +/// right: Russia's bounding box covers most of the northern hemisphere, and +/// Chile's covers Argentina. +#include + +namespace hqtui { +namespace { + +bool equals_ignoring_case(std::string_view a, std::string_view b) { + if (a.size() != b.size()) + return false; + for (std::size_t i = 0; i < a.size(); i++) + if (std::tolower((unsigned char)a[i]) != std::tolower((unsigned char)b[i])) + return false; + return true; +} + +/// Match on either the name or the ISO code, case-insensitively. +bool matches(const CountryOutline &country, const std::vector &keys) { + for (auto &key : keys) { + if (key.empty()) + continue; + if (equals_ignoring_case(country.name, key)) + return true; + if (!country.iso.empty() && equals_ignoring_case(country.iso, key)) + return true; + } + return false; +} + +/// Whether a point is inside a ring, by ray casting. +/// +/// The ring is a flat list of interleaved coordinates, so this walks it two at a +/// time rather than allocating a pair per vertex -- it runs once per country per +/// click, and there are a couple of thousand vertices. +bool inside_ring(const std::vector &ring, double lon, double lat) { + bool inside = false; + std::size_t n = ring.size() / 2; + if (n == 0) + return false; + std::size_t j = n - 1; + for (std::size_t i = 0; i < n; i++) { + double xi = ring[i * 2], yi = ring[i * 2 + 1]; + double xj = ring[j * 2], yj = ring[j * 2 + 1]; + if ((yi > lat) != (yj > lat) && lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) + inside = !inside; + j = i; + } + return inside; +} + +} // namespace + +std::vector world_shapes(const WorldShapeOptions &o) { + std::vector shapes; + for (auto &country : world_countries()) { + bool picked = !o.highlight.empty() && matches(country, o.highlight); + Color color = picked && o.highlight_color ? o.highlight_color : o.color; + for (auto &ring : country.rings) { + Shape shape; + shape.kind = HQ_SHAPE_POLYLINE; + shape.color = color; + std::size_t n = ring.size() / 2; + shape.points.reserve(n + 1); + for (std::size_t i = 0; i < n; i++) + shape.points.push_back({ring[i * 2], ring[i * 2 + 1]}); + // Closed: the last point joins the first, or every country has a gap in + // its coastline where the ring started. + if (n > 0) + shape.points.push_back(shape.points.front()); + shapes.push_back(std::move(shape)); + } + } + return shapes; +} + +const CountryOutline *country_at(double lon, double lat) { + if (!std::isfinite(lon) || !std::isfinite(lat)) + return nullptr; + for (auto &country : world_countries()) + for (auto &ring : country.rings) + if (inside_ring(ring, lon, lat)) + return &country; + return nullptr; +} + +const CountryOutline *find_country(std::string_view key) { + std::vector keys{std::string(key)}; + for (auto &country : world_countries()) + if (matches(country, keys)) + return &country; + return nullptr; +} + +void country_bounds(const CountryOutline &country, double margin, Bounds *x, Bounds *y) { + double min_lon = INFINITY, max_lon = -INFINITY; + double min_lat = INFINITY, max_lat = -INFINITY; + for (auto &ring : country.rings) + for (std::size_t i = 0; i + 1 < ring.size(); i += 2) { + min_lon = std::min(min_lon, ring[i]); + max_lon = std::max(max_lon, ring[i]); + min_lat = std::min(min_lat, ring[i + 1]); + max_lat = std::max(max_lat, ring[i + 1]); + } + if (!std::isfinite(min_lon)) { + *x = Bounds{-180, 180}; + *y = Bounds{-90, 90}; + return; + } + // A single-point country would give a zero-width window, which cannot be + // mapped onto anything. + double pad_x = std::max((max_lon - min_lon) * margin, 1.0); + double pad_y = std::max((max_lat - min_lat) * margin, 1.0); + *x = Bounds{min_lon - pad_x, max_lon + pad_x}; + *y = Bounds{min_lat - pad_y, max_lat + pad_y}; +} + +bool degrees_at(int column, int row, int width, int height, Bounds x, Bounds y, double *lon, + double *lat) { + if (width <= 0 || height <= 0) + return false; + // The canvas is 2x4 Braille pixels per cell, and it spans its bounds across + // `pixels - 1`, so the inverse has to use the same denominators or a click + // drifts from what was drawn. + double px = std::max(1, width * 2 - 1); + double py = std::max(1, height * 4 - 1); + *lon = x.min + ((column * 2 + 1) / px) * (x.max - x.min); + *lat = y.min + (1 - (row * 4 + 2) / py) * (y.max - y.min); + return true; +} + +void draw_world_map(Surface s, const WorldMap &o) { + if (s.rect().width <= 0 || s.rect().height <= 0) + return; + auto &t = theme(s); + WorldShapeOptions shapes; + shapes.color = o.color ? o.color : t.border; + shapes.highlight = o.highlight; + shapes.highlight_color = o.highlight_color ? o.highlight_color : t.accent; + + Canvas canvas; + canvas.shapes = world_shapes(shapes); + canvas.x = o.x.value_or(Bounds{-180, 180}); + canvas.y = o.y.value_or(Bounds{-90, 90}); + canvas.background = o.background; + canvas.grid = o.grid; + draw_canvas(s, canvas); +} + +const CountryOutline *country_at_cell(int column, int row, int width, int height, + const WorldMap &o) { + double lon = 0, lat = 0; + if (!degrees_at(column, row, width, height, o.x.value_or(Bounds{-180, 180}), + o.y.value_or(Bounds{-90, 90}), &lon, &lat)) + return nullptr; + return country_at(lon, lat); +} + +} // namespace hqtui diff --git a/ports/nim/src/hqtui/engine/cpp/src/world_data.cpp b/ports/nim/src/hqtui/engine/cpp/src/world_data.cpp new file mode 100644 index 0000000..ec897a7 --- /dev/null +++ b/ports/nim/src/hqtui/engine/cpp/src/world_data.cpp @@ -0,0 +1,505 @@ +/// Country outlines, flattened for a terminal. +/// +/// Generated by packages/hqtui/scripts/generate-world.ts from Natural Earth's +/// 1:110m Admin 0 countries, which is public domain. Do not edit by hand. +/// +/// Each ring is longitude and latitude interleaved -- lon, lat, lon, lat -- +/// rather than a list of pairs, because at 1982 points the nested form +/// costs a container per coordinate for no gain. A country has more than one +/// ring when it is more than one landmass. +/// +/// 171 countries, 1982 points, simplified at 1 degrees. +#include + +namespace hqtui { +namespace { + +const double POOL[] = { + 66.5,37.4,70.8,38.5,71.8,36.7,75.2,37.1,71.3,36.1,69.3,31.9, + 66.3,29.9,60.9,29.8,61.2,35.7,66.5,37.4,21.0,40.8,19.4,40.3, + 19.7,42.7,21.0,40.8,-8.7,27.4,-8.7,28.8,-1.3,32.3,-1.2,35.7, + 8.4,36.9,7.5,34.1,9.8,29.4,9.3,26.1,12.0,23.5,3.2,19.1, + -8.7,27.4,12.3,-6.1,16.3,-5.9,17.5,-8.1,21.7,-7.3,22.2,-11.1, + 24.0,-11.2,24.0,-12.9,21.9,-12.9,23.2,-17.5,11.7,-17.3,13.7,-11.3, + 12.3,-6.1,-48.7,-78.0,-43.9,-78.5,-43.3,-80.0,-54.2,-80.6,-48.7,-78.0, + -66.3,-80.3,-59.6,-80.0,-66.3,-80.3,-73.9,-71.3,-70.3,-68.9,-68.3,-71.4, + -75.0,-72.1,-73.9,-71.3,-102.3,-71.9,-96.2,-72.5,-102.3,-71.9,-122.6,-73.7, + -118.7,-73.5,-122.6,-73.7,-127.3,-73.5,-124.0,-73.9,-127.3,-73.5,-163.7,-78.6, + -159.2,-79.5,-163.7,-78.6,180.0,-84.7,180.0,-90.0,-180.0,-90.0,-179.1,-84.1, + -143.1,-85.0,-153.6,-83.7,-152.9,-82.0,-156.8,-81.1,-146.4,-80.3,-155.3,-79.1, + -158.4,-76.9,-151.3,-77.4,-146.1,-76.5,-146.2,-75.4,-135.2,-74.3,-100.1,-74.9, + -103.7,-72.6,-74.9,-73.9,-67.4,-72.5,-67.7,-67.3,-57.8,-63.3,-65.7,-68.0, + -61.8,-70.7,-60.8,-73.7,-70.6,-76.6,-77.2,-76.7,-73.7,-77.9,-78.0,-79.2, + -58.2,-83.2,-28.5,-80.3,-35.6,-79.5,-35.8,-78.3,-17.5,-75.1,-15.4,-73.1, + -6.9,-70.9,27.1,-70.5,33.9,-68.5,38.6,-69.8,54.5,-65.8,61.4,-68.0, + 68.9,-67.9,69.7,-69.2,67.9,-71.9,69.9,-72.3,73.9,-69.9,88.0,-66.2, + 95.8,-67.4,102.8,-65.6,106.2,-66.9,113.6,-65.9,119.8,-67.3,135.1,-65.3, + 137.5,-67.0,145.5,-66.9,171.2,-71.7,163.6,-76.2,167.0,-78.8,161.8,-79.2, + 159.8,-80.9,169.4,-83.8,180.0,-84.7,-68.6,-52.6,-65.0,-54.7,-68.6,-54.9, + -68.6,-52.6,-57.6,-30.2,-58.5,-34.4,-56.8,-36.9,-62.3,-38.8,-62.7,-41.0, + -65.1,-41.1,-63.5,-42.6,-67.3,-45.6,-65.6,-47.2,-69.1,-50.7,-68.1,-52.3, + -71.9,-52.0,-73.4,-49.3,-71.2,-44.8,-72.1,-42.3,-68.4,-24.5,-66.3,-21.8, + -62.8,-22.0,-57.8,-25.2,-58.6,-27.1,-55.7,-27.4,-54.1,-25.5,-53.6,-26.9, + -57.6,-30.2,46.5,38.8,43.6,41.1,45.6,40.8,46.5,38.8,147.7,-40.8, + 147.9,-43.2,146.0,-43.5,144.7,-40.7,147.7,-40.8,126.1,-32.2,118.0,-35.1, + 115.0,-34.2,113.7,-22.5,120.9,-19.7,125.7,-14.2,129.6,-15.0,132.4,-11.1, + 136.5,-11.9,135.5,-15.0,140.2,-17.7,142.5,-10.7,146.4,-19.0,150.7,-22.4, + 153.6,-28.1,150.0,-37.4,146.3,-39.0,140.6,-38.0,138.2,-34.4,136.8,-35.3, + 137.8,-32.9,136.0,-34.9,131.3,-31.5,126.1,-32.2,17.0,48.1,14.6,46.4, + 9.5,47.1,12.9,47.5,13.6,48.9,17.0,48.1,46.4,41.9,50.4,40.3, + 48.9,38.3,45.6,39.9,45.0,41.2,46.4,41.9,-78.2,25.2,-77.5,23.8, + -78.2,25.2,92.7,22.0,92.4,20.7,91.4,22.8,89.0,22.1,88.6,26.4, + 92.4,25.0,91.2,23.5,92.7,22.0,28.2,56.2,30.9,55.6,32.7,53.4, + 31.8,52.1,23.5,51.6,23.5,53.9,28.2,56.2,6.2,50.8,5.7,49.5, + 2.5,51.1,6.2,50.8,-89.1,17.8,-88.1,18.3,-88.9,15.9,-89.1,17.8, + 2.7,6.3,0.8,10.5,2.8,12.2,2.7,6.3,91.7,27.8,88.8,27.1, + 91.7,27.8,-69.5,-11.0,-65.3,-9.8,-65.4,-11.6,-60.5,-13.8,-60.2,-16.3, + -58.2,-16.3,-57.9,-20.0,-61.8,-19.6,-62.7,-22.2,-67.8,-22.9,-69.5,-11.0, + 18.6,42.7,16.0,45.2,19.4,44.9,18.6,42.7,29.4,-22.1,25.7,-25.5, + 21.6,-26.7,19.9,-24.8,20.9,-18.3,25.3,-17.7,29.4,-22.1,-53.4,-33.8, + -53.8,-32.0,-57.6,-30.2,-53.6,-26.1,-55.8,-22.4,-57.9,-22.1,-58.2,-16.3, + -60.2,-16.3,-60.5,-13.8,-65.4,-11.6,-65.3,-9.8,-70.5,-11.0,-70.5,-9.5, + -72.2,-10.1,-74.0,-7.5,-72.9,-5.3,-69.9,-4.3,-69.8,1.7,-65.5,0.8, + -63.4,2.2,-64.8,4.1,-60.7,5.2,-59.0,1.3,-52.9,2.1,-51.3,4.2, + -50.4,-0.1,-44.6,-2.7,-40.0,-2.9,-35.6,-5.1,-34.7,-7.3,-38.7,-13.1, + -40.9,-21.9,-47.6,-24.9,-48.9,-28.7,-53.4,-33.8,22.7,44.2,28.6,43.7, + 28.0,42.0,23.0,41.3,22.7,44.2,-5.4,10.4,-4.3,13.2,-1.1,15.0, + 2.2,12.6,0.9,11.0,-2.9,11.0,-2.8,9.6,-5.4,10.4,30.5,-2.4, + 29.3,-4.5,29.0,-2.8,30.5,-2.4,102.6,12.2,103.0,14.2,107.6,13.5, + 106.2,11.0,103.5,10.6,102.6,12.2,14.5,12.9,14.5,4.7,15.9,1.7, + 9.6,2.3,8.8,5.5,11.7,7.0,14.5,12.9,-122.8,49.0,-127.4,50.8, + -130.5,54.3,-130.0,55.9,-135.5,59.8,-137.5,58.9,-141.0,60.3,-141.0,69.7, + -136.5,68.9,-128.1,70.5,-113.5,67.7,-106.1,68.8,-101.5,67.6,-97.7,68.6, + -96.1,67.3,-94.2,69.1,-96.5,70.1,-95.2,71.9,-87.4,67.2,-85.5,69.9, + -82.6,69.7,-81.4,67.1,-85.8,66.6,-90.7,63.6,-94.7,58.9,-92.3,57.1, + -82.3,55.1,-79.9,51.2,-78.6,52.6,-79.8,54.7,-76.5,56.5,-78.5,58.8, + -77.3,59.9,-78.1,62.3,-73.8,62.4,-69.6,61.1,-67.6,58.2,-64.6,60.3, + -61.8,56.3,-57.3,54.6,-55.7,52.1,-60.0,50.2,-66.4,50.2,-71.1,46.8, + -65.1,49.2,-64.5,46.2,-60.5,47.0,-59.8,45.9,-65.4,43.5,-66.2,44.5, + -64.4,45.3,-67.1,45.1,-69.2,47.4,-71.5,45.0,-82.4,41.7,-82.6,45.3, + -88.4,48.3,-122.8,49.0,-84.0,62.5,-81.9,62.9,-84.0,62.5,-79.8,72.8, + -80.8,73.7,-76.3,72.8,-79.8,72.8,-93.6,75.0,-96.8,74.9,-93.6,75.0, + -93.8,77.5,-96.4,77.8,-93.8,77.5,-96.8,78.8,-95.6,78.4,-98.6,78.9, + -96.8,78.8,-88.2,74.4,-97.1,76.8,-79.8,74.9,-88.2,74.4,-111.3,78.2, + -109.9,78.0,-113.5,77.7,-111.3,78.2,-111.0,78.8,-109.7,78.6,-112.5,78.4, + -111.0,78.8,-55.6,51.3,-56.8,49.8,-53.5,49.2,-53.1,46.7,-59.3,47.6, + -55.6,51.3,-83.9,65.1,-80.1,63.7,-87.2,63.5,-85.9,65.7,-83.9,65.1, + -78.8,72.4,-68.8,70.5,-67.0,69.2,-68.8,68.7,-61.9,66.9,-63.9,65.0, + -68.0,66.3,-64.7,63.4,-68.8,63.7,-66.2,61.9,-68.9,62.3,-78.6,64.6, + -74.0,65.5,-73.3,68.1,-79.0,70.2,-88.7,70.4,-90.2,72.2,-85.8,73.8, + -85.8,72.5,-82.3,73.8,-78.8,72.4,-94.5,74.1,-90.5,73.9,-95.4,72.1, + -96.0,73.4,-94.5,74.1,-122.9,76.1,-116.2,77.6,-122.9,76.1,-132.7,54.0, + -131.2,52.2,-132.7,54.0,-105.5,79.3,-99.7,77.9,-105.5,79.3,-123.5,48.5, + -128.4,50.8,-123.5,48.5,-121.5,74.4,-115.5,73.5,-123.1,70.9,-125.9,71.9, + -123.9,73.7,-124.9,74.3,-121.5,74.4,-107.8,75.8,-105.7,75.5,-117.7,75.2, + -115.4,76.5,-107.8,75.8,-106.5,73.1,-101.1,69.6,-113.3,68.5,-117.3,70.0, + -112.4,70.4,-119.4,71.6,-115.2,73.3,-108.2,71.7,-108.4,73.1,-106.5,73.1, + -100.4,72.7,-101.5,73.4,-97.4,73.8,-96.5,72.6,-98.4,71.3,-102.5,72.5, + -100.4,72.7,-106.6,73.6,-104.5,73.4,-106.6,73.6,-98.5,76.7,-98.2,75.0, + -102.5,75.6,-98.5,76.7,-96.0,80.6,-92.4,81.3,-85.8,79.3,-92.9,78.3, + -96.0,80.6,-91.6,81.9,-61.8,82.6,-76.9,79.3,-75.4,78.5,-80.6,76.2, + -89.5,76.5,-88.3,77.9,-85.0,77.5,-88.0,78.4,-85.1,79.3,-86.9,80.3, + -81.8,80.5,-91.6,81.9,-75.2,67.4,-77.2,67.6,-75.2,67.4,-96.3,69.5, + -99.8,69.4,-96.3,69.5,-64.5,49.9,-61.8,49.1,-64.5,49.9,-64.0,47.0, + -62.0,46.4,-64.0,47.0,27.4,5.2,22.4,4.0,19.5,5.0,16.0,2.3, + 14.5,5.5,15.3,7.4,22.9,11.1,27.4,5.2,23.8,19.6,23.9,15.6, + 21.9,12.6,22.9,11.1,15.3,7.4,13.5,14.4,15.9,20.4,14.9,22.9, + 23.8,19.6,-68.6,-52.6,-68.6,-54.9,-67.0,-54.9,-68.1,-55.6,-74.7,-52.8, + -71.1,-54.1,-68.6,-52.6,-69.6,-17.6,-67.0,-23.0,-70.5,-31.4,-69.8,-34.2, + -72.1,-42.3,-71.2,-44.8,-73.4,-49.3,-71.9,-52.0,-68.6,-52.3,-71.4,-53.9, + -74.9,-52.3,-75.6,-48.7,-74.1,-46.9,-75.6,-46.6,-72.7,-42.4,-74.3,-43.2, + -69.6,-17.6,109.5,18.2,108.6,19.4,110.8,20.1,109.5,18.2,80.3,42.3, + 80.0,44.9,87.8,49.3,91.0,46.9,90.9,45.3,96.3,42.7,109.2,42.5, + 111.9,45.1,119.7,46.7,115.5,48.1,122.2,53.4,125.9,52.8,131.0,47.8, + 135.0,48.5,133.1,45.1,131.0,45.0,130.6,42.4,121.1,38.9,121.6,40.9, + 117.5,38.7,122.4,37.5,119.2,34.9,121.9,31.7,121.7,28.2,118.7,24.5, + 110.4,20.3,105.3,23.4,101.7,22.3,101.8,21.2,99.2,22.1,97.6,23.9, + 98.7,27.5,96.1,29.5,88.8,27.3,78.7,31.5,78.9,34.3,73.7,39.4, + 80.3,42.3,-66.9,1.3,-69.8,1.7,-69.9,-4.3,-70.0,-2.7,-77.4,0.4, + -79.0,1.7,-77.1,3.8,-77.5,8.5,-71.4,12.4,-73.3,9.2,-72.0,7.0, + -67.3,6.1,-66.9,1.3,18.5,3.5,16.0,-3.5,11.9,-5.0,11.5,-2.8, + 14.4,-1.3,13.1,2.3,15.9,1.7,18.5,3.5,-82.5,9.6,-83.0,8.2, + -85.9,10.9,-82.5,9.6,-8.0,10.2,-2.8,9.6,-2.9,5.0,-7.7,4.4, + -8.0,10.2,16.6,46.5,19.4,45.2,15.8,44.8,18.5,42.5,13.7,45.1, + 16.6,46.5,-82.3,23.2,-74.2,20.3,-77.8,19.9,-81.8,22.6,-85.0,21.9, + -82.3,23.2,32.7,35.1,34.0,35.0,32.7,35.1,15.0,51.1,18.9,49.5, + 12.5,49.5,15.0,51.1,29.3,-4.5,30.7,-8.3,28.7,-8.5,28.4,-11.8, + 29.7,-13.3,22.2,-11.1,21.7,-7.3,17.5,-8.1,16.3,-5.9,12.2,-5.8, + 16.0,-3.5,19.5,5.0,29.7,4.6,31.2,2.2,29.3,-4.5,9.9,55.0, + 8.1,56.5,10.6,57.7,9.9,55.0,12.4,56.1,12.1,54.8,11.0,55.4, + 12.4,56.1,42.4,12.5,42.8,10.9,42.4,12.5,-71.7,18.0,-71.6,19.9, + -68.3,18.6,-71.7,18.0,-75.4,-0.2,-78.6,-4.5,-80.4,-4.4,-80.1,0.8, + -75.4,-0.2,36.9,22.0,25.0,22.0,25.2,31.6,34.3,31.2,34.2,27.8, + 32.3,29.8,36.9,22.0,-89.4,14.4,-87.9,13.1,-90.1,13.7,-89.4,14.4, + 9.6,2.3,11.3,1.1,9.5,1.0,9.6,2.3,36.4,14.4,38.4,18.0, + 43.1,12.7,36.4,14.4,28.0,59.5,27.3,57.5,23.3,59.2,28.0,59.5, + 32.1,-26.7,31.0,-25.7,32.1,-26.7,47.8,8.0,45.0,5.0,39.6,3.4, + 36.2,4.4,33.0,7.8,37.9,15.0,41.6,13.5,43.7,9.2,47.8,8.0, + -61.2,-51.8,-57.7,-51.5,-61.2,-51.8,28.6,69.1,31.1,62.4,28.1,60.5, + 21.3,60.7,21.5,63.2,25.4,65.1,20.6,69.1,24.7,68.6,27.7,70.2, + 28.6,69.1,68.9,-48.6,70.6,-49.3,68.7,-49.8,68.9,-48.6,-51.7,4.2, + -52.9,2.1,-54.5,2.3,-54.0,5.8,-51.7,4.2,6.2,49.5,8.1,49.0, + 6.0,46.7,7.4,43.7,1.8,42.3,-1.9,43.4,-1.2,46.0,-4.6,48.7, + -1.6,48.6,-1.9,49.8,2.5,51.1,6.2,49.5,8.7,42.6,9.2,41.4, + 8.7,42.6,11.3,2.3,14.3,1.2,14.4,-1.3,11.1,-4.0,8.8,-1.1, + 11.3,2.3,-16.7,13.6,-13.8,13.5,-16.7,13.6,40.0,43.4,46.6,41.2, + 41.6,41.5,40.0,43.4,14.1,53.8,15.0,51.1,12.2,50.3,12.9,47.5, + 7.5,47.6,8.1,49.0,6.0,50.1,7.1,53.7,9.9,55.0,14.1,53.8, + 0.0,11.0,1.1,5.9,-2.9,5.0,-2.9,11.0,0.0,11.0,26.3,35.3, + 23.5,35.3,26.3,35.3,23.0,41.3,26.6,41.6,22.6,40.3,24.0,37.7, + 22.5,36.4,20.2,39.6,23.0,41.3,-46.8,82.6,-27.1,83.5,-20.8,82.7, + -31.9,82.2,-12.2,81.3,-20.0,80.2,-17.7,80.1,-19.7,78.8,-18.5,77.0, + -21.7,76.6,-19.4,74.3,-24.8,72.3,-21.8,70.7,-25.5,71.4,-26.4,70.2, + -22.3,70.1,-39.8,65.5,-43.4,60.1,-48.3,60.9,-51.6,63.6,-54.0,67.2, + -50.9,69.9,-54.7,69.6,-54.4,70.8,-51.4,70.6,-55.8,71.7,-54.7,72.6, + -58.6,75.5,-68.5,76.1,-71.4,77.0,-66.8,77.4,-73.3,78.0,-65.7,79.4, + -68.0,80.1,-62.7,81.8,-44.5,81.7,-46.8,82.6,-92.2,14.5,-90.5,16.1, + -91.0,17.8,-89.1,17.8,-88.2,15.7,-89.4,14.4,-92.2,14.5,-13.7,12.6, + -9.1,12.3,-8.3,7.7,-11.1,10.0,-13.2,8.9,-15.1,11.0,-13.7,12.6, + -16.7,12.4,-13.7,11.8,-15.1,11.0,-16.7,12.4,-56.5,1.9,-59.6,1.8, + -61.4,6.0,-59.8,8.4,-57.1,6.0,-58.0,4.1,-56.5,1.9,-71.7,19.7, + -71.7,18.0,-74.5,18.3,-71.7,19.7,-83.1,15.0,-87.3,13.0,-89.4,14.4, + -87.9,15.9,-83.1,15.0,22.1,48.4,21.0,46.3,16.2,46.9,17.0,48.1, + 22.1,48.4,-14.5,66.5,-13.6,65.1,-18.7,63.5,-22.8,64.0,-21.8,64.4, + -24.0,64.9,-22.2,65.4,-24.3,65.6,-14.5,66.5,97.3,28.3,92.7,22.0, + 91.2,23.5,92.4,25.0,88.6,26.4,88.9,21.7,80.3,15.9,79.9,10.4, + 77.5,8.0,72.6,21.4,70.5,20.9,68.2,23.7,71.0,24.4,69.5,26.9, + 75.3,32.3,73.7,34.3,77.8,35.5,78.7,31.5,81.1,30.2,80.1,28.8, + 83.3,27.4,88.1,26.4,88.7,28.1,92.0,26.8,96.1,29.5,97.3,28.3, + 141.0,-2.6,141.0,-9.1,137.6,-8.4,137.9,-5.4,133.0,-4.1,132.0,-2.8, + 133.7,-2.2,130.5,-0.9,134.0,-0.8,135.5,-3.4,137.4,-1.7,141.0,-2.6, + 125.0,-8.9,123.5,-10.2,125.0,-8.9,117.9,4.1,119.0,0.9,116.1,-4.0, + 110.2,-2.9,109.7,2.0,110.5,0.8,113.8,1.2,115.9,4.3,117.9,4.1, + 129.4,-2.8,130.8,-3.9,127.9,-3.4,129.4,-2.8,127.9,2.2,128.1,-0.9, + 127.9,2.2,122.9,0.9,125.2,1.4,120.0,-0.5,123.3,-0.6,121.5,-1.9, + 123.2,-5.3,121.0,-2.6,119.8,-5.7,118.8,-2.8,119.8,0.2,122.9,0.9, + 120.3,-10.3,119.0,-9.6,120.3,-10.3,121.3,-8.5,122.9,-8.1,119.9,-8.8, + 121.3,-8.5,118.3,-8.4,116.7,-9.0,118.3,-8.4,108.5,-6.4,115.7,-8.4, + 105.4,-6.9,108.5,-6.4,104.4,-1.1,106.1,-3.1,105.8,-5.9,102.6,-4.2, + 95.3,5.5,97.5,5.2,104.4,-1.1,48.6,29.9,45.4,34.0,46.1,35.7, + 44.1,39.4,48.1,39.6,50.8,36.9,56.6,38.1,61.1,36.5,60.9,29.8, + 63.3,26.8,61.5,25.1,57.4,25.7,48.6,29.9,39.2,32.2,41.3,36.4, + 44.8,37.2,48.6,29.9,44.7,29.2,39.2,32.2,-6.2,53.9,-6.8,52.3, + -10.0,51.8,-7.6,55.1,-6.2,53.9,35.7,32.7,34.9,29.5,34.3,31.2, + 35.7,32.7,10.4,46.9,13.8,46.5,12.6,44.1,18.3,39.8,16.9,40.4, + 15.7,37.9,15.4,40.0,10.2,43.9,7.4,43.7,6.8,46.0,10.4,46.9, + 14.8,38.1,15.1,36.6,12.4,37.6,14.8,38.1,8.7,40.9,9.8,40.5, + 8.8,38.9,8.7,40.9,-77.6,18.5,-76.2,17.9,-77.6,18.5,141.9,39.2, + 140.3,35.1,135.8,33.5,135.1,34.6,131.0,33.9,132.0,33.1,130.2,31.4, + 129.4,33.3,139.4,38.2,140.3,41.2,141.9,39.2,144.6,44.0,145.5,43.3, + 140.0,41.6,142.0,45.6,144.6,44.0,132.4,33.5,134.8,33.8,132.4,33.5, + 35.5,32.4,38.8,33.4,39.2,32.2,37.0,31.5,38.0,30.5,36.1,29.2, + 34.9,29.5,35.5,32.4,87.4,49.2,80.0,44.9,80.3,42.3,74.2,43.3, + 68.6,40.7,64.9,43.7,62.0,43.5,58.5,45.6,55.9,45.0,56.0,41.3, + 52.5,41.8,50.3,44.6,53.0,45.3,53.0,46.9,49.1,46.4,46.5,48.4, + 50.8,51.7,61.3,50.8,60.0,52.0,61.4,54.0,69.1,55.4,73.4,53.5, + 76.9,54.5,80.0,50.9,87.4,49.2,39.2,-4.7,33.9,-0.9,35.3,5.5, + 38.1,3.6,41.9,3.9,41.6,-1.7,39.2,-4.7,20.6,41.9,20.6,43.2, + 21.8,42.7,20.6,41.9,48.0,30.0,48.4,28.6,46.6,29.1,48.0,30.0, + 71.0,42.3,74.2,43.3,80.3,42.3,73.7,39.4,69.5,39.5,73.1,40.9, + 71.0,42.3,107.4,14.2,105.2,14.3,104.0,18.2,101.1,17.5,100.1,20.4, + 101.7,22.3,104.4,20.8,103.9,19.3,107.4,14.2,27.3,57.5,28.2,56.2, + 26.5,55.6,21.1,56.0,22.5,57.8,27.3,57.5,35.8,33.3,36.4,34.6, + 35.8,33.3,29.0,-29.0,28.1,-30.5,27.0,-29.9,29.0,-29.0,-8.4,7.7, + -7.7,4.4,-11.4,6.8,-10.2,8.4,-8.4,7.7,25.0,22.0,23.8,19.6, + 10.3,24.4,10.0,31.4,11.5,33.1,19.1,30.3,20.9,32.7,24.9,31.9, + 25.0,22.0,26.5,55.6,23.5,53.9,21.1,56.0,26.5,55.6,49.5,-12.5, + 50.4,-15.7,47.1,-24.9,45.4,-25.6,43.3,-22.8,44.0,-17.4,49.5,-12.5, + 32.8,-9.2,35.7,-14.6,35.0,-16.8,32.7,-13.7,32.8,-9.2,100.1,6.5, + 103.0,5.5,104.2,1.3,101.4,2.8,100.1,6.5,117.9,4.1,115.9,4.3, + 114.6,1.4,109.8,1.3,115.3,4.3,116.7,6.9,119.2,5.4,117.9,4.1, + -11.5,12.4,-11.7,15.4,-5.5,15.5,-6.5,25.0,4.3,19.2,3.6,15.6, + -4.0,13.5,-5.4,10.4,-11.5,12.4,-17.1,21.0,-12.9,21.3,-12.0,25.9, + -8.7,25.9,-8.7,27.4,-4.9,25.0,-6.5,25.0,-5.5,15.5,-12.2,14.6, + -14.6,16.6,-16.5,16.1,-17.1,21.0,-117.1,32.5,-106.5,31.8,-103.9,29.3, + -101.7,29.8,-97.1,25.9,-97.9,22.4,-95.9,18.8,-91.4,18.9,-90.3,21.0, + -87.1,21.5,-87.8,18.3,-91.0,17.8,-90.5,16.1,-92.2,14.5,-103.5,18.3, + -113.1,31.2,-114.9,31.4,-109.9,22.8,-115.1,27.7,-114.2,28.6,-117.1,32.5, + 26.6,48.2,30.0,46.4,28.2,45.5,26.6,48.2,87.8,49.3,92.2,50.8, + 97.3,49.7,98.9,52.0,108.5,49.3,116.7,49.9,115.7,47.7,119.8,47.0, + 105.0,41.6,96.3,42.7,90.9,45.3,91.0,46.9,87.8,49.3,20.1,42.6, + 18.5,42.5,20.1,42.6,-2.2,35.2,-1.3,32.3,-8.7,28.8,-8.8,27.1, + -11.4,26.9,-14.8,21.5,-17.0,21.4,-14.4,26.3,-9.6,29.9,-8.7,33.2, + -5.9,35.8,-2.2,35.2,34.6,-11.5,40.3,-10.3,40.8,-14.7,34.8,-19.8, + 35.5,-24.1,32.1,-26.7,31.2,-22.3,32.8,-16.7,30.2,-14.8,33.2,-14.0, + 35.0,-16.8,34.6,-11.5,100.1,20.4,97.4,18.4,99.6,11.9,98.6,9.9, + 97.2,16.9,94.2,16.0,92.3,21.5,97.3,28.3,98.7,27.5,97.6,23.9, + 101.2,21.8,100.1,20.4,32.7,35.1,34.6,35.7,32.7,35.1,19.9,-24.8, + 19.9,-28.5,16.3,-28.6,11.7,-17.3,25.1,-17.6,20.9,-18.3,19.9,-24.8, + 88.1,27.9,87.2,26.4,80.1,28.8,81.5,30.4,88.1,27.9,6.9,53.5, + 6.2,50.8,3.3,51.3,6.9,53.5,165.8,-21.1,167.1,-22.2,164.0,-20.1, + 165.8,-21.1,176.9,-40.1,174.7,-41.3,174.7,-37.4,172.6,-34.5,176.0,-37.6, + 178.5,-37.7,176.9,-40.1,169.7,-43.6,172.8,-40.5,174.2,-41.3,170.6,-45.9, + 166.7,-46.2,169.7,-43.6,-83.7,10.9,-87.7,12.9,-83.1,15.0,-83.7,10.9, + 14.9,22.9,15.9,20.4,13.5,14.4,14.2,12.5,5.4,13.9,3.6,11.7, + 1.0,12.9,0.4,14.9,3.6,15.6,4.3,19.2,12.0,23.5,14.9,22.9, + 2.7,6.3,4.4,13.7,13.1,13.6,14.6,12.1,11.7,7.0,8.5,4.8, + 5.9,4.3,2.7,6.3,130.6,42.4,127.5,39.8,128.2,38.4,124.7,38.1, + 125.1,40.6,130.6,42.4,22.4,42.3,23.0,41.3,20.6,41.1,22.4,42.3, + 15.1,79.7,21.5,79.0,15.9,76.8,10.4,79.7,15.1,79.7,31.1,69.6, + 18.0,68.6,12.6,64.1,11.0,58.9,5.7,58.6,5.0,62.0,19.2,69.8, + 28.2,71.2,31.1,69.6,27.4,80.1,17.4,80.3,27.4,80.1,24.7,77.9, + 20.7,77.7,24.7,77.9,55.2,22.7,56.4,24.9,59.8,22.3,57.7,18.9, + 53.1,16.7,52.0,19.0,55.0,20.0,55.2,22.7,77.8,35.5,73.7,34.3, + 75.3,32.3,69.5,26.9,71.0,24.4,61.5,25.1,63.3,26.8,60.9,29.8, + 66.3,29.9,71.8,36.5,75.2,37.1,77.8,35.5,-77.4,8.7,-77.9,7.2, + -79.1,9.0,-80.9,7.2,-82.9,9.5,-77.4,8.7,141.0,-2.6,147.6,-6.1, + 147.2,-7.4,150.7,-10.6,144.7,-7.6,141.0,-9.1,141.0,-2.6,152.6,-3.7, + 152.8,-4.8,150.7,-2.7,152.6,-3.7,151.3,-5.8,148.3,-5.7,152.1,-4.1, + 151.3,-5.8,154.8,-5.3,155.9,-6.8,154.8,-5.3,-58.2,-20.2,-57.9,-22.1, + -54.3,-24.0,-55.7,-27.4,-58.6,-27.1,-57.8,-25.2,-62.7,-22.2,-61.8,-19.6, + -58.2,-20.2,-69.9,-4.3,-72.9,-5.3,-74.0,-7.5,-68.7,-12.6,-70.4,-18.3, + -76.0,-14.6,-81.4,-4.7,-80.3,-3.4,-78.6,-4.5,-75.1,-0.1,-73.1,-2.3, + -70.0,-2.7,-69.9,-4.3,122.6,10.0,124.1,11.2,123.0,9.0,122.6,10.0, + 126.4,8.4,125.4,5.6,123.6,7.8,121.9,7.2,125.4,9.8,126.4,8.4, + 118.5,9.3,117.2,8.4,119.5,11.4,118.5,9.3,122.3,18.2,121.7,14.3, + 124.1,12.5,119.9,15.4,120.7,18.5,122.3,18.2,125.5,12.2,124.8,10.1, + 124.3,12.6,125.5,12.2,23.5,53.9,24.0,50.7,22.8,49.0,16.2,50.4, + 14.1,53.0,17.6,54.9,23.5,53.9,-9.0,41.9,-6.4,41.4,-7.9,36.8, + -9.5,38.7,-9.0,41.9,-66.3,18.5,-67.2,17.9,-66.3,18.5,50.8,24.8, + 51.3,26.1,50.8,24.8,28.2,45.5,29.6,45.3,28.6,43.7,22.9,43.8, + 20.2,46.1,26.6,48.2,28.2,45.5,49.1,46.4,46.7,44.6,47.8,41.2, + 36.7,45.2,40.1,49.6,31.8,52.1,32.7,53.4,30.9,55.6,27.3,57.5, + 29.1,60.0,28.1,60.5,31.5,62.9,30.0,63.6,28.6,69.1,32.1,69.9, + 41.1,67.5,38.4,66.0,33.2,66.6,37.0,63.8,37.2,65.1,43.9,66.1, + 43.5,68.6,46.3,68.3,46.3,66.7,53.7,68.9,59.9,68.3,60.6,69.9, + 68.5,68.1,66.7,71.0,69.9,73.0,72.8,72.2,71.8,71.4,73.7,68.4, + 71.3,66.3,72.4,66.2,75.1,67.8,73.1,71.4,74.7,72.8,76.4,71.2, + 81.5,71.8,80.5,73.6,104.4,77.7,114.1,75.8,109.4,74.2,127.0,73.6, + 131.3,70.8,139.9,71.5,139.1,72.4,140.5,72.8,159.0,70.9,160.9,69.4, + 180.0,69.0,180.0,65.0,177.4,64.6,179.2,62.3,170.3,59.9,163.5,59.9, + 162.0,58.2,163.2,57.6,162.1,54.9,156.8,51.0,155.9,56.8,164.5,62.6, + 160.1,60.5,156.7,61.4,154.2,59.8,155.0,59.1,142.2,59.0,135.1,54.7, + 141.3,53.1,140.1,48.4,134.9,43.4,130.8,42.2,131.0,45.0,133.1,45.1, + 135.0,48.5,131.0,47.8,123.6,53.5,120.2,52.8,117.9,49.5,108.5,49.3, + 98.9,52.0,97.3,49.7,92.2,50.8,87.4,49.2,80.0,50.9,76.9,54.5, + 73.4,53.5,69.1,55.4,61.4,54.0,60.0,52.0,61.3,50.8,50.8,51.7, + 47.5,50.5,46.5,48.4,49.1,46.4,93.8,81.0,100.2,79.8,97.8,78.8, + 91.2,80.3,93.8,81.0,102.8,79.3,105.4,78.7,99.4,77.9,102.8,79.3, + 138.8,76.1,145.1,75.6,137.0,75.3,138.8,76.1,148.2,75.3,150.7,75.1, + 146.1,75.2,148.2,75.3,139.9,73.4,143.6,73.2,139.9,73.4,44.8,80.6, + 51.5,80.7,44.8,80.6,22.7,54.3,19.7,54.4,22.7,54.3,53.5,73.7, + 61.2,76.3,68.9,76.5,58.5,74.3,55.4,72.4,57.5,70.7,51.6,71.5, + 53.5,73.7,142.9,53.7,144.7,49.0,143.2,49.3,143.5,46.1,142.1,46.0, + 141.7,53.3,142.9,53.7,-174.9,67.2,-169.9,66.0,-173.0,64.3,-178.7,66.1, + -180.0,65.0,-180.0,69.0,-174.9,67.2,-178.7,70.9,-180.0,71.5,-177.6,71.3, + -178.7,70.9,33.4,46.0,36.5,45.5,33.9,44.4,32.5,45.3,33.4,46.0, + 30.4,-1.1,29.0,-2.8,30.4,-1.1,30.8,3.5,23.9,8.6,25.8,10.4, + 31.4,9.8,33.2,12.2,33.0,7.8,35.3,5.5,30.8,3.5,35.0,29.4, + 39.2,32.2,47.5,29.0,52.0,23.0,55.2,22.7,55.0,20.0,47.0,16.9, + 43.4,17.6,42.8,16.3,35.0,29.4,-16.7,13.6,-17.6,14.7,-14.6,16.6, + -11.5,12.4,-16.7,12.4,-13.8,13.5,-16.7,13.6,18.8,45.9,22.7,44.6, + 22.5,42.5,19.2,43.5,18.8,45.9,-13.2,8.9,-11.1,10.0,-10.2,8.4, + -11.4,6.8,-13.2,8.9,22.6,49.1,16.9,48.5,22.6,49.1,13.8,46.5, + 16.6,46.5,15.3,45.5,13.8,46.5,159.6,-8.0,158.2,-7.4,159.6,-8.0, + 41.6,-1.7,41.0,2.8,45.0,5.0,48.9,9.5,48.9,11.4,51.1,12.0, + 48.6,5.3,41.6,-1.7,48.9,11.4,47.8,8.0,42.6,10.6,48.9,11.4, + 16.3,-28.6,19.9,-28.5,19.9,-24.8,21.6,-26.7,25.7,-25.5,29.4,-22.1, + 31.2,-22.3,31.9,-24.4,30.7,-26.7,32.8,-26.7,28.2,-32.8,20.1,-34.8, + 18.4,-34.1,16.3,-28.6,126.2,37.7,128.3,38.6,129.1,35.1,126.5,34.4, + 126.2,37.7,-7.5,37.1,-6.4,41.4,-9.4,43.0,3.0,42.5,-2.1,36.7, + -7.5,37.1,81.8,7.5,80.3,6.0,80.1,9.8,81.8,7.5,24.6,8.2, + 21.9,12.6,25.0,22.0,36.9,22.0,38.4,18.0,34.0,8.7,32.7,12.2, + 31.4,9.8,25.1,10.3,24.6,8.2,-54.5,2.3,-56.5,1.9,-57.6,3.3, + -57.1,6.0,-54.0,5.8,-54.5,2.3,11.0,58.9,12.6,61.3,11.9,63.1, + 16.8,68.0,20.6,69.1,23.5,67.9,23.9,66.0,17.8,62.7,17.1,61.3, + 18.8,60.1,15.9,56.1,12.9,55.4,11.0,58.9,9.6,47.5,10.4,46.5, + 6.0,46.3,9.6,47.5,35.7,32.7,36.7,36.8,42.3,37.2,41.0,34.4, + 35.7,32.7,121.8,24.4,120.7,22.0,120.1,23.6,121.8,24.4,67.8,37.1, + 67.7,39.6,70.7,41.0,69.5,39.5,73.7,39.4,75.0,37.4,71.8,36.7, + 70.8,38.5,67.8,37.1,33.9,-0.9,39.2,-4.7,39.5,-10.9,34.6,-11.5, + 29.6,-6.5,30.4,-1.1,33.9,-0.9,105.2,14.3,103.0,14.2,102.6,12.2, + 100.1,13.4,99.2,9.2,102.1,6.2,101.2,5.7,98.2,8.4,99.6,11.9, + 97.4,18.4,100.1,20.4,101.1,17.5,104.7,17.4,105.2,14.3,125.0,-8.9, + 127.3,-8.4,125.0,-8.9,0.9,11.0,1.1,5.9,0.9,11.0,9.5,30.3, + 7.5,34.1,9.5,37.3,11.0,37.1,10.1,34.3,11.5,33.1,9.5,30.3, + 44.8,37.2,29.7,36.1,27.6,36.7,26.2,39.5,33.5,42.0,42.6,41.6, + 44.8,39.7,44.8,37.2,26.1,41.8,29.0,41.3,26.4,40.2,26.1,41.8, + 52.5,41.8,57.1,41.3,58.6,42.8,66.5,37.4,62.2,35.3,57.3,38.0, + 53.9,37.2,52.7,40.0,54.7,41.0,52.5,41.8,33.9,-0.9,29.6,-1.3, + 31.2,3.8,34.5,3.6,33.9,-0.9,31.8,52.1,40.1,49.6,39.7,47.9, + 35.0,45.7,31.7,46.7,28.7,45.3,30.0,46.4,28.7,48.1,22.1,48.4, + 23.5,51.6,31.8,52.1,51.6,24.2,56.3,25.7,55.0,22.5,51.6,24.2, + -6.2,53.9,-7.6,55.1,-6.2,53.9,-3.1,53.4,-6.1,56.8,-5.0,58.6, + -2.0,57.7,-3.1,56.0,1.7,52.7,1.4,51.3,-5.8,50.2,-3.4,51.4, + -5.3,52.0,-4.6,53.5,-3.1,53.4,-122.8,49.0,-88.4,48.3,-82.6,45.3, + -82.7,41.7,-71.5,45.0,-69.2,47.4,-67.0,44.8,-70.1,43.7,-70.0,41.6, + -75.5,39.5,-75.9,37.2,-76.3,39.2,-77.0,38.2,-75.7,35.6,-81.3,31.4, + -80.4,25.2,-83.7,29.9,-86.4,30.4,-94.7,29.5,-97.5,25.8,-101.0,29.4, + -103.9,29.3,-106.5,31.8,-117.1,32.5,-120.6,34.6,-124.4,40.3,-124.7,48.2, + -122.6,47.1,-122.8,49.0,-166.5,60.4,-165.6,59.9,-167.5,60.2,-166.5,60.4, + -153.2,58.0,-152.1,57.6,-154.5,57.0,-153.2,58.0,-141.0,69.7,-141.0,60.3, + -137.5,58.9,-135.5,59.8,-130.0,55.9,-130.5,54.8,-134.1,58.1,-139.9,59.5, + -147.1,60.9,-151.7,59.2,-150.6,61.3,-158.4,56.0,-164.9,54.6,-157.0,58.9, + -162.0,58.7,-165.3,60.5,-165.7,62.1,-160.8,64.8,-168.1,65.7,-161.7,66.1, + -166.2,68.9,-156.6,71.4,-141.0,69.7,-171.7,63.8,-168.7,63.3,-171.7,63.8, + -57.6,-30.2,-53.8,-32.0,-53.8,-34.4,-58.4,-33.9,-57.6,-30.2,56.0,41.3, + 55.9,45.0,58.5,45.6,62.0,43.5,64.9,43.7,68.3,40.7,71.0,42.3, + 73.1,40.9,67.7,39.6,67.8,37.1,58.6,42.8,56.0,41.3,-60.7,5.2, + -64.8,4.1,-63.4,2.2,-66.3,0.7,-67.8,2.8,-67.3,6.1,-72.0,7.0, + -72.9,10.5,-71.3,11.8,-71.3,9.1,-69.9,12.2,-68.2,10.6,-61.9,10.7, + -59.8,8.4,-60.7,5.2,104.3,10.5,107.5,12.3,107.6,15.2,102.2,22.5, + 105.3,23.4,108.1,21.6,105.7,19.1,108.9,15.3,109.2,11.7,105.2,8.6, + 104.3,10.5,-8.7,27.7,-8.7,25.9,-12.0,25.9,-12.9,21.3,-17.1,21.0, + -14.8,21.5,-11.4,26.9,-8.7,27.7,52.0,19.0,52.2,15.6,43.5,12.6, + 43.4,17.6,47.0,16.9,52.0,19.0,30.7,-8.3,33.2,-9.7,33.2,-14.0, + 27.0,-17.9,23.2,-17.5,21.9,-12.9,24.0,-12.9,23.9,-10.9,29.7,-13.3, + 28.4,-9.2,30.7,-8.3,31.2,-22.3,28.0,-21.5,25.3,-17.7,30.3,-15.5, + 32.8,-16.7,31.2,-22.3, +}; + +struct Span { + int at, length; +}; + +const Span RINGS[] = { + {0,20},{20,8},{28,22},{50,24},{74,10},{84,6},{90,10},{100,6}, + {106,6},{112,6},{118,6},{124,122},{246,8},{254,48},{302,8},{310,10}, + {320,48},{368,12},{380,12},{392,6},{398,16},{414,14},{428,8},{436,8}, + {444,8},{452,6},{458,22},{480,8},{488,14},{502,70},{572,10},{582,16}, + {598,8},{606,12},{618,14},{632,116},{748,6},{754,8},{762,6},{768,6}, + {774,8},{782,8},{790,8},{798,8},{806,12},{818,10},{828,42},{870,10}, + {880,6},{886,6},{892,6},{898,6},{904,14},{918,10},{928,20},{948,14}, + {962,6},{968,8},{976,10},{986,26},{1012,6},{1018,6},{1024,6},{1030,6}, + {1036,16},{1052,18},{1070,14},{1084,34},{1118,8},{1126,76},{1202,26},{1228,16}, + {1244,8},{1252,10},{1262,12},{1274,12},{1286,6},{1292,8},{1300,30},{1330,8}, + {1338,8},{1346,6},{1352,8},{1360,10},{1370,14},{1384,8},{1392,8},{1400,8}, + {1408,8},{1416,6},{1422,18},{1440,6},{1446,20},{1466,8},{1474,10},{1484,24}, + {1508,6},{1514,12},{1526,6},{1532,8},{1540,20},{1560,10},{1570,6},{1576,14}, + {1590,74},{1664,14},{1678,14},{1692,8},{1700,14},{1714,8},{1722,10},{1732,10}, + {1742,18},{1760,52},{1812,24},{1836,6},{1842,18},{1860,8},{1868,6},{1874,22}, + {1896,6},{1902,8},{1910,6},{1916,8},{1924,14},{1938,26},{1964,12},{1976,10}, + {1986,8},{1994,22},{2016,8},{2024,8},{2032,6},{2038,22},{2060,10},{2070,6}, + {2076,16},{2092,50},{2142,14},{2156,8},{2164,8},{2172,14},{2186,18},{2204,12}, + {2216,6},{2222,8},{2230,10},{2240,18},{2258,8},{2266,14},{2280,10},{2290,10}, + {2300,16},{2316,18},{2334,24},{2358,42},{2400,8},{2408,26},{2434,6},{2440,24}, + {2464,24},{2488,24},{2512,6},{2518,14},{2532,10},{2542,8},{2550,8},{2558,14}, + {2572,12},{2584,8},{2592,24},{2616,16},{2632,12},{2644,8},{2652,10},{2662,18}, + {2680,6},{2686,6},{2692,16},{2708,24},{2732,12},{2744,14},{2758,8},{2766,8}, + {2774,6},{2780,18},{2798,26},{2824,8},{2832,12},{2844,8},{2852,12},{2864,8}, + {2872,14},{2886,10},{2896,6},{2902,6},{2908,14},{2922,192},{3114,10},{3124,8}, + {3132,8},{3140,8},{3148,6},{3154,6},{3160,6},{3166,16},{3182,14},{3196,14}, + {3210,8},{3218,10},{3228,6},{3234,16},{3250,20},{3270,14},{3284,10},{3294,10}, + {3304,6},{3310,8},{3318,6},{3324,16},{3340,8},{3348,28},{3376,10},{3386,12}, + {3398,8},{3406,20},{3426,12},{3438,26},{3464,8},{3472,10},{3482,8},{3490,18}, + {3508,14},{3522,28},{3550,6},{3556,6},{3562,14},{3576,16},{3592,8},{3600,20}, + {3620,10},{3630,22},{3652,8},{3660,6},{3666,24},{3690,58},{3748,8},{3756,8}, + {3764,46},{3810,6},{3816,10},{3826,24},{3850,30},{3880,22},{3902,16},{3918,12}, + {3930,22},{3952,12}, +}; + +const Span COUNTRIES[] = { + {0,1},{1,1},{2,1},{3,1},{4,8},{12,2},{14,1},{15,2}, + {17,1},{18,1},{19,1},{20,1},{21,1},{22,1},{23,1},{24,1}, + {25,1},{26,1},{27,1},{28,1},{29,1},{30,1},{31,1},{32,1}, + {33,1},{34,1},{35,29},{64,1},{65,1},{66,2},{68,2},{70,1}, + {71,1},{72,1},{73,1},{74,1},{75,1},{76,1},{77,1},{78,1}, + {79,2},{81,1},{82,1},{83,1},{84,1},{85,1},{86,1},{87,1}, + {88,1},{89,1},{90,1},{91,1},{92,1},{93,1},{94,3},{97,1}, + {98,1},{99,1},{100,1},{101,1},{102,2},{104,1},{105,1},{106,1}, + {107,1},{108,1},{109,1},{110,1},{111,1},{112,1},{113,1},{114,11}, + {125,1},{126,1},{127,1},{128,1},{129,3},{132,1},{133,3},{136,1}, + {137,1},{138,1},{139,1},{140,1},{141,1},{142,1},{143,1},{144,1}, + {145,1},{146,1},{147,1},{148,1},{149,1},{150,1},{151,2},{153,1}, + {154,1},{155,1},{156,1},{157,1},{158,1},{159,1},{160,1},{161,1}, + {162,1},{163,1},{164,1},{165,1},{166,1},{167,2},{169,1},{170,1}, + {171,1},{172,1},{173,1},{174,4},{178,1},{179,1},{180,1},{181,4}, + {185,1},{186,1},{187,5},{192,1},{193,1},{194,1},{195,1},{196,1}, + {197,13},{210,1},{211,1},{212,1},{213,1},{214,1},{215,1},{216,1}, + {217,1},{218,1},{219,1},{220,1},{221,1},{222,1},{223,1},{224,1}, + {225,1},{226,1},{227,1},{228,1},{229,1},{230,1},{231,1},{232,1}, + {233,1},{234,1},{235,1},{236,1},{237,2},{239,1},{240,1},{241,1}, + {242,1},{243,2},{245,5},{250,1},{251,1},{252,1},{253,1},{254,1}, + {255,1},{256,1},{257,1}, +}; + +const char *const NAMES[] = { + "Afghanistan","Albania","Algeria","Angola", + "Antarctica","Argentina","Armenia","Australia", + "Austria","Azerbaijan","Bahamas","Bangladesh", + "Belarus","Belgium","Belize","Benin", + "Bhutan","Bolivia","Bosnia and Herz.","Botswana", + "Brazil","Bulgaria","Burkina Faso","Burundi", + "Cambodia","Cameroon","Canada","Central African Rep.", + "Chad","Chile","China","Colombia", + "Congo","Costa Rica","Côte d'Ivoire","Croatia", + "Cuba","Cyprus","Czechia","Dem. Rep. Congo", + "Denmark","Djibouti","Dominican Rep.","Ecuador", + "Egypt","El Salvador","Eq. Guinea","Eritrea", + "Estonia","eSwatini","Ethiopia","Falkland Is.", + "Finland","Fr. S. Antarctic Lands","France","Gabon", + "Gambia","Georgia","Germany","Ghana", + "Greece","Greenland","Guatemala","Guinea", + "Guinea-Bissau","Guyana","Haiti","Honduras", + "Hungary","Iceland","India","Indonesia", + "Iran","Iraq","Ireland","Israel", + "Italy","Jamaica","Japan","Jordan", + "Kazakhstan","Kenya","Kosovo","Kuwait", + "Kyrgyzstan","Laos","Latvia","Lebanon", + "Lesotho","Liberia","Libya","Lithuania", + "Madagascar","Malawi","Malaysia","Mali", + "Mauritania","Mexico","Moldova","Mongolia", + "Montenegro","Morocco","Mozambique","Myanmar", + "N. Cyprus","Namibia","Nepal","Netherlands", + "New Caledonia","New Zealand","Nicaragua","Niger", + "Nigeria","North Korea","North Macedonia","Norway", + "Oman","Pakistan","Panama","Papua New Guinea", + "Paraguay","Peru","Philippines","Poland", + "Portugal","Puerto Rico","Qatar","Romania", + "Russia","Rwanda","S. Sudan","Saudi Arabia", + "Senegal","Serbia","Sierra Leone","Slovakia", + "Slovenia","Solomon Is.","Somalia","Somaliland", + "South Africa","South Korea","Spain","Sri Lanka", + "Sudan","Suriname","Sweden","Switzerland", + "Syria","Taiwan","Tajikistan","Tanzania", + "Thailand","Timor-Leste","Togo","Tunisia", + "Turkey","Turkmenistan","Uganda","Ukraine", + "United Arab Emirates","United Kingdom","United States of America","Uruguay", + "Uzbekistan","Venezuela","Vietnam","W. Sahara", + "Yemen","Zambia","Zimbabwe", +}; + +const char *const ISO[] = { + "AF","AL","DZ","AO","AQ","AR","AM","AU","AT","AZ","BS","BD", + "BY","BE","BZ","BJ","BT","BO","BA","BW","BR","BG","BF","BI", + "KH","CM","CA","CF","TD","CL","CN","CO","CG","CR","CI","HR", + "CU","CY","CZ","CD","DK","DJ","DO","EC","EG","SV","GQ","ER", + "EE","SZ","ET","FK","FI","TF","FR","GA","GM","GE","DE","GH", + "GR","GL","GT","GN","GW","GY","HT","HN","HU","IS","IN","ID", + "IR","IQ","IE","IL","IT","JM","JP","JO","KZ","KE","XK","KW", + "KG","LA","LV","LB","LS","LR","LY","LT","MG","MW","MY","ML", + "MR","MX","MD","MN","ME","MA","MZ","MM","-99","NA","NP","NL", + "NC","NZ","NI","NE","NG","KP","MK","NO","OM","PK","PA","PG", + "PY","PE","PH","PL","PT","PR","QA","RO","RU","RW","SS","SA", + "SN","RS","SL","SK","SI","SB","SO","-99","ZA","KR","ES","LK", + "SD","SR","SE","CH","SY","TW","TJ","TZ","TH","TL","TG","TN", + "TR","TM","UG","UA","AE","GB","US","UY","UZ","VE","VN","EH", + "YE","ZM","ZW", +}; + +} // namespace + +/// Built once, on first use: the pool above is plain static data, and this +/// turns it into the shape the rest of the code wants without a static +/// initialiser that has to run before main. +const std::vector &world_countries() { + static const std::vector countries = [] { + std::vector out; + out.reserve(171); + for (std::size_t i = 0; i < 171; i++) { + CountryOutline c; + c.name = NAMES[i]; + c.iso = ISO[i]; + const Span &cs = COUNTRIES[i]; + for (int r = 0; r < cs.length; r++) { + const Span &rs = RINGS[cs.at + r]; + c.rings.emplace_back(POOL + rs.at, POOL + rs.at + rs.length); + } + out.push_back(std::move(c)); + } + return out; + }(); + return countries; +} + +} // namespace hqtui diff --git a/ports/nim/tests/parity.nim b/ports/nim/tests/parity.nim new file mode 100644 index 0000000..def5d55 --- /dev/null +++ b/ports/nim/tests/parity.nim @@ -0,0 +1,26 @@ +## Replays ports/conformance/fixtures/demo-parity.json, the TypeScript reference +## frames for all ten demo screens at several sizes, themes and border modes. +## ports/bindings/tests/check.py replays the same group across every binding; +## this is the copy `nimble test` runs on its own. +import std/[json, os, strformat, unittest] +import ../src/hqtui + +const fixtures = currentSourcePath().parentDir / ".." / ".." / "conformance" / "fixtures" / "demo-parity.json" + +suite "conformance: demo-parity": + test "every reference frame matches, cell hash for cell hash": + let cases = parseFile(fixtures) + check cases.len > 0 + var failures: seq[string] + for c in cases: + var scene = newScene(c["width"].getInt, c["height"].getInt, c["theme"].getStr) + try: + if c.hasKey("collapsed"): scene.collapse(c["collapsed"].getBool) + let actual = parseJson(scene.demoFrame(c["screen"].getStr, "hashes")) + if actual != c["hashes"]: + failures.add &"""{c["screen"].getStr} {c["width"].getInt}x{c["height"].getInt} {c["theme"].getStr} collapsed={c.getOrDefault("collapsed")}""" + finally: + scene.close() + for failure in failures: checkpoint failure + check failures.len == 0 + echo &" {cases.len - failures.len} of {cases.len} reference frames match" diff --git a/ports/nim/tools/vendor.sh b/ports/nim/tools/vendor.sh new file mode 100755 index 0000000..0df8c34 --- /dev/null +++ b/ports/nim/tools/vendor.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Copy the shared native engine into the Nim package, so `nimble install hqtui` +# compiles it from source into the program and needs no CMake, shared library +# or HQTUI_NATIVE_LIB. +# +# src/hqtui/engine is generated. Edit ports/c, ports/cpp and ports/bindings, +# then run this script. CI runs it and fails on any difference, so a stale copy +# cannot ship. +set -eu +port=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +ports=$(dirname -- "$port") +out="$port/src/hqtui/engine" + +rm -rf "$out" +mkdir -p "$out/c/include" "$out/c/src" "$out/cpp/include/hqtui" "$out/cpp/src" \ + "$out/cpp/demo/generated" "$out/bindings/include" "$out/bindings/src" + +cp "$ports"/c/include/*.h "$out/c/include/" +cp "$ports"/c/src/*.c "$ports"/c/src/*.h "$out/c/src/" +cp "$ports"/cpp/include/*.hpp "$out/cpp/include/" +cp "$ports"/cpp/include/hqtui/*.hpp "$out/cpp/include/hqtui/" +cp "$ports"/cpp/src/*.cpp "$out/cpp/src/" +cp "$ports"/cpp/demo/*.cpp "$ports"/cpp/demo/*.hpp "$out/cpp/demo/" +cp "$ports"/bindings/include/*.h "$out/bindings/include/" +cp "$ports"/bindings/src/*.cpp "$out/bindings/src/" + +# What CMake's configure_file does with demo/sample.hpp.in: splice the sample +# document into the raw string literal in place of @HQTUI_DEMO_SAMPLE@. +template="$ports/cpp/demo/sample.hpp.in" +sample="$ports/rust/demo/src/sample.json" +{ + sed -n '/@HQTUI_DEMO_SAMPLE@/!p; /@HQTUI_DEMO_SAMPLE@/q' "$template" + sed -n 's/@HQTUI_DEMO_SAMPLE@.*//p' "$template" | tr -d '\n' + cat "$sample" + sed -n 's/.*@HQTUI_DEMO_SAMPLE@//p' "$template" +} > "$out/cpp/demo/generated/sample.hpp" + +# The engine is versioned with the C++ port; the demo prints it for --version. +sed -n 's/^project(hqtui_cpp VERSION \([0-9.]*\).*/\1/p' "$ports/cpp/CMakeLists.txt" \ + > "$out/VERSION" + +cat > "$out/README.md" <<'EOF' +# Generated: do not edit + +A copy of `ports/c`, `ports/cpp` and `ports/bindings`, made by +`ports/nim/tools/vendor.sh` so the Nim package builds the engine from source. +Change the originals and rerun the script. +EOF