diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings.yml
new file mode 100644
index 0000000..9f43230
--- /dev/null
+++ b/.github/workflows/bindings.yml
@@ -0,0 +1,49 @@
+name: Ruby PHP Perl bindings
+on:
+ push:
+ branches: [main]
+ pull_request:
+permissions:
+ contents: read
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+jobs:
+ bindings:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v7
+ - uses: jdx/mise-action@v4.3.0
+ with:
+ install: false
+ - name: Install only binding test toolchains
+ run: mise install ruby@4.0.6 conda:php@8.5.9 perl@5.44.0.0 cmake@4.4.3 python@3.12.13
+ - name: Install private Perl dependencies
+ run: mise exec perl@5.44.0.0 -- cpanm --local-lib-contained "${{ runner.temp }}/hqtui-perl" --notest --mirror https://cpan.metacpan.org --mirror-only FFI::Platypus@2.11
+ - name: Build optimized binding library and PHP adapter
+ shell: bash
+ run: |
+ mise exec -- cmake -S ports/cpp -B ports/cpp/build-bindings -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=ON -DHQTUI_LTO=ON -DHQTUI_BUILD_BINDINGS=ON -DHQTUI_PHP_CONFIG="$(mise exec conda:php@8.5.9 -- which php-config)"
+ mise exec -- cmake --build ports/cpp/build-bindings --target hqtui_bindings hqtui_php hqtui_bindings_test --parallel 3
+ mise exec -- ctest --test-dir ports/cpp/build-bindings -R bindings_abi --output-on-failure
+ - name: Check APIs, 360 reference frames, real data and terminal cleanup
+ shell: bash
+ run: |
+ extension=so
+ if [ "$(uname -s)" = Darwin ]; then extension=dylib; fi
+ export HQTUI_NATIVE_LIB="$PWD/ports/cpp/build-bindings/libhqtui_bindings.$extension"
+ export PERL5LIB="${{ runner.temp }}/hqtui-perl/lib/perl5"
+ mise exec -- python ports/bindings/tests/check.py
+ - name: Verify vanilla PHP FFI fallback without the native PHP adapter
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y php-cli php-common
+ export HQTUI_NATIVE_LIB="$PWD/ports/cpp/build-bindings/libhqtui_bindings.so"
+ export HQTUI_BINDING_COMMANDS='{"php":["/usr/bin/php","-d","ffi.enable=true"]}'
+ mise exec -- python ports/bindings/tests/check.py
diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml
index e50c4bd..d12c3b4 100644
--- a/.github/workflows/c-cpp.yml
+++ b/.github/workflows/c-cpp.yml
@@ -2,9 +2,9 @@ name: C and C++ core
on:
push:
branches: [main]
- paths: ['ports/c/**', 'ports/cpp/**', 'ports/conformance/**', 'apps/benchmark/src/*native-core.ts', '.github/workflows/c-cpp.yml']
+ paths: ['ports/c/**', 'ports/cpp/**', 'ports/bindings/**', 'ports/php/native/**', 'ports/conformance/**', 'apps/benchmark/src/*native-core.ts', '.github/workflows/c-cpp.yml']
pull_request:
- paths: ['ports/c/**', 'ports/cpp/**', 'ports/conformance/**', 'apps/benchmark/src/*native-core.ts', '.github/workflows/c-cpp.yml']
+ paths: ['ports/c/**', 'ports/cpp/**', 'ports/bindings/**', 'ports/php/native/**', 'ports/conformance/**', 'apps/benchmark/src/*native-core.ts', '.github/workflows/c-cpp.yml']
permissions:
contents: read
jobs:
@@ -43,7 +43,7 @@ jobs:
with:
bun-version: '1.4.0'
- name: Sanitizer build
- run: cmake -S ports/cpp -B ports/cpp/build-sanitize -DCMAKE_BUILD_TYPE=Debug -DHQTUI_SANITIZE=ON
+ run: cmake -S ports/cpp -B ports/cpp/build-sanitize -DCMAKE_BUILD_TYPE=Debug -DHQTUI_SANITIZE=ON -DHQTUI_BUILD_BINDINGS=ON
- run: cmake --build ports/cpp/build-sanitize --parallel
- run: ctest --test-dir ports/cpp/build-sanitize --output-on-failure
- name: Optimized build
diff --git a/apps/demo/scripts/check-native-data.py b/apps/demo/scripts/check-native-data.py
index 5aed445..b9b323b 100644
--- a/apps/demo/scripts/check-native-data.py
+++ b/apps/demo/scripts/check-native-data.py
@@ -18,6 +18,9 @@
}
if os.environ.get('HQTUI_CPP_DEMO'):
COMMANDS['cpp'] = [os.environ['HQTUI_CPP_DEMO']]
+if os.environ.get('HQTUI_BINDING_DEMOS'):
+ COMMANDS.update(json.loads(os.environ['HQTUI_BINDING_DEMOS']))
+SHARED = {'cpp', 'ruby', 'php', 'perl'}
if os.environ.get('HQTUI_NATIVE_LANGUAGES'):
selected = os.environ['HQTUI_NATIVE_LANGUAGES'].split(',')
COMMANDS = {language: COMMANDS[language] for language in selected}
@@ -43,7 +46,7 @@ def main():
'''
with tempfile.TemporaryDirectory(prefix='hqtui-native-data-') as directory:
utilities = ['who', 'last', 'lastb', 'ss', 'journalctl', 'nvidia-smi']
- if 'cpp' in COMMANDS:
+ if SHARED.intersection(COMMANDS):
utilities += ['tail', 'docker']
for name in utilities:
path = Path(directory) / name
@@ -55,7 +58,7 @@ def main():
cases = [('dashboard', ['Sensors', 'Test GPU', '25%']),
('traffic', ['HTTPS', 'DNS', 'SSH', 'accepted', 'alice']),
('sessions', ['alice', 'eve', 'still', 'failed'])]
- if language == 'cpp':
+ if language in SHARED:
cases[1][1].extend(['/health', '/missing', '/chat'])
cases.append(('services', ['cpp-fixture-container', 'test:local']))
cases.append(('network', ['127.0.0.1']))
@@ -67,7 +70,7 @@ def main():
for label in expected:
assert label.lower() in result.stdout.lower(), (language, screen, f'missing {label}')
assert 'simulated' not in result.stdout.lower(), (language, screen)
- if language == 'cpp':
+ if language in SHARED:
assert 'token=secret' not in result.stdout, 'HTTP query secret leaked'
print(f'{language} {screen}: fixture utilities reached the real collector and rendered rows', flush=True)
diff --git a/apps/demo/scripts/test-updater.py b/apps/demo/scripts/test-updater.py
index 00e3452..7b1f13f 100644
--- a/apps/demo/scripts/test-updater.py
+++ b/apps/demo/scripts/test-updater.py
@@ -40,7 +40,7 @@ def git(self, *args):
return subprocess.check_output(["git", *args], cwd=self.repo, stderr=subprocess.DEVNULL, text=True).strip()
def commit(self, label):
- (self.repo / "mise.toml").write_text('[tools]\nbun = "1.4.0"\npython = "3.12.13"\nrust = "1.97.1"\ngo = "1.26.0"\nzig = "0.16.0"\ncmake = "4.4.3"\n')
+ (self.repo / "mise.toml").write_text('[tools]\nbun = "1.4.0"\npython = "3.12.13"\nrust = "1.97.1"\ngo = "1.26.0"\nzig = "0.16.0"\ncmake = "4.4.3"\nruby = "4.0.6"\n"conda:php" = "8.5.9"\nperl = "5.44.0.0"\n')
(self.repo / ".gitignore").write_text('__pycache__/\n')
path = self.repo / "ports/python/examples"
path.mkdir(parents=True, exist_ok=True)
@@ -49,7 +49,7 @@ def commit(self, label):
'import json, os, sys\n'
f'print(json.dumps({{"revision": {label!r}, "args": sys.argv[1:], "cwd": os.getcwd(), "tty": os.isatty(0)}}), flush=True)\n'
'if "--wait" in sys.argv: input()\n')
- for language in ("rust", "go", "zig", "cpp"):
+ for language in ("rust", "go", "zig", "cpp", "ruby", "php", "perl"):
(self.repo / "ports" / language).mkdir(exist_ok=True)
(self.repo / "ports" / language / "source").write_text(label)
self.git("add", ".")
@@ -115,6 +115,17 @@ def stub_compilers(self):
assert args[0:2]==["--no-config","exec"]
assert args[3]=="--"
os.execvp(args[4],args[4:])
+elif tool in ("ruby","php","perl"):
+ if args[0]=="-r":
+ if args[1].startswith('echo PHP_VERSION'): print(os.environ.get('UPDATER_PHP_VERSION','8.5.9'))
+ sys.exit(0)
+ if args[0] in ("-rfiddle/import","-MFFI::Platypus=2.11"): sys.exit(0)
+ if args[0]=="-MConfig": print("fixture-perl-abi");sys.exit(0)
+ if args[0]=="-d": args=args[2:]
+ label=(pathlib.Path(args[0]).parents[1]/"source").read_text()
+ print(json.dumps(dict(revision=label,args=args[1:])))
+ sys.exit(0)
+elif tool=="php-config": print(os.environ.get('UPDATER_PHP_CONFIG_VERSION',os.environ.get('UPDATER_PHP_VERSION','8.5.9')));sys.exit(0)
elif tool=="cargo":
assert args==["build","--release","--example","dashboard"]
output=pathlib.Path(os.environ["CARGO_TARGET_DIR"])/"release/examples/dashboard"
@@ -130,6 +141,11 @@ def stub_compilers(self):
build.mkdir(parents=True,exist_ok=True)
(build/"source").write_text((source/"source").read_text())
sys.exit(0)
+ if args[0]=="--build" and args[3] in ("hqtui_bindings","hqtui_php"):
+ build=pathlib.Path(args[1])
+ for name in ('libhqtui_bindings.so','libhqtui_bindings.dylib','hqtui_php.so'):
+ (build/name).write_text('fixture shared library')
+ sys.exit(0)
assert args[0]=="--build" and args[2:4]==["--target","hqtui-demo-cpp"]
output=pathlib.Path(args[1])/"hqtui-demo-cpp"
else: raise AssertionError(tool)
@@ -138,7 +154,7 @@ def stub_compilers(self):
output.write_text("#!/usr/bin/env python3\\nimport json,sys\\nprint(json.dumps(dict(revision="+repr(label)+",args=sys.argv[1:])))\\n")
output.chmod(0o700)
'''
- for name in ("mise", "cargo", "go", "zig", "cmake"):
+ for name in ("mise", "cargo", "go", "zig", "cmake", "ruby", "php", "perl", "php-config"):
path = self.bin / name
path.write_text(code)
path.chmod(0o700)
@@ -163,6 +179,47 @@ def test_vanilla_and_mise_build_latest_and_reuse_only_same_revision(self):
log = [json.loads(line) for line in (self.root / "tools.jsonl").read_text().splitlines()]
self.assertEqual(sum(row[0] in ("cargo", "go", "zig") or row[:2]==["cmake","--build"] for row in log), 12)
+ def test_bindings_update_both_managers_and_keep_builds_outside_source(self):
+ self.stub_compilers()
+ for manager in ('--system', '--mise'):
+ for language in ('ruby', 'php', 'perl'):
+ result=self.run_demo(manager,language,'--snapshot','literal argument')
+ self.assertEqual(result.returncode,0,result.stderr)
+ self.assertEqual(json.loads(result.stdout),dict(revision='first',args=['--snapshot','literal argument']))
+ def builds():
+ return sum(json.loads(line)[:2]==['cmake','--build'] for line in (self.root/'tools.jsonl').read_text().splitlines())
+ count=builds()
+ self.assertEqual(count,8) # engine × three languages, plus PHP adapter; two managers
+ for language in ('ruby','php','perl'):
+ self.assertEqual(self.run_demo('--mise',language,'--version').returncode,0)
+ self.assertEqual(builds(),count)
+ self.commit('second')
+ for language in ('ruby','php','perl'):
+ result=self.run_demo('--mise',language,'--snapshot')
+ self.assertEqual(result.returncode,0,result.stderr)
+ self.assertEqual(json.loads(result.stdout)['revision'],'second')
+ self.assertEqual(builds(),count+4)
+ self.assertFalse((self.root/'cache/v1/update.lock').exists())
+
+ def test_php_upgrade_rebuilds_adapter_and_mismatched_headers_are_rejected(self):
+ self.stub_compilers()
+ first=self.run_demo('--system','php','--version')
+ self.assertEqual(first.returncode,0,first.stderr)
+ def builds():
+ return sum(json.loads(line)[:2]==['cmake','--build'] for line in
+ (self.root/'tools.jsonl').read_text().splitlines())
+ self.assertEqual(builds(),2)
+ upgraded={**self.env,'UPDATER_PHP_VERSION':'8.6.0'}
+ result=self.run_demo('--system','php','--version',env=upgraded)
+ self.assertEqual(result.returncode,0,result.stderr)
+ self.assertEqual(builds(),4)
+ bad={**upgraded,'UPDATER_PHP_CONFIG_VERSION':'8.5.9'}
+ result=self.run_demo('--system','php','--version',env=bad)
+ self.assertNotEqual(result.returncode,0)
+ self.assertIn('php-config must match',result.stderr)
+ self.assertEqual(result.stdout,'')
+ self.assertEqual(builds(),4)
+
@unittest.skipUnless(os.name == "posix", "requires a controlling PTY")
def test_pipe_launcher_reattaches_keyboard_and_releases_build_lock(self):
pid, master = pty.fork()
diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx
index 35583fa..85d1673 100644
--- a/apps/web/app/docs/page.tsx
+++ b/apps/web/app/docs/page.tsx
@@ -68,6 +68,7 @@ export default async function Docs() {
HQTUI builds terminal applications in TypeScript, Rust, Go, Python and Zig,
with a new native C++ ten-screen demo over the shared C rendering core.
The C++ library API remains experimental.
+ Ruby, PHP and Perl now have experimental bindings to the same native engine.
Choose a language
@@ -80,9 +81,9 @@ export default async function Docs() {
Rust, Go, Python, Zig and C++ demos need no JavaScript runtime.
Both the vanilla and mise commands below fetch latest main before running.
They work from any directory and never switch branches, reset, or pull in your checkout.
- All five native dashboard commands launch ten-screen demos. Live metrics
+ All dashboard commands launch ten-screen demos. Live metrics in the native demos
currently require Linux; use --sim for generated sample data on other platforms.
- All five native demos use the TypeScript reference's ten screen layouts,
+ The native demos and bindings use the TypeScript reference's ten screen layouts,
including its responsive dashboard, detailed telemetry tabs and widget showcases.
Each port is checked against 120 shared TypeScript reference frames across four
terminal sizes and three themes. Live-data availability still depends on the host
@@ -95,7 +96,17 @@ export default async function Docs() {
Its mise command supplies pinned CMake; you still need your platform's
C/C++ build tools. The interactive C++ terminal supports Linux/macOS;
live collection and exact 120-frame parity are currently tested on Linux.
- The C-only demo and other mise language integrations are not ready yet.
+ The C-only demo and languages beyond those listed here are not ready yet.
+
+
+ Ruby, PHP and Perl 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.
+ 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.
+ Mise supplies pinned Ruby/Perl, prebuilt PHP via its Conda backend, and CMake.
+ No system packages or global gems/CPAN modules are installed by the launcher.
Linux sensor panels now collect available hwmon temperatures, fans, voltage,
@@ -109,7 +120,7 @@ export default async function Docs() {
Vanilla commands require Git, curl and the language's installed toolchain
(Bun for TypeScript). The mise
- alternatives install/use only the selected pinned toolchain without loading project
+ alternatives install/use the selected pinned runtime and required build tools without loading project
hooks. The launcher prints the full Git revision, reuses completed builds for that
revision, and fails rather than silently launching stale code if fetching fails.
First builds take longer. Append --sim or --snapshot directly; no extra Cargo/Zig separator is needed.
@@ -123,6 +134,7 @@ export default async function Docs() {
{PORTS.map((port) => (
{port.name}
+ {port.description}
Interactive dashboard
With mise
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 5a77674..1cd01b8 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -149,7 +149,7 @@ export default async function Home() {
High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++
- v0.1.12 · 6 language demos · MIT
+ v0.1.12 · 9 language demos · MIT
Terminal dashboards that
@@ -227,10 +227,11 @@ export default async function Home() {
- One terminal UI, six language demos
+ One terminal UI, nine language demos
Build in TypeScript or use a native Rust, Go, Python or Zig implementation.
C++ now has a native ten-screen demo over the shared C rendering core, with an experimental library API.
+ Ruby, PHP and Perl add experimental bindings to that same engine, with APIs for your own CLI applications.
The native demos need no JavaScript runtime.
diff --git a/apps/web/lib/languages.ts b/apps/web/lib/languages.ts
index dac4350..b2d3d91 100644
--- a/apps/web/lib/languages.ts
+++ b/apps/web/lib/languages.ts
@@ -11,17 +11,25 @@ export type Language = {
/** Same latest-source demo with a pinned mise-managed toolchain. */
miseDemo: string;
native: boolean;
+ binding?: boolean;
};
/** Optional developer checkout; public demo commands do not require it. */
export const CLONE = "git clone https://github.com/profullstack/hqtui";
export const LAUNCHER = "https://hqtui.com/demo.sh";
export function latestDemo(language: string, mise = false): string {
- if (!["typescript", "rust", "go", "python", "zig", "cpp"].includes(language)) throw new Error("Unsupported demo language");
+ if (!["typescript", "rust", "go", "python", "zig", "cpp", "ruby", "php", "perl"].includes(language)) throw new Error("Unsupported demo language");
return `curl -fsSL ${LAUNCHER} | sh -s -- --${mise ? "mise" : "system"} ${language}`;
}
export const LANGUAGES: readonly Language[] = [
+ ...([['Ruby', 'ruby'], ['PHP', 'php'], ['Perl', 'perl']] as const).map(([name, id]) => ({
+ name, id, href: `/docs#${id}`,
+ description: `${name} bindings with a batched widget API. The shared C/C++ demo engine runs inside your language runtime; not an independent port.`,
+ demo: latestDemo(id), interactiveDemo: latestDemo(id),
+ snapshotDemo: `${latestDemo(id)} --snapshot`, miseDemo: latestDemo(id, true),
+ native: true, binding: true,
+ })),
{
name: "C++",
id: "cpp",
diff --git a/apps/web/public/demo.sh b/apps/web/public/demo.sh
index 96a3529..2766047 100644
--- a/apps/web/public/demo.sh
+++ b/apps/web/public/demo.sh
@@ -9,7 +9,7 @@ main() (
note() { printf 'hqtui-demo: %s\n' "$*" >&2; }
usage() {
printf '%s\n' 'Usage: demo.sh [--mise|--system] [--check] LANGUAGE [demo arguments...]' \
- 'Languages: typescript (ts), rust, go, python, zig, cpp (c++)' \
+ 'Languages: typescript (ts), rust, go, python, zig, cpp (c++), ruby, php, perl' \
'Every invocation fetches latest main. --check prints the revision without building.' \
'Defaults to mise when installed, otherwise uses your installed compiler/runtime.' \
'Examples: demo.sh rust --sim; demo.sh --mise rust --snapshot'
@@ -30,9 +30,10 @@ main() (
language=$1; shift
case "$language" in
ts|typescript) language=typescript; tool=bun ;;
- rust|go|python|zig) tool=$language ;;
+ rust|go|python|zig|ruby|perl) tool=$language ;;
+ php) tool=conda:php ;;
cpp|c++) language=cpp; tool=cmake ;;
- *) fail "Unsupported language '$language'. The C-only demo is not ready; use typescript, rust, go, python, zig or cpp." ;;
+ *) fail "Unsupported language '$language'. Use typescript, rust, go, python, zig, cpp, ruby, php or perl. The C-only demo is not ready." ;;
esac
# When invoked through curl | sh, the pipe is not the demo's keyboard.
# Connect only interactive runs to the controlling terminal; preserve pipes
@@ -130,11 +131,14 @@ main() (
[ -z "$(git_safe -C "$source" status --porcelain --untracked-files=normal)" ] \
|| fail "Cached source was modified: $source. It has been left untouched; choose a new HQTUI_DEMO_CACHE location."
# Read only the selected version string, not mise hooks or environment code.
- version=$(awk -v key="$tool" '$1 == key && $2 == "=" {gsub(/"/, "", $3); print $3; exit}' "$source/mise.toml")
+ version=$(awk -v key="$tool" '{name=$1; gsub(/"/, "", name)} name == key && $2 == "=" {gsub(/"/, "", $3); print $3; exit}' "$source/mise.toml")
case "$version" in ''|*[!0-9.]*) fail "Invalid pinned $tool version in mise.toml." ;; esac
+ tool_spec=$tool
+ # Prebuilt PHP includes development headers for our small native adapter.
+ [ "$language" != php ] || tool_spec=conda:php
run_tool() {
if [ "$manager" = mise ]; then
- mise --no-config exec "$tool@$version" -- "$@"
+ mise --no-config exec "$tool_spec@$version" -- "$@"
else
"$@"
fi
@@ -143,7 +147,7 @@ main() (
cleanup; locked=0
trap - EXIT INT TERM HUP
if [ "$manager" = mise ]; then
- exec mise --no-config exec "$tool@$version" -- "$@"
+ exec mise --no-config exec "$tool_spec@$version" -- "$@"
else
exec "$@"
fi
@@ -153,6 +157,69 @@ main() (
bins=$cache/bin/$platform/$manager-$tool-$version/$revision
mkdir -p "$bins" "$cache/build"
case "$language" in
+ ruby|php|perl)
+ case "$(uname -s)" in Linux) library_suffix=so ;; Darwin) library_suffix=dylib ;; *) fail 'These bindings currently support Linux/macOS.' ;; esac
+ command -v "${CXX:-c++}" >/dev/null 2>&1 || fail 'A C++17 compiler (GCC/Clang) is required.'
+ cmake_version=$(awk '$1 == "cmake" && $2 == "=" {gsub(/"/, "", $3); print $3; exit}' "$source/mise.toml")
+ case "$cmake_version" in ''|*[!0-9.]*) fail 'Invalid CMake pin.' ;; esac
+ cmake_driver=cmake
+ if [ "$manager" = system ]; then
+ cmake_driver=$(command -v cmake) || fail 'CMake is required. Install it or use --mise.'
+ case "$cmake_driver" in */mise/shims/*) cmake_driver=$(mise which cmake) || fail 'Activate CMake or use --mise.' ;; esac
+ fi
+ binding_cmake() {
+ if [ "$manager" = mise ]; then mise --no-config exec "cmake@$cmake_version" -- cmake "$@"; else "$cmake_driver" "$@"; fi
+ }
+ binding_identity=$language-$version
+ php_config=
+ if [ "$language" = php ]; then
+ # Prefer the compiled adapter; a vanilla FFI-enabled PHP can
+ # instead use the C ABI without PHP development headers.
+ php_config=$(run_tool sh -c 'command -v php-config' 2>/dev/null) || php_config=
+ php_version=$(run_tool php -r 'echo PHP_VERSION;')
+ if [ -n "$php_config" ]; then
+ [ "$(run_tool "$php_config" --version)" = "$php_version" ] || fail 'php-config must match the selected PHP runtime. Fix PATH or use --mise.'
+ fi
+ php_abi=$(run_tool php -r 'echo PHP_VERSION, ":", PHP_ZTS, ":", PHP_DEBUG, ":", PHP_BINARY;' | git_safe hash-object --stdin)
+ binding_identity=$binding_identity-$php_abi
+ if [ -z "$php_config" ]; then
+ run_tool php -r 'exit(extension_loaded("ffi") ? 0 : 1);' || fail 'PHP needs php-config/development headers or its FFI extension.'
+ fi
+ fi
+ binding_build=$cache/build/bindings/$platform/$manager/$binding_identity/$revision
+ if [ ! -f "$binding_build/ready" ] || [ ! -f "$binding_build/libhqtui_bindings.$library_suffix" ]; then
+ note "Building shared native renderer for $language (first run of this revision)…"
+ binding_cmake -S "$source/ports/cpp" -B "$binding_build" -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF -DHQTUI_LTO=ON -DHQTUI_BUILD_BINDINGS=ON "-DHQTUI_PHP_CONFIG=$php_config" >&2
+ binding_cmake --build "$binding_build" --target hqtui_bindings --parallel 2 >&2
+ if [ -n "$php_config" ]; then binding_cmake --build "$binding_build" --target hqtui_php --parallel 2 >&2; fi
+ printf '%s\n' "$revision" > "$binding_build/ready"
+ fi
+ HQTUI_NATIVE_LIB=$binding_build/libhqtui_bindings.$library_suffix
+ export HQTUI_NATIVE_LIB
+ case "$language" in
+ ruby)
+ run_tool ruby -rfiddle/import -e '' || fail 'Ruby needs Fiddle (gem install fiddle).'
+ launch ruby "$source/ports/ruby/examples/dashboard.rb" "$@"
+ ;;
+ php)
+ if [ -f "$binding_build/hqtui_php.so" ]; then
+ launch php -d "extension=$binding_build/hqtui_php.so" "$source/ports/php/examples/dashboard.php" "$@"
+ else
+ launch php "$source/ports/php/examples/dashboard.php" "$@"
+ fi
+ ;;
+ perl)
+ perl_abi=$(run_tool perl -MConfig -e 'print "$^X:$Config{version}:$Config{archname}"' | git_safe hash-object --stdin)
+ perl_deps=$cache/deps/perl/$platform/$perl_abi-platypus-2.11
+ PERL5LIB=$perl_deps/lib/perl5${PERL5LIB:+:$PERL5LIB}; export PERL5LIB
+ if ! run_tool perl -MFFI::Platypus=2.11 -e '' 2>/dev/null; then
+ note 'Installing Perl FFI::Platypus into the private demo cache…'
+ run_tool cpanm --local-lib-contained "$perl_deps" --notest --mirror https://cpan.metacpan.org --mirror-only FFI::Platypus@2.11 >&2 || fail 'Install cpanm and native build tools, then retry. No global Perl modules were modified.'
+ fi
+ launch perl "$source/ports/perl/examples/dashboard.pl" "$@"
+ ;;
+ esac
+ ;;
cpp)
case "$(uname -s)" in Linux|Darwin) ;; *) fail 'The C++ terminal demo currently supports Linux/macOS; use another demo on this platform.' ;; esac
# mise manages CMake here. The native C/C++ compiler is supplied by
diff --git a/apps/web/test/languages.test.ts b/apps/web/test/languages.test.ts
index 98f5dac..c273a6a 100644
--- a/apps/web/test/languages.test.ts
+++ b/apps/web/test/languages.test.ts
@@ -7,7 +7,7 @@ import { LANGUAGES, PORTS, LAUNCHER, latestDemo } from "../lib/languages.ts";
const root = resolve(import.meta.dirname, "../../..");
test("every supported language has latest-source vanilla and mise demo commands", () => {
- assert.deepEqual(LANGUAGES.map(({ id }) => id), ["cpp", "typescript", "rust", "go", "python", "zig"]);
+ assert.deepEqual(LANGUAGES.map(({ id }) => id), ["ruby", "php", "perl", "cpp", "typescript", "rust", "go", "python", "zig"]);
for (const language of LANGUAGES) {
assert.ok(language.interactiveDemo.length > 0, language.id);
assert.ok(language.interactiveDemo.startsWith(`curl -fsSL ${LAUNCHER} | sh -s -- --system ${language.id}`));
@@ -18,6 +18,9 @@ test("every supported language has latest-source vanilla and mise demo commands"
test("native updater commands retain full-dashboard source entrypoints", () => {
const files: Record
= {
+ ruby: ["examples/dashboard.rb", "examples/hello.rb"],
+ php: ["examples/dashboard.php", "examples/hello.php"],
+ perl: ["examples/dashboard.pl", "examples/hello.pl"],
cpp: ["demo/main.cpp", "demo/dashboard.cpp"],
rust: ["examples/dashboard.rs", "examples/screenshot.rs"],
go: ["examples/dashboard/main.go", "examples/screenshot/main.go"],
diff --git a/mise.toml b/mise.toml
index efbf469..9c98a29 100644
--- a/mise.toml
+++ b/mise.toml
@@ -7,6 +7,21 @@ go = "1.26.0"
rust = "1.97.1"
zig = "0.16.0"
cmake = "4.4.3"
+ruby = "4.0.6"
+"conda:php" = "8.5.9"
+perl = "5.44.0.0"
+
+[tasks."demo:ruby"]
+description = "Update and run Ruby binding demo"
+run = "sh apps/web/public/demo.sh --mise ruby"
+
+[tasks."demo:php"]
+description = "Update and run PHP binding demo"
+run = "sh apps/web/public/demo.sh --mise php"
+
+[tasks."demo:perl"]
+description = "Update and run Perl binding demo"
+run = "sh apps/web/public/demo.sh --mise perl"
[tasks."demo:cpp"]
description = "Update and run the native C++ ten-screen demo (requires C/C++ compiler)"
diff --git a/ports/MISE-LANGUAGES.md b/ports/MISE-LANGUAGES.md
index 51d57c1..b6cea01 100644
--- a/ports/MISE-LANGUAGES.md
+++ b/ports/MISE-LANGUAGES.md
@@ -20,7 +20,7 @@ The following runtime/tool IDs were checked with `mise registry` on 2026-09-07:
| C++ | cmake (host GCC/Clang compiler required) | Native demo; experimental API |
| C# / F# / .NET | dotnet | Pending implementation strategy |
| Java / Kotlin / Scala / Clojure | java, kotlin, scala, clojure | Pending |
-| Ruby, PHP, Perl | ruby, php, perl | Pending |
+| Ruby, PHP, Perl | ruby, conda:php, perl | Experimental shared-engine bindings implemented |
| Lua / LuaJIT | lua, luajit | Pending |
| Swift, Crystal, Odin, V | swift, crystal, odin, v | Pending |
| Elixir / Erlang / Gleam | elixir, erlang, gleam | Pending |
@@ -29,10 +29,10 @@ The following runtime/tool IDs were checked with `mise registry` on 2026-09-07:
This is a checked expansion inventory, not an exhaustive claim about third-party
mise plugins, future registry additions or completed HQTUI ports.
-## Decision needed after C++
+## Selected architecture after C++
-The user has been asked whether the remaining languages should bind the shared
-C core or each maintain a separate implementation. C-core bindings can preserve
-the native renderer's performance, but must be named and documented as bindings,
-not independent native ports. Do not quietly launch another language's demo or
-replay reference screenshots under a different command name.
+Ruby, PHP and Perl use an in-process C ABI over the C/C++ engine, with batched
+custom widget APIs and shared collectors/demo implementation. The user selected
+the fastest/easiest shared architecture. These are explicitly bindings, not
+independent native ports or subprocess launchers. No reference screenshots are
+replayed. Other languages in this inventory remain pending.
diff --git a/ports/README.md b/ports/README.md
index 5869d14..48eee92 100644
--- a/ports/README.md
+++ b/ports/README.md
@@ -6,7 +6,11 @@ a C++17 ownership/widget API. The C++ ten-screen demo is now runnable and has
The C-only demo is **not complete**. See
[C](c/README.md), [C++](cpp/README.md), and the [acceptance checklist](c/STATUS.md).
-Native ports of [the TypeScript reference implementation](https://hqtui.com).
+Ruby, PHP and Perl now have experimental [shared-engine bindings](bindings/README.md)
+with custom widget APIs and the ten-screen demo running inside each language VM.
+These are bindings, not independent ports. Their native dependency is intentional.
+
+The following four are independent native ports of [the TypeScript reference implementation](https://hqtui.com).
Not bindings: there is no Node in the picture at runtime or at build time, and
each port is idiomatic in its own language rather than a transliteration.
diff --git a/ports/TARGETS.md b/ports/TARGETS.md
index a702578..1b73f45 100644
--- a/ports/TARGETS.md
+++ b/ports/TARGETS.md
@@ -1,5 +1,10 @@
# Which language gets a port next
+Current implementation status is in [the port index](README.md). Ruby, PHP and
+Perl now use [shared native bindings](bindings/README.md); they do not duplicate
+the renderer in each interpreter. The scoring below describes potential
+independent implementations, not the speed of those shared-engine bindings.
+
A port is a real commitment — it has to track the reference implementation
forever, or it rots and becomes a liability. So the decision is made on two
axes and written down, rather than on whoever shouted loudest.
diff --git a/ports/bindings/PROTOCOL.md b/ports/bindings/PROTOCOL.md
new file mode 100644
index 0000000..d6b3dfb
--- /dev/null
+++ b/ports/bindings/PROTOCOL.md
@@ -0,0 +1,42 @@
+# Scene batch protocol (experimental ABI 1)
+
+`hqb_set(scene, utf8_json, byte_length)` accepts one root widget object. No eval,
+file paths, external commands, or language callbacks are interpreted. Input is
+limited to 1 MiB, 4096 widget nodes and 32 widget nesting levels. The underlying
+JSON parser also rejects duplicate keys and non-finite numbers.
+
+Scene dimensions: 1–500 columns × 1–200 rows. Themes are the existing nine HQTUI
+themes. Returned text is borrowed; each binding copies it into a runtime string.
+Scenes are single-thread-owned. Only one native terminal/demo may be acquired per
+process. Explicit close plus language cleanup releases it; SIGKILL cannot restore
+a terminal. Do not serialize, clone or transfer native handles between threads.
+
+| Widget `type` | Fields |
+|---|---|
+| `row`, `col` | `children` (array), `gap` (0–100) |
+| `panel` | `title`, `subtitle`, `children` |
+| `text` | `text`, `color`, `align` (0 left / 1 center / 2 right), `attrs` (C ABI bitmask) |
+| `meter`, `gauge` | `value` (0–1), `label`; meter also accepts `color` |
+| `graph` | `values` (number array), `min`, `max`, `color` |
+| `table` | `columns` (string array), `rows` (arrays of values), `selected`, `offset` |
+| `keys` | `rows` (label/value pairs), `color` |
+| `log` | `entries` (`time`, `level`, `message`, `meta`), `offset` |
+| `divider` | `text` |
+| `spacer` | `size` |
+
+`size` is an optional fixed extent in the parent's layout direction. Otherwise
+containers/graphs/tables/logs share remaining space. Text and meters normally
+occupy one row. Key/value lists size to their row count. Colors accept `#rrggbb`
+or `primary`, `secondary`, `accent`, `muted`, `success`, `warning`, `danger`,
+`foreground`. Unsupported widget types and invalid rendered options raise a
+language exception. The previous scene tree survives a rejected update.
+
+`render('text')` returns UTF-8 rows with newlines; `ansi` emits a full frame and
+`diff` emits changes against the last rendered frame. Do not interleave snapshot
+rendering with terminal presentation without forcing a fresh full frame. `hashes`
+is an inspection/testing format, not a persistent application protocol.
+
+`demo_frame(screen, format)` renders the shared deterministic sample body, useful
+for acceptance tests. `demo(arguments)` synchronously runs the shared interactive
+or snapshot demo inside the calling runtime. It is not a general callback-driven
+application API; build custom applications with Scene and the language builder.
diff --git a/ports/bindings/README.md b/ports/bindings/README.md
new file mode 100644
index 0000000..a14bd1d
--- /dev/null
+++ b/ports/bindings/README.md
@@ -0,0 +1,77 @@
+# Shared native bindings: Ruby, PHP, Perl
+
+Experimental bindings, not independent renderer ports. Custom applications build
+widget trees in their own language; a versioned C ABI parses a bounded batch and
+uses the C++ layout/widgets plus the C11 framebuffer/diff renderer. The JSON tree
+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.
+
+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**,
+including its Linux collectors, responsive layouts, input and terminal loop.
+This explicit reuse is how demo fixes reach all three bindings together.
+
+## Launch latest
+
+```sh
+curl -fsSL https://hqtui.com/demo.sh | sh -s -- --mise ruby
+curl -fsSL https://hqtui.com/demo.sh | sh -s -- --mise php
+curl -fsSL https://hqtui.com/demo.sh | sh -s -- --mise perl
+```
+
+Replace `--mise` with `--system` for installed runtimes/build tools. Every launch
+fetches current main and prints the source revision. First builds compile native
+code; later launches reuse that revision. Host GCC/Clang and Make are required.
+Mise supplies pinned CMake and runtimes; PHP uses `conda:php` prebuilt packages.
+Vanilla additionally requires CMake, Ruby Fiddle, Perl cpanm (if Platypus is missing),
+and PHP development headers/php-config or enabled FFI. Perl dependencies go in a
+private ABI-keyed cache, never into the global Perl installation.
+
+Linux/macOS terminals are supported; live data requires Linux, with sample mode
+defaulting on macOS. Use `--sim`, `--snapshot`, `--screen traffic`, and 1–9/0/Tab.
+Host permissions and available utilities determine actual live rows.
+
+## Build locally
+
+```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
+ruby ports/ruby/examples/hello.rb
+```
+
+For the PHP native adapter, add `-DHQTUI_PHP_CONFIG=/absolute/path/to/php-config`
+matching the PHP binary, then build target `hqtui_php`. Run PHP with
+`-d extension=/absolute/path/to/build-bindings/hqtui_php.so`. This flag is scoped
+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.
+
+## API and tests
+
+- [Ruby example](../ruby/examples/hello.rb), [PHP example](../php/examples/hello.php), [Perl example](../perl/examples/hello.pl).
+- `Scene.set(tree)` sends a batch; `render` returns text, full ANSI or ANSI diff.
+- `with_terminal` / `withTerminal` acquires/restores the terminal; `present` handles
+ resize and output, `poll` returns raw key/escape bytes for application handling.
+ Use structured cleanup even when application code raises an exception.
+- [Protocol and limits](PROTOCOL.md), [C ABI](include/hqtui_bindings.h).
+
+The acceptance runner `tests/check.py` uses the actual three VMs, checks 120 shared
+TypeScript demo-body frames per binding, exercises independent widget builders,
+UTF-8, errors, ownership, diff/resize, ten-tab PTYs, custom interactive apps and
+signal cleanup. Linux additionally checks live snapshots and injected sensor,
+traffic, session, HTTP and container data through the real collectors. Core memory/error-path
+checks run through the native ABI under address/undefined-behavior sanitizers.
+
+The APIs are experimental: the custom builder covers rows/columns/panels, text,
+meters, graphs, gauges, tables, key/value lists, logs, dividers and spacers. It does
+not yet expose every TypeScript widget or a normalized high-level input API.
+Shared demo parity does not imply complete library/control parity. JSON batching
+avoids per-cell crossings but still costs serialization/parsing on updates; no
+zero-allocation or end-to-end speed claim is made without measurements.
diff --git a/ports/bindings/include/hqtui_bindings.h b/ports/bindings/include/hqtui_bindings.h
new file mode 100644
index 0000000..5dfc51b
--- /dev/null
+++ b/ports/bindings/include/hqtui_bindings.h
@@ -0,0 +1,46 @@
+#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);
+/* 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/bindings/src/bridge.cpp b/ports/bindings/src/bridge.cpp
new file mode 100644
index 0000000..ded2897
--- /dev/null
+++ b/ports/bindings/src/bridge.cpp
@@ -0,0 +1,396 @@
+#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"};
+ 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);
+}
+void node(UI &ui, const Json &n) {
+ auto type = n["type"].s();
+ auto body = [&n](UI &p) {
+ for (auto &child : n["children"].array())
+ node(p, child);
+ };
+ 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 == "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);
+ },
+ 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;
+ 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));
+ node(ui, scene);
+ ui.flush();
+ }
+ 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;
+ });
+}
+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;
+ s->frame.clear(s->theme->background, s->theme->foreground);
+ UI ui(s->frame.surface(s->theme));
+ 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/bindings/tests/abi.cpp b/ports/bindings/tests/abi.cpp
new file mode 100644
index 0000000..f517ce3
--- /dev/null
+++ b/ports/bindings/tests/abi.cpp
@@ -0,0 +1,60 @@
+#include "hqtui_bindings.h"
+#include
+#include
+#include
+#include
+static void check(bool ok) {
+ if (!ok)
+ throw std::runtime_error(hqb_error());
+}
+int main() {
+ try {
+ check(hqb_abi_version() == 1);
+ check(hqb_create(-1, 20, "dark") == nullptr);
+ check(hqb_render(nullptr, "text") == nullptr);
+ hqb_destroy(nullptr);
+ hqb_close(nullptr);
+ auto *s = hqb_create(80, 24, "dark");
+ check(s);
+ const char *good =
+ R"({"type":"panel","title":"ABI","children":[{"type":"text","text":"Unicode → 世界"}]})";
+ check(hqb_set(s, good, strlen(good)));
+ std::string text = hqb_render(s, "text");
+ check(text.find("Unicode") != text.npos);
+ check(std::string(hqb_render(s, "diff")).empty());
+ for (auto bad :
+ {"{", R"({"type":"unknown"})", R"({"type":"col","children":"wrong"})",
+ R"({"type":"text","size":-1})",
+ R"({"type":"text","color":"invalid"})"}) {
+ check(!hqb_set(s, bad, strlen(bad)));
+ check(std::string(hqb_render(s, "text")) == text);
+ }
+ std::string deep = R"({"type":"text","text":"deep"})";
+ for (int i = 0; i < 40; i++)
+ deep = R"({"type":"col","children":[)" + deep + "]}";
+ check(!hqb_set(s, deep.data(), deep.size()));
+ check(!hqb_set(s, good, 1048577));
+ check(!hqb_resize(s, 501, 200));
+ check(hqb_render(s, "unknown") == nullptr);
+ check(hqb_demo_frame(s, "missing", "text") == nullptr);
+ check(hqb_demo("{}", 2) == -1);
+ check(hqb_demo("[1]", 3) == -1);
+ check(hqb_poll(s, 0) == nullptr);
+ check(!hqb_present(s));
+ check(hqb_resize(s, 1, 1));
+ check(hqb_render(s, "text") != nullptr);
+ hqb_destroy(s);
+ for (int i = 0; i < 500; i++) {
+ s = hqb_create(40, 12, "light");
+ check(s);
+ check(hqb_set(s, good, strlen(good)));
+ check(hqb_render(s, "ansi"));
+ hqb_destroy(s);
+ }
+ std::cout << "Binding ABI: limits, ownership, Unicode, unchanged frames, "
+ "rejected updates and errors passed\n";
+ } catch (const std::exception &e) {
+ std::cerr << e.what() << "\n";
+ return 1;
+ }
+}
diff --git a/ports/bindings/tests/check.py b/ports/bindings/tests/check.py
new file mode 100644
index 0000000..d34fe6a
--- /dev/null
+++ b/ports/bindings/tests/check.py
@@ -0,0 +1,117 @@
+"""Exercise actual Ruby/PHP/Perl APIs and VMs against the shared TS corpus."""
+import ctypes
+import fcntl
+import json
+import os
+from pathlib import Path
+import pty
+import select
+import signal
+import struct
+import subprocess
+import sys
+import termios
+import time
+
+ROOT = Path(__file__).resolve().parents[3]
+LIB = Path(os.environ['HQTUI_NATIVE_LIB']).resolve()
+DEFAULT = {'ruby': ['ruby'], 'php': ['php', '-d', f'extension={LIB.parent / "hqtui_php.so"}'], 'perl': ['perl']}
+COMMANDS = json.loads(os.environ.get('HQTUI_BINDING_COMMANDS', json.dumps(DEFAULT)))
+EXT = {'ruby':'rb','php':'php','perl':'pl'}
+
+def invoke(language, relative, args=(), **kwargs):
+ return subprocess.run([*COMMANDS[language], str(ROOT/'ports'/language/relative), *args],
+ check=True, capture_output=True, text=True, timeout=120, **kwargs)
+
+def terminal(language, custom=False, terminate=False):
+ master, slave = pty.openpty()
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH',60,200,0,0))
+ before=termios.tcgetattr(slave)
+ relative=f'examples/{"hello" if custom else "dashboard"}.{EXT[language]}'
+ args=['--interactive'] if custom else ['--sim']
+ # Keep the VM a non-session-leader. Some VMs reopen their stdin device;
+ # making them session leaders allows an implicit controlling-TTY acquisition
+ # and macOS can revoke the slave when that session exits, before inspection.
+ # These in-process simulated demos spawn no children; signal the VM directly.
+ print(f'PTY: {language}, custom={custom}, signal={terminate}',flush=True)
+ proc=subprocess.Popen([*COMMANDS[language],str(ROOT/'ports'/language/relative),*args],
+ stdin=slave,stdout=slave,stderr=slave)
+ os.set_blocking(master,False)
+ output=bytearray()
+ def read():
+ if select.select([master],[],[],.02)[0]:
+ try: output.extend(os.read(master,65536))
+ except (BlockingIOError,OSError): pass
+ def frame(label):
+ end=time.monotonic()+5
+ while label not in output and time.monotonic() $)
+# Keep each arithmetic operation rounded like the TypeScript reference. Fused
+# multiply-add can change a final glyph/color rounding boundary on ARM targets.
+target_compile_options(hqtui PUBLIC $<$:-ffp-contract=off> $<$:-ffp-contract=off>)
if(NOT MSVC)
target_compile_options(hqtui PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wshadow)
target_link_libraries(hqtui PUBLIC m)
diff --git a/ports/cpp/CMakeLists.txt b/ports/cpp/CMakeLists.txt
index 01ca630..bdddf87 100644
--- a/ports/cpp/CMakeLists.txt
+++ b/ports/cpp/CMakeLists.txt
@@ -15,6 +15,7 @@ if(HQTUI_LTO)
endif()
add_library(hqtui_cpp INTERFACE)
add_library(hqtui_cpp_widgets src/widgets.cpp)
+set_target_properties(hqtui_cpp_widgets PROPERTIES POSITION_INDEPENDENT_CODE ON)
target_compile_features(hqtui_cpp_widgets PUBLIC cxx_std_17)
if(MSVC)
target_compile_options(hqtui_cpp_widgets PUBLIC /utf-8)
@@ -30,6 +31,36 @@ file(READ ${CMAKE_CURRENT_SOURCE_DIR}/../rust/demo/src/sample.json HQTUI_DEMO_SA
configure_file(demo/sample.hpp.in generated/sample.hpp @ONLY)
if(UNIX)
find_package(Threads REQUIRED)
+ option(HQTUI_BUILD_BINDINGS "Build the shared Ruby/PHP/Perl binding ABI" OFF)
+ if(HQTUI_BUILD_BINDINGS)
+ add_library(hqtui_bindings SHARED ../bindings/src/bridge.cpp demo/collect.cpp demo/dashboard.cpp demo/telemetry.cpp demo/showcase.cpp)
+ target_include_directories(hqtui_bindings PRIVATE demo ${CMAKE_CURRENT_BINARY_DIR}/generated PUBLIC $ $)
+ target_compile_definitions(hqtui_bindings PRIVATE HQTUI_DEMO_VERSION="${PROJECT_VERSION}")
+ target_link_libraries(hqtui_bindings PRIVATE hqtui::cpp Threads::Threads)
+ set_target_properties(hqtui_bindings PROPERTIES CXX_VISIBILITY_PRESET default VERSION 1.0.0 SOVERSION 1)
+ install(TARGETS hqtui_bindings LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR})
+ install(FILES ../bindings/include/hqtui_bindings.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
+ if(BUILD_TESTING)
+ add_executable(hqtui_bindings_test ../bindings/tests/abi.cpp)
+ target_link_libraries(hqtui_bindings_test PRIVATE hqtui_bindings hqtui::cpp)
+ add_test(NAME bindings_abi COMMAND hqtui_bindings_test)
+ set_tests_properties(bindings_abi PROPERTIES TIMEOUT 60)
+ endif()
+ set(HQTUI_PHP_CONFIG "" CACHE FILEPATH "Optional php-config for a native PHP adapter")
+ if(HQTUI_PHP_CONFIG)
+ execute_process(COMMAND ${HQTUI_PHP_CONFIG} --includes OUTPUT_VARIABLE php_includes OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY)
+ separate_arguments(php_includes UNIX_COMMAND "${php_includes}")
+ add_library(hqtui_php MODULE ../php/native/extension.c)
+ target_compile_options(hqtui_php PRIVATE ${php_includes})
+ target_link_libraries(hqtui_php PRIVATE hqtui_bindings)
+ set_target_properties(hqtui_php PROPERTIES PREFIX "" BUILD_RPATH "$ORIGIN" INSTALL_RPATH "$ORIGIN")
+ if(APPLE)
+ target_link_options(hqtui_php PRIVATE -undefined dynamic_lookup)
+ set_target_properties(hqtui_php PROPERTIES BUILD_RPATH "@loader_path" INSTALL_RPATH "@loader_path")
+ endif()
+ install(TARGETS hqtui_php LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR})
+ endif()
+ endif()
add_executable(hqtui-demo-cpp demo/main.cpp demo/collect.cpp demo/dashboard.cpp demo/telemetry.cpp demo/showcase.cpp)
target_include_directories(hqtui-demo-cpp PRIVATE demo ${CMAKE_CURRENT_BINARY_DIR}/generated)
target_compile_definitions(hqtui-demo-cpp PRIVATE HQTUI_DEMO_VERSION="${PROJECT_VERSION}")
@@ -49,7 +80,7 @@ if(BUILD_TESTING)
target_include_directories(hqtui_cpp_dashboard_reference PRIVATE demo ${CMAKE_CURRENT_BINARY_DIR}/generated)
target_link_libraries(hqtui_cpp_dashboard_reference PRIVATE hqtui::cpp)
if(Python3_Interpreter_FOUND)
- if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
+ if(CMAKE_SYSTEM_NAME STREQUAL "Linux" OR APPLE)
add_test(NAME cpp_demo_parity COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/demo_parity.py $)
endif()
if(UNIX)
diff --git a/ports/cpp/demo/main.cpp b/ports/cpp/demo/main.cpp
index e8f9244..8314357 100644
--- a/ports/cpp/demo/main.cpp
+++ b/ports/cpp/demo/main.cpp
@@ -490,7 +490,8 @@ static void simulate(State &s) {
}
} // namespace demo
#ifndef HQTUI_DEMO_TEST
-int main(int argc, char **argv) {
+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;
@@ -692,4 +693,7 @@ int main(int argc, char **argv) {
return 2;
}
}
+#ifndef HQTUI_DEMO_LIBRARY
+int main(int argc, char **argv) { return hqtui_demo_main(argc, argv); }
+#endif
#endif
diff --git a/ports/perl/Makefile.PL b/ports/perl/Makefile.PL
new file mode 100644
index 0000000..940d8fa
--- /dev/null
+++ b/ports/perl/Makefile.PL
@@ -0,0 +1,8 @@
+use ExtUtils::MakeMaker;
+WriteMakefile(
+ NAME=>'Hqtui', VERSION_FROM=>'lib/Hqtui.pm',
+ ABSTRACT=>'Perl bindings to the shared HQTUI native terminal renderer',
+ AUTHOR=>'Profullstack', LICENSE=>'mit',
+ PREREQ_PM=>{'FFI::Platypus'=>'2.11','JSON::PP'=>0},
+ MIN_PERL_VERSION=>'5.020',
+);
diff --git a/ports/perl/README.md b/ports/perl/README.md
new file mode 100644
index 0000000..ecfca26
--- /dev/null
+++ b/ports/perl/README.md
@@ -0,0 +1,22 @@
+# HQTUI Perl bindings (experimental)
+
+Language-friendly widget APIs over the shared native C/C++ engine. These are
+bindings, not a separately implemented renderer. The ten-screen demo runs inside
+the Perl runtime; it does not launch the C++ executable.
+
+```sh
+curl -fsSL https://hqtui.com/demo.sh | sh -s -- --mise perl
+curl -fsSL https://hqtui.com/demo.sh | sh -s -- --system perl
+```
+
+Both update before launch. GCC/Clang and Make are required. Mise also provides
+CMake and the pinned runtime. See [build prerequisites, native library setup and
+API limitations](../bindings/README.md). Linux supplies live collectors; macOS
+uses sample mode by default.
+
+[Build your own CLI](examples/hello.pl) using panels, text, meters, graphs,
+tables and the native terminal transport. The example renders a headless frame
+by default; add `--interactive` to run it, then press q to quit.
+
+Package metadata is provided for future distribution; this new binding has not
+been published to its language package registry yet.
diff --git a/ports/perl/examples/dashboard.pl b/ports/perl/examples/dashboard.pl
new file mode 100644
index 0000000..c5f57b0
--- /dev/null
+++ b/ports/perl/examples/dashboard.pl
@@ -0,0 +1,5 @@
+#!/usr/bin/env perl
+use FindBin;
+use lib "$FindBin::Bin/../lib";
+use Hqtui;
+exit Hqtui::demo([@ARGV]);
diff --git a/ports/perl/examples/hello.pl b/ports/perl/examples/hello.pl
new file mode 100644
index 0000000..971ff54
--- /dev/null
+++ b/ports/perl/examples/hello.pl
@@ -0,0 +1,22 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use FindBin;
+use lib "$FindBin::Bin/../lib";
+use Hqtui;
+my $ui=Hqtui::UI->new;
+$ui->panel('Perl + HQTUI',sub {
+ my ($p)=@_;
+ $p->text('A real Perl widget tree',color=>'primary');
+ $p->meter(.72,label=>'CPU',color=>'success');
+ $p->table(['Service','Status'],[['worker','running'],['queue','ready']]);
+});
+my $scene=Hqtui::Scene->new(width=>60,height=>12);
+$scene->set($ui);
+if(grep {$_ eq '--interactive'} @ARGV) {
+ $scene->with_terminal(sub {
+ my ($app)=@_;
+ while(!$app->interrupted) {$app->present;last if index($app->poll,'q')>=0;}
+ });
+} else {print $scene->render;}
+$scene->close;
diff --git a/ports/perl/lib/Hqtui.pm b/ports/perl/lib/Hqtui.pm
new file mode 100644
index 0000000..31baed5
--- /dev/null
+++ b/ports/perl/lib/Hqtui.pm
@@ -0,0 +1,84 @@
+package Hqtui;
+use strict;
+use warnings;
+use JSON::PP ();
+use FFI::Platypus 2.11;
+use File::Basename qw(dirname);
+use File::Spec;
+our $VERSION = '0.1.12';
+my $ffi;
+sub native {
+ return $ffi if $ffi;
+ my $suffix = $^O eq 'darwin' ? 'dylib' : 'so';
+ my $path = $ENV{HQTUI_NATIVE_LIB} || File::Spec->catfile(dirname(__FILE__), '../../cpp/build-bindings', "libhqtui_bindings.$suffix");
+ -f $path or die "Native library missing. Build ports/cpp with -DHQTUI_BUILD_BINDINGS=ON or set HQTUI_NATIVE_LIB.\n";
+ my $f = FFI::Platypus->new(api=>2, lib=>$path);
+ $f->attach([hqb_abi_version=>'_abi'],[]=>'int');
+ $f->attach([hqb_error=>'_error'],[]=>'string');
+ $f->attach([hqb_create=>'_create'],['int','int','string']=>'opaque');
+ $f->attach([hqb_destroy=>'_destroy'],['opaque']=>'void');
+ $f->attach([hqb_set=>'_set'],['opaque','string','size_t']=>'int');
+ $f->attach([hqb_resize=>'_resize'],['opaque','int','int']=>'int');
+ $f->attach([hqb_render=>'_render'],['opaque','string']=>'string');
+ $f->attach([hqb_demo_frame=>'_demo_frame'],['opaque','string','string']=>'string');
+ $f->attach([hqb_open=>'_open'],['opaque']=>'int');
+ $f->attach([hqb_present=>'_present'],['opaque']=>'int');
+ $f->attach([hqb_poll=>'_poll'],['opaque','int']=>'string');
+ $f->attach([hqb_interrupted=>'_interrupted'],[]=>'int');
+ $f->attach([hqb_close=>'_close'],['opaque']=>'void');
+ $f->attach([hqb_demo=>'_demo'],['string','size_t']=>'int');
+ _abi()==1 or die "Unsupported native ABI\n";
+ return $ffi=$f;
+}
+sub checked { defined $_[0] or die _error()."\n"; return $_[0]; }
+sub ok { $_[0] or die _error()."\n"; return $_[0]; }
+sub json { JSON::PP->new->utf8->allow_nonref->encode($_[0]); }
+sub demo {
+ my ($args)=@_; native(); my $json=json($args);
+ my $code=_demo($json,length($json)); $code>=0 or die _error()."\n"; return $code;
+}
+
+package Hqtui::UI;
+sub new { my ($class,%options)=@_; bless {type=>'col',%options,children=>[]},$class; }
+sub data { my ($s)=@_; return { %$s, children=>[map {ref($_) eq __PACKAGE__ ? $_->data : $_} @{$s->{children}}]}; }
+sub add { my ($s,$type,%options)=@_; push @{$s->{children}}, {type=>$type,%options}; return $s; }
+sub group { my ($s,$type,$body,%options)=@_; my $child=__PACKAGE__->new(type=>$type,%options); $body->($child); push @{$s->{children}},$child; return $s; }
+sub row { my ($s,$body,%o)=@_; $s->group('row',$body,%o); }
+sub col { my ($s,$body,%o)=@_; $s->group('col',$body,%o); }
+sub panel { my ($s,$title,$body,%o)=@_; $s->group('panel',$body,title=>$title,%o); }
+sub text { my ($s,$text,%o)=@_; $s->add('text',text=>$text,%o); }
+sub meter { my ($s,$value,%o)=@_; $s->add('meter',value=>$value,%o); }
+sub graph { my ($s,$values,%o)=@_; $s->add('graph',values=>$values,%o); }
+sub gauge { my ($s,$value,%o)=@_; $s->add('gauge',value=>$value,%o); }
+sub table { my ($s,$cols,$rows,%o)=@_; $s->add('table',columns=>$cols,rows=>$rows,%o); }
+sub keys { my ($s,$rows,%o)=@_; $s->add('keys',rows=>$rows,%o); }
+sub log { my ($s,$entries,%o)=@_; $s->add('log',entries=>$entries,%o); }
+sub spacer { my ($s,%o)=@_; $s->add('spacer',%o); }
+sub divider { my ($s,$text)=@_; $s->add('divider',text=>$text//''); }
+
+package Hqtui::Scene;
+sub new {
+ my ($class,%o)=@_; my $native=Hqtui::native();
+ my $handle=Hqtui::ok(Hqtui::_create($o{width}//80,$o{height}//24,$o{theme}//'dark'));
+ bless {handle=>$handle, native=>$native},$class;
+}
+sub handle { $_[0]->{handle} or die "Scene is closed\n"; }
+sub set {
+ my ($s,$ui)=@_; my $json=Hqtui::json(ref($ui) eq 'Hqtui::UI' ? $ui->data : $ui);
+ Hqtui::ok(Hqtui::_set($s->handle,$json,length($json))); return $s;
+}
+sub resize {my ($s,$w,$h)=@_;Hqtui::ok(Hqtui::_resize($s->handle,$w,$h));return $s;}
+sub render {my ($s,$format)=@_;Hqtui::checked(Hqtui::_render($s->handle,$format//'text'));}
+sub demo_frame {my ($s,$screen,$format)=@_;Hqtui::checked(Hqtui::_demo_frame($s->handle,$screen,$format//'text'));}
+sub present {Hqtui::ok(Hqtui::_present($_[0]->handle));}
+sub poll {my ($s,$ms)=@_;Hqtui::checked(Hqtui::_poll($s->handle,$ms//33));}
+sub interrupted {Hqtui::_interrupted()!=0;}
+sub with_terminal {
+ my ($s,$body)=@_;Hqtui::ok(Hqtui::_open($s->handle));
+ my $ok=eval {$body->($s);1};my $error=$@;
+ Hqtui::_close($s->{handle}) if $s->{handle};die $error unless $ok;
+}
+sub close {my ($s)=@_;Hqtui::_destroy(delete $s->{handle}) if $s->{handle};}
+sub DESTROY {$_[0]->close;}
+sub CLONE_SKIP {1;}
+1;
diff --git a/ports/perl/tests/core.pl b/ports/perl/tests/core.pl
new file mode 100644
index 0000000..2442b11
--- /dev/null
+++ b/ports/perl/tests/core.pl
@@ -0,0 +1,22 @@
+use strict;
+use warnings;
+use utf8;
+use FindBin;
+use lib "$FindBin::Bin/../lib";
+use Hqtui;
+use Encode qw(decode);
+sub check {$_[0] or die $_[1];}
+sub expect_error {my($f)=@_;eval {$f->();1} and die 'Expected native error';}
+my $s=Hqtui::Scene->new(width=>40,height=>8);
+my $ui=Hqtui::UI->new;
+$ui->panel('Custom',sub {my($p)=@_;$p->text('Perl → 世界');$p->meter(.72,label=>'CPU');$p->table(['A','B'],[['one','two']]);});
+$s->set($ui);my $text=$s->render;
+check(index(decode('UTF-8',$text),'Perl → 世界')>=0,'UTF-8');
+check($s->render('diff') eq '','unchanged frame');
+expect_error(sub {$s->set({type=>'missing'})});
+check($s->render eq $text,'failed update replaced tree');
+expect_error(sub {$s->resize(-1,20)});expect_error(sub {$s->render('bad-format')});
+$s->resize(20,4);my @lines=split(/\n/,$s->render);check(@lines==4,'resize');
+$s->close;$s->close;expect_error(sub {$s->render});
+expect_error(sub {Hqtui::Scene->new(theme=>'unknown')});
+print "Perl: custom widgets, UTF-8, diff, errors, resize, ownership passed\n";
diff --git a/ports/perl/tests/render.pl b/ports/perl/tests/render.pl
new file mode 100644
index 0000000..e377b3d
--- /dev/null
+++ b/ports/perl/tests/render.pl
@@ -0,0 +1,12 @@
+use strict;
+use warnings;
+use FindBin;
+use lib "$FindBin::Bin/../lib";
+use Hqtui;
+while(my $line=) {
+ my $c=JSON::PP->new->utf8->decode($line);
+ my $s=Hqtui::Scene->new(width=>$c->{width},height=>$c->{height},theme=>$c->{theme});
+ if($c->{screen}) {print $s->demo_frame($c->{screen},'hashes'),"\n";}
+ else {$s->set($c->{tree});print $s->render('hashes'),"\n";}
+ $s->close;
+}
diff --git a/ports/php/README.md b/ports/php/README.md
new file mode 100644
index 0000000..b8e1f05
--- /dev/null
+++ b/ports/php/README.md
@@ -0,0 +1,22 @@
+# HQTUI PHP bindings (experimental)
+
+Language-friendly widget APIs over the shared native C/C++ engine. These are
+bindings, not a separately implemented renderer. The ten-screen demo runs inside
+the PHP runtime; it does not launch the C++ executable.
+
+```sh
+curl -fsSL https://hqtui.com/demo.sh | sh -s -- --mise php
+curl -fsSL https://hqtui.com/demo.sh | sh -s -- --system php
+```
+
+Both update before launch. GCC/Clang and Make are required. Mise also provides
+CMake and the pinned runtime. See [build prerequisites, native library setup and
+API limitations](../bindings/README.md). Linux supplies live collectors; macOS
+uses sample mode by default.
+
+[Build your own CLI](examples/hello.php) using panels, text, meters, graphs,
+tables and the native terminal transport. The example renders a headless frame
+by default; add `--interactive` to run it, then press q to quit.
+
+Package metadata is provided for future distribution; this new binding has not
+been published to its language package registry yet.
diff --git a/ports/php/composer.json b/ports/php/composer.json
new file mode 100644
index 0000000..b34750e
--- /dev/null
+++ b/ports/php/composer.json
@@ -0,0 +1,9 @@
+{
+ "name": "profullstack/hqtui",
+ "description": "PHP bindings to the shared HQTUI native terminal renderer",
+ "type": "library",
+ "license": "MIT",
+ "require": { "php": ">=8.1", "ext-json": "*" },
+ "suggest": { "ext-ffi": "Use FFI if the compiled hqtui_native adapter is not installed" },
+ "autoload": { "files": ["src/Hqtui.php"] }
+}
diff --git a/ports/php/examples/dashboard.php b/ports/php/examples/dashboard.php
new file mode 100644
index 0000000..8b298cf
--- /dev/null
+++ b/ports/php/examples/dashboard.php
@@ -0,0 +1,4 @@
+#!/usr/bin/env php
+panel('PHP + HQTUI', function ($p) {
+ $p->text('A real PHP widget tree', ['color'=>'primary']);
+ $p->meter(0.72, ['label'=>'CPU', 'color'=>'success']);
+ $p->table(['Service','Status'], [['worker','running'],['queue','ready']]);
+});
+$scene = new \Hqtui\Scene(60,12);
+try {
+ $scene->set($ui);
+ if (in_array('--interactive', $argv, true)) {
+ $scene->withTerminal(function ($app) {
+ while (!$app->interrupted()) {
+ $app->present();
+ if (str_contains($app->poll(), 'q')) break;
+ }
+ });
+ } else echo $scene->render();
+} finally { $scene->close(); }
diff --git a/ports/php/native/extension.c b/ports/php/native/extension.c
new file mode 100644
index 0000000..c4571aa
--- /dev/null
+++ b/ports/php/native/extension.c
@@ -0,0 +1,138 @@
+/* Thin Zend adapter to the same batched C ABI used by Ruby and Perl. */
+#include "hqtui_bindings.h"
+#include
+static int scene_type;
+typedef struct {
+ hqb_scene *scene;
+} scene_resource;
+static void scene_free(zend_resource *resource) {
+ scene_resource *r = resource->ptr;
+ hqb_destroy(r->scene);
+ efree(r);
+}
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_bridge, 0, 1, IS_MIXED, 0)
+ZEND_ARG_TYPE_INFO(0, operation, IS_STRING, 0)
+ZEND_ARG_VARIADIC_INFO(0, arguments)
+ZEND_END_ARG_INFO()
+static int is_string_arg(zval *args, int count, int index) {
+ if (index >= count || Z_TYPE(args[index]) != IS_STRING) {
+ zend_type_error("HQTUI expects a string argument");
+ return 0;
+ }
+ return 1;
+}
+static int is_int_arg(zval *args, int count, int index) {
+ if (index >= count || Z_TYPE(args[index]) != IS_LONG ||
+ Z_LVAL(args[index]) < INT_MIN || Z_LVAL(args[index]) > INT_MAX) {
+ zend_type_error("HQTUI expects a bounded integer argument");
+ return 0;
+ }
+ return 1;
+}
+PHP_FUNCTION(hqtui_bridge) {
+ char *operation;
+ size_t operation_length;
+ zval *args = NULL;
+ int count = 0;
+ ZEND_PARSE_PARAMETERS_START(1, -1)
+ Z_PARAM_STRING(operation, operation_length)
+ Z_PARAM_VARIADIC('*', args, count)
+ ZEND_PARSE_PARAMETERS_END();
+ if (!strcmp(operation, "hqb_abi_version"))
+ RETURN_LONG(hqb_abi_version());
+ if (!strcmp(operation, "hqb_error"))
+ RETURN_STRING(hqb_error());
+ if (!strcmp(operation, "hqb_interrupted"))
+ RETURN_LONG(hqb_interrupted());
+ if (!strcmp(operation, "hqb_demo")) {
+ if (!is_string_arg(args, count, 0))
+ RETURN_THROWS();
+ RETURN_LONG(hqb_demo(Z_STRVAL(args[0]), Z_STRLEN(args[0])));
+ }
+ if (!strcmp(operation, "hqb_create")) {
+ if (!is_int_arg(args, count, 0) || !is_int_arg(args, count, 1) ||
+ !is_string_arg(args, count, 2))
+ RETURN_THROWS();
+ hqb_scene *scene = hqb_create((int)Z_LVAL(args[0]), (int)Z_LVAL(args[1]),
+ Z_STRVAL(args[2]));
+ if (!scene)
+ RETURN_NULL();
+ scene_resource *r = emalloc(sizeof(*r));
+ r->scene = scene;
+ RETURN_RES(zend_register_resource(r, scene_type));
+ }
+ if (!count || Z_TYPE(args[0]) != IS_RESOURCE) {
+ zend_type_error("HQTUI expects a live Scene resource");
+ RETURN_THROWS();
+ }
+ scene_resource *r =
+ zend_fetch_resource(Z_RES(args[0]), "HQTUI Scene", scene_type);
+ if (!r)
+ RETURN_THROWS();
+ if (!strcmp(operation, "hqb_destroy")) {
+ hqb_destroy(r->scene);
+ r->scene = NULL;
+ RETURN_NULL();
+ }
+ if (!strcmp(operation, "hqb_close")) {
+ hqb_close(r->scene);
+ RETURN_NULL();
+ }
+ if (!r->scene) {
+ zend_throw_error(NULL, "Scene is closed");
+ RETURN_THROWS();
+ }
+ if (!strcmp(operation, "hqb_open"))
+ RETURN_LONG(hqb_open(r->scene));
+ if (!strcmp(operation, "hqb_present"))
+ RETURN_LONG(hqb_present(r->scene));
+ if (!strcmp(operation, "hqb_set")) {
+ if (!is_string_arg(args, count, 1))
+ RETURN_THROWS();
+ RETURN_LONG(hqb_set(r->scene, Z_STRVAL(args[1]), Z_STRLEN(args[1])));
+ }
+ if (!strcmp(operation, "hqb_resize")) {
+ if (!is_int_arg(args, count, 1) || !is_int_arg(args, count, 2))
+ RETURN_THROWS();
+ RETURN_LONG(
+ hqb_resize(r->scene, (int)Z_LVAL(args[1]), (int)Z_LVAL(args[2])));
+ }
+ const char *out = NULL;
+ if (!strcmp(operation, "hqb_render")) {
+ if (!is_string_arg(args, count, 1))
+ RETURN_THROWS();
+ out = hqb_render(r->scene, Z_STRVAL(args[1]));
+ } else if (!strcmp(operation, "hqb_demo_frame")) {
+ if (!is_string_arg(args, count, 1) || !is_string_arg(args, count, 2))
+ RETURN_THROWS();
+ out = hqb_demo_frame(r->scene, Z_STRVAL(args[1]), Z_STRVAL(args[2]));
+ } else if (!strcmp(operation, "hqb_poll")) {
+ if (!is_int_arg(args, count, 1))
+ RETURN_THROWS();
+ out = hqb_poll(r->scene, (int)Z_LVAL(args[1]));
+ } else {
+ zend_value_error("Unknown HQTUI native operation");
+ RETURN_THROWS();
+ }
+ if (!out)
+ RETURN_NULL();
+ RETURN_STRING(out);
+}
+static const zend_function_entry functions[] = {
+ PHP_FE(hqtui_bridge, arginfo_bridge) PHP_FE_END};
+PHP_MINIT_FUNCTION(hqtui_native) {
+ scene_type = zend_register_list_destructors_ex(scene_free, NULL,
+ "HQTUI Scene", module_number);
+ return SUCCESS;
+}
+zend_module_entry hqtui_native_module_entry = {STANDARD_MODULE_HEADER,
+ "hqtui_native",
+ functions,
+ PHP_MINIT(hqtui_native),
+ NULL,
+ NULL,
+ NULL,
+ NULL,
+ "0.1.12",
+ STANDARD_MODULE_PROPERTIES};
+ZEND_GET_MODULE(hqtui_native)
diff --git a/ports/php/src/Hqtui.php b/ports/php/src/Hqtui.php
new file mode 100644
index 0000000..1c5499e
--- /dev/null
+++ b/ports/php/src/Hqtui.php
@@ -0,0 +1,107 @@
+hqb_abi_version() !== 1) throw new \RuntimeException('Unsupported native ABI');
+ return self::$api = $api;
+ }
+ public static function check(mixed $value): mixed {
+ if ($value === 0 || $value === null || ($value instanceof \FFI\CData && \FFI::isNull($value)))
+ throw new \RuntimeException(self::string(self::api()->hqb_error()));
+ return $value;
+ }
+ public static function string(mixed $value): string { return is_string($value) ? $value : \FFI::string($value); }
+ public static function demo(array $arguments): int {
+ $json = json_encode(array_values($arguments), JSON_THROW_ON_ERROR);
+ $status = self::api()->hqb_demo($json, strlen($json));
+ if ($status < 0) throw new \RuntimeException(self::string(self::api()->hqb_error()));
+ return $status;
+ }
+}
+
+final class UI implements \JsonSerializable {
+ private array $node;
+ public function __construct(string $type = 'col', array $options = []) { $this->node = ['type'=>$type, ...$options, 'children'=>[]]; }
+ public function jsonSerialize(): array { return $this->node; }
+ public function add(string $type, array $options = []): self { $this->node['children'][] = ['type'=>$type, ...$options]; return $this; }
+ public function group(string $type, callable $body, array $options = []): self {
+ $child = new self($type, $options); $body($child); $this->node['children'][] = $child; return $this;
+ }
+ public function row(callable $body, array $options = []): self { return $this->group('row', $body, $options); }
+ public function col(callable $body, array $options = []): self { return $this->group('col', $body, $options); }
+ public function panel(string $title, callable $body, array $options = []): self { return $this->group('panel', $body, ['title'=>$title, ...$options]); }
+ public function text(string $text, array $options = []): self { return $this->add('text', ['text'=>$text, ...$options]); }
+ public function meter(float $value, array $options = []): self { return $this->add('meter', ['value'=>$value, ...$options]); }
+ public function graph(array $values, array $options = []): self { return $this->add('graph', ['values'=>$values, ...$options]); }
+ public function gauge(float $value, array $options = []): self { return $this->add('gauge', ['value'=>$value, ...$options]); }
+ public function table(array $columns, array $rows, array $options = []): self { return $this->add('table', ['columns'=>$columns, 'rows'=>$rows, ...$options]); }
+ public function keys(array $rows, array $options = []): self { return $this->add('keys', ['rows'=>$rows, ...$options]); }
+ public function log(array $entries, array $options = []): self { return $this->add('log', ['entries'=>$entries, ...$options]); }
+ public function spacer(array $options = []): self { return $this->add('spacer', $options); }
+ public function divider(string $text = ''): self { return $this->add('divider', ['text'=>$text]); }
+}
+
+final class Scene {
+ private mixed $handle = null;
+ private object $native;
+ public function __construct(int $width=80, int $height=24, string $theme='dark') {
+ $this->native = Native::api();
+ $this->handle = Native::check($this->native->hqb_create($width,$height,$theme));
+ }
+ private function __clone() {}
+ public function __serialize(): array { throw new \RuntimeException('Scenes cannot be serialized'); }
+ private function handle(): mixed {
+ if ($this->handle === null) throw new \RuntimeException('Scene is closed');
+ return $this->handle;
+ }
+ public function set(UI|array $ui): self {
+ $json = json_encode($ui, JSON_THROW_ON_ERROR);
+ Native::check($this->native->hqb_set($this->handle(), $json, strlen($json))); return $this;
+ }
+ public function resize(int $width, int $height): self { Native::check($this->native->hqb_resize($this->handle(),$width,$height)); return $this; }
+ public function render(string $format='text'): string { return Native::string(Native::check($this->native->hqb_render($this->handle(),$format))); }
+ public function demoFrame(string $screen, string $format='text'): string { return Native::string(Native::check($this->native->hqb_demo_frame($this->handle(),$screen,$format))); }
+ public function present(): void { Native::check($this->native->hqb_present($this->handle())); }
+ public function poll(int $timeoutMs=33): string { return Native::string(Native::check($this->native->hqb_poll($this->handle(),$timeoutMs))); }
+ public function interrupted(): bool { return $this->native->hqb_interrupted() !== 0; }
+ public function withTerminal(callable $body): mixed {
+ Native::check($this->native->hqb_open($this->handle()));
+ try { return $body($this); }
+ finally { if ($this->handle !== null) $this->native->hqb_close($this->handle); }
+ }
+ public function close(): void {
+ if ($this->handle !== null) { $this->native->hqb_destroy($this->handle); $this->handle=null; }
+ }
+ public function __destruct() { $this->close(); }
+}
diff --git a/ports/php/tests/core.php b/ports/php/tests/core.php
new file mode 100644
index 0000000..c00bc96
--- /dev/null
+++ b/ports/php/tests/core.php
@@ -0,0 +1,17 @@
+panel('Custom',function($p){$p->text('PHP → 世界');$p->meter(.72,['label'=>'CPU']);$p->table(['A','B'],[['one','two']]);});
+$s->set($ui);$text=$s->render();
+check(str_contains($text,'PHP → 世界'),'UTF-8');
+check($s->render('diff')==='','unchanged frame');
+expectError(fn()=>$s->set(['type'=>'missing']));
+check($s->render()===$text,'failed update replaced tree');
+expectError(fn()=>$s->resize(-1,20));expectError(fn()=>$s->render('bad-format'));
+$s->resize(20,4);check(substr_count($s->render(),"\n")===4,'resize');
+$s->close();$s->close();expectError(fn()=>$s->render());
+expectError(fn()=>new \Hqtui\Scene(40,8,'unknown'));
+echo "PHP: custom widgets, UTF-8, diff, errors, resize, ownership passed\n";
diff --git a/ports/php/tests/render.php b/ports/php/tests/render.php
new file mode 100644
index 0000000..44e7af2
--- /dev/null
+++ b/ports/php/tests/render.php
@@ -0,0 +1,10 @@
+demoFrame($c['screen'],'hashes'),"\n";
+ else { $s->set($c['tree']); echo $s->render('hashes'),"\n"; }
+ } finally {$s->close();}
+}
diff --git a/ports/ruby/README.md b/ports/ruby/README.md
new file mode 100644
index 0000000..3674673
--- /dev/null
+++ b/ports/ruby/README.md
@@ -0,0 +1,22 @@
+# HQTUI Ruby bindings (experimental)
+
+Language-friendly widget APIs over the shared native C/C++ engine. These are
+bindings, not a separately implemented renderer. The ten-screen demo runs inside
+the Ruby runtime; it does not launch the C++ executable.
+
+```sh
+curl -fsSL https://hqtui.com/demo.sh | sh -s -- --mise ruby
+curl -fsSL https://hqtui.com/demo.sh | sh -s -- --system ruby
+```
+
+Both update before launch. GCC/Clang and Make are required. Mise also provides
+CMake and the pinned runtime. See [build prerequisites, native library setup and
+API limitations](../bindings/README.md). Linux supplies live collectors; macOS
+uses sample mode by default.
+
+[Build your own CLI](examples/hello.rb) using panels, text, meters, graphs,
+tables and the native terminal transport. The example renders a headless frame
+by default; add `--interactive` to run it, then press q to quit.
+
+Package metadata is provided for future distribution; this new binding has not
+been published to its language package registry yet.
diff --git a/ports/ruby/examples/dashboard.rb b/ports/ruby/examples/dashboard.rb
new file mode 100644
index 0000000..b760a33
--- /dev/null
+++ b/ports/ruby/examples/dashboard.rb
@@ -0,0 +1,3 @@
+#!/usr/bin/env ruby
+require_relative '../lib/hqtui'
+exit Hqtui.demo(ARGV)
diff --git a/ports/ruby/examples/hello.rb b/ports/ruby/examples/hello.rb
new file mode 100644
index 0000000..1b2ab58
--- /dev/null
+++ b/ports/ruby/examples/hello.rb
@@ -0,0 +1,24 @@
+#!/usr/bin/env ruby
+require_relative '../lib/hqtui'
+ui = Hqtui::UI.new
+ui.panel('Ruby + HQTUI') do |p|
+ p.text('A real Ruby widget tree', color: 'primary')
+ p.meter(0.72, label: 'CPU', color: 'success')
+ p.table(['Service', 'Status'], [['worker', 'running'], ['queue', 'ready']])
+end
+scene = Hqtui::Scene.new(width: 60, height: 12)
+begin
+ scene.set(ui)
+ if ARGV.include?('--interactive')
+ scene.with_terminal do |app|
+ until app.interrupted?
+ app.present
+ break if app.poll.include?('q')
+ end
+ end
+ else
+ print scene.render
+ end
+ensure
+ scene.close
+end
diff --git a/ports/ruby/hqtui.gemspec b/ports/ruby/hqtui.gemspec
new file mode 100644
index 0000000..8b2a1a7
--- /dev/null
+++ b/ports/ruby/hqtui.gemspec
@@ -0,0 +1,12 @@
+Gem::Specification.new do |s|
+ s.name = 'hqtui'
+ s.version = '0.1.12'
+ s.summary = 'Ruby bindings to the HQTUI native terminal renderer'
+ s.authors = ['Profullstack']
+ s.homepage = 'https://hqtui.com/docs#ruby'
+ s.license = 'MIT'
+ s.required_ruby_version = '>= 3.1'
+ s.files = Dir['lib/**/*.rb', 'examples/*.rb', 'README.md']
+ s.require_paths = ['lib']
+ s.add_dependency 'fiddle', '>= 1.1', '< 2'
+end
diff --git a/ports/ruby/lib/hqtui.rb b/ports/ruby/lib/hqtui.rb
new file mode 100644
index 0000000..e04c410
--- /dev/null
+++ b/ports/ruby/lib/hqtui.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+require 'json'
+require 'fiddle/import'
+
+module Hqtui
+ VERSION = '0.1.12'
+ class Error < StandardError; end
+ def self.native
+ @native ||= Module.new do
+ extend Fiddle::Importer
+ path = ENV['HQTUI_NATIVE_LIB'] || File.expand_path('../../cpp/build-bindings/libhqtui_bindings.' + (RUBY_PLATFORM.include?('darwin') ? 'dylib' : 'so'), __dir__)
+ raise Error, 'Native library missing. Build ports/cpp with -DHQTUI_BUILD_BINDINGS=ON or set HQTUI_NATIVE_LIB.' unless File.file?(path)
+ dlload path
+ extern 'int hqb_abi_version()'
+ extern 'const char* hqb_error()'
+ extern 'void* hqb_create(int, int, const char*)'
+ extern 'void hqb_destroy(void*)'
+ extern 'int hqb_set(void*, const char*, size_t)'
+ extern 'int hqb_resize(void*, int, int)'
+ extern 'const char* hqb_render(void*, const char*)'
+ extern 'const char* hqb_demo_frame(void*, const char*, const char*)'
+ extern 'int hqb_open(void*)'
+ extern 'int hqb_present(void*)'
+ extern 'const char* hqb_poll(void*, int)'
+ extern 'int hqb_interrupted()'
+ extern 'void hqb_close(void*)'
+ extern 'int hqb_demo(const char*, size_t)'
+ raise Error, 'Unsupported native ABI' unless hqb_abi_version == 1
+ end
+ end
+ def self.check(value)
+ raise Error, native.hqb_error.to_s if value == 0 || value.nil? || (value.respond_to?(:null?) && value.null?)
+ value
+ end
+ def self.demo(arguments = ARGV)
+ encoded = JSON.generate(arguments)
+ status = native.hqb_demo(encoded, encoded.bytesize)
+ raise Error, native.hqb_error.to_s if status < 0
+ status
+ end
+ class UI
+ def initialize(type = 'col', **options)
+ @node = { type: type, **options, children: [] }
+ end
+ def to_h = @node
+ def add(type, **options)
+ @node[:children] << { type: type, **options }; self
+ end
+ def group(type, **options)
+ child = UI.new(type, **options)
+ yield child if block_given?
+ @node[:children] << child.to_h; self
+ end
+ def row(**options, &block) = group('row', **options, &block)
+ def col(**options, &block) = group('col', **options, &block)
+ def panel(title, **options, &block) = group('panel', title: title, **options, &block)
+ def text(text, **options) = add('text', text: text, **options)
+ def meter(value, **options) = add('meter', value: value, **options)
+ def graph(values, **options) = add('graph', values: values, **options)
+ def gauge(value, **options) = add('gauge', value: value, **options)
+ def table(columns, rows, **options) = add('table', columns: columns, rows: rows, **options)
+ def keys(rows, **options) = add('keys', rows: rows, **options)
+ def log(entries, **options) = add('log', entries: entries, **options)
+ def spacer(**options) = add('spacer', **options)
+ def divider(text = '') = add('divider', text: text)
+ end
+ class Scene
+ def self.finalizer(native, pointer) = proc { native.hqb_destroy(pointer) }
+ def initialize(width: 80, height: 24, theme: 'dark')
+ @native = Hqtui.native
+ @handle = Hqtui.check(@native.hqb_create(width, height, theme))
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@native, @handle))
+ end
+ def initialize_copy(*) = raise(Error, 'Scenes cannot be copied')
+ def handle
+ raise Error, 'Scene is closed' unless @handle
+ @handle
+ end
+ def set(ui)
+ encoded = JSON.generate(ui.respond_to?(:to_h) ? ui.to_h : ui)
+ Hqtui.check(@native.hqb_set(handle, encoded, encoded.bytesize)); self
+ end
+ def resize(width, height)
+ Hqtui.check(@native.hqb_resize(handle, width, height)); self
+ end
+ def render(format = 'text') = Hqtui.check(@native.hqb_render(handle, format)).to_s.force_encoding(Encoding::UTF_8)
+ def demo_frame(screen, format = 'text') = Hqtui.check(@native.hqb_demo_frame(handle, screen, format)).to_s.force_encoding(Encoding::UTF_8)
+ def present = Hqtui.check(@native.hqb_present(handle))
+ def poll(timeout_ms = 33) = Hqtui.check(@native.hqb_poll(handle, timeout_ms)).to_s
+ def interrupted? = @native.hqb_interrupted != 0
+ def with_terminal
+ Hqtui.check(@native.hqb_open(handle))
+ begin
+ yield self
+ ensure
+ @native.hqb_close(handle) if @handle
+ end
+ end
+ def close
+ return unless @handle
+ ObjectSpace.undefine_finalizer(self)
+ @native.hqb_destroy(@handle); @handle = nil
+ end
+ end
+end
diff --git a/ports/ruby/tests/core.rb b/ports/ruby/tests/core.rb
new file mode 100644
index 0000000..63e59c1
--- /dev/null
+++ b/ports/ruby/tests/core.rb
@@ -0,0 +1,23 @@
+require_relative '../lib/hqtui'
+def expect_error
+ begin; yield; rescue Hqtui::Error; return; end
+ raise 'Expected a native error'
+end
+s=Hqtui::Scene.new(width:40,height:8)
+ui=Hqtui::UI.new
+ui.panel('Custom') { |p| p.text('Ruby → 世界'); p.meter(0.72,label:'CPU'); p.table(['A','B'],[['one','two']]) }
+s.set(ui)
+text=s.render
+raise 'UTF-8' unless text.include?('Ruby → 世界')
+raise 'unchanged frame' unless s.render('diff').empty?
+expect_error {s.set({type:'missing'})}
+raise 'failed update replaced tree' unless s.render == text
+expect_error {s.resize(-1,20)}
+expect_error {s.render('bad-format')}
+raise 'borrowed output was mutated' unless text.include?('Ruby → 世界')
+s.resize(20,4)
+raise 'resize' unless s.render.lines.length==4
+s.close;s.close
+expect_error {s.render}
+expect_error {Hqtui::Scene.new(theme:'unknown')}
+puts 'Ruby: custom widgets, UTF-8, diff, errors, resize, ownership passed'
diff --git a/ports/ruby/tests/render.rb b/ports/ruby/tests/render.rb
new file mode 100644
index 0000000..a680e0f
--- /dev/null
+++ b/ports/ruby/tests/render.rb
@@ -0,0 +1,15 @@
+require_relative '../lib/hqtui'
+STDIN.each_line do |line|
+ c = JSON.parse(line)
+ s = Hqtui::Scene.new(width: c['width'], height: c['height'], theme: c['theme'])
+ begin
+ if c['screen']
+ puts s.demo_frame(c['screen'], 'hashes')
+ else
+ s.set(c['tree'])
+ puts s.render('hashes')
+ end
+ ensure
+ s.close
+ end
+end