From 5bc547062c3dd425c5702b214cebcdedc6b1121f Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 18126e3..2add662 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 and Zig
Terminal dashboards that
diff --git a/apps/web/package.json b/apps/web/package.json
index 9ad0cd0..d1d7e4a 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.1.11",
+ "@profullstack/hqtui": "^0.1.12",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.37.0",
diff --git a/bun.lock b/bun.lock
index a2d1c99..f67383b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -21,7 +21,7 @@
},
"apps/demo": {
"name": "@profullstack/hqtui-demo",
- "version": "0.1.11",
+ "version": "0.1.12",
"bin": {
"hqtui-demo": "./src/main.ts",
},
@@ -34,7 +34,7 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "1.7.0",
- "@profullstack/hqtui": "^0.1.11",
+ "@profullstack/hqtui": "^0.1.12",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.37.0",
@@ -58,12 +58,12 @@
"name": "@profullstack/hqtui-examples",
"version": "0.1.0",
"dependencies": {
- "@profullstack/hqtui": "^0.1.11",
+ "@profullstack/hqtui": "^0.1.12",
},
},
"packages/hqtui": {
"name": "@profullstack/hqtui",
- "version": "0.1.11",
+ "version": "0.1.12",
"bin": {
"hqtui": "./bin/hqtui.mjs",
},
diff --git a/examples/package.json b/examples/package.json
index acd60e2..7260f25 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -4,6 +4,6 @@
"version": "0.1.0",
"type": "module",
"dependencies": {
- "@profullstack/hqtui": "^0.1.11"
+ "@profullstack/hqtui": "^0.1.12"
}
}
diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json
index e1efa91..c8f863c 100644
--- a/packages/hqtui/package.json
+++ b/packages/hqtui/package.json
@@ -1,6 +1,6 @@
{
"name": "@profullstack/hqtui",
- "version": "0.1.11",
+ "version": "0.1.12",
"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 798ba92..4886010 100644
--- a/packages/hqtui/src/cli.ts
+++ b/packages/hqtui/src/cli.ts
@@ -13,7 +13,7 @@ import { detectCapabilities } from "./capabilities.ts";
import { themeList, themes } from "./theme.ts";
import { BrailleCanvas } from "./graphics/braille.ts";
-const VERSION = "0.1.11";
+const VERSION = "0.1.12";
function help(): void {
console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript
diff --git a/ports/c/CMakeLists.txt b/ports/c/CMakeLists.txt
index e936902..d0b228c 100644
--- a/ports/c/CMakeLists.txt
+++ b/ports/c/CMakeLists.txt
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20)
-project(hqtui_c VERSION 0.1.11 LANGUAGES C)
+project(hqtui_c VERSION 0.1.12 LANGUAGES C)
include(GNUInstallDirs)
include(CTest)
option(HQTUI_SANITIZE "Build with address/undefined behavior sanitizers" OFF)
diff --git a/ports/conformance/DEMO.md b/ports/conformance/DEMO.md
new file mode 100644
index 0000000..e279bbe
--- /dev/null
+++ b/ports/conformance/DEMO.md
@@ -0,0 +1,36 @@
+# Demo visual contract
+
+`fixtures/demo-parity.json` is generated by the real TypeScript screen functions,
+not by a native port or by replaying screenshots. Regenerate it from the repo root:
+
+```sh
+mise exec bun@1.4.0 -- bun apps/demo/scripts/native-parity.ts
+```
+
+The 120 cases cover all ten screens at 80×30, 120×40, 168×46 and 200×60 in dark,
+Dracula and Nord. Every native implementation renders the same source sample
+using its own library. Tests compare glyphs, foreground/background colors and
+attributes. No JavaScript renderer or snapshot playback ships in a native demo.
+
+Rust, Python and Zig require exact cell hashes. Go also checks exact colors and
+attributes, allowing at most one Braille dot in the Components gauge: Go's cosine
+and the reference runtime differ by one ULP at 2π/3, which straddles a half-pixel.
+That tolerance cannot hide spacing, color, text or other-screen differences.
+
+The reference frames check screen bodies, not every possible interaction or live
+system sample. Native suites additionally exercise keyboard/overlay priority,
+scroll offsets, mouse selection, real-source parsing, real/simulated CLI launches
+and terminal restoration. Real missing data must stay missing, never be replaced
+with simulated rows. Traffic protocol/direction and HTTP-rate estimates remain
+identified as estimates.
+
+Run the complete acceptance checks with the pinned toolchains:
+
+```sh
+mise run test:go
+mise run test:python
+mise run test:zig
+mise exec rust@1.97.1 -- cargo test --manifest-path ports/rust/Cargo.toml --example dashboard
+mise exec python@3.12.13 -- python apps/demo/scripts/check-native-data.py
+mise exec python@3.12.13 -- python apps/demo/scripts/check-native-commands.py
+```
diff --git a/ports/rust/demo/parity.json b/ports/conformance/fixtures/demo-parity.json
similarity index 100%
rename from ports/rust/demo/parity.json
rename to ports/conformance/fixtures/demo-parity.json
diff --git a/ports/cpp/CMakeLists.txt b/ports/cpp/CMakeLists.txt
index 6d2d51f..1697ee5 100644
--- a/ports/cpp/CMakeLists.txt
+++ b/ports/cpp/CMakeLists.txt
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20)
-project(hqtui_cpp VERSION 0.1.11 LANGUAGES C CXX)
+project(hqtui_cpp VERSION 0.1.12 LANGUAGES C CXX)
include(CTest)
include(GNUInstallDirs)
if(NOT TARGET hqtui::c)
diff --git a/ports/go/internal/demo/components.go b/ports/go/internal/demo/components.go
new file mode 100644
index 0000000..83a9ad0
--- /dev/null
+++ b/ports/go/internal/demo/components.go
@@ -0,0 +1,110 @@
+package demo
+
+import ui "github.com/profullstack/hqtui/ports/go"
+
+func (s *state) scrollHandlers(name string, pane *pane) ui.ScrollHandlers {
+ return ui.ScrollHandlers{OnFocus: s.action(func() { s.focused[s.screen] = name }), OnScroll: func(d int) {
+ if !s.overlay() {
+ s.focused[s.screen] = name
+ pane.move(d)
+ }
+ }, OnSelectRow: func(i int) {
+ if !s.overlay() {
+ pane.selected = clamp(pane.offset+i, 0, pane.total-1)
+ }
+ }}
+}
+func processTree() []ui.TreeNode {
+ node := func(name, cpu, mem string, children ...ui.TreeNode) ui.TreeNode {
+ return ui.TreeNode{Label: name, Values: []ui.TreeValue{{Text: cpu, Width: 6}, {Text: mem, Width: 6}}, Children: children}
+ }
+ return []ui.TreeNode{node("systemd", "1.3", "0.1", node("bash", "0.1", "0.2"), node("bun", "32.8", "4.2", node("bun:worker", "12.4", "1.8"), node("bun:worker", "8.7", "1.3")), node("node", "18.1", "2.1", node("node:worker", "6.1", "0.8")), node("postgres", "6.7", "1.8"))}
+}
+func (s *state) components(p *ui.Container) {
+ t := p.Theme()
+ c, m, n := obj(s.sample["cpu"]), obj(s.sample["memory"]), obj(s.sample["network"])
+ row(p, ui.Fr(1), 1, func(r *ui.Container) {
+ r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(left *ui.Container) {
+ left.Panel(ui.PanelOptions{Title: "Buttons & Inputs", Layout: fixed(13)}, func(p *ui.Container) {
+ row(p, ui.Cells(1), 1, func(r *ui.Container) {
+ for i, label := range []string{"Primary", "Success", "Warning", "Danger"} {
+ width := []int{11, 11, 11, 10}[i]
+ var action func()
+ if i == 0 {
+ action = s.action(func() { s.modal = true })
+ }
+ r.Button(ui.ButtonOptions{Label: label, Width: width, Variant: []ui.ButtonVariant{ui.ButtonPrimary, ui.ButtonSuccess, ui.ButtonWarning, ui.ButtonDanger}[i]}, action, fixed(width))
+ }
+ r.Spacer(ui.Fill())
+ })
+ p.Spacer(ui.Cells(1))
+ row(p, ui.Cells(1), 2, func(r *ui.Container) {
+ options := []string{"Dark", "Dracula", "Nord", "Tokyo Night"}
+ r.Select(ui.SelectOptions{Value: options[s.selectIndex%4], Width: 20, Open: s.selectOpen, Options: options, SelectedIndex: s.selectIndex}, s.action(func() { s.selectOpen = !s.selectOpen }), fixed(20))
+ r.Checkbox(ui.CheckboxOptions{Label: "Toggle", Checked: s.toggle, Variant: ui.CheckboxToggle}, s.action(func() { s.toggle = !s.toggle }), fixed(12))
+ r.Checkbox(ui.CheckboxOptions{Label: "Checkbox", Checked: s.checked}, s.action(func() { s.checked = !s.checked }), fixed(14))
+ r.Spacer(ui.Fill())
+ })
+ p.Spacer(ui.Cells(1))
+ p.TextInput(ui.TextInputOptions{Label: "Search", Value: s.input, Placeholder: "type to filter…", Focused: s.editing}, fixed(1))
+ p.Spacer(ui.Cells(1))
+ p.Meter(ui.MeterOptions{Label: "Slider", Value: s.slider, Heat: ref(false), Color: &t.Primary})
+ p.Progress(ui.ProgressOptions{Label: "Progress", Value: 37, Max: ref(120.), ShowCount: true})
+ })
+ left.Panel(ui.PanelOptions{Title: "Table Widget"}, func(p *ui.Container) {
+ rows := []any{}
+ for _, r := range [][4]string{{"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"}} {
+ rows = append(rows, object{"name": r[0], "size": r[1], "type": r[2], "modified": r[3]})
+ }
+ s.dataTable(p, "components.files", rows, []dataColumn{dc("name", "Name", 0, 10, &t.Primary, false), dc("size", "Size", 9, 0, nil, true), dc("type", "Type", 6, 0, nil, false), dc("modified", "Modified", 10, 0, &t.Muted, true)}, true, false, true)
+ })
+ left.Panel(ui.PanelOptions{Title: "Log Viewer", Layout: fixed(11)}, func(p *ui.Container) {
+ data := arr(s.sample["logs"])
+ pane := s.pane("components.logs", len(data))
+ pane.log = true
+ entries := []ui.LogEntry{}
+ for _, v := range data {
+ d := obj(v)
+ entries = append(entries, ui.LogEntry{Time: scalar(d["time"]), Level: scalar(d["level"]), Message: scalar(d["message"]), Meta: "{" + scalar(d["meta"]) + "}"})
+ }
+ p.Log(ui.LogOptions{Entries: entries, FromEnd: pane.offset, Scrollbar: true}, ui.ScrollHandlers{OnFocus: s.action(func() { s.focused[s.screen] = "components.logs" }), OnScroll: func(d int) {
+ if !s.overlay() {
+ s.focused[s.screen] = "components.logs"
+ pane.offset = clamp(pane.offset-d, 0, len(data)-1)
+ }
+ }})
+ })
+ })
+ r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(right *ui.Container) {
+ right.Panel(ui.PanelOptions{Title: "Process Tree", Layout: fixed(13)}, func(p *ui.Container) {
+ row(p, ui.Cells(1), 0, func(r *ui.Container) {
+ r.StyledText("Name", ui.TextStyle{Fg: &t.Muted, Bold: true})
+ r.StyledText("CPU% MEM%", ui.TextStyle{Fg: &t.Muted, Bold: true, Align: ui.AlignRight})
+ })
+ pane := s.pane("components.tree", 8)
+ p.Tree(ui.TreeOptions{Nodes: processTree(), Selected: &pane.selected, Offset: &pane.offset, FollowSelection: true}, s.scrollHandlers("components.tree", pane))
+ })
+ right.Panel(ui.PanelOptions{Title: "Sparklines & Gauges", Layout: fixed(12)}, func(p *ui.Container) {
+ p.Sparkline(ui.SparklineWidgetOptions{Label: "CPU ", Values: values(c["history"]), Text: percent(num(c["total"])), Color: &t.Success})
+ p.Sparkline(ui.SparklineWidgetOptions{Label: "Mem ", Values: values(m["history"]), Text: percent(num(m["used"]) / num(m["total"])), Color: &t.Warning})
+ p.Sparkline(ui.SparklineWidgetOptions{Label: "Net ", Values: values(n["downHistory"]), Text: bytes(num(n["downRate"]), 2) + "/s", Color: &t.Primary})
+ p.Spacer(ui.Cells(1))
+ row(p, ui.Fr(1), 2, func(r *ui.Container) {
+ r.Gauge(ui.GaugeOptions{Value: num(c["total"]), Label: percent(num(c["total"]))})
+ r.Donut(ui.DonutOptions{Segments: []ui.DonutSegment{{Value: num(m["used"]), Color: &t.Primary, Label: "Used"}, {Value: num(m["available"]), Color: &t.Warning, Label: "Free"}}})
+ })
+ })
+ right.Panel(ui.PanelOptions{Title: "Lists & Badges"}, func(p *ui.Container) {
+ row(p, ui.Cells(1), 1, func(r *ui.Container) {
+ r.Badge(ui.BadgeOptions{Text: "active", Color: &t.Success}, fixed(10))
+ r.Badge(ui.BadgeOptions{Text: "idle", Color: &t.Warning, Variant: ui.BadgeSubtle}, fixed(8))
+ r.Badge(ui.BadgeOptions{Text: "failed", Color: &t.Danger, Variant: ui.BadgeOutline}, fixed(10))
+ r.Spacer(ui.Fill())
+ })
+ p.Spacer(ui.Cells(1))
+ pane := s.pane("components.list", 4)
+ p.List(ui.ListOptions{Items: []ui.ListItem{{Label: "apps/demo", Color: &t.Primary}, {Label: "packages/hqtui"}, {Label: "apps/web"}, {Label: "docs"}}, Selected: &pane.selected, Offset: &pane.offset, FollowSelection: true, Bullet: "▸", Scrollbar: true}, s.scrollHandlers("components.list", pane))
+ })
+ })
+ })
+}
diff --git a/ports/go/internal/demo/dashboard.go b/ports/go/internal/demo/dashboard.go
new file mode 100644
index 0000000..8de8155
--- /dev/null
+++ b/ports/go/internal/demo/dashboard.go
@@ -0,0 +1,444 @@
+package demo
+
+// Native counterpart of apps/demo/src/screens/dashboard.ts. Keep widget
+// options and responsive tracks in sync; parity_test.go compares actual cells.
+import (
+ "fmt"
+ ui "github.com/profullstack/hqtui/ports/go"
+ "math"
+ "strconv"
+ "strings"
+)
+
+func ref[T any](v T) *T { return &v }
+func fixed(n int) ui.Layout { return ui.Layout{Size: ref(ui.Cells(n))} }
+func fraction(n float64) ui.Layout { return ui.Layout{Size: ref(ui.Fr(n))} }
+func row(p *ui.Container, size ui.Size, gap int, fn func(*ui.Container)) {
+ p.Row(ui.RowOptions{Layout: ui.Layout{Size: &size, Gap: gap}}, fn)
+}
+func col(p *ui.Container, size ui.Size, fn func(*ui.Container)) {
+ p.Column(ui.ColumnOptions{Layout: ui.Layout{Size: &size}}, fn)
+}
+func txt(p *ui.Container, text string, color ui.Color) { p.StyledText(text, ui.TextStyle{Fg: &color}) }
+func kv(label, value string, color ui.Color) ui.KeyValueRow {
+ return ui.KeyValueRow{Label: label, Value: value, Color: &color}
+}
+func keys(p *ui.Container, rows []ui.KeyValueRow, spread bool) {
+ p.KeyValues(ui.KeyValueOptions{Rows: rows, NoSpread: !spread})
+}
+func scalar(v any) string {
+ if v == nil {
+ return "—"
+ }
+ if n, ok := v.(float64); ok {
+ return strconv.FormatFloat(n, 'f', -1, 64)
+ }
+ return fmt.Sprint(v)
+}
+func bytes(v float64, digits int) string {
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ return "—"
+ }
+ v = math.Max(0, v)
+ unit := 0
+ units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}
+ for v >= 1024 && unit < 5 {
+ v /= 1024
+ unit++
+ }
+ if unit == 0 {
+ digits = 0
+ }
+ return fmt.Sprintf("%.*f %s", digits, v, units[unit])
+}
+func percent(v float64) string { return fmt.Sprintf("%.0f%%", math.Floor(ui.ClampRatio(v)*100+0.5)) }
+func bitRate(v float64) string {
+ v = math.Max(0, v) * 8
+ for i, scale := range []float64{1e9, 1e6, 1e3} {
+ if v >= scale {
+ return fmt.Sprintf("%.1f %s", v/scale, []string{"Gb/s", "Mb/s", "Kb/s"}[i])
+ }
+ }
+ return fmt.Sprintf("%.0f b/s", v)
+}
+func byteRate(v float64) string {
+ for i, scale := range []float64{1e9, 1e6, 1e3} {
+ if v >= scale {
+ return fmt.Sprintf("%.1f %s", v/scale, []string{"GB/s", "MB/s", "KB/s"}[i])
+ }
+ }
+ return fmt.Sprintf("%.0f B/s", math.Max(0, v))
+}
+func duration(v float64) string {
+ n := int(math.Max(0, v))
+ d, h, m := n/86400, n%86400/3600, n%3600/60
+ if d > 0 {
+ return fmt.Sprintf("%dd %dh %dm", d, h, m)
+ }
+ if h > 0 {
+ return fmt.Sprintf("%dh %dm", h, m)
+ }
+ return fmt.Sprintf("%dm %ds", m, n%60)
+}
+func plot(p *ui.Container, v any, c ui.Color, maxValue *float64, axis bool) {
+ p.Graph(ui.GraphOptions{Values: values(v), Axis: axis, Plot: ui.PlotOptions{Min: ref(0.), Max: maxValue, Fill: ref(true), Color: &c}})
+}
+func multi(p *ui.Container, a, b any, ca, cb ui.Color) {
+ p.Graph(ui.GraphOptions{Series: []ui.Series{{Values: values(a), Color: &ca, Fill: ref(true)}, {Values: values(b), Color: &cb, Fill: ref(true)}}, Plot: ui.PlotOptions{Min: ref(0.)}})
+}
+func meter(p *ui.Container, v float64, c *ui.Color) {
+ p.Meter(ui.MeterOptions{Value: v, Color: c, HideValue: true, Style: ui.BarSegmented})
+}
+
+func (s *state) cpuPanel(p *ui.Container, columns int) {
+ c := obj(s.sample["cpu"])
+ p.Panel(ui.PanelOptions{Title: "CPU Overview", Subtitle: percent(num(c["total"]))}, func(p *ui.Container) {
+ t := p.Theme()
+ p.Label(fmt.Sprintf("%s %.1f GHz", scalar(c["model"]), num(c["frequencyGhz"])))
+ plot(p, c["history"], t.Success, ref(100.), false)
+ items := []ui.MeterItem{}
+ for i, v := range arr(c["cores"]) {
+ items = append(items, ui.Meter(fmt.Sprintf("P%d", i), num(v)))
+ }
+ p.Meters(ui.MetersOptions{Items: items, Columns: columns, LabelWidth: 4, ValueWidth: 5, Style: ui.BarSegmented})
+ p.Divider(ui.DividerOptions{})
+ load := values(c["load"])
+ for len(load) < 3 {
+ load = append(load, 0)
+ }
+ keys(p, []ui.KeyValueRow{kv("Load Avg", fmt.Sprintf("%.2f %.2f %.2f", load[0], load[1], load[2]), t.Warning)}, true)
+ })
+}
+func (s *state) memoryPanel(p *ui.Container) {
+ m := obj(s.sample["memory"])
+ used, swap := num(m["used"])/math.Max(1, num(m["total"])), num(m["swapUsed"])/math.Max(1, num(m["swapTotal"]))
+ p.Panel(ui.PanelOptions{Title: "Memory & Swap"}, func(p *ui.Container) {
+ t := p.Theme()
+ txt(p, fmt.Sprintf("Memory %s / %s (%s)", bytes(num(m["used"]), 2), bytes(num(m["total"]), 2), percent(used)), t.Foreground)
+ meter(p, used, nil)
+ p.Spacer(ui.Cells(1))
+ keys(p, []ui.KeyValueRow{kv("Used:", bytes(num(m["used"]), 2), t.Warning), kv("Available:", bytes(num(m["available"]), 2), t.Success), kv("Cached:", bytes(num(m["cached"]), 2), t.Accent), kv("Buffers:", bytes(num(m["buffers"]), 2), t.Secondary), kv("Free:", bytes(num(m["free"]), 2), t.Muted)}, true)
+ p.Spacer(ui.Fill())
+ p.Divider(ui.DividerOptions{})
+ txt(p, fmt.Sprintf("Swap %s / %s (%s)", bytes(num(m["swapUsed"]), 2), bytes(num(m["swapTotal"]), 2), percent(swap)), t.Foreground)
+ meter(p, swap, &t.Secondary)
+ keys(p, []ui.KeyValueRow{kv("Used:", bytes(num(m["swapUsed"]), 2), t.Secondary), kv("Free:", bytes(num(m["swapTotal"])-num(m["swapUsed"]), 2), t.Muted)}, true)
+ })
+}
+func (s *state) disksPanel(p *ui.Container) {
+ disks := arr(s.sample["disks"])
+ p.Panel(ui.PanelOptions{Title: "Disks"}, func(p *ui.Container) {
+ t := p.Theme()
+ if len(disks) == 0 {
+ p.Label("No disks reported")
+ return
+ }
+ for i, v := range disks[:min(2, len(disks))] {
+ d := obj(v)
+ used := num(d["used"]) / math.Max(1, num(d["total"]))
+ txt(p, fmt.Sprintf("%s — %s (%s)", scalar(d["device"]), bytes(num(d["total"]), 2), scalar(d["type"])), t.Foreground)
+ txt(p, fmt.Sprintf("Used: %s (%s)", bytes(num(d["used"]), 2), percent(used)), t.Muted)
+ meter(p, used, nil)
+ txt(p, "Free: "+bytes(num(d["total"])-num(d["used"]), 2), t.Muted)
+ row(p, ui.Cells(1), 0, func(r *ui.Container) {
+ txt(r, "Read: "+byteRate(num(d["readRate"])), t.Success)
+ r.StyledText("Write: "+byteRate(num(d["writeRate"])), ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight})
+ })
+ multi(p, d["readHistory"], d["writeHistory"], t.Success, t.Secondary)
+ if i == 0 && len(disks) > 1 {
+ p.Divider(ui.DividerOptions{})
+ }
+ }
+ })
+}
+func (s *state) systemPanel(p *ui.Container) {
+ sys, c, m := obj(s.sample["system"]), obj(s.sample["cpu"]), obj(s.sample["memory"])
+ p.Panel(ui.PanelOptions{Title: "System"}, func(p *ui.Container) {
+ t := p.Theme()
+ used := num(m["used"]) / math.Max(1, num(m["total"]))
+ source := "simulated"
+ if s.real {
+ source = "linux/proc"
+ }
+ count := int(num(sys["processCount"]))
+ if count == 0 {
+ count = len(arr(s.sample["processes"]))
+ }
+ swap := "—"
+ if num(m["swapTotal"]) > 0 {
+ swap = percent(num(m["swapUsed"]) / num(m["swapTotal"]))
+ }
+ load := values(c["load"])
+ for len(load) < 3 {
+ load = append(load, 0)
+ }
+ p.Row(ui.RowOptions{Layout: ui.Layout{Size: ref(ui.Cells(6)), Min: ref(6), Gap: 2}}, func(r *ui.Container) {
+ keys(r, []ui.KeyValueRow{kv("OS:", scalar(sys["os"]), t.Foreground), kv("Kernel:", scalar(sys["kernel"]), t.Foreground), kv("Uptime:", duration(num(sys["uptime"])), t.Foreground), kv("Hostname:", scalar(sys["hostname"]), t.Foreground), kv("Shell:", scalar(sys["shell"]), t.Foreground), kv("Source:", source, t.Accent)}, false)
+ keys(r, []ui.KeyValueRow{kv("CPU:", percent(num(c["total"])), ui.HeatColor(t, num(c["total"]))), kv("Memory:", fmt.Sprintf("%s (%s)", percent(used), bytes(num(m["used"]), 2)), t.Warning), kv("Swap:", swap, t.Secondary), kv("Load:", fmt.Sprintf("%.2f %.2f %.2f", load[0], load[1], load[2]), t.Foreground), kv("Processes:", fmt.Sprint(count), t.Foreground), kv("Threads:", scalar(sys["threadCount"]), t.Foreground)}, false)
+ })
+ p.Panel(ui.PanelOptions{Title: "CPU History", Layout: ui.Layout{Min: ref(5)}}, func(g *ui.Container) { plot(g, c["history"], t.Success, ref(100.), true) })
+ if p.Width() >= 46 && p.Height() >= 16 {
+ row(p, ui.Cells(6), 1, func(r *ui.Container) {
+ r.Panel(ui.PanelOptions{Title: "Quick Stats"}, func(q *ui.Container) {
+ threads := scalar(sys["threadCount"])
+ if num(sys["threadCount"]) == 0 {
+ threads = "—"
+ }
+ keys(q, []ui.KeyValueRow{kv("Uptime", duration(num(sys["uptime"])), t.Accent), kv("Procs", fmt.Sprint(count), t.Accent), kv("Threads", threads, t.Accent), kv("Ctx/s", fmt.Sprintf("%.1fK", num(obj(obj(s.sample["telemetry"])["kernel"])["contextSwitchRate"])/1000), t.Accent)}, true)
+ })
+ r.Panel(ui.PanelOptions{Title: "Memory"}, func(q *ui.Container) {
+ txt(q, percent(used), t.Warning)
+ plot(q, m["history"], t.Primary, ref(100.), false)
+ })
+ r.Panel(ui.PanelOptions{Title: "Temp", Layout: fixed(14)}, func(q *ui.Container) {
+ v, label := num(c["total"]), percent(num(c["total"]))
+ if temps := arr(s.sample["temperatures"]); len(temps) > 0 {
+ temp := obj(temps[0])
+ maxV := num(temp["max"])
+ if maxV == 0 {
+ maxV = 100
+ }
+ v = math.Min(1, num(temp["value"])/maxV)
+ label = fmt.Sprintf("%.0f°C", math.Floor(num(temp["value"])+.5))
+ }
+ q.Gauge(ui.GaugeOptions{Value: v, Label: label})
+ })
+ })
+ } else {
+ p.Divider(ui.DividerOptions{})
+ keys(p, []ui.KeyValueRow{kv("Threads", scalar(sys["threadCount"]), t.Accent), kv("Ctx switches", fmt.Sprintf("%.1fK", num(sys["contextSwitches"])/1000), t.Accent)}, true)
+ }
+ })
+}
+
+// Table formatting stays native. Per-cell colors preserve TS render callbacks.
+type dataColumn struct {
+ key string
+ ui.TableColumn
+ format func(object) string
+ color func(object) ui.Color
+}
+
+func dc(key, title string, width, minWidth int, color *ui.Color, right bool) dataColumn {
+ c := ui.Col(title)
+ if width > 0 {
+ c = c.Sized(ui.Cells(width))
+ }
+ if minWidth > 0 {
+ c.Min = ref(minWidth)
+ }
+ c.Color = color
+ if right {
+ c = c.Right()
+ }
+ return dataColumn{key: key, TableColumn: c}
+}
+func (s *state) dataTable(p *ui.Container, name string, data []any, cols []dataColumn, zebra bool, flags ...bool) {
+ p.Column(ui.ColumnOptions{}, func(surface *ui.Container) { s.drawDataTable(surface, name, data, cols, zebra, flags...) })
+}
+func (s *state) drawDataTable(p *ui.Container, name string, data []any, cols []dataColumn, zebra bool, flags ...bool) {
+ pane := s.pane(name, len(data))
+ headerRows := 1
+ if len(flags) > 0 && flags[0] {
+ headerRows = 0
+ }
+ pane.offset = ui.ResolveOffset(&pane.offset, &pane.selected, max(0, p.Height()-headerRows), len(data), true)
+ rows := make([]ui.TableRow, 0, len(data))
+ columns := make([]ui.TableColumn, 0, len(cols))
+ for _, c := range cols {
+ columns = append(columns, c.TableColumn)
+ }
+ for _, v := range data {
+ d := obj(v)
+ r := ui.TableRow{}
+ for _, c := range cols {
+ text := scalar(d[c.key])
+ if c.format != nil {
+ text = c.format(d)
+ }
+ r.Cells = append(r.Cells, text)
+ color := c.Color
+ if c.color != nil {
+ color = ref(c.color(d))
+ }
+ r.CellColors = append(r.CellColors, color)
+ }
+ rows = append(rows, r)
+ }
+ p.Table(ui.TableOptions{Rows: rows, Columns: columns, Selected: &pane.selected, Offset: &pane.offset, FollowSelection: true, Zebra: zebra, Scrollbar: len(flags) < 2 || !flags[1], NoHeader: len(flags) > 0 && flags[0]}, ui.ScrollHandlers{OnFocus: s.action(func() { s.focused[s.screen] = name }), OnScroll: func(d int) {
+ if !s.overlay() {
+ s.focused[s.screen] = name
+ pane.move(d)
+ }
+ }, OnSelectRow: func(i int) {
+ if !s.overlay() {
+ pane.selected = clamp(pane.offset+i, 0, len(data)-1)
+ }
+ }})
+}
+func (s *state) processesPanel(p *ui.Container) {
+ p.Panel(ui.PanelOptions{Title: "Processes (sorted by " + []string{"CPU", "MEM", "PID", "NAME"}[s.sort] + ")", Subtitle: func() string {
+ if s.filter != "" {
+ return "filter: " + s.filter
+ }
+ return ""
+ }(), Focusable: true}, func(p *ui.Container) {
+ t := p.Theme()
+ cols := []dataColumn{dc("pid", "PID", 7, 0, nil, true), dc("name", "Name", 0, 8, &t.Primary, false), dc("cpu", "CPU%", 6, 0, nil, true), dc("mem", "MEM%", 6, 0, &t.Warning, true), dc("rss", "RSS", 9, 0, nil, true), dc("threads", "Threads", 7, 0, nil, true), dc("state", "S", 2, 0, nil, false), dc("user", "User", 10, 0, &t.Muted, false), dc("command", "Command", 0, 10, &t.Muted, false)}
+ cols[2].format = func(d object) string { return fmt.Sprintf("%.1f", num(d["cpu"])) }
+ cols[2].color = func(d object) ui.Color { return ui.HeatColor(t, math.Min(1, num(d["cpu"])/100)) }
+ cols[3].format = func(d object) string { return fmt.Sprintf("%.1f", num(d["mem"])) }
+ cols[4].format = func(d object) string { return bytes(num(d["rss"]), 0) }
+ cols[6].color = func(d object) ui.Color {
+ if d["state"] == "R" {
+ return t.Success
+ }
+ return t.Muted
+ }
+ s.dataTable(p, "dashboard.processes", s.processes(), cols, false)
+ })
+}
+func (s *state) networkPanel(p *ui.Container) {
+ n := obj(s.sample["network"])
+ p.Panel(ui.PanelOptions{Title: "Network"}, func(p *ui.Container) {
+ t := p.Theme()
+ row(p, ui.Cells(1), 0, func(r *ui.Container) {
+ txt(r, "Download: "+bitRate(num(n["downRate"])), t.Primary)
+ r.StyledText("Upload: "+bitRate(num(n["upRate"])), ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight})
+ })
+ for i, key := range []string{"downHistory", "upHistory"} {
+ color := []ui.Color{t.Primary, t.Secondary}[i]
+ p.Graph(ui.GraphOptions{Values: values(n[key]), Axis: true, AxisFormat: func(v float64) string { return strings.ReplaceAll(bitRate(v), " ", "") }, Plot: ui.PlotOptions{Min: ref(0.), Color: &color, Fill: ref(true)}})
+ }
+ p.Divider(ui.DividerOptions{})
+ row(p, ui.Cells(3), 2, func(r *ui.Container) {
+ for i, prefix := range []string{"down", "up"} {
+ color := []ui.Color{t.Primary, t.Secondary}[i]
+ keys(r, []ui.KeyValueRow{kv("Total:", bytes(num(n[prefix+"Total"]), 2), color), kv("Current:", bitRate(num(n[prefix+"Rate"])), color), kv("Peak:", bitRate(num(n[prefix+"Peak"])), color)}, false)
+ }
+ })
+ })
+}
+func (s *state) diskUsagePanel(p *ui.Container) {
+ disks := arr(s.sample["disks"])
+ first := object{}
+ if len(disks) > 0 {
+ first = obj(disks[0])
+ }
+ subtitle := ""
+ if len(disks) > 0 {
+ subtitle = scalar(first["device"])
+ }
+ p.Panel(ui.PanelOptions{Title: "Disk Usage", Subtitle: subtitle}, func(p *ui.Container) {
+ t := p.Theme()
+ for _, v := range disks {
+ d := obj(v)
+ used := num(d["used"]) / math.Max(1, num(d["total"]))
+ p.Meter(ui.MeterOptions{Value: used, Text: fmt.Sprintf("%s %s / %s", percent(used), bytes(num(d["used"]), 0), bytes(num(d["total"]), 0)), Style: ui.BarSegmented})
+ p.Label(fmt.Sprintf("%s (%s)", scalar(d["mount"]), scalar(d["device"])))
+ }
+ p.Spacer(ui.Cells(1))
+ p.Panel(ui.PanelOptions{Title: "I/O Summary"}, func(io *ui.Container) {
+ row(io, ui.Fill(), 2, func(r *ui.Container) {
+ for i, prefix := range []string{"read", "write"} {
+ color := []ui.Color{t.Success, t.Secondary}[i]
+ label := []string{"Read: ", "Write: "}[i]
+ col(r, ui.Fill(), func(c *ui.Container) {
+ txt(c, label+byteRate(num(first[prefix+"Rate"])), color)
+ plot(c, first[prefix+"History"], color, nil, false)
+ })
+ }
+ })
+ })
+ })
+}
+func (s *state) temperaturesPanel(p *ui.Container) {
+ p.Panel(ui.PanelOptions{Title: "Temperatures"}, func(p *ui.Container) {
+ t := p.Theme()
+ temps := arr(s.sample["temperatures"])
+ if len(temps) == 0 {
+ txt(p, "No thermal sensors on this host.", t.Muted)
+ p.Spacer(ui.Cells(1))
+ p.Label("Run with --sim to see this panel populated.")
+ return
+ }
+ for _, v := range temps[:min(10, len(temps))] {
+ temp := obj(v)
+ maxV := num(temp["max"])
+ if maxV == 0 {
+ maxV = 100
+ }
+ ratio := math.Min(1, num(temp["value"])/maxV)
+ row(p, ui.Cells(1), 0, func(r *ui.Container) {
+ r.StyledText(scalar(temp["label"]), ui.TextStyle{Fg: &t.Muted}, fixed(16))
+ r.HeatBar(ui.HeatBarOptions{Value: ratio})
+ r.StyledText(fmt.Sprintf("%.0f°C", math.Floor(num(temp["value"])+.5)), ui.TextStyle{Fg: ref(ui.HeatColor(t, ratio)), Align: ui.AlignRight}, fixed(6))
+ })
+ }
+ })
+}
+func (s *state) sensorsPanel(p *ui.Container) {
+ p.Panel(ui.PanelOptions{Title: "Sensors"}, func(p *ui.Container) {
+ t := p.Theme()
+ sensors := arr(s.sample["sensors"])
+ if len(sensors) == 0 {
+ p.Label("No hardware sensors on this host.")
+ p.Spacer(ui.Cells(1))
+ p.Label("Probed: /sys/class/hwmon, thermal zones, lm-sensors,")
+ p.Label("power supplies and nvidia-smi.")
+ return
+ }
+ rows := []ui.KeyValueRow{}
+ for _, v := range sensors {
+ d := obj(v)
+ rows = append(rows, kv(scalar(d["label"]), scalar(d["value"]), t.Accent))
+ }
+ keys(p, rows, true)
+ })
+}
+func (s *state) logsPanel(p *ui.Container) {
+ p.Panel(ui.PanelOptions{Title: "Logs"}, func(p *ui.Container) {
+ data := arr(s.sample["logs"])
+ pane := s.pane("dashboard.logs", len(data))
+ pane.log = true
+ entries := []ui.LogEntry{}
+ for _, v := range data {
+ d := obj(v)
+ entries = append(entries, ui.LogEntry{Time: scalar(d["time"]), Level: scalar(d["level"]), Message: scalar(d["message"]), Meta: "{" + scalar(d["meta"]) + "}"})
+ }
+ p.Log(ui.LogOptions{Entries: entries, FromEnd: pane.offset, Scrollbar: true}, ui.ScrollHandlers{OnFocus: s.action(func() { s.focused[s.screen] = "dashboard.logs" }), OnScroll: func(delta int) {
+ if !s.overlay() {
+ s.focused[s.screen] = "dashboard.logs"
+ pane.offset = clamp(pane.offset-delta, 0, len(data)-1)
+ }
+ }})
+ })
+}
+func (s *state) dashboard(p *ui.Container) {
+ if p.Width() >= 150 {
+ height := 16
+ if p.Height() >= 44 {
+ height = 19
+ }
+ row(p, ui.Cells(height), 1, func(r *ui.Container) {
+ col(r, ui.Fr(1), func(c *ui.Container) { s.cpuPanel(c, 2) })
+ col(r, ui.Fr(.95), s.memoryPanel)
+ col(r, ui.Fr(.95), s.disksPanel)
+ col(r, ui.Fr(1.35), s.systemPanel)
+ })
+ row(p, ui.Fr(1), 1, func(r *ui.Container) {
+ col(r, ui.Fr(2), s.processesPanel)
+ col(r, ui.Fr(1.2), s.networkPanel)
+ col(r, ui.Fr(1.2), s.diskUsagePanel)
+ })
+ row(p, ui.Cells(12), 1, func(r *ui.Container) { s.temperaturesPanel(r); s.sensorsPanel(r); col(r, ui.Fr(1.6), s.logsPanel) })
+ } else if p.Width() >= 100 {
+ row(p, ui.Cells(14), 1, func(r *ui.Container) { s.cpuPanel(r, 2); s.memoryPanel(r); s.systemPanel(r) })
+ row(p, ui.Fr(1), 1, func(r *ui.Container) { col(r, ui.Fr(1.6), s.processesPanel); col(r, ui.Fill(), s.networkPanel) })
+ row(p, ui.Cells(10), 1, func(r *ui.Container) { s.temperaturesPanel(r); s.logsPanel(r) })
+ } else {
+ row(p, ui.Cells(10), 1, func(r *ui.Container) { s.cpuPanel(r, 1); s.memoryPanel(r) })
+ col(p, ui.Fill(), s.processesPanel)
+ row(p, ui.Cells(8), 1, s.networkPanel)
+ }
+}
diff --git a/ports/go/internal/demo/demo_test.go b/ports/go/internal/demo/demo_test.go
index d519ac2..6beef28 100644
--- a/ports/go/internal/demo/demo_test.go
+++ b/ports/go/internal/demo/demo_test.go
@@ -1,6 +1,7 @@
package demo
import (
+ "fmt"
ui "github.com/profullstack/hqtui/ports/go"
"io"
"reflect"
@@ -8,6 +9,36 @@ import (
"testing"
)
+func TestHTTPPaneUsesFinalViewport(t *testing.T) {
+ s := newState(false, 1337)
+ s.screen = 1
+ paths := []any{}
+ for i := 0; i < 50; i++ {
+ paths = append(paths, object{"path": fmt.Sprintf("/route-%03d", i), "count": i})
+ }
+ obj(obj(s.sample["telemetry"])["http"])["topPaths"] = paths
+ pane := s.pane("traffic.paths", 50)
+ pane.selected = 49
+ frame := ui.RenderToScreen(200, 60, "dark", s.render)
+ x, y, found := frame.Find("/route-049")
+ if !found {
+ t.Fatal("last HTTP path is not visible")
+ }
+ for _, region := range frame.Regions {
+ if region.OnClick != nil && x >= region.Rect.X && x < region.Rect.X+region.Rect.Width && y >= region.Rect.Y && y < region.Rect.Y+region.Rect.Height {
+ if pane.offset != 50-region.Rect.Height {
+ t.Fatalf("offset %d does not match final height %d", pane.offset, region.Rect.Height)
+ }
+ region.OnClick(0, 0, "left")
+ if pane.selected != pane.offset {
+ t.Fatal("no-header click selected wrong row")
+ }
+ return
+ }
+ }
+ t.Fatal("HTTP path table has no hit region")
+}
+
func TestAllScreensThemesAndSizes(t *testing.T) {
s := newState(false, 1337)
for screen, name := range screens {
@@ -16,7 +47,8 @@ func TestAllScreensThemesAndSizes(t *testing.T) {
s.screen = screen
s.theme = theme
frame := ui.RenderToScreen(120, 40, themeName, s.render)
- if !frame.Contains("hqtui") || !frame.Contains(name) {
+ title := []string{"CPU Overview", "Protocols", "Active Sessions", "Connections", "Filesystems", "Buttons & Inputs", "Braille (2×4", "Theme ", "Last Events", "Full-screen churn"}[screen]
+ if !frame.Contains("hqtui") || !frame.Contains(title) {
t.Fatal("missing screen header")
}
})
@@ -102,8 +134,8 @@ func TestIndependentPanes(t *testing.T) {
if len(regions) < 2 {
t.Fatal("missing scroll regions")
}
- regions[1].OnScroll(3)
- if log.selected != 3 {
+ regions[1].OnScroll(-3)
+ if log.offset != 3 || log.selected != 0 {
t.Fatal("wheel ignored")
}
}
diff --git a/ports/go/internal/demo/main.go b/ports/go/internal/demo/main.go
index 14ebbac..f0c02ee 100644
--- a/ports/go/internal/demo/main.go
+++ b/ports/go/internal/demo/main.go
@@ -11,7 +11,7 @@ import (
"time"
)
-const version = "0.1.11"
+const version = "0.1.12"
type options struct {
sim, real, snapshot, version bool
@@ -128,6 +128,7 @@ func run(args []string) int {
results := make(chan result, 1)
busy := false
next := time.Time{}
+ lastFrame := time.Now()
app.Render(func(frame ui.RenderArgs) {
select {
case r := <-results:
@@ -139,6 +140,13 @@ func run(args []string) int {
default:
}
now := time.Now()
+ s.fps = 1 / math.Max(.001, now.Sub(lastFrame).Seconds())
+ lastFrame = now
+ s.clock = now.UTC().Format("15:04:05")
+ stats := app.Stats()
+ s.renderMs = float64(stats.Render) / float64(time.Millisecond)
+ s.changedCells = stats.ChangedCells
+ s.outputBytes = stats.Bytes
if !s.paused && !busy && now.After(next) {
if real {
busy = true
diff --git a/ports/go/internal/demo/model.go b/ports/go/internal/demo/model.go
index f29cc55..007067e 100644
--- a/ports/go/internal/demo/model.go
+++ b/ports/go/internal/demo/model.go
@@ -111,9 +111,18 @@ func clone(sample object) object {
return result
}
-type pane struct{ selected, offset, total int }
+type pane struct {
+ selected, offset, total int
+ log bool
+}
-func (p *pane) move(delta int) { p.selected = clamp(p.selected+delta, 0, p.total-1) }
+func (p *pane) move(delta int) {
+ if p.log {
+ p.offset = clamp(p.offset-delta, 0, p.total-1)
+ return
+ }
+ p.selected = clamp(p.selected+delta, 0, p.total-1)
+}
type state struct {
sample object
@@ -127,10 +136,13 @@ type state struct {
missing []string
tick int
seed uint32
+ fps, renderMs, slider float64
+ changedCells, outputBytes int
+ clock string
}
func newState(real bool, seed uint32) *state {
- s := &state{sample: loadSample(real), real: real, seed: seed, checked: true, toggle: true, panes: map[string]*pane{}, focused: map[int]string{}}
+ s := &state{sample: loadSample(real), real: real, seed: seed, checked: true, toggle: true, slider: .7, clock: "12:00:00", lastKey: "—", lastMouse: "—", panes: map[string]*pane{}, focused: map[int]string{}}
if !real {
for i := 0; i < 120; i++ {
s.simulate()
@@ -331,9 +343,9 @@ func (s *state) key(key, char string) bool {
case "escape":
s.selectOpen = false
case "up":
- s.selectIndex = (s.selectIndex + len(themes) - 1) % len(themes)
+ s.selectIndex = (s.selectIndex + 3) % 4
case "down":
- s.selectIndex = (s.selectIndex + 1) % len(themes)
+ s.selectIndex = (s.selectIndex + 1) % 4
case "enter":
s.theme = s.selectIndex
s.selectOpen = false
diff --git a/ports/go/internal/demo/parity_test.go b/ports/go/internal/demo/parity_test.go
new file mode 100644
index 0000000..74d6e61
--- /dev/null
+++ b/ports/go/internal/demo/parity_test.go
@@ -0,0 +1,105 @@
+package demo
+
+import (
+ "encoding/json"
+ ui "github.com/profullstack/hqtui/ports/go"
+ "math/bits"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestTypeScriptScreenCells(t *testing.T) {
+ raw, err := os.ReadFile("../../../conformance/fixtures/demo-parity.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var cases []struct {
+ Screen, Theme, Text string
+ Width, Height int
+ Hashes []uint32
+ }
+ if err = json.Unmarshal(raw, &cases); err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range cases {
+ t.Run(c.Screen+"/"+c.Theme+"/"+scalar(c.Width), func(t *testing.T) {
+ s := newState(false, 1337)
+ s.sample = loadSample(false)
+ s.theme = index(themes, c.Theme)
+ s.screen = index(screens, c.Screen)
+ f := ui.RenderToScreen(c.Width, c.Height, c.Theme, func(p *ui.Container) {
+ s.body(p)
+ })
+ expected := strings.Split(c.Text, "\n")
+ gaugeDots := 0
+ for y := 0; y < c.Height; y++ {
+ hash := uint32(2166136261)
+ add := func(v byte) { hash = (hash ^ uint32(v)) * 16777619 }
+ for x := 0; x < c.Width; x++ {
+ cell := f.Cell(x, y)
+ for _, b := range []byte(cell.Char) {
+ add(b)
+ }
+ add(0)
+ for _, v := range []uint32{uint32(cell.Fg), uint32(cell.Bg), uint32(cell.Attrs)} {
+ for i := 0; i < 4; i++ {
+ add(byte(v >> (i * 8)))
+ }
+ }
+ }
+ if hash != c.Hashes[y] {
+ // libm and Go's pure-Go cosine differ by one ULP at 2π/3.
+ // In the Components gauge this straddles a half-pixel. Permit
+ // one Braille dot per frame only; colors/attributes, spacing,
+ // every other glyph and every other screen remain exact.
+ if c.Screen == "components" {
+ glyphs := []string{}
+ for _, ch := range expected[y] {
+ glyphs = append(glyphs, string(ch))
+ if ui.StringWidth(string(ch)) == 2 {
+ glyphs = append(glyphs, "")
+ }
+ }
+ for len(glyphs) < c.Width {
+ glyphs = append(glyphs, " ")
+ }
+ normalized := uint32(2166136261)
+ addExpected := func(v byte) { normalized = (normalized ^ uint32(v)) * 16777619 }
+ dots := 0
+ valid := true
+ for x := 0; x < c.Width; x++ {
+ cell := f.Cell(x, y)
+ want := glyphs[x]
+ if cell.Char != want {
+ if y != 20 || x <= c.Width/2 || x >= c.Width*3/4 {
+ valid = false
+ }
+ a, b := []rune(cell.Char), []rune(want)
+ if len(a) != 1 || len(b) != 1 || a[0] < 0x2800 || a[0] > 0x28ff || b[0] < 0x2800 || b[0] > 0x28ff {
+ valid = false
+ } else {
+ dots += bits.OnesCount32(uint32(a[0] ^ b[0]))
+ }
+ }
+ for _, b := range []byte(want) {
+ addExpected(b)
+ }
+ addExpected(0)
+ for _, v := range []uint32{uint32(cell.Fg), uint32(cell.Bg), uint32(cell.Attrs)} {
+ for i := 0; i < 4; i++ {
+ addExpected(byte(v >> (i * 8)))
+ }
+ }
+ }
+ if valid && dots > 0 && gaugeDots+dots <= 1 && normalized == c.Hashes[y] {
+ gaugeDots += dots
+ continue
+ }
+ }
+ t.Errorf("row %d\nTS: %s\nGO: %s", y, expected[y], f.Line(y))
+ }
+ }
+ })
+ }
+}
diff --git a/ports/go/internal/demo/showcase.go b/ports/go/internal/demo/showcase.go
new file mode 100644
index 0000000..02948e0
--- /dev/null
+++ b/ports/go/internal/demo/showcase.go
@@ -0,0 +1,157 @@
+package demo
+
+import (
+ "fmt"
+ ui "github.com/profullstack/hqtui/ports/go"
+ "math"
+)
+
+func wave(phase, freq float64) []float64 {
+ out := make([]float64, 240)
+ for i := range out {
+ out[i] = math.Sin(float64(i)/freq+phase)*50 + 50
+ }
+ return out
+}
+func (s *state) graphics(p *ui.Container) {
+ t := p.Theme()
+ time := num(s.sample["time"])
+ a, b, c := wave(time/3, 9), wave(time/3+2, 5), wave(time/2, 17)
+ row(p, ui.Fr(1), 1, func(r *ui.Container) {
+ r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(left *ui.Container) {
+ for i, title := range []string{"Braille (2×4 pixels per cell)", "Block elements", "ASCII fallback"} {
+ left.Panel(ui.PanelOptions{Title: title}, func(p *ui.Container) {
+ o := ui.PlotOptions{Min: ref(0.), Max: ref(100.)}
+ switch i {
+ case 0:
+ o.Color = &t.Accent
+ o.Grid = true
+ o.Fill = ref(true)
+ case 1:
+ o.Mode = ui.FillBlock
+ o.Colors = t.Heat
+ case 2:
+ o.Mode = ui.FillASCII
+ o.Color = &t.Foreground
+ }
+ p.Graph(ui.GraphOptions{Values: a, Plot: o})
+ })
+ }
+ })
+ r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(right *ui.Container) {
+ right.Panel(ui.PanelOptions{Title: "Multi-series"}, func(p *ui.Container) {
+ p.Graph(ui.GraphOptions{Series: []ui.Series{{Values: a, Color: &t.Primary, Label: "alpha"}, {Values: b, Color: &t.Success, Label: "beta"}, {Values: c, Color: &t.Secondary, Label: "gamma"}}, Axis: true, Legend: true, Plot: ui.PlotOptions{Min: ref(0.), Max: ref(100.)}})
+ })
+ right.Panel(ui.PanelOptions{Title: "Gradients"}, func(p *ui.Container) {
+ p.Draw(func(surface ui.Surface) {
+ steps := ui.GradientOf(t.Heat).Steps(surface.Width())
+ for y := 0; y < surface.Height(); y++ {
+ for x, color := range steps {
+ surface.Glyph(x, y, '█', ui.Style{Fg: &color})
+ }
+ }
+ })
+ })
+ right.Panel(ui.PanelOptions{Title: "Raw Braille canvas"}, func(p *ui.Container) {
+ p.Canvas(&t.Accent, func(c *ui.BrailleCanvas, _ ui.Surface) {
+ cx, cy := float64(c.Width)/2, float64(c.Height)/2
+ radius := math.Min(cx, cy) - 2
+ c.Circle(cx, cy, radius)
+ for i := 0; i < 12; i++ {
+ angle := float64(i)/12*math.Pi*2 + time/4
+ c.Line(cx, cy, cx+math.Cos(angle)*radius, cy+math.Sin(angle)*radius*.9)
+ }
+ })
+ })
+ })
+ })
+}
+func (s *state) themeScreen(p *ui.Container) {
+ p.Label(fmt.Sprintf("Theme %d/9: %s ←/→ or F2 to change", s.theme+1, p.Theme().Name))
+ p.Spacer(ui.Cells(1))
+ p.Grid(ui.GridOptions{ColumnCount: 3, RowCount: 3, Layout: ui.Layout{Gap: 1}}, func(g *ui.GridContainer) {
+ for i, name := range themes {
+ e := ui.ResolveTheme(name)
+ border := e.Border
+ if i == s.theme {
+ border = e.BorderFocused
+ }
+ g.Panel(ui.PanelOptions{Title: e.Name, BorderColor: &border, Layout: ui.Layout{Background: &e.Background}}, ui.CellOptions{}, func(p *ui.Container) {
+ row(p, ui.Cells(1), 1, func(r *ui.Container) {
+ for i, label := range []string{"primary", "ok", "warn", "err"} {
+ color := []ui.Color{e.Primary, e.Success, e.Warning, e.Danger}[i]
+ r.Badge(ui.BadgeOptions{Text: label, Color: &color}, fixed([]int{10, 5, 7, 6}[i]))
+ }
+ r.Spacer(ui.Fill())
+ })
+ p.Meter(ui.MeterOptions{Value: .72, Label: "cpu", Background: &e.Background})
+ p.Graph(ui.GraphOptions{Values: values(obj(s.sample["cpu"])["history"]), Plot: ui.PlotOptions{Min: ref(0.), Max: ref(100.), Fill: ref(true), Color: &e.Graph[0], Background: &e.Background}})
+ p.Draw(func(surface ui.Surface) {
+ for ci, color := range e.Graph {
+ for x := 0; x < 3; x++ {
+ surface.Glyph(ci*4+x, 0, '█', ui.Style{Fg: &color, Bg: &e.Background})
+ }
+ }
+ }, fixed(1))
+ })
+ }
+ })
+}
+func (s *state) inputScreen(p *ui.Container) {
+ t := p.Theme()
+ row(p, ui.Fr(1), 1, func(r *ui.Container) {
+ r.Panel(ui.PanelOptions{Title: "Last Events"}, func(p *ui.Container) {
+ keys(p, []ui.KeyValueRow{kv("Key", s.lastKey, t.Accent), kv("Mouse", s.lastMouse, t.Primary)}, true)
+ p.Spacer(ui.Cells(1))
+ p.Divider(ui.DividerOptions{Label: "history"})
+ items := []ui.ListItem{}
+ for i := len(s.keyLog) - 1; i >= max(0, len(s.keyLog)-20); i-- {
+ items = append(items, ui.Item(scalar(s.keyLog[i])))
+ }
+ p.List(ui.ListOptions{Items: items}, ui.ScrollHandlers{})
+ })
+ r.Panel(ui.PanelOptions{Title: "Try it"}, func(p *ui.Container) {
+ txt(p, "Press any key — modifiers are normalized.", t.Foreground)
+ p.Label("Arrows, Function keys, Ctrl/Alt/Shift combinations,")
+ p.Label("paste, focus, mouse move, click, drag and scroll.")
+ p.Spacer(ui.Cells(1))
+ p.Divider(ui.DividerOptions{Label: "focusable controls"})
+ p.Spacer(ui.Cells(1))
+ row(p, ui.Cells(1), 2, func(r *ui.Container) {
+ r.Button(ui.ButtonOptions{Label: "Button A", Width: 12}, nil, fixed(12))
+ r.Button(ui.ButtonOptions{Label: "Button B", Width: 12, Variant: ui.ButtonSuccess}, nil, fixed(12))
+ r.Checkbox(ui.CheckboxOptions{Label: "Check", Checked: s.checked}, s.action(func() { s.checked = !s.checked }), fixed(12))
+ r.Spacer(ui.Fill())
+ })
+ p.Spacer(ui.Cells(1))
+ p.Label("Tab / Shift+Tab moves focus. Enter activates.")
+ p.Spacer(ui.Fill())
+ keys(p, []ui.KeyValueRow{ui.KV("Mouse tracking", "on"), ui.KV("Bracketed paste", "on"), ui.KV("Focus events", "on")}, true)
+ })
+ })
+}
+func (s *state) stress(p *ui.Container) {
+ t := p.Theme()
+ row(p, ui.Cells(3), 1, func(r *ui.Container) {
+ for i, title := range []string{"Render", "Changed cells", "Bytes/frame", "FPS"} {
+ r.Panel(ui.PanelOptions{Title: title}, func(p *ui.Container) {
+ txt(p, []string{fmt.Sprintf("%.2f ms/frame", s.renderMs), fmt.Sprint(s.changedCells), fmt.Sprint(s.outputBytes), fmt.Sprintf("%.1f", s.fps)}[i], []ui.Color{t.Success, t.Warning, t.Primary, t.Accent}[i])
+ })
+ }
+ })
+ p.Panel(ui.PanelOptions{Title: "Full-screen churn"}, func(p *ui.Container) {
+ p.Draw(func(surface ui.Surface) {
+ ramp := ui.GradientOf(t.Graph)
+ chars := []rune("▖▗▘▙▚▛▜▝▞▟█▓▒░")
+ time := num(s.sample["time"])
+ for y := 0; y < surface.Height(); y++ {
+ for x := 0; x < surface.Width(); x++ {
+ v := (math.Sin(float64(x)/6+time) + math.Cos(float64(y)/4-time)) / 2
+ n := (v + 1) / 2
+ color := ramp.Sample(n)
+ surface.Glyph(x, y, chars[int(math.Floor(n*float64(len(chars)-1)))], ui.Style{Fg: &color})
+ }
+ }
+ })
+ })
+}
diff --git a/ports/go/internal/demo/telemetry_view.go b/ports/go/internal/demo/telemetry_view.go
new file mode 100644
index 0000000..dac6edb
--- /dev/null
+++ b/ports/go/internal/demo/telemetry_view.go
@@ -0,0 +1,404 @@
+package demo
+
+import (
+ "fmt"
+ ui "github.com/profullstack/hqtui/ports/go"
+ "math"
+ "slices"
+ "strings"
+)
+
+func rate(v float64) string {
+ if v >= 1e6 {
+ return fmt.Sprintf("%.1fM/s", v/1e6)
+ }
+ if v >= 1000 {
+ return fmt.Sprintf("%.1fK/s", v/1000)
+ }
+ return fmt.Sprintf("%.0f/s", v)
+}
+func commas(v float64) string {
+ s := fmt.Sprintf("%.0f", v)
+ for i := len(s) - 3; i > 0; i -= 3 {
+ s = s[:i] + "," + s[i:]
+ }
+ return s
+}
+func statusColor(t ui.Theme, class string) ui.Color {
+ switch class {
+ case "1xx":
+ return t.Secondary
+ case "2xx":
+ return t.Success
+ case "3xx":
+ return t.Accent
+ case "4xx":
+ return t.Warning
+ case "5xx":
+ return t.Danger
+ }
+ return t.Muted
+}
+func (s *state) telemetry(p *ui.Container) {
+ switch s.screen {
+ case 1:
+ s.trafficScreen(p)
+ case 2:
+ s.sessionsScreen(p)
+ case 3:
+ s.networkScreen(p)
+ case 4:
+ s.servicesScreen(p)
+ }
+}
+func (s *state) trafficScreen(p *ui.Container) {
+ if s.real {
+ p.Label(trafficNotice())
+ }
+ t := p.Theme()
+ d := obj(s.sample["telemetry"])
+ net := obj(d["net"])
+ rates := obj(net["rates"])
+ http := obj(d["http"])
+ row(p, ui.Cells(13), 1, func(r *ui.Container) {
+ r.Panel(ui.PanelOptions{Title: "Protocols", Subtitle: fmt.Sprintf("%s in / %s out", scalar(d["inboundConnections"]), scalar(d["outboundConnections"])), BorderColor: &t.Accent}, func(p *ui.Container) {
+ data := arr(d["protocols"])
+ if len(data) == 0 {
+ p.Label("No sockets visible.")
+ return
+ }
+ maxV := 1.
+ for _, v := range data {
+ maxV = math.Max(maxV, num(obj(v)["total"]))
+ }
+ items := []ui.MeterItem{}
+ for i, v := range data[:min(9, len(data))] {
+ b := obj(v)
+ items = append(items, ui.MeterItem{Label: scalar(b["protocol"]), Value: num(b["total"]) / maxV, Color: ref(ui.SeriesColor(t, i)), Text: scalar(b["total"])})
+ }
+ p.Meters(ui.MetersOptions{Items: items, LabelWidth: 13, ValueWidth: 5})
+ })
+ r.Panel(ui.PanelOptions{Title: "TCP", Layout: fraction(.9), BorderColor: &t.Primary}, func(p *ui.Container) {
+ row(p, ui.Cells(1), 0, func(r *ui.Container) {
+ txt(r, "↓ "+rate(num(rates["inSegs"]))+" seg", t.Primary)
+ r.StyledText("↑ "+rate(num(rates["outSegs"]))+" seg", ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight})
+ })
+ multi(p, d["netInHistory"], d["netOutHistory"], t.Primary, t.Secondary)
+ p.Divider(ui.DividerOptions{})
+ keys(p, []ui.KeyValueRow{kv("Established", scalar(net["tcpEstablished"]), t.Success), kv("Opens in/out", rate(num(rates["passiveOpens"]))+" / "+rate(num(rates["activeOpens"])), t.Accent), kv("Resets sent", commas(num(net["tcpOutRsts"])), t.Muted)}, true)
+ })
+ color := t.Success
+ if num(net["retransRatio"]) > .02 {
+ color = t.Danger
+ }
+ r.Panel(ui.PanelOptions{Title: "Retransmits", Layout: fraction(.7), BorderColor: &color}, func(p *ui.Container) {
+ p.StyledText(fmt.Sprintf("%.2f%%", ui.ClampRatio(num(net["retransRatio"]))*100), ui.TextStyle{Fg: &color, Bold: true})
+ p.Label("of outbound segments")
+ plot(p, d["retransHistory"], t.Danger, nil, false)
+ keys(p, []ui.KeyValueRow{kv("UDP in/out", rate(num(rates["udpIn"]))+" / "+rate(num(rates["udpOut"])), t.Muted), kv("ICMP", scalar(net["icmpInMsgs"])+" / "+scalar(net["icmpOutMsgs"]), t.Muted)}, true)
+ })
+ })
+ row(p, ui.Fr(1), 1, func(r *ui.Container) {
+ r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(c *ui.Container) {
+ subtitle := "no access log"
+ if len(http) > 0 {
+ subtitle = fmt.Sprintf("%.1f req/s", num(http["requestsPerSecond"]))
+ }
+ c.Panel(ui.PanelOptions{Title: "HTTP", Subtitle: subtitle, BorderColor: &t.Success}, func(p *ui.Container) {
+ if len(http) == 0 {
+ 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
+ }
+ row(p, ui.Cells(1), 0, func(r *ui.Container) {
+ txt(r, scalar(http["source"]), t.Muted)
+ r.StyledText(scalar(http["upgrades"])+" upgrades (ws)", ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight})
+ })
+ p.Graph(ui.GraphOptions{Values: values(http["history"]), Plot: ui.PlotOptions{Min: ref(0.), Fill: ref(true), Color: &t.Success}}, fixed(6))
+ p.Divider(ui.DividerOptions{Label: "status"})
+ maxV := 1.
+ for _, v := range arr(http["statusClasses"]) {
+ maxV = math.Max(maxV, num(obj(v)["count"]))
+ }
+ items := []ui.MeterItem{}
+ for _, v := range arr(http["statusClasses"]) {
+ b := obj(v)
+ items = append(items, ui.MeterItem{Label: scalar(b["class"]), Value: num(b["count"]) / maxV, Color: ref(statusColor(t, scalar(b["class"]))), Text: scalar(b["count"])})
+ }
+ p.Meters(ui.MetersOptions{Items: items, LabelWidth: 5, ValueWidth: 7})
+ p.Divider(ui.DividerOptions{Label: "top paths"})
+ s.dataTable(p, "traffic.paths", arr(http["topPaths"]), []dataColumn{dc("path", "Path", 0, 20, &t.Primary, false), dc("count", "Hits", 7, 0, &t.Accent, true)}, false, true)
+ })
+ })
+ r.Column(ui.ColumnOptions{Layout: ui.Layout{Size: ref(ui.Fr(.85)), Gap: 1}}, func(c *ui.Container) {
+ c.Panel(ui.PanelOptions{Title: "SSH Activity", Subtitle: fmt.Sprint(len(arr(d["ssh"]))), BorderColor: &t.Warning}, func(p *ui.Container) {
+ data := slices.Clone(arr(d["ssh"]))
+ slices.Reverse(data)
+ if len(data) == 0 {
+ p.Label("No sshd events in the journal.")
+ return
+ }
+ cols := []dataColumn{dc("time", "Time", 9, 0, &t.Muted, false), dc("action", "Action", 11, 0, nil, false), dc("user", "User", 12, 0, &t.Primary, false), dc("from", "From", 0, 14, &t.Accent, false), dc("method", "Method", 10, 0, &t.Muted, false)}
+ cols[1].color = func(d object) ui.Color {
+ switch d["action"] {
+ case "accepted":
+ return t.Success
+ case "disconnect":
+ return t.Muted
+ }
+ return t.Danger
+ }
+ s.dataTable(p, "traffic.ssh", data, cols, true)
+ })
+ c.Panel(ui.PanelOptions{Title: "Top Remote Hosts", Layout: fixed(10), BorderColor: &t.Secondary}, func(p *ui.Container) {
+ data := arr(d["remotes"])
+ if len(data) == 0 {
+ p.Label("No remote peers.")
+ return
+ }
+ s.dataTable(p, "traffic.remotes", data, []dataColumn{dc("host", "Host", 0, 16, &t.Accent, false), dc("connections", "Conns", 6, 0, &t.Success, true), dc("protocols", "Protocols", 0, 12, &t.Muted, false)}, true)
+ })
+ })
+ })
+ if len(arr(http["recent"])) > 0 {
+ p.Panel(ui.PanelOptions{Title: "Recent Requests", Layout: fixed(10), BorderColor: &t.Primary}, func(p *ui.Container) {
+ cols := []dataColumn{dc("time", "Time", 9, 0, &t.Muted, false), dc("method", "Method", 7, 0, &t.Secondary, false), dc("path", "Path", 0, 24, &t.Primary, false), dc("status", "Status", 7, 0, nil, true), dc("client", "Client", 16, 0, &t.Accent, false), dc("bytes", "Bytes", 9, 0, &t.Muted, true)}
+ cols[3].color = func(d object) ui.Color {
+ v := scalar(d["status"])
+ if len(v) > 0 {
+ return statusColor(t, v[:1]+"xx")
+ }
+ return t.Muted
+ }
+ s.dataTable(p, "traffic.requests", arr(http["recent"]), cols, true)
+ })
+ }
+}
+func (s *state) sessionsScreen(p *ui.Container) {
+ t := p.Theme()
+ d := obj(s.sample["telemetry"])
+ row(p, ui.Cells(9), 1, func(r *ui.Container) {
+ r.Panel(ui.PanelOptions{Title: "Active Sessions", Subtitle: fmt.Sprint(len(arr(d["sessions"]))), BorderColor: &t.Success}, func(p *ui.Container) {
+ data := arr(d["sessions"])
+ if len(data) == 0 {
+ p.Label("No interactive sessions.")
+ p.Label("(`who` reports nothing on this host)")
+ return
+ }
+ s.dataTable(p, "sessions.active", data, []dataColumn{dc("user", "User", 12, 0, &t.Primary, false), dc("tty", "TTY", 10, 0, nil, false), dc("from", "From", 0, 12, &t.Accent, false), dc("loginAt", "Login", 14, 0, &t.Muted, false), dc("idle", "Idle", 8, 0, nil, true)}, false)
+ })
+ r.Panel(ui.PanelOptions{Title: "Process States", Layout: fixed(34), BorderColor: &t.Primary}, func(p *ui.Container) {
+ states := obj(d["states"])
+ for i, key := range []string{"running", "sleeping", "stopped", "zombie"} {
+ color := []ui.Color{t.Success, t.Primary, t.Warning, t.Danger}[i]
+ p.Meter(ui.MeterOptions{Label: []string{"run ", "slp ", "stop", "zomb"}[i], Value: num(states[key]) / math.Max(1, num(states["total"])), Text: scalar(states[key]), Heat: ref(false), Color: &color})
+ }
+ p.Spacer(ui.Cells(1))
+ keys(p, []ui.KeyValueRow{kv("Total", scalar(states["total"]), t.Accent)}, true)
+ })
+ })
+ row(p, ui.Fr(1), 1, func(r *ui.Container) {
+ r.Panel(ui.PanelOptions{Title: "Recent Logins", Subtitle: fmt.Sprintf("%d from wtmp", len(arr(d["logins"]))), BorderColor: &t.Accent}, func(p *ui.Container) {
+ data := arr(d["logins"])
+ if len(data) == 0 {
+ p.Label("No login history available.")
+ return
+ }
+ cols := []dataColumn{dc("user", "User", 12, 0, &t.Primary, false), dc("tty", "TTY", 12, 0, &t.Muted, false), dc("from", "From", 0, 14, &t.Accent, false), dc("when", "When", 0, 16, &t.Muted, false), dc("status", "Status", 8, 0, nil, false)}
+ cols[4].color = func(d object) ui.Color {
+ if d["status"] == "still" {
+ return t.Success
+ }
+ return t.Muted
+ }
+ s.dataTable(p, "sessions.logins", data, cols, true)
+ })
+ r.Column(ui.ColumnOptions{Layout: ui.Layout{Size: ref(ui.Fr(.8)), Gap: 1}}, func(c *ui.Container) {
+ c.Panel(ui.PanelOptions{Title: "Failed Logins", BorderColor: &t.Danger}, func(p *ui.Container) {
+ data := arr(d["failedLogins"])
+ if len(data) == 0 {
+ p.Label("None recorded.")
+ p.Label("(btmp is usually root-only)")
+ return
+ }
+ s.dataTable(p, "sessions.failed", data, []dataColumn{dc("user", "User", 12, 0, &t.Danger, false), dc("from", "From", 0, 12, nil, false), dc("when", "When", 0, 14, &t.Muted, false)}, false)
+ })
+ c.Panel(ui.PanelOptions{Title: "Session History", Layout: fixed(8), BorderColor: &t.Secondary}, func(p *ui.Container) {
+ p.Label("concurrent sessions")
+ plot(p, d["sessionHistory"], t.Success, nil, false)
+ })
+ })
+ })
+}
+func (s *state) networkScreen(p *ui.Container) {
+ t := p.Theme()
+ d := obj(s.sample["telemetry"])
+ active := []any{}
+ for _, v := range arr(d["interfaces"]) {
+ i := obj(v)
+ if num(i["rxTotal"]) > 0 || i["state"] == "up" {
+ active = append(active, v)
+ }
+ }
+ if len(active) == 0 {
+ active = arr(d["interfaces"])
+ }
+ active = active[:min(3, len(active))]
+ row(p, ui.Cells(13), 1, func(r *ui.Container) {
+ if len(active) == 0 {
+ r.Panel(ui.PanelOptions{Title: "Interfaces"}, func(p *ui.Container) { p.Label("No interfaces reported.") })
+ return
+ }
+ for i, v := range active {
+ d := obj(v)
+ color := []ui.Color{t.Primary, t.Success, t.Secondary}[i]
+ r.Panel(ui.PanelOptions{Title: scalar(d["name"]) + " (" + scalar(d["state"]) + ")", Subtitle: scalar(d["ip"]), BorderColor: &color}, func(p *ui.Container) {
+ row(p, ui.Cells(1), 0, func(r *ui.Container) {
+ txt(r, "↓ "+byteRate(num(d["rxRate"])), t.Primary)
+ r.StyledText("↑ "+byteRate(num(d["txRate"])), ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight})
+ })
+ multi(p, d["rxHistory"], d["txHistory"], t.Primary, t.Secondary)
+ p.Divider(ui.DividerOptions{})
+ keys(p, []ui.KeyValueRow{kv("RX total", bytes(num(d["rxTotal"]), 2), t.Primary), kv("TX total", bytes(num(d["txTotal"]), 2), t.Secondary), kv("MAC", scalar(d["mac"]), t.Muted), kv("MTU / err / drop", scalar(d["mtu"])+" / "+scalar(d["errors"])+" / "+scalar(d["drops"]), t.Muted)}, true)
+ })
+ }
+ })
+ row(p, ui.Fr(1), 1, func(r *ui.Container) {
+ r.Panel(ui.PanelOptions{Title: "Connections", Subtitle: fmt.Sprintf("%d open", len(arr(d["connections"]))), BorderColor: &t.Accent}, func(p *ui.Container) {
+ data := arr(d["connections"])
+ if len(data) == 0 {
+ p.Label("No connections visible (`ss` unavailable).")
+ return
+ }
+ s.dataTable(p, "network.connections", data, []dataColumn{dc("proto", "Proto", 6, 0, &t.Muted, false), dc("local", "Local", 0, 18, nil, false), dc("remote", "Remote", 0, 18, &t.Accent, false), dc("state", "State", 10, 0, &t.Success, false), dc("process", "Process", 0, 12, &t.Primary, false)}, true)
+ })
+ r.Column(ui.ColumnOptions{Layout: ui.Layout{Size: ref(ui.Fr(.7)), Gap: 1}}, func(c *ui.Container) {
+ c.Panel(ui.PanelOptions{Title: "Listening Ports", Subtitle: fmt.Sprint(len(arr(d["listeners"]))), BorderColor: &t.Warning}, func(p *ui.Container) {
+ s.dataTable(p, "network.listeners", arr(d["listeners"]), []dataColumn{dc("proto", "Proto", 6, 0, &t.Muted, false), dc("port", "Port", 7, 0, &t.Warning, true), dc("address", "Address", 0, 10, &t.Muted, false), dc("process", "Process", 0, 10, &t.Primary, false)}, true)
+ })
+ c.Panel(ui.PanelOptions{Title: "Open Connections", Layout: fixed(6), BorderColor: &t.Secondary}, func(p *ui.Container) { plot(p, d["connectionHistory"], t.Accent, nil, false) })
+ })
+ })
+}
+func (s *state) servicesScreen(p *ui.Container) {
+ t := p.Theme()
+ d := obj(s.sample["telemetry"])
+ failed := 0
+ for _, v := range arr(d["services"]) {
+ if obj(v)["active"] == "failed" {
+ failed++
+ }
+ }
+ row(p, ui.Fr(1), 1, func(r *ui.Container) {
+ subtitle := fmt.Sprintf("%d units", len(arr(d["services"])))
+ border, subcolor := t.Success, t.Muted
+ if failed > 0 {
+ subtitle = fmt.Sprintf("%d failed", failed)
+ border = t.Danger
+ subcolor = t.Danger
+ }
+ r.Panel(ui.PanelOptions{Title: "Services", Subtitle: subtitle, SubtitleColor: &subcolor, BorderColor: &border}, func(p *ui.Container) {
+ data := arr(d["services"])
+ if len(data) == 0 {
+ p.Label("systemd not available on this host.")
+ return
+ }
+ cols := []dataColumn{dc("name", "Unit", 0, 18, &t.Primary, false), dc("active", "Active", 10, 0, nil, false), dc("sub", "Sub", 10, 0, &t.Muted, false), dc("description", "Description", 0, 16, &t.Muted, false)}
+ cols[1].color = func(d object) ui.Color {
+ switch d["active"] {
+ case "failed":
+ return t.Danger
+ case "active":
+ return t.Success
+ }
+ return t.Muted
+ }
+ s.dataTable(p, "services.units", data, cols, true)
+ })
+ r.Column(ui.ColumnOptions{Layout: ui.Layout{Size: ref(ui.Fr(.85)), Gap: 1}}, func(c *ui.Container) {
+ c.Panel(ui.PanelOptions{Title: "Kernel", Layout: fixed(11), BorderColor: &t.Accent}, func(p *ui.Container) {
+ k := obj(d["kernel"])
+ blocked, entropy := t.Muted, t.Success
+ if num(k["procsBlocked"]) > 0 {
+ blocked = t.Warning
+ }
+ if num(k["entropy"]) < 200 {
+ entropy = t.Warning
+ }
+ krate := func(v float64) string {
+ if v >= 1000 {
+ return fmt.Sprintf("%.1fK/s", v/1000)
+ }
+ return fmt.Sprintf("%.0f/s", v)
+ }
+ keys(p, []ui.KeyValueRow{kv("Context switches", krate(num(k["contextSwitchRate"])), t.Accent), kv("Interrupts", krate(num(k["interruptRate"])), t.Accent), kv("Forks", krate(num(k["forkRate"])), t.Accent), kv("Procs running", scalar(k["procsRunning"]), t.Success), kv("Procs blocked", scalar(k["procsBlocked"]), blocked), kv("Open file descriptors", commas(num(k["openFiles"])), t.Primary), kv("Entropy available", scalar(k["entropy"]), entropy), kv("Page in / out", fmt.Sprintf("%.0fK / %.0fK", num(k["pageIn"])/1000, num(k["pageOut"])/1000), t.Muted)}, true)
+ })
+ c.Panel(ui.PanelOptions{Title: "Containers", Layout: fixed(9), BorderColor: &t.Primary}, func(p *ui.Container) {
+ data := arr(d["containers"])
+ if len(data) == 0 {
+ p.Label("No running containers.")
+ p.Label("(docker not installed or not reachable)")
+ return
+ }
+ s.dataTable(p, "services.containers", data, []dataColumn{dc("name", "Name", 0, 12, &t.Primary, false), dc("image", "Image", 0, 14, &t.Muted, false), dc("status", "Status", 0, 12, &t.Success, false)}, true)
+ })
+ c.Panel(ui.PanelOptions{Title: "Hardware", BorderColor: &t.Warning}, func(p *ui.Container) {
+ rows := []ui.KeyValueRow{}
+ if power := obj(d["power"]); len(power) > 0 {
+ ac := "on battery"
+ if power["acConnected"] == true {
+ ac = "connected"
+ }
+ rows = append(rows, kv("Battery", scalar(power["battery"])+"% ("+scalar(power["timeRemaining"])+")", t.Success), kv("AC", ac, t.Muted), kv("Draw", fmt.Sprintf("%.1f W", num(power["powerDraw"])), t.Warning))
+ }
+ for _, v := range arr(d["gpus"]) {
+ g := obj(v)
+ rows = append(rows, kv(scalar(g["name"]), percent(num(g["utilization"]))+" · "+scalar(g["temperature"])+"°C", t.Accent), kv("GPU memory", bytes(num(g["memoryUsed"]), 2)+" / "+bytes(num(g["memoryTotal"]), 2), t.Muted))
+ }
+ if len(rows) == 0 {
+ p.Label("No battery or GPU telemetry on this host.")
+ return
+ }
+ keys(p, rows, true)
+ })
+ })
+ })
+ p.Panel(ui.PanelOptions{Title: "Filesystems", Layout: fixed(10), BorderColor: &t.Secondary}, func(p *ui.Container) {
+ data := arr(d["filesystems"])
+ if len(data) == 0 {
+ p.Label("No filesystems reported.")
+ return
+ }
+ cols := []dataColumn{dc("mount", "Mount", 0, 14, &t.Primary, false), dc("device", "Device", 0, 12, &t.Muted, false), dc("type", "Type", 8, 0, &t.Muted, false), dc("size", "Size", 10, 0, nil, true), dc("used", "Used", 10, 0, nil, true), dc("pct", "Use%", 6, 0, nil, true), dc("inodes", "Inodes", 16, 0, &t.Muted, true)}
+ for _, i := range []int{3, 4} {
+ key := cols[i].key
+ cols[i].format = func(d object) string { return bytes(num(d[key]), 0) }
+ }
+ cols[5].format = func(d object) string {
+ if num(d["size"]) == 0 {
+ return "-"
+ }
+ return percent(num(d["used"]) / num(d["size"]))
+ }
+ cols[5].color = func(d object) ui.Color {
+ if num(d["size"]) > 0 && num(d["used"])/num(d["size"]) > .9 {
+ return t.Danger
+ }
+ return t.Warning
+ }
+ cols[6].format = func(d object) string {
+ if num(d["inodesTotal"]) == 0 {
+ return "-"
+ }
+ return fmt.Sprintf("%s of %.1fM", percent(num(d["inodesUsed"])/num(d["inodesTotal"])), num(d["inodesTotal"])/1e6)
+ }
+ s.dataTable(p, "services.filesystems", data, cols, true)
+ })
+}
+
+// Keep estimates distinct from measured throughput without changing simulated
+// reference layouts. The shell displays this only in real Traffic mode.
+func trafficNotice() string {
+ return strings.Join([]string{"Protocol/direction: port-based estimates", "HTTP rate: estimated from log growth"}, "; ")
+}
diff --git a/ports/go/internal/demo/view.go b/ports/go/internal/demo/view.go
index d8b9938..d1b364a 100644
--- a/ports/go/internal/demo/view.go
+++ b/ports/go/internal/demo/view.go
@@ -3,25 +3,8 @@ package demo
import (
"fmt"
ui "github.com/profullstack/hqtui/ports/go"
- "math"
- "strings"
)
-type panelView struct {
- title string
- draw func(*ui.Container)
-}
-type column struct{ key, title string }
-
-func size(v float64) string {
- for _, unit := range []string{"B", "KiB", "MiB", "GiB", "TiB"} {
- if math.Abs(v) < 1024 || unit == "TiB" {
- return fmt.Sprintf("%.1f %s", v, unit)
- }
- v /= 1024
- }
- return ""
-}
func (s *state) overlay() bool { return s.help || s.modal || s.palette || s.filtering }
func (s *state) action(fn func()) func() {
return func() {
@@ -30,341 +13,67 @@ func (s *state) action(fn func()) func() {
}
}
}
-func grid(p *ui.Container, panels []panelView, cols int) {
- if p.Width() < 45 {
- cols = 1
- }
- columns := []ui.Size{}
- rows := []ui.Size{}
- for i := 0; i < cols; i++ {
- columns = append(columns, ui.Fr(1))
- }
- for i := 0; i < (len(panels)+cols-1)/cols; i++ {
- rows = append(rows, ui.Fr(1))
- }
- p.Grid(ui.GridOptions{Columns: columns, Rows: rows, Layout: ui.Layout{Gap: 1}}, func(g *ui.GridContainer) {
- for _, panel := range panels {
- g.Panel(ui.PanelOptions{Title: panel.title}, ui.CellOptions{}, panel.draw)
- }
- })
-}
-func graph(p *ui.Container, data any, label string, second ...any) {
- fill := true
- series := []ui.Series{{Values: values(data), Label: label}}
- if len(second) > 0 {
- series = append(series, ui.Series{Values: values(second[0]), Label: "out"})
- }
- p.Graph(ui.GraphOptions{Series: series, Axis: true, Legend: label != "", Plot: ui.PlotOptions{Fill: &fill}})
-}
-func (s *state) table(p *ui.Container, name string, data []any, cols []column) {
- pane := s.pane(name, len(data))
- capacity := max(0, p.Height()-1)
- if name == "dashboard.processes" {
- capacity = max(0, capacity-1)
- }
- pane.offset = ui.ResolveOffset(&pane.offset, &pane.selected, capacity, len(data), true)
- rows := []ui.TableRow{}
- columns := []ui.TableColumn{}
- for _, c := range cols {
- columns = append(columns, ui.Col(c.title))
- }
- for _, row := range data {
- cells := []string{}
- for _, c := range cols {
- cells = append(cells, str(obj(row)[c.key]))
- }
- rows = append(rows, ui.Row(cells...))
- }
- p.Table(ui.TableOptions{Rows: rows, Columns: columns, Selected: &pane.selected, Offset: &pane.offset, FollowSelection: true, Zebra: true, Scrollbar: true}, ui.ScrollHandlers{
- OnFocus: s.action(func() { s.focused[s.screen] = name }), OnScroll: func(d int) {
- if !s.overlay() {
- s.focused[s.screen] = name
- pane.move(d)
- }
- }, OnSelectRow: func(row int) {
- if !s.overlay() {
- pane.selected = clamp(pane.offset+row, 0, len(data)-1)
- }
- }})
-}
-func (s *state) dashboard(p *ui.Container) {
- c, m, n, sys := obj(s.sample["cpu"]), obj(s.sample["memory"]), obj(s.sample["network"]), obj(s.sample["system"])
- panels := []panelView{
- {"CPU Overview", func(p *ui.Container) {
- p.Label(fmt.Sprintf("%s %.1f GHz", str(c["model"]), num(c["frequencyGhz"])))
- graph(p, c["history"], "CPU %")
- for i, v := range arr(c["cores"]) {
- p.Meter(ui.MeterOptions{Value: num(v), Label: fmt.Sprintf("P%d", i)})
- }
- p.Label("Load: " + str(c["load"]))
- }},
- {"Processes", func(p *ui.Container) {
- p.Label(fmt.Sprintf("F3 filter: %s F6 sort: %s", s.filter, []string{"CPU", "MEM", "PID", "NAME"}[s.sort]))
- s.table(p, "dashboard.processes", s.processes(), []column{{"pid", "PID"}, {"name", "Name"}, {"cpu", "CPU%"}, {"mem", "MEM%"}, {"threads", "THR"}, {"state", "S"}, {"user", "User"}, {"command", "Command"}})
- }},
- {"Memory & Swap", func(p *ui.Container) {
- p.Meter(ui.MeterOptions{Value: num(m["used"]) / math.Max(1, num(m["total"])), Label: "RAM"})
- for _, k := range []string{"used", "available", "cached", "buffers", "free"} {
- p.Text(fmt.Sprintf("%-12s %s", k, size(num(m[k]))))
- }
- p.Meter(ui.MeterOptions{Value: num(m["swapUsed"]) / math.Max(1, num(m["swapTotal"])), Label: "Swap"})
- graph(p, m["history"], "")
- }},
- {"Network", func(p *ui.Container) {
- p.Label(fmt.Sprintf("Down %s/s Up %s/s", size(num(n["downRate"])), size(num(n["upRate"]))))
- graph(p, n["downHistory"], "down", n["upHistory"])
- p.Label("Received " + size(num(n["downTotal"])))
- p.Label("Sent " + size(num(n["upTotal"])))
- }}}
- if p.Width() >= 90 && p.Height() >= 46 {
- panels = append(panels,
- panelView{"Disks", func(p *ui.Container) {
- for i, v := range arr(s.sample["disks"]) {
- if i >= 2 {
- break
- }
- d := obj(v)
- p.Label(str(d["device"]) + " " + str(d["mount"]))
- p.Meter(ui.MeterOptions{Value: num(d["used"]) / math.Max(1, num(d["total"])), Label: "Used"})
- graph(p, d["readHistory"], "read", d["writeHistory"])
- }
- }},
- panelView{"System", func(p *ui.Container) {
- for _, k := range []string{"hostname", "os", "kernel", "shell", "processCount", "threadCount", "uptime"} {
- p.Text(fmt.Sprintf("%-12s %s", k, str(sys[k])))
- }
- graph(p, c["history"], "CPU History")
- }},
- panelView{"Temperatures", func(p *ui.Container) {
- temps := arr(s.sample["temperatures"])
- if len(temps) == 0 {
- p.Label("No thermal sensors available")
- }
- for _, v := range temps {
- t := obj(v)
- p.Meter(ui.MeterOptions{Value: num(t["value"]) / 100, Label: str(t["label"]), Text: fmt.Sprintf("%.0f°C", num(t["value"]))})
- }
- }},
- panelView{"Sensors", func(p *ui.Container) {
- rows := arr(s.sample["sensors"])
- if len(rows) == 0 {
- p.Label("No sensor readings available")
- }
- for _, v := range rows {
- row := obj(v)
- p.Label(str(row["label"]) + ": " + str(row["value"]))
- }
- }},
- panelView{"Logs", func(p *ui.Container) {
- s.table(p, "dashboard.logs", arr(s.sample["logs"]), []column{{"time", "Time"}, {"level", "Level"}, {"message", "Message"}})
- }})
- }
- cols := 2
- if p.Width() >= 160 && len(panels) > 4 {
- cols = 3
- }
- grid(p, panels, cols)
-}
-func (s *state) telemetry(p *ui.Container) {
- t := obj(s.sample["telemetry"])
- tab := func(title, key string, cols []column) panelView {
- return panelView{title, func(p *ui.Container) { s.table(p, screens[s.screen]+"."+key, arr(t[key]), cols) }}
- }
- trace := func(title, key string) panelView {
- return panelView{title, func(p *ui.Container) { graph(p, t[key], title) }}
- }
- var panels []panelView
+func (s *state) body(p *ui.Container) {
switch s.screen {
- case 2:
- panels = []panelView{tab("Active Sessions", "sessions", []column{{"user", "User"}, {"tty", "TTY"}, {"from", "From"}, {"idle", "Idle"}, {"what", "Command"}}), tab("Login History", "logins", []column{{"user", "User"}, {"tty", "TTY"}, {"from", "From"}, {"when", "When"}, {"status", "Status"}}), tab("SSH Authentication", "ssh", []column{{"time", "Time"}, {"action", "Action"}, {"user", "User"}, {"from", "From"}}), trace("Session History", "sessionHistory")}
- case 3:
- panels = []panelView{tab("Interfaces", "interfaces", []column{{"name", "Name"}, {"state", "State"}, {"rxRate", "RX B/s"}, {"txRate", "TX B/s"}, {"errors", "Errors"}, {"drops", "Drops"}}), tab("Connections", "connections", []column{{"proto", "Proto"}, {"state", "State"}, {"local", "Local"}, {"remote", "Remote"}, {"process", "Process"}}), tab("Listeners", "listeners", []column{{"proto", "Proto"}, {"address", "Address"}, {"port", "Port"}, {"process", "Process"}}), trace("Connection History", "connectionHistory")}
- case 4:
- panels = []panelView{tab("Services", "services", []column{{"name", "Unit"}, {"active", "State"}, {"description", "Description"}}), tab("Filesystems", "filesystems", []column{{"mount", "Mount"}, {"device", "Device"}, {"type", "Type"}, {"used", "Used B"}, {"size", "Size B"}}), {"Kernel", func(p *ui.Container) {
- k := obj(t["kernel"])
- for _, name := range []string{"contextSwitchRate", "interruptRate", "forkRate", "procsRunning", "procsBlocked", "entropy", "openFiles"} {
- p.Text(name + " " + str(k[name]))
- }
- }}, tab("Journal", "journal", []column{{"time", "Time"}, {"level", "Level"}, {"unit", "Unit"}, {"message", "Message"}})}
+ case 0:
+ s.dashboard(p)
+ case 5:
+ s.components(p)
+ case 6:
+ s.graphics(p)
+ case 7:
+ s.themeScreen(p)
+ case 8:
+ s.inputScreen(p)
+ case 9:
+ s.stress(p)
default:
- panels = []panelView{tab("Protocols", "protocols", []column{{"protocol", "Protocol"}, {"inbound", "In"}, {"outbound", "Out"}, {"total", "Total"}}), {"TCP Segments", func(p *ui.Container) { graph(p, t["netInHistory"], "in", t["netOutHistory"]) }}, trace("Retransmits", "retransHistory"), {"HTTP", func(p *ui.Container) {
- h := obj(t["http"])
- if len(h) == 0 {
- p.Label("HTTP access log unavailable (read-only)")
- return
- }
- p.Label(fmt.Sprintf("%.1f requests/s", num(h["requestsPerSecond"])))
- s.table(p, "traffic.http", arr(h["recent"]), []column{{"time", "Time"}, {"method", "Method"}, {"path", "Path"}, {"status", "Status"}, {"client", "Client"}})
- }}, tab("Remote Hosts", "remotes", []column{{"host", "Host"}, {"connections", "Connections"}, {"protocols", "Protocols"}}), tab("SSH Authentication", "ssh", []column{{"time", "Time"}, {"action", "Action"}, {"user", "User"}, {"from", "From"}})}
- }
- cols := 2
- if s.screen == 1 && p.Width() >= 120 {
- cols = 3
- }
- if s.screen == 2 {
- panels = append(panels, tab("Failed Logins", "failedLogins", []column{{"user", "User"}, {"tty", "TTY"}, {"from", "From"}, {"when", "When"}, {"status", "Status"}}))
- }
- if s.screen == 1 && s.real {
- p.Label("Protocol/direction: port-based estimates; HTTP rate: estimated from log growth")
- }
- grid(p, panels, cols)
-}
-func (s *state) components(p *ui.Container) {
- grid(p, []panelView{
- {"Controls", func(p *ui.Container) {
- p.Label("Click controls; e edits text; Esc finishes")
- for _, v := range []string{"Primary", "Success", "Warning", "Danger", "Ghost"} {
- p.Button(ui.ButtonOptions{Label: v}, s.action(func() { s.modal = true }))
- }
- p.Checkbox(ui.CheckboxOptions{Label: "Notifications", Checked: s.checked}, s.action(func() { s.checked = !s.checked }))
- p.Checkbox(ui.CheckboxOptions{Label: "Live updates", Checked: s.toggle, Variant: ui.CheckboxToggle}, s.action(func() { s.toggle = !s.toggle }))
- p.Select(ui.SelectOptions{Value: themes[s.selectIndex], Options: themes, SelectedIndex: s.selectIndex, Open: s.selectOpen}, s.action(func() { s.selectOpen = !s.selectOpen }))
- p.TextInput(ui.TextInputOptions{Value: s.input, Placeholder: "Type here", Focused: s.editing})
- }},
- {"Meters & Gauge", func(p *ui.Container) {
- for i := 0; i < 8; i++ {
- p.Meter(ui.MeterOptions{Value: float64(i+1) / 8, Label: fmt.Sprintf("Meter %d", i+1)})
- }
- p.Gauge(ui.GaugeOptions{Value: num(obj(s.sample["cpu"])["total"]), Label: "CPU"})
- }},
- {"Text & Badges", func(p *ui.Container) {
- p.Heading("Typography & Unicode")
- p.Label("日本語 中文 한국어 • café • 🚀")
- for _, v := range []string{"READY", "WARNING", "LIVE"} {
- p.Badge(ui.BadgeOptions{Text: v})
- }
- p.Button(ui.ButtonOptions{Label: "Command palette"}, s.action(func() { s.palette = true }))
- graph(p, obj(s.sample["cpu"])["history"], "History")
- }}}, 3)
-}
-func (s *state) graphics(p *ui.Container) {
- c, n := obj(s.sample["cpu"]), obj(s.sample["network"])
- grid(p, []panelView{
- {"Braille Canvas", func(p *ui.Container) {
- p.Canvas(nil, func(c *ui.BrailleCanvas, _ ui.Surface) {
- for x := 0; x < c.Width; x++ {
- c.Pixel(float64(x), (.5+.35*math.Sin(float64(x)/12+num(s.sample["time"])))*float64(max(0, c.Height-1)))
- }
- })
- }},
- {"CPU Plot", func(p *ui.Container) { graph(p, c["history"], "CPU %") }},
- {"Network Plot", func(p *ui.Container) { graph(p, n["downHistory"], "down", n["upHistory"]) }},
- {"Gauge", func(p *ui.Container) { p.Gauge(ui.GaugeOptions{Value: num(c["total"]), Label: "CPU"}) }}}, 2)
-}
-func (s *state) themeScreen(p *ui.Container) {
- grid(p, []panelView{{"Built-in Themes", func(p *ui.Container) {
- p.Label("F2 or Left/Right to switch themes")
- for i, name := range themes {
- p.Button(ui.ButtonOptions{Label: name}, s.action(func() { s.theme = i }))
- }
- }}, {"Live Preview", func(p *ui.Container) {
- p.Heading(themes[s.theme])
- theme := p.Theme()
- for _, c := range []struct {
- name string
- color ui.Color
- }{{"primary", theme.Primary}, {"secondary", theme.Secondary}, {"accent", theme.Accent}, {"success", theme.Success}, {"warning", theme.Warning}, {"danger", theme.Danger}, {"muted", theme.Muted}} {
- p.Meter(ui.MeterOptions{Value: .7, Label: c.name, Color: &c.color})
- }
- graph(p, obj(s.sample["cpu"])["history"], "Preview")
- }}}, 2)
-}
-func (s *state) inputScreen(p *ui.Container) {
- grid(p, []panelView{{"Input Inspector", func(p *ui.Container) {
- p.Label("Last key: " + s.lastKey)
- p.Label("Mouse: " + s.lastMouse)
- p.Label("e to edit; Esc exits text input")
- p.TextInput(ui.TextInputOptions{Value: s.input, Focused: s.editing})
- s.table(p, "input.events", s.keyLog, []column{{"event", "Key Events"}})
- }}, {"Diagnostics", func(p *ui.Container) {
- p.Text("Renderer: native Go")
- p.Text(fmt.Sprintf("Size: %d×%d", p.Width(), p.Height()))
- p.Text("Arrows scroll the focused pane")
- p.Text("Ctrl+K palette · F1 help · q quit")
- }}}, 2)
-}
-func (s *state) stress(p *ui.Container) {
- panels := []panelView{}
- for i := 0; i < 12; i++ {
- panels = append(panels, panelView{fmt.Sprintf("Stress %02d", i+1), func(p *ui.Container) {
- p.Meter(ui.MeterOptions{Value: (math.Sin(num(s.sample["time"])+float64(i)) + 1) / 2, Label: "Load"})
- graph(p, obj(s.sample["cpu"])["history"], "")
- }})
+ s.telemetry(p)
}
- cols := 3
- if p.Width() >= 120 {
- cols = 4
- }
- grid(p, panels, cols)
}
func (s *state) render(p *ui.Container) {
- one := ui.Cells(1)
- source := "SIMULATED"
- if s.real {
- source = "REAL"
- }
- status := "LIVE"
- if s.paused {
- status = "PAUSED"
- }
- p.Row(ui.RowOptions{Layout: ui.Layout{Size: &one}}, func(r *ui.Container) { r.Heading("hqtui — go"); r.Spacer(ui.Fill()); r.Label(source + " " + status) })
- groups := [][]string{screens}
- if p.Width() < 150 {
- groups = [][]string{screens[:5], screens[5:]}
- }
- for _, group := range groups {
- base := index(screens, group[0])
- tabs := []string{}
- for i, name := range group {
- tabs = append(tabs, fmt.Sprintf("%d %s", (base+i+1)%10, name))
+ t := p.Theme()
+ row(p, ui.Cells(1), 0, func(r *ui.Container) {
+ r.StyledText(" hqtui.com", ui.TextStyle{Fg: &t.Title, Bold: true}, fixed(12))
+ tabs := make([]string, len(screens))
+ for i, name := range screens {
+ tabs[i] = fmt.Sprintf("%d %s", (i+1)%10, name)
}
- p.Tabs(ui.TabsOptions{Tabs: tabs, Active: s.screen - base}, func(i int) {
+ r.Tabs(ui.TabsOptions{Tabs: tabs, Active: s.screen}, func(i int) {
if !s.overlay() {
- s.screen = base + i
- }
- })
- }
- if p.Width() < 30 || p.Height() < 12 {
- p.Label("Terminal too small; resize to 30×12")
- p.Spacer(ui.Fill())
- } else {
- p.Column(ui.ColumnOptions{}, func(body *ui.Container) {
- switch s.screen {
- case 0:
- s.dashboard(body)
- case 5:
- s.components(body)
- case 6:
- s.graphics(body)
- case 7:
- s.themeScreen(body)
- case 8:
- s.inputScreen(body)
- case 9:
- s.stress(body)
- default:
- s.telemetry(body)
+ s.screen = i
}
})
- }
- if len(s.missing) > 0 {
- p.Label("Unavailable: " + strings.Join(s.missing, ", "))
- }
- p.StatusBar(ui.StatusBarOptions{Items: []ui.StatusItem{ui.StatusKey("F1", "help"), ui.StatusKey("F2", "theme"), ui.StatusKey("F3", "filter"), ui.StatusKey("^K", "palette"), ui.StatusKey("q", "quit")}, Right: []ui.StatusItem{ui.Status(screens[s.screen] + " · " + themes[s.theme])}})
+ status, color := "live", t.Success
+ if s.paused {
+ status = "paused"
+ color = t.Warning
+ }
+ source := "simulated"
+ if s.real {
+ source = "real"
+ }
+ r.StyledText(fmt.Sprintf("%s %s %.0ffps %s ", status, source, s.fps, s.clock), ui.TextStyle{Fg: &color, Align: ui.AlignRight})
+ })
+ p.Spacer(ui.Cells(1))
+ col(p, ui.Cells(max(0, p.Height()-4)), s.body)
+ p.Spacer(ui.Cells(1))
+ filter := "Filter"
if s.filtering {
- p.Modal(ui.ModalOptions{Title: "Filter Processes", Message: s.filter + "▏\nEnter applies · Esc clears", Width: 60}, nil)
+ filter = "Filter: " + s.filter + "_"
}
+ p.StatusBar(ui.StatusBarOptions{Items: []ui.StatusItem{ui.StatusKey("F1", "Help"), ui.StatusKey("F2", "Theme ("+t.Name+")"), {Key: "F3", Label: filter, Active: s.filtering}, ui.StatusKey("F6", "Sort: "+[]string{"cpu", "mem", "pid", "name"}[s.sort]), ui.StatusKey("^K", "Palette"), ui.StatusKey("Tab", "Screen"), ui.StatusKey("q", "Quit")}, Right: []ui.StatusItem{ui.Status(fmt.Sprintf("%.2fms %d cells %dB", s.renderMs, s.changedCells, s.outputBytes))}})
if s.help {
- p.Modal(ui.ModalOptions{Title: "hqtui — Help", Width: 66, Message: "1–9/0 / Tab: screen\nF2: theme F3: filter F6: sort\nCtrl+K: palette Space: pause\nArrows / PgUp / PgDn / Home / End: scroll\nMouse: tabs, controls, selection and wheel\ne: edit text in Components/Input\nq / Ctrl+C: quit · Any key: close help"}, nil)
+ p.Modal(ui.ModalOptions{Title: "hqtui — Help", Message: "1–9/0 / Tab: screen\nF2 theme · F3 filter · F6 sort\nCtrl+K palette · Space pause\nArrows / PgUp / PgDn / Home / End: scroll\nMouse tabs, controls, selection and wheel\ne edits text · Esc finishes\nq / Ctrl+C quit · Any key closes help"}, nil)
}
if s.modal {
- p.Modal(ui.ModalOptions{Title: "Read-only Demo", Width: 58, Message: "No process will be killed and no service changed.\nPress any key to close."}, nil)
+ p.Modal(ui.ModalOptions{Title: "Read-only Demo", Message: "No process will be killed and no service changed.\nPress any key to close."}, nil)
}
if s.palette {
items := []ui.PaletteItem{}
for _, name := range s.commands() {
items = append(items, ui.PaletteItem{Label: name})
}
- p.CommandPalette(ui.CommandPaletteOptions{Query: s.query, Items: items, Selected: s.paletteIndex, Placeholder: "Search commands"})
+ p.CommandPalette(ui.CommandPaletteOptions{Query: s.query, Items: items, Selected: s.paletteIndex})
}
}
diff --git a/ports/python/hqtui/__init__.py b/ports/python/hqtui/__init__.py
index 7a48886..223d1e5 100644
--- a/ports/python/hqtui/__init__.py
+++ b/ports/python/hqtui/__init__.py
@@ -31,9 +31,9 @@
from .ui import Cell, Container, Grid, GridSpec, Layout, Panel, ScrollHandlers
from .unicode import Align, fit, string_width, truncate, wrap
-__version__ = "0.1.11"
+__version__ = "0.1.12"
-REFERENCE_VERSION = "0.1.11"
+REFERENCE_VERSION = "0.1.12"
"""The version of the TypeScript reference implementation this port tracks."""
__all__ = [
diff --git a/ports/python/hqtui_demo/__init__.py b/ports/python/hqtui_demo/__init__.py
index cc50515..832b7e5 100644
--- a/ports/python/hqtui_demo/__init__.py
+++ b/ports/python/hqtui_demo/__init__.py
@@ -1,3 +1,3 @@
"""The native Python HQTUI reference system monitor."""
-__version__ = "0.1.11"
+__version__ = "0.1.12"
diff --git a/ports/python/hqtui_demo/__main__.py b/ports/python/hqtui_demo/__main__.py
index 24ffcf7..d4aa9b7 100644
--- a/ports/python/hqtui_demo/__main__.py
+++ b/ports/python/hqtui_demo/__main__.py
@@ -43,7 +43,7 @@ def main(argv=None):
print("An interactive terminal is required. Use --snapshot for headless output.",file=sys.stderr)
return 2
source=Simulation(args.seed) if args.sim or (args.snapshot and not args.real) else Collector()
- source.refresh(0.1)
+ if not isinstance(source,Simulation): source.refresh(0.1)
for _ in range(args.ticks):
if isinstance(source,Simulation): source.refresh(.1)
state=State(copy.deepcopy(source.sample),source.source,list(source.unavailable),source.sensor_note,screen=args.screen,theme_index=THEMES.index(args.theme))
@@ -69,6 +69,7 @@ def paste(event):
def update(frame):
nonlocal pending,next_poll,last_frame
now=time.monotonic(); state.fps=1/max(.001,now-last_frame); last_frame=now
+ state.clock=time.strftime("%H:%M:%S",time.gmtime())
if pending is not None and pending.done():
pending.result(); pending=None
if not state.paused:
diff --git a/ports/python/hqtui_demo/dashboard.py b/ports/python/hqtui_demo/dashboard.py
new file mode 100644
index 0000000..eb94d70
--- /dev/null
+++ b/ports/python/hqtui_demo/dashboard.py
@@ -0,0 +1,181 @@
+"""Native reference dashboard. Cell expectations come from the TypeScript app."""
+from __future__ import annotations
+import math
+import hqtui.widgets as w
+from hqtui import Layout, Panel, ScrollHandlers
+from hqtui.graphics import GaugeOptions, PlotOptions, Series
+from hqtui.theme import heat_color
+from hqtui.widgets.table import resolve_offset
+
+def scalar(v):
+ if v is None: return "—"
+ if isinstance(v, float) and v.is_integer(): return str(int(v))
+ return str(v)
+
+def bytes(v, digits=2):
+ if v is None or not math.isfinite(v): return "—"
+ v=max(0,v)
+ for unit in ("B","KiB","MiB","GiB","TiB","PiB"):
+ if v<1024 or unit=="PiB": return f'{v:.{0 if unit=="B" else digits}f} {unit}'
+ v/=1024
+
+def percent(v): return f'{math.floor(max(0,min(1,v))*100+.5)}%'
+def bit_rate(v):
+ v=max(0,v)*8
+ for scale,unit in ((1e9,"Gb/s"),(1e6,"Mb/s"),(1e3,"Kb/s")):
+ if v>=scale: return f'{v/scale:.1f} {unit}'
+ return f'{v:.0f} b/s'
+def byte_rate(v):
+ for scale,unit in ((1e9,"GB/s"),(1e6,"MB/s"),(1e3,"KB/s")):
+ if v>=scale: return f'{v/scale:.1f} {unit}'
+ return f'{max(0,v):.0f} B/s'
+def duration(v):
+ v=int(max(0,v));d,h,m=v//86400,v%86400//3600,v%3600//60
+ if d: return f'{d}d {h}h {m}m'
+ if h: return f'{h}h {m}m'
+ return f'{m}m {v%60}s'
+def row(p,size,gap,fn): p.row(Layout(size=size,gap=gap),fn)
+def col(p,size,fn): p.column(Layout(size=size),fn)
+def txt(p,value,color): p.text(value,w.TextStyle(fg=color))
+def kv(label,value,color=None): return w.KeyValueRow(label,scalar(value),color=color)
+def keys(p,rows,spread=True): p.key_values(w.KeyValueOptions(rows=rows,spread=spread))
+def plot(p,values,color,maximum=None,axis=False): p.graph(w.GraphOptions(values=values,axis=axis,plot=PlotOptions(min=0,max=maximum,fill=True,color=color)))
+def multi(p,a,b,ca,cb): p.graph(w.GraphOptions(series=[Series(values=a,color=ca,fill=True),Series(values=b,color=cb,fill=True)],plot=PlotOptions(min=0)))
+def meter(p,value,color=None): p.meter(w.MeterOptions(value=value,color=color,show_value=False,style="segmented"))
+def overlay(s): return s.help or s.modal or s.palette or s.filtering
+
+def data_table(p,s,name,data,columns,zebra=False,header=True,scrollbar=True):
+ p.column(Layout(),lambda surface:_data_table(surface,s,name,data,columns,zebra,header,scrollbar))
+
+def _data_table(p,s,name,data,columns,zebra=False,header=True,scrollbar=True):
+ """Columns are (field, widget options, optional formatter, optional color)."""
+ pane=s.pane(name,len(data))
+ pane.offset=resolve_offset(pane.offset,pane.selected,max(0,p.height-int(header)),len(data),True)
+ rows=[]
+ for d in data:
+ rows.append(w.TableRow([fmt(d) if fmt else scalar(d.get(key)) for key,c,fmt,color in columns],cell_colors=[color(d) if color else c.color for key,c,fmt,color in columns]))
+ def focus():
+ if not overlay(s): s.focused[s.screen]=name
+ def scroll(delta):
+ if not overlay(s): focus();pane.move(delta)
+ def select(i):
+ if not overlay(s): pane.selected=min(max(0,len(data)-1),pane.offset+i)
+ p.table(w.TableOptions(rows=rows,columns=[c for _,c,_,_ in columns],selected=pane.selected,offset=pane.offset,follow_selection=True,zebra=zebra,scrollbar=scrollbar,header=header),ScrollHandlers(on_focus=focus,on_scroll=scroll,on_select_row=select))
+def dc(key,title,width=None,minimum=None,color=None,right=False,fmt=None,cell_color=None):
+ return key,w.TableColumn(title,width=width,min=minimum,color=color,align="right" if right else "left"),fmt,cell_color
+
+def cpu_panel(ui,s,columns):
+ c=s.sample["cpu"]
+ def draw(p):
+ t=p.theme;p.label(f'{c["model"]} {c["frequencyGhz"]:.1f} GHz');plot(p,c["history"],t.success,100)
+ p.meters(w.MetersOptions(items=[w.MeterItem(label=f'P{i}',value=v) for i,v in enumerate(c["cores"])],columns=columns,label_width=4,value_width=5,style="segmented"))
+ p.divider();keys(p,[kv("Load Avg"," ".join(f'{v:.2f}' for v in c["load"]),t.warning)])
+ ui.panel(Panel(title="CPU Overview",subtitle=percent(c["total"])),draw)
+def memory_panel(ui,s):
+ m=s.sample["memory"];used=m["used"]/max(1,m["total"]);swap=m["swapUsed"]/max(1,m["swapTotal"])
+ def draw(p):
+ t=p.theme;txt(p,f'Memory {bytes(m["used"])} / {bytes(m["total"])} ({percent(used)})',t.foreground);meter(p,used);p.spacer(1)
+ keys(p,[kv(label,bytes(m[key]),color) for key,label,color in (("used","Used:",t.warning),("available","Available:",t.success),("cached","Cached:",t.accent),("buffers","Buffers:",t.secondary),("free","Free:",t.muted))])
+ p.spacer();p.divider();txt(p,f'Swap {bytes(m["swapUsed"])} / {bytes(m["swapTotal"])} ({percent(swap)})',t.foreground);meter(p,swap,t.secondary)
+ keys(p,[kv("Used:",bytes(m["swapUsed"]),t.secondary),kv("Free:",bytes(m["swapTotal"]-m["swapUsed"]),t.muted)])
+ ui.panel(Panel(title="Memory & Swap"),draw)
+def disks_panel(ui,s):
+ disks=s.sample["disks"]
+ def draw(p):
+ t=p.theme
+ if not disks: p.label("No disks reported");return
+ for i,d in enumerate(disks[:2]):
+ used=d["used"]/max(1,d["total"])
+ txt(p,f'{d["device"]} — {bytes(d["total"])} ({d["type"]})',t.foreground);txt(p,f'Used: {bytes(d["used"])} ({percent(used)})',t.muted);meter(p,used);txt(p,"Free: "+bytes(d["total"]-d["used"]),t.muted)
+ row(p,1,0,lambda r,d=d:(txt(r,"Read: "+byte_rate(d["readRate"]),t.success),r.text("Write: "+byte_rate(d["writeRate"]),w.TextStyle(fg=t.secondary,align="right"))))
+ multi(p,d["readHistory"],d["writeHistory"],t.success,t.secondary)
+ if i==0 and len(disks)>1: p.divider()
+ ui.panel(Panel(title="Disks"),draw)
+def system_panel(ui,s):
+ sys,c,m=s.sample["system"],s.sample["cpu"],s.sample["memory"];used=m["used"]/max(1,m["total"]);count=sys["processCount"] or len(s.sample["processes"])
+ def draw(p):
+ t=p.theme
+ def summary(r):
+ keys(r,[kv("OS:",sys["os"]),kv("Kernel:",sys["kernel"]),kv("Uptime:",duration(sys["uptime"])),kv("Hostname:",sys["hostname"]),kv("Shell:",sys["shell"]),kv("Source:","simulated" if s.source=="simulated" else "linux/proc",t.accent)],False)
+ keys(r,[kv("CPU:",percent(c["total"]),heat_color(t,c["total"])),kv("Memory:",f'{percent(used)} ({bytes(m["used"])})',t.warning),kv("Swap:",percent(m["swapUsed"]/m["swapTotal"]) if m["swapTotal"] else "—",t.secondary),kv("Load:"," ".join(f'{v:.2f}' for v in c["load"])),kv("Processes:",count),kv("Threads:",sys["threadCount"])],False)
+ p.row(Layout(size=6,min=6,gap=2),summary)
+ p.panel(Panel(title="CPU History",min=5),lambda g:plot(g,c["history"],t.success,100,True))
+ if p.width>=46 and p.height>=16:
+ def stats(r):
+ r.panel(Panel(title="Quick Stats"),lambda q:keys(q,[kv("Uptime",duration(sys["uptime"]),t.accent),kv("Procs",count,t.accent),kv("Threads",sys["threadCount"] or "—",t.accent),kv("Ctx/s",f'{s.sample["telemetry"]["kernel"]["contextSwitchRate"]/1000:.1f}K',t.accent)]))
+ r.panel(Panel(title="Memory"),lambda q:(txt(q,percent(used),t.warning),plot(q,m["history"],t.primary,100)))
+ temp=next(iter(s.sample["temperatures"]),None)
+ r.panel(Panel(title="Temp",size=14),lambda q:q.gauge(GaugeOptions(value=min(1,temp["value"]/(temp["max"] or 100)) if temp else c["total"],label=f'{math.floor(temp["value"]+.5)}°C' if temp else percent(c["total"]))))
+ row(p,6,1,stats)
+ else:
+ p.divider();keys(p,[kv("Threads",sys["threadCount"],t.accent),kv("Ctx switches",f'{sys["contextSwitches"]/1000:.1f}K',t.accent)])
+ ui.panel(Panel(title="System"),draw)
+def processes_panel(ui,s):
+ def draw(p):
+ t=p.theme
+ columns=[dc("pid","PID",7,right=True),dc("name","Name",minimum=8,color=t.primary),dc("cpu","CPU%",6,right=True,fmt=lambda d:f'{d["cpu"]:.1f}',cell_color=lambda d:heat_color(t,min(1,d["cpu"]/100))),dc("mem","MEM%",6,color=t.warning,right=True,fmt=lambda d:f'{d["mem"]:.1f}'),dc("rss","RSS",9,right=True,fmt=lambda d:bytes(d["rss"],0)),dc("threads","Threads",7,right=True),dc("state","S",2,cell_color=lambda d:t.success if d["state"]=="R" else t.muted),dc("user","User",10,color=t.muted),dc("command","Command",minimum=10,color=t.muted)]
+ data_table(p,s,"dashboard.processes",s.processes(),columns)
+ ui.panel(Panel(title=f'Processes (sorted by {s.sort.upper()})',subtitle="filter: "+s.filter if s.filter else "",focusable=True),draw)
+def network_panel(ui,s):
+ n=s.sample["network"]
+ def draw(p):
+ t=p.theme;row(p,1,0,lambda r:(txt(r,"Download: "+bit_rate(n["downRate"]),t.primary),r.text("Upload: "+bit_rate(n["upRate"]),w.TextStyle(fg=t.secondary,align="right"))))
+ for prefix,color in (("down",t.primary),("up",t.secondary)):
+ p.graph(w.GraphOptions(values=n[prefix+"History"],axis=True,axis_format=lambda v:bit_rate(v).replace(" ",""),plot=PlotOptions(min=0,fill=True,color=color)))
+ p.divider()
+ def totals(r):
+ for prefix,color in (("down",t.primary),("up",t.secondary)):
+ keys(r,[kv("Total:",bytes(n[prefix+"Total"]),color),kv("Current:",bit_rate(n[prefix+"Rate"]),color),kv("Peak:",bit_rate(n[prefix+"Peak"]),color)],False)
+ row(p,3,2,totals)
+ ui.panel(Panel(title="Network"),draw)
+def disk_usage_panel(ui,s):
+ disks=s.sample["disks"];first=disks[0] if disks else {}
+ def draw(p):
+ t=p.theme
+ for d in disks:
+ used=d["used"]/max(1,d["total"]);p.meter(w.MeterOptions(value=used,text=f'{percent(used)} {bytes(d["used"],0)} / {bytes(d["total"],0)}',style="segmented"));p.label(f'{d["mount"]} ({d["device"]})')
+ p.spacer(1)
+ def io(p):
+ def columns(r):
+ for prefix,label,color in (("read","Read: ",t.success),("write","Write: ",t.secondary)):
+ col(r,"fill",lambda c,prefix=prefix,label=label,color=color:(txt(c,label+byte_rate(first.get(prefix+"Rate",0)),color),plot(c,first.get(prefix+"History",[]),color)))
+ row(p,"fill",2,columns)
+ p.panel(Panel(title="I/O Summary"),io)
+ ui.panel(Panel(title="Disk Usage",subtitle=first.get("device","")),draw)
+def temperatures_panel(ui,s):
+ def draw(p):
+ t=p.theme;temps=s.sample["temperatures"]
+ if not temps:
+ txt(p,"No thermal sensors on this host.",t.muted);p.spacer(1);p.label("Run with --sim to see this panel populated.");return
+ for temp in temps[:10]:
+ v=min(1,temp["value"]/(temp["max"] or 100))
+ row(p,1,0,lambda r,temp=temp,v=v:(r.text(temp["label"],w.TextStyle(fg=t.muted),Layout(size=16)),r.heat_bar(w.HeatBarOptions(value=v)),r.text(f'{math.floor(temp["value"]+.5)}°C',w.TextStyle(fg=heat_color(t,v),align="right"),Layout(size=6))))
+ ui.panel(Panel(title="Temperatures"),draw)
+def sensors_panel(ui,s):
+ def draw(p):
+ sensors=s.sample["sensors"]
+ if not sensors:
+ p.label("No hardware sensors on this host.");p.spacer(1);p.label("Probed: /sys/class/hwmon, thermal zones, lm-sensors,");p.label("power supplies and nvidia-smi.");return
+ keys(p,[kv(v["label"],v["value"],p.theme.accent) for v in sensors])
+ ui.panel(Panel(title="Sensors"),draw)
+def logs_panel(ui,s):
+ def draw(p):
+ data=s.sample["logs"];pane=s.pane("dashboard.logs",len(data));pane.log=True
+ def focus():
+ if not overlay(s): s.focused[s.screen]="dashboard.logs"
+ def scroll(d):
+ if not overlay(s): focus();pane.offset=max(0,min(max(0,len(data)-1),pane.offset-d))
+ p.log(w.LogOptions(entries=[w.LogEntry(time=d["time"],level=d["level"],message=d["message"],meta="{"+d["meta"]+"}") for d in data],from_end=pane.offset,scrollbar=True),ScrollHandlers(on_focus=focus,on_scroll=scroll))
+ ui.panel(Panel(title="Logs"),draw)
+def dashboard(ui,s):
+ if ui.width>=150:
+ row(ui,19 if ui.height>=44 else 16,1,lambda r:(col(r,"1fr",lambda c:cpu_panel(c,s,2)),col(r,"0.95fr",lambda c:memory_panel(c,s)),col(r,"0.95fr",lambda c:disks_panel(c,s)),col(r,"1.35fr",lambda c:system_panel(c,s))))
+ row(ui,"1fr",1,lambda r:(col(r,"2fr",lambda c:processes_panel(c,s)),col(r,"1.2fr",lambda c:network_panel(c,s)),col(r,"1.2fr",lambda c:disk_usage_panel(c,s))))
+ row(ui,12,1,lambda r:(temperatures_panel(r,s),sensors_panel(r,s),col(r,"1.6fr",lambda c:logs_panel(c,s))))
+ elif ui.width>=100:
+ row(ui,14,1,lambda r:(cpu_panel(r,s,2),memory_panel(r,s),system_panel(r,s)))
+ row(ui,"1fr",1,lambda r:(col(r,"1.6fr",lambda c:processes_panel(c,s)),col(r,"fill",lambda c:network_panel(c,s))))
+ row(ui,10,1,lambda r:(temperatures_panel(r,s),logs_panel(r,s)))
+ else:
+ row(ui,10,1,lambda r:(cpu_panel(r,s,1),memory_panel(r,s)))
+ col(ui,"fill",lambda c:processes_panel(c,s));row(ui,8,1,lambda r:network_panel(r,s))
diff --git a/ports/python/hqtui_demo/model.py b/ports/python/hqtui_demo/model.py
index 267698d..484ac7d 100644
--- a/ports/python/hqtui_demo/model.py
+++ b/ports/python/hqtui_demo/model.py
@@ -107,8 +107,11 @@ class Pane:
selected: int = 0
offset: int = 0
total: int = 0
+ log: bool = False
def move(self, delta: int) -> None:
+ if self.log:
+ self.offset=max(0,min(max(0,self.total-1),self.offset-delta));return
self.selected = max(0, min(max(0, self.total - 1), self.selected + delta))
@@ -144,6 +147,8 @@ class State:
render_ms: float = 0
changed_cells: int = 0
output_bytes: int = 0
+ slider: float = .7
+ clock: str = "12:00:00"
def pane(self, name: str, total: int) -> Pane:
pane = self.panes.setdefault(name, Pane())
@@ -199,8 +204,8 @@ def key(self, key: str, char: str = "") -> bool:
return False
if self.select_open:
if key == "escape": self.select_open = False
- elif key == "up": self.select_index = (self.select_index - 1) % len(THEMES)
- elif key == "down": self.select_index = (self.select_index + 1) % len(THEMES)
+ elif key == "up": self.select_index = (self.select_index - 1) % 4
+ elif key == "down": self.select_index = (self.select_index + 1) % 4
elif key == "enter": self.theme_index = self.select_index; self.select_open = False
return False
if key == "e" and self.screen in ("components", "input"):
diff --git a/ports/python/hqtui_demo/showcase.py b/ports/python/hqtui_demo/showcase.py
new file mode 100644
index 0000000..602c4d1
--- /dev/null
+++ b/ports/python/hqtui_demo/showcase.py
@@ -0,0 +1,134 @@
+"""The reference widget, graphics, theme, input and stress showcases in Python."""
+import math
+from hqtui import GridSpec, Cell, ScrollHandlers
+from hqtui.buffer import Style
+from hqtui.color import Gradient
+from hqtui.graphics import DonutOptions, DonutSegment
+from hqtui.theme import resolve_theme
+from .model import THEMES
+from .dashboard import (w,Layout,Panel,row,txt,kv,keys,percent,bytes,data_table,dc,
+ scalar,overlay,PlotOptions,Series,GaugeOptions)
+
+def graphics(ui,s):
+ t=ui.theme;time=s.sample["time"]
+ wave=lambda phase,freq:[math.sin(i/freq+phase)*50+50 for i in range(240)]
+ a,b,c=wave(time/3,9),wave(time/3+2,5),wave(time/2,17)
+ def draw(r):
+ def left(p):
+ p.panel(Panel(title="Braille (2×4 pixels per cell)"),lambda p:p.graph(w.GraphOptions(values=a,plot=PlotOptions(min=0,max=100,fill=True,color=t.accent,grid=True))))
+ p.panel(Panel(title="Block elements"),lambda p:p.graph(w.GraphOptions(values=a,plot=PlotOptions(min=0,max=100,mode="block",colors=t.heat))))
+ p.panel(Panel(title="ASCII fallback"),lambda p:p.graph(w.GraphOptions(values=a,plot=PlotOptions(min=0,max=100,mode="ascii",color=t.foreground))))
+ r.column(Layout(gap=1),left)
+ def right(p):
+ p.panel(Panel(title="Multi-series"),lambda p:p.graph(w.GraphOptions(series=[Series(a,t.primary,"alpha"),Series(b,t.success,"beta"),Series(c,t.secondary,"gamma")],axis=True,legend=True,plot=PlotOptions(min=0,max=100))))
+ def gradient(surface):
+ steps=Gradient(t.heat).steps(surface.width)
+ for y in range(surface.height):
+ for x,color in enumerate(steps): surface.char(x,y,"█",Style(fg=color))
+ p.panel(Panel(title="Gradients"),lambda p:p.draw(gradient))
+ def canvas(c,surface):
+ cx,cy=c.width/2,c.height/2;radius=min(cx,cy)-2;c.circle(cx,cy,radius)
+ for i in range(12):
+ angle=i/12*math.pi*2+time/4;c.line(cx,cy,cx+math.cos(angle)*radius,cy+math.sin(angle)*radius*.9)
+ p.panel(Panel(title="Raw Braille canvas"),lambda p:p.canvas(canvas,color=t.accent))
+ r.column(Layout(gap=1),right)
+ row(ui,"1fr",1,draw)
+def themes(ui,s):
+ ui.label(f'Theme {s.theme_index+1}/9: {ui.theme.name} ←/→ or F2 to change');ui.spacer(1)
+ def grid(g):
+ for i,name in enumerate(THEMES):
+ e=resolve_theme(name)
+ def draw(p,e=e):
+ def badges(r):
+ for label,color,width in (("primary",e.primary,10),("ok",e.success,5),("warn",e.warning,7),("err",e.danger,6)):
+ r.badge(w.BadgeOptions(text=label,color=color),Layout(size=width))
+ r.spacer()
+ row(p,1,1,badges);p.meter(w.MeterOptions(value=.72,label="cpu",background=e.background))
+ p.graph(w.GraphOptions(values=s.sample["cpu"]["history"],plot=PlotOptions(min=0,max=100,fill=True,color=e.graph[0],background=e.background)))
+ def ramp(surface):
+ for ci,color in enumerate(e.graph):
+ for x in range(3): surface.char(ci*4+x,0,"█",Style(fg=color,bg=e.background))
+ p.draw(ramp,Layout(size=1))
+ g.panel(Panel(title=e.name,border_color=e.border_focused if i==s.theme_index else e.border,background=e.background),Cell(),draw)
+ ui.grid(GridSpec(columns=3,rows=3,gap=1),grid)
+def input_screen(ui,s):
+ t=ui.theme
+ def draw(r):
+ def events(p):
+ keys(p,[kv("Key",s.last_key,t.accent),kv("Mouse",s.last_mouse,t.primary)]);p.spacer(1);p.divider(w.DividerOptions(label="history"));p.list(w.ListOptions(items=[w.ListItem(v) for v in reversed(s.key_log[-20:])]))
+ r.panel(Panel(title="Last Events"),events)
+ def controls(p):
+ txt(p,"Press any key — modifiers are normalized.",t.foreground);p.label("Arrows, Function keys, Ctrl/Alt/Shift combinations,");p.label("paste, focus, mouse move, click, drag and scroll.");p.spacer(1);p.divider(w.DividerOptions(label="focusable controls"));p.spacer(1)
+ def buttons(r):
+ r.button(w.ButtonOptions(label="Button A",width=12),layout=Layout(size=12));r.button(w.ButtonOptions(label="Button B",width=12,variant="success"),layout=Layout(size=12))
+ r.checkbox(w.CheckboxOptions(label="Check",checked=s.checkbox),lambda:toggle(s,"checkbox"),Layout(size=12));r.spacer()
+ row(p,1,2,buttons);p.spacer(1);p.label("Tab / Shift+Tab moves focus. Enter activates.");p.spacer();keys(p,[kv("Mouse tracking","on"),kv("Bracketed paste","on"),kv("Focus events","on")])
+ r.panel(Panel(title="Try it"),controls)
+ row(ui,"1fr",1,draw)
+def stress(ui,s):
+ t=ui.theme
+ def stats(r):
+ for title,value,color in (("Render",f'{s.render_ms:.2f} ms/frame',t.success),("Changed cells",str(s.changed_cells),t.warning),("Bytes/frame",str(s.output_bytes),t.primary),("FPS",f'{s.fps:.1f}',t.accent)):
+ r.panel(Panel(title=title),lambda p,value=value,color=color:txt(p,value,color))
+ row(ui,3,1,stats)
+ def draw(surface):
+ ramp=Gradient(t.graph);chars="▖▗▘▙▚▛▜▝▞▟█▓▒░";time=s.sample["time"]
+ for y in range(surface.height):
+ for x in range(surface.width):
+ v=(math.sin(x/6+time)+math.cos(y/4-time))/2;n=(v+1)/2;surface.char(x,y,chars[math.floor(n*(len(chars)-1))],Style(fg=ramp.sample(n)))
+ ui.panel(Panel(title="Full-screen churn"),lambda p:p.draw(draw))
+def toggle(s,key):
+ if not overlay(s): setattr(s,key,not getattr(s,key))
+def tree():
+ def node(name,cpu,mem,*children): return w.TreeNode(label=name,values=[w.TreeValue(cpu,6),w.TreeValue(mem,6)],children=children)
+ return [node("systemd","1.3","0.1",node("bash","0.1","0.2"),node("bun","32.8","4.2",node("bun:worker","12.4","1.8"),node("bun:worker","8.7","1.3")),node("node","18.1","2.1",node("node:worker","6.1","0.8")),node("postgres","6.7","1.8"))]
+def handlers(s,name,pane):
+ def focus():
+ if not overlay(s): s.focused[s.screen]=name
+ def scroll(d):
+ if not overlay(s): focus();pane.move(d)
+ def select(i):
+ if not overlay(s): pane.selected=max(0,min(pane.total-1,pane.offset+i))
+ return ScrollHandlers(on_focus=focus,on_scroll=scroll,on_select_row=select)
+def components(ui,s):
+ t=ui.theme;c,m,n=s.sample["cpu"],s.sample["memory"],s.sample["network"]
+ def draw(r):
+ def left(left):
+ def controls(p):
+ def buttons(r):
+ for label,variant,width in (("Primary","primary",11),("Success","success",11),("Warning","warning",11),("Danger","danger",10)):
+ r.button(w.ButtonOptions(label=label,variant=variant,width=width),(lambda:toggle(s,"modal")) if label=="Primary" else None,Layout(size=width))
+ r.spacer()
+ row(p,1,1,buttons);p.spacer(1)
+ def inputs(r):
+ options=("Dark","Dracula","Nord","Tokyo Night")
+ r.select(w.SelectOptions(value=options[s.select_index%4],width=20,open=s.select_open,options=options,selected_index=s.select_index),lambda:toggle(s,"select_open"),Layout(size=20))
+ r.checkbox(w.CheckboxOptions(label="Toggle",checked=s.toggle,variant="toggle"),lambda:toggle(s,"toggle"),Layout(size=12))
+ r.checkbox(w.CheckboxOptions(label="Checkbox",checked=s.checkbox),lambda:toggle(s,"checkbox"),Layout(size=14));r.spacer()
+ row(p,1,2,inputs);p.spacer(1);p.text_input(w.TextInputOptions(label="Search",value=s.input_value,placeholder="type to filter…",focused=s.editing),Layout(size=1));p.spacer(1)
+ p.meter(w.MeterOptions(label="Slider",value=s.slider,style="smooth",heat=False,color=t.primary));p.progress(w.ProgressOptions(label="Progress",value=37,max=120,show_count=True))
+ left.panel(Panel(title="Buttons & Inputs",size=13),controls)
+ files=[dict(zip(("name","size","type","modified"),v)) for v in (("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"))]
+ left.panel(Panel(title="Table Widget"),lambda p:data_table(p,s,"components.files",files,[dc("name","Name",minimum=10,color=t.primary),dc("size","Size",9,right=True),dc("type","Type",6),dc("modified","Modified",10,color=t.muted,right=True)],True,scrollbar=False))
+ def logs(p):
+ data=s.sample["logs"];pane=s.pane("components.logs",len(data));pane.log=True;h=handlers(s,"components.logs",pane)
+ def scroll(d):
+ if not overlay(s): h.on_focus();pane.offset=max(0,min(max(0,len(data)-1),pane.offset-d))
+ p.log(w.LogOptions(entries=[w.LogEntry(time=d["time"],level=d["level"],message=d["message"],meta="{"+d["meta"]+"}") for d in data],from_end=pane.offset,scrollbar=True),ScrollHandlers(on_focus=h.on_focus,on_scroll=scroll))
+ left.panel(Panel(title="Log Viewer",size=11),logs)
+ r.column(Layout(gap=1),left)
+ def right(right):
+ def processes(p):
+ row(p,1,0,lambda r:(r.text("Name",w.TextStyle(fg=t.muted,bold=True)),r.text("CPU% MEM%",w.TextStyle(fg=t.muted,bold=True,align="right"))))
+ pane=s.pane("components.tree",8);p.tree(w.TreeOptions(nodes=tree(),selected=pane.selected,offset=pane.offset,follow_selection=True),handlers(s,"components.tree",pane))
+ right.panel(Panel(title="Process Tree",size=13),processes)
+ def gauges(p):
+ p.sparkline(w.SparklineWidgetOptions(label="CPU ",values=c["history"],text=percent(c["total"]),color=t.success));p.sparkline(w.SparklineWidgetOptions(label="Mem ",values=m["history"],text=percent(m["used"]/max(1,m["total"])),color=t.warning));p.sparkline(w.SparklineWidgetOptions(label="Net ",values=n["downHistory"],text=bytes(n["downRate"])+"/s",color=t.primary));p.spacer(1)
+ row(p,"1fr",2,lambda r:(r.gauge(GaugeOptions(value=c["total"],label=percent(c["total"]))),r.donut(DonutOptions(segments=[DonutSegment(m["used"],t.primary,"Used"),DonutSegment(m["available"],t.warning,"Free")]))))
+ right.panel(Panel(title="Sparklines & Gauges",size=12),gauges)
+ def lists(p):
+ row(p,1,1,lambda r:(r.badge(w.BadgeOptions(text="active",color=t.success),Layout(size=10)),r.badge(w.BadgeOptions(text="idle",color=t.warning,variant="subtle"),Layout(size=8)),r.badge(w.BadgeOptions(text="failed",color=t.danger,variant="outline"),Layout(size=10)),r.spacer()))
+ p.spacer(1);pane=s.pane("components.list",4);p.list(w.ListOptions(items=[w.ListItem("apps/demo",color=t.primary),w.ListItem("packages/hqtui"),w.ListItem("apps/web"),w.ListItem("docs")],selected=pane.selected,offset=pane.offset,follow_selection=True,bullet="▸",scrollbar=True),handlers(s,"components.list",pane))
+ right.panel(Panel(title="Lists & Badges"),lists)
+ r.column(Layout(gap=1),right)
+ row(ui,"1fr",1,draw)
diff --git a/ports/python/hqtui_demo/telemetry_view.py b/ports/python/hqtui_demo/telemetry_view.py
new file mode 100644
index 0000000..ab5517d
--- /dev/null
+++ b/ports/python/hqtui_demo/telemetry_view.py
@@ -0,0 +1,140 @@
+"""Native Traffic, Sessions, Network and Services reference screens."""
+from .dashboard import (w, Layout, Panel, row, col, txt, kv, keys, plot, multi,
+ scalar, bytes, percent, byte_rate, data_table, dc, PlotOptions)
+from hqtui.theme import series_color
+
+def rate(v):
+ if v>=1e6: return f'{v/1e6:.1f}M/s'
+ if v>=1000: return f'{v/1000:.1f}K/s'
+ return f'{v:.0f}/s'
+def status_color(t,c): return {"1xx":t.secondary,"2xx":t.success,"3xx":t.accent,"4xx":t.warning,"5xx":t.danger}.get(c,t.muted)
+def traffic(ui,s):
+ if s.source!="simulated": ui.label("Protocol/direction: port-based estimates; HTTP rate: estimated from log growth")
+ t=ui.theme;d=s.sample["telemetry"];net=d["net"];rates=net["rates"];http=d["http"]
+ def top(r):
+ def protocols(p):
+ data=d["protocols"]
+ if not data: p.label("No sockets visible.");return
+ maximum=max(1,*(x["total"] for x in data))
+ p.meters(w.MetersOptions(items=[w.MeterItem(label=b["protocol"],value=b["total"]/maximum,color=series_color(t,i),text=scalar(b["total"])) for i,b in enumerate(data[:9])],label_width=13,value_width=5))
+ r.panel(Panel(title="Protocols",subtitle=f'{scalar(d["inboundConnections"])} in / {scalar(d["outboundConnections"])} out',border_color=t.accent),protocols)
+ def tcp(p):
+ row(p,1,0,lambda r:(txt(r,'↓ '+rate(rates["inSegs"])+" seg",t.primary),r.text('↑ '+rate(rates["outSegs"])+" seg",w.TextStyle(fg=t.secondary,align="right"))))
+ multi(p,d["netInHistory"],d["netOutHistory"],t.primary,t.secondary);p.divider()
+ keys(p,[kv("Established",net["tcpEstablished"],t.success),kv("Opens in/out",rate(rates["passiveOpens"])+" / "+rate(rates["activeOpens"]),t.accent),kv("Resets sent",f'{net["tcpOutRsts"]:,.0f}',t.muted)])
+ r.panel(Panel(title="TCP",size="0.9fr",border_color=t.primary),tcp)
+ color=t.danger if net["retransRatio"]>.02 else t.success
+ def retrans(p):
+ p.text(f'{max(0,min(1,net["retransRatio"]))*100:.2f}%',w.TextStyle(fg=color,bold=True));p.label("of outbound segments");plot(p,d["retransHistory"],t.danger)
+ keys(p,[kv("UDP in/out",rate(rates["udpIn"])+" / "+rate(rates["udpOut"]),t.muted),kv("ICMP",scalar(net["icmpInMsgs"])+" / "+scalar(net["icmpOutMsgs"]),t.muted)])
+ r.panel(Panel(title="Retransmits",size="0.7fr",border_color=color),retrans)
+ row(ui,13,1,top)
+ def middle(r):
+ def left(c):
+ def http_panel(p):
+ if not http:
+ 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
+ row(p,1,0,lambda r:(txt(r,http["source"],t.muted),r.text(scalar(http["upgrades"])+" upgrades (ws)",w.TextStyle(fg=t.secondary,align="right"))))
+ p.graph(w.GraphOptions(values=http["history"],plot=PlotOptions(min=0,fill=True,color=t.success)),Layout(size=6));p.divider(w.DividerOptions(label="status"))
+ maximum=max([1]+[b["count"] for b in http["statusClasses"]])
+ p.meters(w.MetersOptions(items=[w.MeterItem(label=b["class"],value=b["count"]/maximum,color=status_color(t,b["class"]),text=scalar(b["count"])) for b in http["statusClasses"]],label_width=5,value_width=7));p.divider(w.DividerOptions(label="top paths"))
+ data_table(p,s,"traffic.paths",http["topPaths"],[dc("path","Path",minimum=20,color=t.primary),dc("count","Hits",7,color=t.accent,right=True)],header=False)
+ c.panel(Panel(title="HTTP",subtitle=f'{http["requestsPerSecond"]:.1f} req/s' if http else "no access log",border_color=t.success),http_panel)
+ r.column(Layout(gap=1),left)
+ def right(c):
+ def ssh(p):
+ if not d["ssh"]: p.label("No sshd events in the journal.");return
+ data_table(p,s,"traffic.ssh",list(reversed(d["ssh"])),[dc("time","Time",9,color=t.muted),dc("action","Action",11,cell_color=lambda d:t.success if d["action"]=="accepted" else t.muted if d["action"]=="disconnect" else t.danger),dc("user","User",12,color=t.primary),dc("from","From",minimum=14,color=t.accent),dc("method","Method",10,color=t.muted)],True)
+ c.panel(Panel(title="SSH Activity",subtitle=str(len(d["ssh"])),border_color=t.warning),ssh)
+ def remotes(p):
+ if not d["remotes"]: p.label("No remote peers.");return
+ data_table(p,s,"traffic.remotes",d["remotes"],[dc("host","Host",minimum=16,color=t.accent),dc("connections","Conns",6,color=t.success,right=True),dc("protocols","Protocols",minimum=12,color=t.muted)],True)
+ c.panel(Panel(title="Top Remote Hosts",size=10,border_color=t.secondary),remotes)
+ r.column(Layout(size="0.85fr",gap=1),right)
+ row(ui,"1fr",1,middle)
+ if http and http["recent"]:
+ ui.panel(Panel(title="Recent Requests",size=10,border_color=t.primary),lambda p:data_table(p,s,"traffic.requests",http["recent"],[dc("time","Time",9,color=t.muted),dc("method","Method",7,color=t.secondary),dc("path","Path",minimum=24,color=t.primary),dc("status","Status",7,right=True,cell_color=lambda d:status_color(t,str(d["status"])[0]+"xx")),dc("client","Client",16,color=t.accent),dc("bytes","Bytes",9,color=t.muted,right=True)],True))
+def sessions(ui,s):
+ t=ui.theme;d=s.sample["telemetry"]
+ def top(r):
+ def active(p):
+ if not d["sessions"]: p.label("No interactive sessions.");p.label("(`who` reports nothing on this host)");return
+ data_table(p,s,"sessions.active",d["sessions"],[dc("user","User",12,color=t.primary),dc("tty","TTY",10),dc("from","From",minimum=12,color=t.accent),dc("loginAt","Login",14,color=t.muted),dc("idle","Idle",8,right=True)])
+ r.panel(Panel(title="Active Sessions",subtitle=str(len(d["sessions"])),border_color=t.success),active)
+ def states(p):
+ states=d["states"]
+ for key,label,color in (("running","run ",t.success),("sleeping","slp ",t.primary),("stopped","stop",t.warning),("zombie","zomb",t.danger)):
+ p.meter(w.MeterOptions(label=label,value=states[key]/max(1,states["total"]),text=scalar(states[key]),heat=False,color=color))
+ p.spacer(1);keys(p,[kv("Total",states["total"],t.accent)])
+ r.panel(Panel(title="Process States",size=34,border_color=t.primary),states)
+ row(ui,9,1,top)
+ def bottom(r):
+ def logins(p):
+ if not d["logins"]: p.label("No login history available.");return
+ data_table(p,s,"sessions.logins",d["logins"],[dc("user","User",12,color=t.primary),dc("tty","TTY",12,color=t.muted),dc("from","From",minimum=14,color=t.accent),dc("when","When",minimum=16,color=t.muted),dc("status","Status",8,cell_color=lambda d:t.success if d["status"]=="still" else t.muted)],True)
+ r.panel(Panel(title="Recent Logins",subtitle=f'{len(d["logins"])} from wtmp',border_color=t.accent),logins)
+ def right(c):
+ def failed(p):
+ if not d["failedLogins"]: p.label("None recorded.");p.label("(btmp is usually root-only)");return
+ data_table(p,s,"sessions.failed",d["failedLogins"],[dc("user","User",12,color=t.danger),dc("from","From",minimum=12),dc("when","When",minimum=14,color=t.muted)])
+ c.panel(Panel(title="Failed Logins",border_color=t.danger),failed)
+ c.panel(Panel(title="Session History",size=8,border_color=t.secondary),lambda p:(p.label("concurrent sessions"),plot(p,d["sessionHistory"],t.success)))
+ r.column(Layout(size="0.8fr",gap=1),right)
+ row(ui,"1fr",1,bottom)
+def network(ui,s):
+ t=ui.theme;d=s.sample["telemetry"];active=[i for i in d["interfaces"] if i["rxTotal"]>0 or i["state"]=="up"];shown=(active or d["interfaces"])[:3]
+ def top(r):
+ if not shown: r.panel(Panel(title="Interfaces"),lambda p:p.label("No interfaces reported."));return
+ for i,iface in enumerate(shown):
+ def draw(p,d=iface):
+ row(p,1,0,lambda r:(txt(r,"↓ "+byte_rate(d["rxRate"]),t.primary),r.text("↑ "+byte_rate(d["txRate"]),w.TextStyle(fg=t.secondary,align="right"))))
+ multi(p,d["rxHistory"],d["txHistory"],t.primary,t.secondary);p.divider()
+ keys(p,[kv("RX total",bytes(d["rxTotal"]),t.primary),kv("TX total",bytes(d["txTotal"]),t.secondary),kv("MAC",d["mac"],t.muted),kv("MTU / err / drop"," / ".join(scalar(d[k]) for k in ("mtu","errors","drops")),t.muted)])
+ r.panel(Panel(title=f'{iface["name"]} ({iface["state"]})',subtitle=iface["ip"],border_color=(t.primary,t.success,t.secondary)[i]),draw)
+ row(ui,13,1,top)
+ def bottom(r):
+ def connections(p):
+ if not d["connections"]: p.label("No connections visible (`ss` unavailable).");return
+ data_table(p,s,"network.connections",d["connections"],[dc("proto","Proto",6,color=t.muted),dc("local","Local",minimum=18),dc("remote","Remote",minimum=18,color=t.accent),dc("state","State",10,color=t.success),dc("process","Process",minimum=12,color=t.primary)],True)
+ r.panel(Panel(title="Connections",subtitle=f'{len(d["connections"])} open',border_color=t.accent),connections)
+ def right(c):
+ c.panel(Panel(title="Listening Ports",subtitle=str(len(d["listeners"])),border_color=t.warning),lambda p:data_table(p,s,"network.listeners",d["listeners"],[dc("proto","Proto",6,color=t.muted),dc("port","Port",7,color=t.warning,right=True),dc("address","Address",minimum=10,color=t.muted),dc("process","Process",minimum=10,color=t.primary)],True))
+ c.panel(Panel(title="Open Connections",size=6,border_color=t.secondary),lambda p:plot(p,d["connectionHistory"],t.accent))
+ r.column(Layout(size="0.7fr",gap=1),right)
+ row(ui,"1fr",1,bottom)
+def services(ui,s):
+ t=ui.theme;d=s.sample["telemetry"];failed=sum(x["active"]=="failed" for x in d["services"])
+ def top(r):
+ def units(p):
+ if not d["services"]: p.label("systemd not available on this host.");return
+ data_table(p,s,"services.units",d["services"],[dc("name","Unit",minimum=18,color=t.primary),dc("active","Active",10,cell_color=lambda d:t.danger if d["active"]=="failed" else t.success if d["active"]=="active" else t.muted),dc("sub","Sub",10,color=t.muted),dc("description","Description",minimum=16,color=t.muted)],True)
+ r.panel(Panel(title="Services",subtitle=f'{failed} failed' if failed else f'{len(d["services"])} units',subtitle_color=t.danger if failed else t.muted,border_color=t.danger if failed else t.success),units)
+ def right(c):
+ def kernel(p):
+ k=d["kernel"]
+ krate=lambda v:f'{v/1000:.1f}K/s' if v>=1000 else f'{v:.0f}/s'
+ keys(p,[kv("Context switches",krate(k["contextSwitchRate"]),t.accent),kv("Interrupts",krate(k["interruptRate"]),t.accent),kv("Forks",krate(k["forkRate"]),t.accent),kv("Procs running",k["procsRunning"],t.success),kv("Procs blocked",k["procsBlocked"],t.warning if k["procsBlocked"] else t.muted),kv("Open file descriptors",f'{k["openFiles"]:,.0f}',t.primary),kv("Entropy available",k["entropy"],t.warning if k["entropy"]<200 else t.success),kv("Page in / out",f'{k["pageIn"]/1000:.0f}K / {k["pageOut"]/1000:.0f}K',t.muted)])
+ c.panel(Panel(title="Kernel",size=11,border_color=t.accent),kernel)
+ def containers(p):
+ if not d["containers"]: p.label("No running containers.");p.label("(docker not installed or not reachable)");return
+ data_table(p,s,"services.containers",d["containers"],[dc("name","Name",minimum=12,color=t.primary),dc("image","Image",minimum=14,color=t.muted),dc("status","Status",minimum=12,color=t.success)],True)
+ c.panel(Panel(title="Containers",size=9,border_color=t.primary),containers)
+ def hardware(p):
+ rows=[];power=d["power"]
+ if power:
+ draw=f'{power["powerDraw"]:.1f} W' if power["powerDraw"] is not None else "—"
+ rows.extend([kv("Battery",f'{scalar(power["battery"])}% ({power["timeRemaining"]})',t.success),kv("AC","connected" if power["acConnected"] else "on battery",t.muted),kv("Draw",draw,t.warning)])
+ for g in d["gpus"]:
+ util=percent(g["utilization"]) if g["utilization"] is not None else "—"
+ rows.extend([kv(g["name"],f'{util} · {scalar(g["temperature"])}°C',t.accent),kv("GPU memory",bytes(g["memoryUsed"])+" / "+bytes(g["memoryTotal"]),t.muted)])
+ if not rows: p.label("No battery or GPU telemetry on this host.");return
+ keys(p,rows)
+ c.panel(Panel(title="Hardware",border_color=t.warning),hardware)
+ r.column(Layout(size="0.85fr",gap=1),right)
+ row(ui,"1fr",1,top)
+ def filesystems(p):
+ if not d["filesystems"]: p.label("No filesystems reported.");return
+ data_table(p,s,"services.filesystems",d["filesystems"],[dc("mount","Mount",minimum=14,color=t.primary),dc("device","Device",minimum=12,color=t.muted),dc("type","Type",8,color=t.muted),dc("size","Size",10,right=True,fmt=lambda d:bytes(d["size"],0)),dc("used","Used",10,right=True,fmt=lambda d:bytes(d["used"],0)),dc("pct","Use%",6,right=True,fmt=lambda d:percent(d["used"]/d["size"]) if d["size"] else "-",cell_color=lambda d:t.danger if d["size"] and d["used"]/d["size"]>.9 else t.warning),dc("inodes","Inodes",16,color=t.muted,right=True,fmt=lambda d:f'{percent(d["inodesUsed"]/d["inodesTotal"])} of {d["inodesTotal"]/1e6:.1f}M' if d["inodesTotal"] else "-")],True)
+ ui.panel(Panel(title="Filesystems",size=10,border_color=t.secondary),filesystems)
+
+def telemetry(ui,s): {"traffic":traffic,"sessions":sessions,"network":network,"services":services}[s.screen](ui,s)
diff --git a/ports/python/hqtui_demo/view.py b/ports/python/hqtui_demo/view.py
index 2e80cf2..67678b9 100644
--- a/ports/python/hqtui_demo/view.py
+++ b/ports/python/hqtui_demo/view.py
@@ -1,236 +1,26 @@
-"""The ten-screen reference application, rendered exclusively with Python hqtui."""
-from __future__ import annotations
-
-import math
-
+"""Native reference app shell; screens are implemented with Python hqtui."""
+from hqtui import Layout
import hqtui.widgets as w
-from hqtui import Cell, GridSpec, Layout, Panel, ScrollHandlers
-from hqtui.graphics import GaugeOptions, PlotOptions, Series
-from hqtui.ui import HitRegion
-from hqtui.widgets.table import resolve_offset
-
-from .model import SCREENS, THEMES, State
-
-
-def size(value: float) -> str:
- for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
- if abs(value) < 1024 or unit == "TiB":
- return f"{value:.1f} {unit}"
- value /= 1024
- return ""
-
-
-def display(value) -> str:
- if isinstance(value, float): return f"{value:.1f}"
- if isinstance(value, (list, dict)): return str(value)
- return str(value)
-
-
-def table(ui, state: State, name: str, rows: list, columns: list[tuple[str, str]]) -> None:
- """Offset is calculated against the final surface, including mouse mapping."""
- pane = state.pane(name, len(rows))
- def draw(surface):
- pane.offset = resolve_offset(pane.offset, pane.selected, max(0, surface.height - 1), len(rows), True)
- w.draw_table(surface, w.TableOptions(
- rows=[w.TableRow(tuple(display(row.get(key, "—")) for key, _ in columns)) for row in rows],
- columns=[w.TableColumn(title) for _, title in columns],
- selected=pane.selected if rows else None, offset=pane.offset, zebra=True, scrollbar=True))
- def focus(): state.focused[state.screen] = name
- def scroll(delta): focus(); pane.move(delta)
- def click(x, y, button):
- focus()
- if y > 0: pane.selected = min(max(0, len(rows) - 1), pane.offset + y - 1)
- ui.ctx.hit(HitRegion(rect=surface.hit_rect(), on_scroll=scroll, on_click=click))
- if not rows: surface.text(0, 2, "No data available")
- ui.draw(draw)
-
-
-def graph(ui, values, label="", second=None):
- series = [Series(values=values, label=label)]
- if second is not None: series.append(Series(values=second, label="out"))
- ui.graph(w.GraphOptions(series=series, axis=True, legend=bool(label), plot=PlotOptions(fill=True)))
-
-
-def pairs(ui, values):
- for label, value in values:
- ui.text(f"{label:<15} {display(value)}")
-
-
-def dashboard(ui, s: State):
- data = s.sample
- def cpu(p):
- c = data["cpu"]
- p.label(f'{c["model"]} {c["frequencyGhz"]:.1f} GHz')
- graph(p, c["history"], "CPU %")
- p.meters(w.MetersOptions(items=[w.MeterItem(label=f"P{i}", value=v) for i, v in enumerate(c["cores"])], columns=2 if p.width > 35 else 1))
- p.label("Load " + " ".join(f"{n:.2f}" for n in c["load"]))
- def memory(p):
- m = data["memory"]
- p.meter(w.MeterOptions(value=m["used"], max=max(1, m["total"]), label="RAM"))
- pairs(p, [(name.title(), size(m[name])) for name in ("used", "available", "cached", "buffers", "free")])
- p.meter(w.MeterOptions(value=m["swapUsed"], max=max(1, m["swapTotal"]), label="Swap"))
- graph(p, m["history"])
- def disks(p):
- for d in data["disks"][:2]:
- p.label(d["device"] + " " + d["mount"])
- p.meter(w.MeterOptions(value=d["used"], max=max(1, d["total"]), text=size(d["used"])))
- p.label(f'Read {size(d["readRate"])}/s Write {size(d["writeRate"])}/s')
- graph(p, d["readHistory"], "read", d["writeHistory"])
- if not data["disks"]: p.label("No disks reported")
- def system(p):
- pairs(p, [(k.title(), data["system"][k]) for k in ("hostname", "os", "kernel", "shell", "processCount", "threadCount")])
- pairs(p, [("Uptime", f'{data["system"]["uptime"] / 3600:.1f}h'), ("Source", s.source)])
- graph(p, data["cpu"]["history"], "CPU history")
- def procs(p):
- p.label(f"F3 filter: {s.filter or 'all'} F6 sort: {s.sort}")
- table(p, s, "dashboard.processes", s.processes(), [(k, v) for k,v in (("pid","PID"),("name","Name"),("cpu","CPU%"),("mem","MEM%"),("threads","THR"),("state","S"),("user","User"),("command","Command"))])
- def network(p):
- n = data["network"]
- p.label(f'Down {size(n["downRate"])}/s Up {size(n["upRate"])}/s')
- graph(p, n["downHistory"], "down", n["upHistory"])
- pairs(p, [("Received", size(n["downTotal"])), ("Sent", size(n["upTotal"]))])
- def temperatures(p):
- for t in data["temperatures"][:12]:
- p.meter(w.MeterOptions(label=t["label"], value=t["value"], max=t.get("max") or 100, text=f'{t["value"]:.0f}°C'))
- if not data["temperatures"]: p.label("No thermal sensors available")
- def sensors(p):
- for sensor in data["sensors"][:14]: p.label(f'{sensor["label"]}: {sensor["value"]}')
- if not data["sensors"]: p.label("No sensor readings available")
- def logs(p): table(p,s,"dashboard.logs", data["logs"], [("time","Time"),("level","Level"),("message","Message")])
- panels = [("CPU Overview",cpu),("Memory & Swap",memory),("Disks",disks),("System",system),("Processes",procs),("Network",network),("Temperatures",temperatures),("Sensors",sensors),("Logs",logs)]
- # Preserve a useful compact dashboard rather than squeezing eight panels
- # into a terminal too small to show their contents.
- if ui.width < 90 or ui.height < 46: panels = [panels[0],panels[4],panels[1],panels[5]]
- grid(ui, panels, 3 if ui.width >= 160 and len(panels)>4 else 2)
-
-
-def grid(ui, panels, columns=2):
- if ui.width < 45: columns=1
- def build(g):
- for title, draw in panels: g.panel(Panel(title=title), Cell(), draw)
- ui.grid(GridSpec(columns=["1fr"]*columns, rows=["1fr"]*math.ceil(len(panels)/columns), gap=1),build)
-
-
-def telemetry(ui, s: State):
- t=s.sample["telemetry"]
- def tab(title, key, columns):
- return (title, lambda p: table(p,s,s.screen+"."+key,t[key],columns))
- def trace(title, key): return (title,lambda p: graph(p,t[key],title))
- if s.screen=="sessions":
- panels=[tab("Active Sessions","sessions",[("user","User"),("tty","TTY"),("from","From"),("idle","Idle"),("what","Command")]),
- tab("Login History","logins",[("user","User"),("tty","TTY"),("from","From"),("when","When"),("status","Status")]),
- tab("SSH Authentication","ssh",[("time","Time"),("action","Action"),("user","User"),("from","From")]),trace("Session History","sessionHistory")]
- elif s.screen=="network":
- panels=[tab("Interfaces","interfaces",[("name","Name"),("state","State"),("rxRate","RX B/s"),("txRate","TX B/s"),("errors","Errors"),("drops","Drops")]),
- tab("Connections","connections",[("proto","Proto"),("state","State"),("local","Local"),("remote","Remote"),("process","Process")]),
- tab("Listeners","listeners",[("proto","Proto"),("address","Address"),("port","Port"),("process","Process")]),trace("Connection History","connectionHistory")]
- elif s.screen=="services":
- panels=[tab("Services","services",[("name","Unit"),("active","State"),("description","Description")]),
- tab("Filesystems","filesystems",[("mount","Mount"),("device","Device"),("type","Type"),("used","Used B"),("size","Size B")]),
- ("Kernel",lambda p:pairs(p,[(k,v) for k,v in t["kernel"].items()][:15])),
- tab("Journal","journal",[("time","Time"),("level","Level"),("unit","Unit"),("message","Message")])]
- else:
- def http(p):
- h=t["http"]
- if not h: p.label("HTTP access log unavailable (read-only)"); return
- p.label(f'{h["requestsPerSecond"]:.1f} req/s {h["total"]} total')
- table(p,s,"traffic.http",h["recent"],[("time","Time"),("method","Method"),("path","Path"),("status","Status"),("client","Client")])
- panels=[tab("Protocols","protocols",[("protocol","Protocol"),("inbound","In"),("outbound","Out"),("total","Total")]),
- ("TCP Segments",lambda p:graph(p,t["netInHistory"],"in",t["netOutHistory"])),trace("Retransmits","retransHistory"),
- ("HTTP",http),tab("Remote Hosts","remotes",[("host","Host"),("connections","Connections"),("protocols","Protocols")]),
- tab("SSH Authentication","ssh",[("time","Time"),("action","Action"),("user","User"),("from","From")])]
- if s.screen=="sessions": panels.append(tab("Failed Logins","failedLogins",[("user","User"),("tty","TTY"),("from","From"),("when","When"),("status","Status")]))
- if s.screen=="traffic" and s.source=="real": ui.label("Protocol/direction: port-based estimates; HTTP rate: estimated from log growth")
- grid(ui,panels,3 if s.screen=="traffic" and ui.width>=120 else 2)
-
-
-def components(ui,s):
- def controls(p):
- p.label("Click controls; e edits text; Esc finishes editing")
- for variant in ("primary","success","warning","danger","ghost"):
- p.button(w.ButtonOptions(label=variant.title(),variant=variant),lambda:setattr(s,"modal",True))
- p.checkbox(w.CheckboxOptions(label="Notifications",checked=s.checkbox),lambda:setattr(s,"checkbox",not s.checkbox))
- p.checkbox(w.CheckboxOptions(label="Live updates",checked=s.toggle,variant="toggle"),lambda:setattr(s,"toggle",not s.toggle))
- p.select(w.SelectOptions(value=THEMES[s.select_index],options=THEMES,selected_index=s.select_index,open=s.select_open),lambda:setattr(s,"select_open",not s.select_open))
- p.text_input(w.TextInputOptions(value=s.input_value,placeholder="Type here",focused=s.editing))
- def meters(p):
- for i in range(8): p.meter(w.MeterOptions(value=(i+1)/8,label=f"Meter {i+1}",style="segmented" if i%2 else "smooth"))
- p.gauge(GaugeOptions(value=s.sample["cpu"]["total"],label="CPU"))
- def text(p):
- p.heading("Typography & Unicode")
- p.text("Readable, clipped and dependency-free")
- p.label("日本語 中文 한국어 • café • 🚀")
- for label in ("READY","WARNING","LIVE"): p.badge(w.BadgeOptions(text=label))
- p.divider()
- p.button(w.ButtonOptions(label="Open command palette"),lambda:setattr(s,"palette",True))
- graph(p,s.sample["cpu"]["history"],"History")
- grid(ui,[("Controls",controls),("Meters & Gauge",meters),("Text & Badges",text)],3)
-
-
-def graphics(ui,s):
- def canvas(c,surface):
- for x in range(c.width):
- y=int((.5+.35*math.sin(x/12+s.sample["time"]))*max(0,c.height-1))
- c.pixel(x,y)
- grid(ui,[("Braille Canvas",lambda p:p.canvas(canvas)),
- ("CPU Plot",lambda p:graph(p,s.sample["cpu"]["history"],"CPU %")),
- ("Network Plot",lambda p:graph(p,s.sample["network"]["downHistory"],"down",s.sample["network"]["upHistory"])),
- ("Gauge",lambda p:p.gauge(GaugeOptions(value=s.sample["cpu"]["total"],label="CPU")))])
-
-
-def themes(ui,s):
- def picker(p):
- p.label("F2 or Left/Right to switch themes")
- for i,name in enumerate(THEMES):
- p.button(w.ButtonOptions(label=("● " if i==s.theme_index else " ")+name),lambda index=i:setattr(s,"theme_index",index))
- def preview(p):
- p.heading(THEMES[s.theme_index])
- for name in ("primary","secondary","accent","success","warning","danger","muted"):
- p.meter(w.MeterOptions(value=.7,label=name,color=getattr(p.theme,name)))
- graph(p,s.sample["cpu"]["history"],"Preview")
- grid(ui,[("Built-in Themes",picker),("Live Preview",preview)])
-
-
-def input_screen(ui,s):
- def events(p):
- p.text("Last key: "+s.last_key)
- p.text("Mouse: "+s.last_mouse)
- p.label("e to edit; Esc exits text input")
- p.text_input(w.TextInputOptions(value=s.input_value,focused=s.editing,placeholder="UTF-8 + paste"))
- table(p,s,"input.events",[{"event":v} for v in reversed(s.key_log)],[("event","Key Events")])
- def diagnostics(p):
- pairs(p,[("Renderer","native Python"),("Source",s.source),("FPS",s.fps),("Render ms",s.render_ms),("Changed cells",s.changed_cells),("Output bytes",s.output_bytes)])
- p.label("Arrows scroll only the focused pane")
- p.label("Ctrl+K palette · F1 help · q quit")
- grid(ui,[("Input Inspector",events),("Diagnostics",diagnostics)])
-
-
-def stress(ui,s):
- panels=[]
- for i in range(12):
- def draw(p,index=i):
- p.meter(w.MeterOptions(value=(math.sin(s.sample["time"]+index)+1)/2,label=f"Load {index+1}"))
- graph(p,s.sample["cpu"]["history"])
- panels.append((f"Stress {i+1:02d}",draw))
- grid(ui,panels,4 if ui.width>=120 else 3)
-
-
-def render(ui,s: State):
- ui.row(Layout(size=1),lambda r:(r.heading("hqtui — python"),r.spacer(),r.label(f'{s.source.upper()} {"PAUSED" if s.paused else "LIVE"}')))
- # Split the tab bar at a narrow width so every screen remains reachable.
- groups=[SCREENS] if ui.width>=150 else [SCREENS[:5],SCREENS[5:]]
- for group in groups:
- start=SCREENS.index(group[0])
- ui.tabs(w.TabsOptions(tabs=[f'{(start+i+1)%10} {name}' for i,name in enumerate(group)],active=SCREENS.index(s.screen)-start),lambda i,base=start:setattr(s,"screen",SCREENS[base+i]))
- if ui.width<30 or ui.height<12:
- ui.text("Terminal too small; resize to 30×12 or larger")
- ui.spacer()
- else:
- fn={"dashboard":dashboard,"components":components,"graphics":graphics,"themes":themes,"input":input_screen,"stress":stress}.get(s.screen,telemetry)
- ui.column(Layout(size="fill"),lambda body:fn(body,s))
- if s.unavailable: ui.label("Unavailable: "+", ".join(s.unavailable))
- ui.status_bar(w.StatusBarOptions(items=[w.StatusItem("help","F1"),w.StatusItem("theme","F2"),w.StatusItem("filter","F3"),w.StatusItem("palette","^K"),w.StatusItem("quit","q")],right=[w.StatusItem(f'{s.screen} · {THEMES[s.theme_index]}')]))
- if s.filtering: ui.modal(w.ModalOptions(title="Filter Processes",message=s.filter+"▏\nEnter applies · Esc clears",width=60))
- if s.help: ui.modal(w.ModalOptions(title="hqtui — Help",width=66,message="1–9/0 / Tab: screen\nF2: theme F3: filter F6: sort\nCtrl+K: command palette Space: pause\nArrows / PgUp / PgDn / Home / End: scroll\nMouse: tabs, controls, selection and wheel\ne: edit text in Components/Input\nq / Ctrl+C: quit Any key: close help"))
- if s.modal: ui.modal(w.ModalOptions(title="Read-only Demo",message="No process will be killed and no service changed.\nPress any key to close.",width=58))
- if s.palette: ui.command_palette(w.CommandPaletteOptions(query=s.palette_query,items=[w.PaletteItem(label,hint) for label,hint in s.commands()],selected=s.palette_index,placeholder="Search commands"))
+from .model import SCREENS, THEMES
+from .dashboard import dashboard
+from .telemetry_view import telemetry
+from .showcase import components, graphics, themes, input_screen, stress
+
+def body(ui,s):
+ draw={"dashboard":dashboard,"components":components,"graphics":graphics,"themes":themes,"input":input_screen,"stress":stress}.get(s.screen,telemetry)
+ draw(ui,s)
+
+def render(ui,s):
+ t=ui.theme
+ def header(r):
+ r.text(" hqtui.com",w.TextStyle(fg=t.title,bold=True),Layout(size=12))
+ def select(i):
+ if not (s.help or s.modal or s.palette or s.filtering): s.screen=SCREENS[i]
+ r.tabs(w.TabsOptions(tabs=[f'{(i+1)%10} {name}' for i,name in enumerate(SCREENS)],active=SCREENS.index(s.screen)),select)
+ r.text(f'{"paused" if s.paused else "live"} {"simulated" if s.source=="simulated" else "real"} {s.fps:.0f}fps {s.clock} ',w.TextStyle(fg=t.warning if s.paused else t.success,align="right"))
+ ui.row(Layout(size=1),header);ui.spacer(1)
+ ui.column(Layout(size=max(0,ui.height-4)),lambda p:body(p,s));ui.spacer(1)
+ ui.status_bar(w.StatusBarOptions(items=[w.StatusItem("Help","F1"),w.StatusItem(f"Theme ({t.name})","F2"),w.StatusItem("Filter: "+s.filter+"_" if s.filtering else "Filter","F3",active=s.filtering),w.StatusItem("Sort: "+s.sort,"F6"),w.StatusItem("Palette","^K"),w.StatusItem("Screen","Tab"),w.StatusItem("Quit","q")],right=[w.StatusItem(f'{s.render_ms:.2f}ms {s.changed_cells} cells {s.output_bytes}B')]))
+ if s.help: ui.modal(w.ModalOptions(title="hqtui — Help",message="1–9/0 / Tab: screen\nF2 theme · F3 filter · F6 sort\nCtrl+K palette · Space pause\nArrows / PgUp / PgDn / Home / End: scroll\nMouse tabs, controls, selection and wheel\ne edits text · Esc finishes\nq / Ctrl+C quit · Any key closes help"))
+ if s.modal: ui.modal(w.ModalOptions(title="Read-only Demo",message="No process will be killed and no service changed.\nPress any key to close."))
+ if s.palette: ui.command_palette(w.CommandPaletteOptions(query=s.palette_query,items=[w.PaletteItem(label) for label,hint in s.commands()],selected=s.palette_index))
diff --git a/ports/python/pyproject.toml b/ports/python/pyproject.toml
index a11d063..0c25c98 100644
--- a/ports/python/pyproject.toml
+++ b/ports/python/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hqtui"
-version = "0.1.11"
+version = "0.1.12"
description = "High Quality Terminal UI. btop-grade dashboards with a one-import API, dark by default, zero dependencies."
readme = "README.md"
license = { text = "MIT" }
diff --git a/ports/python/tests/test_demo.py b/ports/python/tests/test_demo.py
index 4acfa58..ace0bec 100644
--- a/ports/python/tests/test_demo.py
+++ b/ports/python/tests/test_demo.py
@@ -29,7 +29,8 @@ def test_all_screens_and_themes(self):
state.screen=screen; state.theme_index=i
frame=self.snapshot(state)
self.assertTrue(frame.contains("hqtui"))
- self.assertTrue(frame.contains(screen))
+ title=dict(zip(SCREENS,("CPU Overview","Protocols","Active Sessions","Connections","Filesystems","Buttons & Inputs","Braille (2×4","Theme ","Last Events","Full-screen churn")))[screen]
+ self.assertTrue(frame.contains(title))
self.assertEqual(len(frame.text().splitlines()),40)
def test_small_and_large_screens(self):
@@ -75,21 +76,32 @@ def test_dropdown(self):
self.assertEqual(state.theme_index,1); self.assertFalse(state.select_open)
def test_panes_are_independent_and_click_uses_offset(self):
- state=self.state(); frame=self.snapshot(state,160,50)
+ state=self.state(); frame=self.snapshot(state,120,40)
process=state.panes["dashboard.processes"]; logs=state.panes["dashboard.logs"]
state.focused[state.screen]="dashboard.processes"; state.key("end")
- frame=self.snapshot(state,160,50)
+ frame=self.snapshot(state,120,40)
self.assertGreater(process.offset,0); self.assertEqual(logs.selected,0)
regions=[r for r in frame.regions if r.on_scroll]
proc_region=regions[0]; proc_region.on_click(0,1,"left")
self.assertEqual(process.selected,process.offset)
- regions[1].on_scroll(3); self.assertEqual(logs.selected,3)
+ regions[1].on_scroll(-3); self.assertEqual(logs.offset,3);self.assertEqual(logs.selected,0)
def test_controls_click(self):
state=self.state("components"); frame=self.snapshot(state)
- region=next(r for r in frame.regions if r.on_click and r.rect.y>4)
+ position=frame.find("Primary");self.assertIsNotNone(position)
+ x,y=position
+ region=next(r for r in reversed(frame.regions) if r.on_click and r.rect.x<=x