@profullstack/hqtui-demo 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import type { Container, Theme } from "@profullstack/hqtui";
2
- import { cursor, type DemoState } from "../state.ts";
2
+ import { focusPane, pane, scrollPane, type DemoState } from "../state.ts";
3
3
  import { bytes, num, percent } from "../format.ts";
4
4
 
5
5
  function rate(value: number): string {
@@ -23,11 +23,15 @@ export function servicesScreen(ui: Container, state: DemoState, theme: Theme): v
23
23
  p.label("systemd not available on this host.");
24
24
  return;
25
25
  }
26
+ const units = pane(state, "services.units", t.services.length);
26
27
  p.table({
27
28
  rows: t.services,
28
- selected: cursor(state).selected,
29
- offset: cursor(state).offset,
29
+ selected: units.selected,
30
+ offset: units.offset,
30
31
  followSelection: true,
32
+ onScroll: (delta) => scrollPane(units, delta),
33
+ onFocus: () => focusPane(state, "services.units"),
34
+ onSelectRow: (row) => { units.selected = units.offset + row; },
31
35
  zebra: true,
32
36
  scrollbar: true,
33
37
  columns: [
@@ -66,8 +70,16 @@ export function servicesScreen(ui: Container, state: DemoState, theme: Theme): v
66
70
  p.label("(docker not installed or not reachable)", { size: 1 });
67
71
  return;
68
72
  }
73
+ const containers = pane(state, "services.containers", t.containers.length);
69
74
  p.table({
70
75
  rows: t.containers,
76
+ selected: containers.selected,
77
+ offset: containers.offset,
78
+ followSelection: true,
79
+ scrollbar: true,
80
+ onScroll: (delta) => scrollPane(containers, delta),
81
+ onFocus: () => focusPane(state, "services.containers"),
82
+ onSelectRow: (row) => { containers.selected = containers.offset + row; },
71
83
  zebra: true,
72
84
  columns: [
73
85
  { key: "name", title: "Name", min: 12, color: theme.primary },
@@ -106,8 +118,16 @@ export function servicesScreen(ui: Container, state: DemoState, theme: Theme): v
106
118
  p.label("No filesystems reported.");
107
119
  return;
108
120
  }
121
+ const filesystems = pane(state, "services.filesystems", t.filesystems.length);
109
122
  p.table({
110
123
  rows: t.filesystems,
124
+ selected: filesystems.selected,
125
+ offset: filesystems.offset,
126
+ followSelection: true,
127
+ scrollbar: true,
128
+ onScroll: (delta) => scrollPane(filesystems, delta),
129
+ onFocus: () => focusPane(state, "services.filesystems"),
130
+ onSelectRow: (row) => { filesystems.selected = filesystems.offset + row; },
111
131
  zebra: true,
112
132
  columns: [
113
133
  { key: "mount", title: "Mount", min: 14, color: theme.primary },
@@ -1,5 +1,5 @@
1
1
  import type { Container, Theme } from "@profullstack/hqtui";
2
- import { cursor, type DemoState } from "../state.ts";
2
+ import { focusPane, pane, scrollPane, type DemoState } from "../state.ts";
3
3
 
4
4
  /** Who is on this machine, who has been, and who failed to get in. */
5
5
  export function sessionsScreen(ui: Container, state: DemoState, theme: Theme): void {
@@ -12,8 +12,16 @@ export function sessionsScreen(ui: Container, state: DemoState, theme: Theme): v
12
12
  p.label("(`who` reports nothing on this host)", { size: 1 });
13
13
  return;
14
14
  }
15
+ const active = pane(state, "sessions.active", t.sessions.length);
15
16
  p.table({
16
17
  rows: t.sessions,
18
+ selected: active.selected,
19
+ offset: active.offset,
20
+ followSelection: true,
21
+ scrollbar: true,
22
+ onScroll: (delta) => scrollPane(active, delta),
23
+ onFocus: () => focusPane(state, "sessions.active"),
24
+ onSelectRow: (row) => { active.selected = active.offset + row; },
17
25
  columns: [
18
26
  { key: "user", title: "User", width: 12, color: theme.primary },
19
27
  { key: "tty", title: "TTY", width: 10 },
@@ -40,11 +48,15 @@ export function sessionsScreen(ui: Container, state: DemoState, theme: Theme): v
40
48
  p.label("No login history available.");
41
49
  return;
42
50
  }
51
+ const logins = pane(state, "sessions.logins", t.logins.length);
43
52
  p.table({
44
53
  rows: t.logins,
45
- selected: cursor(state).selected,
46
- offset: cursor(state).offset,
47
- followSelection: true,
54
+ selected: logins.selected,
55
+ offset: logins.offset,
56
+ followSelection: true,
57
+ onScroll: (delta) => scrollPane(logins, delta),
58
+ onFocus: () => focusPane(state, "sessions.logins"),
59
+ onSelectRow: (row) => { logins.selected = logins.offset + row; },
48
60
  zebra: true,
49
61
  scrollbar: true,
50
62
  columns: [
@@ -69,8 +81,16 @@ export function sessionsScreen(ui: Container, state: DemoState, theme: Theme): v
69
81
  p.label("(btmp is usually root-only)", { size: 1 });
70
82
  return;
71
83
  }
84
+ const failed = pane(state, "sessions.failed", t.failedLogins.length);
72
85
  p.table({
73
86
  rows: t.failedLogins,
87
+ selected: failed.selected,
88
+ offset: failed.offset,
89
+ followSelection: true,
90
+ scrollbar: true,
91
+ onScroll: (delta) => scrollPane(failed, delta),
92
+ onFocus: () => focusPane(state, "sessions.failed"),
93
+ onSelectRow: (row) => { failed.selected = failed.offset + row; },
74
94
  columns: [
75
95
  { key: "user", title: "User", width: 12, color: theme.danger },
76
96
  { key: "from", title: "From", min: 12 },
@@ -1,6 +1,6 @@
1
1
  import type { Container, Theme } from "@profullstack/hqtui";
2
2
  import { seriesColor } from "@profullstack/hqtui";
3
- import { cursor, type DemoState } from "../state.ts";
3
+ import { focusPane, pane, scrollPane, type DemoState } from "../state.ts";
4
4
  import { num, percent } from "../format.ts";
5
5
 
6
6
  function rate(value: number): string {
@@ -116,8 +116,16 @@ export function trafficScreen(ui: Container, state: DemoState, theme: Theme): vo
116
116
  { labelWidth: 5, valueWidth: 7 },
117
117
  );
118
118
  p.divider({ label: "top paths" });
119
+ const paths = pane(state, "traffic.paths", http.topPaths.length);
119
120
  p.table({
120
121
  rows: http.topPaths,
122
+ selected: paths.selected,
123
+ offset: paths.offset,
124
+ followSelection: true,
125
+ scrollbar: true,
126
+ onScroll: (delta) => scrollPane(paths, delta),
127
+ onFocus: () => focusPane(state, "traffic.paths"),
128
+ onSelectRow: (row) => { paths.selected = paths.offset + row; },
121
129
  header: false,
122
130
  columns: [
123
131
  { key: "path", title: "Path", min: 20, color: theme.primary },
@@ -133,11 +141,15 @@ export function trafficScreen(ui: Container, state: DemoState, theme: Theme): vo
133
141
  p.label("No sshd events in the journal.");
134
142
  return;
135
143
  }
144
+ const ssh = pane(state, "traffic.ssh", t.ssh.length);
136
145
  p.table({
137
146
  rows: [...t.ssh].reverse(),
138
- selected: cursor(state).selected,
139
- offset: cursor(state).offset,
147
+ selected: ssh.selected,
148
+ offset: ssh.offset,
140
149
  followSelection: true,
150
+ onScroll: (delta) => scrollPane(ssh, delta),
151
+ onFocus: () => focusPane(state, "traffic.ssh"),
152
+ onSelectRow: (row) => { ssh.selected = ssh.offset + row; },
141
153
  zebra: true,
142
154
  scrollbar: true,
143
155
  columns: [
@@ -163,8 +175,16 @@ export function trafficScreen(ui: Container, state: DemoState, theme: Theme): vo
163
175
  p.label("No remote peers.");
164
176
  return;
165
177
  }
178
+ const remotes = pane(state, "traffic.remotes", t.remotes.length);
166
179
  p.table({
167
180
  rows: t.remotes,
181
+ selected: remotes.selected,
182
+ offset: remotes.offset,
183
+ followSelection: true,
184
+ scrollbar: true,
185
+ onScroll: (delta) => scrollPane(remotes, delta),
186
+ onFocus: () => focusPane(state, "traffic.remotes"),
187
+ onSelectRow: (row) => { remotes.selected = remotes.offset + row; },
168
188
  zebra: true,
169
189
  columns: [
170
190
  { key: "host", title: "Host", min: 16, color: theme.accent },
@@ -178,8 +198,15 @@ export function trafficScreen(ui: Container, state: DemoState, theme: Theme): vo
178
198
 
179
199
  if (http && http.recent.length) {
180
200
  ui.panel({ title: "Recent Requests", size: 10, borderColor: theme.primary }, (p) => {
201
+ const requests = pane(state, "traffic.requests", http.recent.length);
181
202
  p.table({
182
203
  rows: http.recent,
204
+ selected: requests.selected,
205
+ offset: requests.offset,
206
+ followSelection: true,
207
+ onScroll: (delta) => scrollPane(requests, delta),
208
+ onFocus: () => focusPane(state, "traffic.requests"),
209
+ onSelectRow: (row) => { requests.selected = requests.offset + row; },
183
210
  zebra: true,
184
211
  scrollbar: true,
185
212
  columns: [
package/src/state.ts CHANGED
@@ -18,10 +18,13 @@ export interface DemoState {
18
18
  /** Why hardware sensors are missing on this host, when they are. */
19
19
  sensorNote: string;
20
20
  /**
21
- * One cursor per screen. A single shared selection meant the arrow keys moved
22
- * an index the visible table did not use, so its scrollbar never moved.
21
+ * One cursor per scrollable pane, not per screen. Sharing a cursor across a
22
+ * screen meant only one of its lists could ever be driven, so the second
23
+ * scrollbar on a screen sat there doing nothing.
23
24
  */
24
- cursors: Record<ScreenName, { selected: number; offset: number }>;
25
+ panes: Record<string, Pane>;
26
+ /** Which pane the arrow keys drive, per screen. Clicking a pane sets it. */
27
+ focused: Partial<Record<ScreenName, string>>;
25
28
  sort: "cpu" | "mem" | "pid" | "name";
26
29
  filter: string;
27
30
  filtering: boolean;
@@ -48,9 +51,67 @@ export interface DemoState {
48
51
  bytes: number;
49
52
  }
50
53
 
51
- /** The cursor for whichever screen is showing. */
52
- export function cursor(state: DemoState): { selected: number; offset: number } {
53
- return state.cursors[state.screen] ?? { selected: 0, offset: 0 };
54
+ export interface Pane {
55
+ selected: number;
56
+ offset: number;
57
+ /** Row count, refreshed by the screen each frame so keys can clamp. */
58
+ total: number;
59
+ /**
60
+ * A log has no selected row: the arrows move its window instead, and it
61
+ * counts backwards from the newest line rather than forwards from the first.
62
+ */
63
+ kind: "list" | "log";
64
+ }
65
+
66
+ /**
67
+ * The cursor for one scrollable, created on first use. Screens call this while
68
+ * drawing, which is also what registers the pane as existing.
69
+ */
70
+ export function pane(state: DemoState, id: string, total: number, kind: Pane["kind"] = "list"): Pane {
71
+ const existing = state.panes[id];
72
+ if (existing) {
73
+ existing.total = total;
74
+ return existing;
75
+ }
76
+ const created: Pane = { selected: 0, offset: 0, total, kind };
77
+ state.panes[id] = created;
78
+ // The first pane a screen draws is the one the arrows drive by default.
79
+ if (!state.focused[state.screen]) state.focused[state.screen] = id;
80
+ return created;
81
+ }
82
+
83
+ /** The pane the arrow keys act on, for the screen that is showing. */
84
+ export function focusedPane(state: DemoState): Pane | undefined {
85
+ const id = state.focused[state.screen];
86
+ return id ? state.panes[id] : undefined;
87
+ }
88
+
89
+ export function focusPane(state: DemoState, id: string): void {
90
+ state.focused[state.screen] = id;
91
+ }
92
+
93
+ /** Move a pane's window, dragging the selection so it stays inside. */
94
+ export function scrollPane(p: Pane, delta: number, rows = 3): void {
95
+ const max = Math.max(0, p.total - 1);
96
+ p.offset = Math.max(0, Math.min(p.offset + delta * rows, max));
97
+ if (p.kind !== "log") p.selected = Math.max(p.offset, Math.min(p.selected, max));
98
+ }
99
+
100
+ /**
101
+ * What the arrow keys do to the focused pane. Lists move the selection and let
102
+ * the widget scroll to follow it; logs have nothing to select, so they move
103
+ * their own window.
104
+ */
105
+ export function moveSelection(state: DemoState, delta: number): void {
106
+ const p = focusedPane(state);
107
+ if (!p || p.total === 0) return;
108
+ if (p.kind === "log") {
109
+ // Down means newer, which is a smaller distance from the end.
110
+ p.offset = Math.max(0, Math.min(p.total - 1, p.offset - delta));
111
+ return;
112
+ }
113
+ p.selected = Math.max(0, Math.min(p.total - 1, p.selected + delta));
114
+ p.offset = Math.max(0, Math.min(p.offset, Math.max(0, p.total - 1)));
54
115
  }
55
116
 
56
117
  export function createState(
@@ -65,9 +126,8 @@ export function createState(
65
126
  screen: "dashboard",
66
127
  source,
67
128
  unavailable,
68
- cursors: Object.fromEntries(
69
- SCREENS.map((screen) => [screen, { selected: 0, offset: 0 }]),
70
- ) as DemoState["cursors"],
129
+ panes: {},
130
+ focused: {},
71
131
  sort: "cpu",
72
132
  filter: "",
73
133
  filtering: false,