@yuneta/gobj-ui 7.23.28 → 7.23.30

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.
@@ -0,0 +1,184 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {
3
+ node_role,
4
+ node_status,
5
+ node_status_key,
6
+ node_badge_keys,
7
+ node_info_rows,
8
+ describe_backend_node,
9
+ describe_backend_tree,
10
+ } from "./gobj_tree_model.js";
11
+
12
+ /* A `view-gobj-tree` answer, trimmed to what the view reads. */
13
+ const BACKEND_TREE = {
14
+ "Yuno^my_yuno": {
15
+ fullname: "Yuno^my_yuno",
16
+ shortname: "Yuno^my_yuno",
17
+ gclass: "C_YUNO",
18
+ name: "my_yuno",
19
+ parent: "",
20
+ state: "ST_IDLE",
21
+ running: true,
22
+ playing: true,
23
+ service: true,
24
+ disabled: false,
25
+ volatil: false,
26
+ commands: true,
27
+ gobj_flags: ["gobj_flag_yuno", "gobj_flag_service"],
28
+ gobj_trace_level: ["machine"],
29
+ children: {
30
+ "C_TIMER^timer": {
31
+ shortname: "C_TIMER^timer",
32
+ gclass: "C_TIMER",
33
+ name: "timer",
34
+ parent: "Yuno^my_yuno",
35
+ state: "ST_IDLE",
36
+ running: true,
37
+ playing: false,
38
+ service: false,
39
+ disabled: false,
40
+ volatil: false,
41
+ gobj_flags: ["gobj_flag_pure_child"],
42
+ },
43
+ "C_TCP^conn": {
44
+ shortname: "C_TCP^conn",
45
+ gclass: "C_TCP",
46
+ name: "conn",
47
+ state: "ST_STOPPED",
48
+ running: false,
49
+ playing: false,
50
+ service: false,
51
+ disabled: true,
52
+ volatil: true,
53
+ bottom_gobj: "C_TCP^raw",
54
+ gobj_flags: ["gobj_flag_volatil"],
55
+ },
56
+ },
57
+ },
58
+ };
59
+
60
+ describe("describe_backend_tree", () => {
61
+ it("reads the root and its children out of the command answer", () => {
62
+ let root = describe_backend_tree(BACKEND_TREE);
63
+ expect(root.gclass).toBe("C_YUNO");
64
+ expect(root.is_yuno).toBe(true);
65
+ expect(root.children.length).toBe(2);
66
+ expect(root.children.map(c => c.gclass).sort())
67
+ .toEqual(["C_TCP", "C_TIMER"]);
68
+ });
69
+
70
+ it("a malformed answer is null, not an empty tree", () => {
71
+ expect(describe_backend_tree(null)).toBe(null);
72
+ expect(describe_backend_tree({})).toBe(null);
73
+ expect(describe_backend_tree([])).toBe(null);
74
+ });
75
+
76
+ it("takes pure_child from gobj_flags, which has no boolean of its own", () => {
77
+ let root = describe_backend_tree(BACKEND_TREE);
78
+ let timer = root.children.find(c => c.gclass === "C_TIMER");
79
+ expect(timer.pure_child).toBe(true);
80
+ expect(timer.service).toBe(false);
81
+ });
82
+
83
+ it("a node with no children answers an empty list, not undefined", () => {
84
+ let d = describe_backend_node("C_X^x", {gclass: "C_X"}, false);
85
+ expect(d.children).toEqual([]);
86
+ });
87
+ });
88
+
89
+ describe("node_role", () => {
90
+ it("the yuno wins over every other mark", () => {
91
+ expect(node_role({is_yuno: true, service: true, volatil: true}))
92
+ .toBe("yuno");
93
+ });
94
+
95
+ it("orders service over volatil over pure", () => {
96
+ expect(node_role({service: true, volatil: true})).toBe("service");
97
+ expect(node_role({volatil: true, pure_child: true})).toBe("volatil");
98
+ expect(node_role({pure_child: true})).toBe("pure");
99
+ expect(node_role({})).toBe("child");
100
+ });
101
+ });
102
+
103
+ describe("node_status", () => {
104
+ it("tells the three states apart", () => {
105
+ expect(node_status({running: false, playing: false})).toBe("stopped");
106
+ expect(node_status({running: true, playing: false})).toBe("running");
107
+ expect(node_status({running: true, playing: true})).toBe("playing");
108
+ });
109
+
110
+ it("says PAUSED for the one that runs and does not play", () => {
111
+ expect(node_status_key({running: true, playing: false}))
112
+ .toBe("running (paused)");
113
+ expect(node_status_key({running: true, playing: true}))
114
+ .toBe("playing");
115
+ });
116
+
117
+ it("a gobj that plays without running cannot happen, and reads stopped", () => {
118
+ expect(node_status({running: false, playing: true})).toBe("stopped");
119
+ });
120
+ });
121
+
122
+ describe("node_badge_keys", () => {
123
+ it("marks the role and every extra fact, in a fixed order", () => {
124
+ let root = describe_backend_tree(BACKEND_TREE);
125
+ expect(node_badge_keys(root)).toEqual(["yuno", "commands"]);
126
+
127
+ let tcp = root.children.find(c => c.gclass === "C_TCP");
128
+ expect(node_badge_keys(tcp))
129
+ .toEqual(["volatil child", "disabled", "bottom"]);
130
+ });
131
+
132
+ it("a plain child carries no role badge", () => {
133
+ expect(node_badge_keys({})).toEqual([]);
134
+ });
135
+ });
136
+
137
+ describe("node_info_rows", () => {
138
+ const rows_of = (d) => {
139
+ let map = {};
140
+ for(let [label, value] of node_info_rows(d, null)) {
141
+ map[label] = value;
142
+ }
143
+ return map;
144
+ };
145
+
146
+ it("names the role and the status with the reader's words", () => {
147
+ let root = describe_backend_tree(BACKEND_TREE);
148
+ let timer = root.children.find(c => c.gclass === "C_TIMER");
149
+ let r = rows_of(timer);
150
+ expect(r["role"]).toBe("pure child");
151
+ expect(r["status"]).toBe("running (paused)");
152
+ expect(r["state"]).toBe("ST_IDLE");
153
+ });
154
+
155
+ it("shows the facts a card has no room for", () => {
156
+ let root = describe_backend_tree(BACKEND_TREE);
157
+ let tcp = root.children.find(c => c.gclass === "C_TCP");
158
+ let r = rows_of(tcp);
159
+ expect(r["disabled"]).toBe("yes");
160
+ expect(r["bottom gobj"]).toBe("C_TCP^raw");
161
+ expect(r["status"]).toBe("stopped");
162
+ });
163
+
164
+ it("carries the traces and the flags the backend reports", () => {
165
+ let r = rows_of(describe_backend_tree(BACKEND_TREE));
166
+ expect(r["traces"]).toBe("machine");
167
+ expect(r["flags"]).toBe("gobj_flag_yuno, gobj_flag_service");
168
+ });
169
+
170
+ it("omits every row whose fact is absent", () => {
171
+ let r = rows_of(describe_backend_node("C_X^x", {gclass: "C_X"}, false));
172
+ expect(r["bottom gobj"]).toBeUndefined();
173
+ expect(r["traces"]).toBeUndefined();
174
+ expect(r["disabled"]).toBeUndefined();
175
+ expect(r["children"]).toBeUndefined();
176
+ });
177
+
178
+ it("says how many children are hidden when the node is folded", () => {
179
+ let root = describe_backend_tree(BACKEND_TREE);
180
+ expect(rows_of(root)["children"]).toBe("2");
181
+ root.is_collapsed = true;
182
+ expect(rows_of(root)["children"]).toBe("2 (collapsed)");
183
+ });
184
+ });
package/src/lib_graph.css CHANGED
@@ -273,6 +273,34 @@
273
273
  * wherever a graph is.
274
274
  */
275
275
 
276
+ /*
277
+ * The gobj tree's popover close button, for a FINGER.
278
+ *
279
+ * It is an 18px glyph with 4px of padding either side -- about 26x18
280
+ * of target, which a mouse hits and a thumb does not. The popover
281
+ * opens on a tap, so the thing that closes it has to be reachable by
282
+ * the same thumb that opened it.
283
+ *
284
+ * 44px is the size every touch guideline settles on, and it is applied
285
+ * only under `(pointer: coarse)`: on a mouse the small button is fine
286
+ * and a 44px square in a 26px header row would not be.
287
+ *
288
+ * `!important` because the button carries an inline `cssText` -- a
289
+ * media query has no way to win against one, and the size of a target
290
+ * is not a preference.
291
+ */
292
+ @media (pointer: coarse) {
293
+ .GOBJ_TREE_POPOVER_CLOSE {
294
+ min-width: 44px !important;
295
+ min-height: 44px !important;
296
+ font-size: 26px !important;
297
+ padding: 0 !important;
298
+ display: inline-flex !important;
299
+ align-items: center !important;
300
+ justify-content: center !important;
301
+ }
302
+ }
303
+
276
304
  /*
277
305
  * Picking the node to anchor: the pointer says a click is expected,
278
306
  * which is otherwise announced only by a button at the far end of a
@@ -299,12 +327,20 @@
299
327
  * things down one channel. `outline` draws OUTSIDE the box, so it
300
328
  * cannot move a single row.
301
329
  *
330
+ * DASHED, and the same dash the treedb graph draws inline for its own
331
+ * cards: there, amber already means a find match or a focused topic
332
+ * and blue means the selection, so a third SOLID ring would be a third
333
+ * thing to learn in one channel. A dash is a different channel, and it
334
+ * reads as the provisional thing a camera state is. One feature, one
335
+ * drawing, in all three viewers.
336
+ *
302
337
  * Amber, the same "this one, right now" the toolbar's arming state
303
338
  * uses, and not one of the palette's action colours -- it is not an
304
339
  * action.
305
340
  */
306
- .JSON_CARD_ANCHORED {
307
- outline: 3px solid #fa8c16;
341
+ .JSON_CARD_ANCHORED,
342
+ .GOBJ_CARD_ANCHORED {
343
+ outline: 3px dashed #fa8c16;
308
344
  outline-offset: 2px;
309
345
  }
310
346
 
@@ -0,0 +1,277 @@
1
+ /***********************************************************************
2
+ * yui_gclass_view.js
3
+ *
4
+ * The gclass viewer: what a GClass IS, in a window.
5
+ *
6
+ * The framework had no such viewer. The C kernel answers
7
+ * `view-gclass` with a full description (attrs, commands,
8
+ * methods, FSM) and the only reader of it was a terminal.
9
+ * This one draws that same document with C_YUI_JSON, so the
10
+ * FSM of a gclass is a graph and its attrs table is a tree,
11
+ * and it takes the document from either side:
12
+ *
13
+ * - a gclass of the browser yuno -> describe_local_gclass()
14
+ * - a gclass of a backend yuno -> `opts.description`, the
15
+ * answer of `view-gclass`
16
+ *
17
+ * One window at a time per host: the caller keeps the handle
18
+ * and closes it, the way the treedb graph keeps its raw-JSON
19
+ * viewer.
20
+ *
21
+ * Copyright (c) 2026, ArtGins.
22
+ * All Rights Reserved.
23
+ ***********************************************************************/
24
+ import {
25
+ clean_name,
26
+ gclass_find_by_name,
27
+ gobj_create_service,
28
+ gobj_destroy,
29
+ gobj_find_service,
30
+ gobj_is_running,
31
+ gobj_name,
32
+ gobj_read_pointer_attr,
33
+ gobj_short_name,
34
+ gobj_start,
35
+ gobj_stop,
36
+ is_gobj,
37
+ log_error,
38
+ log_warning,
39
+ } from "@yuneta/gobj-js";
40
+
41
+ import {describe_local_gclass} from "./gclass_describe.js";
42
+ import {yui_shell_show_error, yui_shell_show_modal, yui_shell_popup_layer} from "./shell_modals.js";
43
+ import {yui_shell_of} from "./c_yui_shell.js";
44
+
45
+ import {t} from "i18next";
46
+
47
+
48
+ /************************************************************
49
+ * Can this app show a gclass at all?
50
+ *
51
+ * The viewer is C_YUI_JSON, and an app registers only the
52
+ * gclasses it mounts. The caller asks this BEFORE drawing a
53
+ * button, so an app that does not carry the viewer shows no
54
+ * control instead of a control that fails.
55
+ ************************************************************/
56
+ function gclass_view_available()
57
+ {
58
+ return !!gclass_find_by_name("C_YUI_JSON", false);
59
+ }
60
+
61
+
62
+ /************************************************************
63
+ * Open the viewer on `gclass_name`.
64
+ *
65
+ * opts:
66
+ * description the document to draw. Omitted, it is
67
+ * built from the browser registry.
68
+ * title_prefix what the window says before the gclass
69
+ * name (the yuno it belongs to).
70
+ * on_close called when the reader dismisses it.
71
+ *
72
+ * Returns a handle for close_gclass_view(), or null (and a
73
+ * logged reason) when there is nothing to show.
74
+ ************************************************************/
75
+ function open_gclass_view(host, gclass_name, opts)
76
+ {
77
+ opts = opts || {};
78
+
79
+ if(!host || !gclass_name) {
80
+ log_error(`yui_gclass_view: open without a host or a gclass name`);
81
+ return null;
82
+ }
83
+
84
+ let shell = yui_shell_of(host);
85
+
86
+ if(!gclass_view_available()) {
87
+ log_error(`${gobj_short_name(host)}: C_YUI_JSON not registered by the app`);
88
+ yui_shell_show_error(shell, "gclass viewer unavailable", {t: t});
89
+ return null;
90
+ }
91
+
92
+ let description = opts.description || describe_local_gclass(gclass_name);
93
+ if(!description) {
94
+ /* Not an error: a gclass of a BACKEND yuno is not in this
95
+ * registry and never will be. It is the caller's job to bring
96
+ * the description; say which gclass could not be found. */
97
+ log_warning(`${gobj_short_name(host)}: gclass not registered here: ${gclass_name}`);
98
+ yui_shell_show_error(shell, "gclass not registered in this yuno", {t: t});
99
+ return null;
100
+ }
101
+
102
+ let jv = gobj_create_service(
103
+ `gclass-view-${clean_name(gobj_name(host))}`,
104
+ "C_YUI_JSON",
105
+ {
106
+ /* No `title`: the host titles it -- the window's title bar
107
+ * on desktop, the dialog's header on mobile. The viewer's
108
+ * own title would land INSIDE that host, doubling it. */
109
+ json_data: description,
110
+ subscriber: null,
111
+ },
112
+ host
113
+ );
114
+ if(!jv) {
115
+ log_error(`${gobj_short_name(host)}: cannot create the gclass viewer`);
116
+ return null;
117
+ }
118
+ gobj_start(jv);
119
+
120
+ let handle = {json_gobj: jv, win: null, modal: null};
121
+ let $box = gobj_read_pointer_attr(jv, "$container");
122
+
123
+ let on_close = () => {
124
+ if(typeof opts.on_close === "function") {
125
+ opts.on_close();
126
+ }
127
+ };
128
+
129
+ if(is_mobile()) {
130
+ if(!shell) {
131
+ log_error(`${gobj_short_name(host)}: no shell, cannot open the gclass sheet`);
132
+ close_gclass_view(handle);
133
+ return null;
134
+ }
135
+ handle.modal = yui_shell_show_modal(shell, $box, {
136
+ dialog: true,
137
+ logical_class: "GCLASS_VIEW_SHEET",
138
+ title_prefix: opts.title_prefix || "",
139
+ title: gclass_name,
140
+ t: t,
141
+ on_close: on_close,
142
+ });
143
+ return handle;
144
+ }
145
+
146
+ let $win_parent = window_layer_of(host, shell);
147
+
148
+ handle.win = gobj_create_service(
149
+ `gclass-win-${clean_name(gobj_name(host))}`,
150
+ "C_YUI_WINDOW",
151
+ {
152
+ $parent: $win_parent,
153
+ subscriber: null,
154
+ modal: false,
155
+ showMax: true,
156
+ showFooter: false,
157
+ resizable: true,
158
+ center: true,
159
+ auto_save_size_and_position: true,
160
+ width: 720,
161
+ height: 640,
162
+ logical_class: "GCLASS_VIEW_WINDOW",
163
+ title_prefix: opts.title_prefix || "",
164
+ title: gclass_name,
165
+ icon: "yi-code",
166
+ body: $box,
167
+ /* The same dock the other windows are on: raising a window
168
+ * is the manager's job, and a viewer registered nowhere
169
+ * cannot come to the front when the reader clicks it. `||
170
+ * null` because gobj_find_service answers undefined when
171
+ * absent, and an undefined attr logs "attr undefined". */
172
+ manager: gobj_find_service("__window_manager__", false) || null,
173
+ on_close: on_close,
174
+ },
175
+ host
176
+ );
177
+ if(!handle.win) {
178
+ log_error(`${gobj_short_name(host)}: cannot create the gclass window`);
179
+ close_gclass_view(handle);
180
+ return null;
181
+ }
182
+ gobj_start(handle.win);
183
+
184
+ return handle;
185
+ }
186
+
187
+ /************************************************************
188
+ * Close whatever open_gclass_view() opened. Safe on null and
189
+ * on a handle whose window the reader already dismissed.
190
+ ************************************************************/
191
+ function close_gclass_view(handle)
192
+ {
193
+ if(!handle) {
194
+ return;
195
+ }
196
+
197
+ let jv = handle.json_gobj;
198
+ let win = handle.win;
199
+ let modal = handle.modal;
200
+
201
+ handle.json_gobj = null;
202
+ handle.win = null;
203
+ handle.modal = null;
204
+
205
+ if(win && is_gobj(win)) {
206
+ try {
207
+ /* STOP, then destroy: gobj_destroy() raises the `destroying`
208
+ * flag before it can stop a running gobj, so destroying it
209
+ * straight logs two errors and skips mt_stop. The ✕ path
210
+ * already stopped it (close_window) -- hence the guard. */
211
+ if(gobj_is_running(win)) {
212
+ gobj_stop(win);
213
+ }
214
+ gobj_destroy(win);
215
+ } catch(e) {
216
+ log_warning(`yui_gclass_view: window already gone: ${e}`);
217
+ }
218
+ }
219
+ if(modal && typeof modal.close === "function") {
220
+ try {
221
+ modal.close();
222
+ } catch(e) {
223
+ log_warning(`yui_gclass_view: modal already gone: ${e}`);
224
+ }
225
+ }
226
+ if(jv && is_gobj(jv)) {
227
+ try {
228
+ if(gobj_is_running(jv)) {
229
+ gobj_stop(jv);
230
+ }
231
+ gobj_destroy(jv);
232
+ } catch(e) {
233
+ log_warning(`yui_gclass_view: viewer already gone: ${e}`);
234
+ }
235
+ }
236
+ }
237
+
238
+ /************************************************************
239
+ * Where to mount the window: the SAME element the caller's
240
+ * own window hangs from.
241
+ *
242
+ * Not a preference -- a requirement. z-index only orders
243
+ * siblings of one stacking context, so a viewer opened from a
244
+ * window on `body` and mounted in the shell's popup layer
245
+ * lands BEHIND its opener whatever number it is given, and
246
+ * the button reads as one that did nothing.
247
+ ************************************************************/
248
+ function window_layer_of(host, shell)
249
+ {
250
+ let $c = gobj_read_pointer_attr(host, "$container");
251
+ if($c && typeof $c.closest === "function") {
252
+ let $win = $c.closest(".C_YUI_WINDOW");
253
+ if($win && $win.parentNode) {
254
+ return $win.parentNode;
255
+ }
256
+ }
257
+
258
+ return (typeof document !== "undefined" &&
259
+ document.getElementById("top-layer")) ||
260
+ (shell && yui_shell_popup_layer(shell)) ||
261
+ null;
262
+ }
263
+
264
+ /************************************************************
265
+ * A window is not a sheet: below this width there is no room
266
+ * for a title bar the reader can drag.
267
+ ************************************************************/
268
+ function is_mobile()
269
+ {
270
+ return typeof window !== "undefined" && window.innerWidth <= 768;
271
+ }
272
+
273
+ export {
274
+ gclass_view_available,
275
+ open_gclass_view,
276
+ close_gclass_view,
277
+ };
@@ -235,6 +235,66 @@ export function yui_graph_update_anchor($container, state)
235
235
  ************************************************************/
236
236
  const __centring__ = new WeakMap();
237
237
 
238
+ /************************************************************
239
+ * Put `node_id` at a GIVEN point of the viewport.
240
+ *
241
+ * What "the graph does not jump when I fold something" is
242
+ * made of: read where the card is, rebuild the layout, put
243
+ * that card back where it was. Everything that did not move
244
+ * in the layout stays put with it.
245
+ ************************************************************/
246
+ export async function yui_graph_place_at(graph, node_id, viewport_xy)
247
+ {
248
+ if(!graph || !node_id || !viewport_xy) {
249
+ return false;
250
+ }
251
+ try {
252
+ if(typeof graph.getElementPosition !== "function" ||
253
+ typeof graph.getCanvasCenter !== "function" ||
254
+ typeof graph.translateTo !== "function") {
255
+ return false;
256
+ }
257
+ let z = graph.getZoom() || 1;
258
+ let c = graph.getCanvasCenter();
259
+ let n = graph.getElementPosition(node_id);
260
+
261
+ /*
262
+ * Same derivation as the centring below, with the target point
263
+ * left free instead of fixed at the middle:
264
+ *
265
+ * T = (canvasCentre - node) * zoom + (V - canvasCentre)
266
+ *
267
+ * and putting V = canvasCentre gives the centring exactly.
268
+ */
269
+ await graph.translateTo([
270
+ (c[0] - n[0]) * z + (viewport_xy[0] - c[0]),
271
+ (c[1] - n[1]) * z + (viewport_xy[1] - c[1])
272
+ ]);
273
+ return true;
274
+ } catch(e) {
275
+ return false; /* the element is gone, or the graph is between renders */
276
+ }
277
+ }
278
+
279
+ /************************************************************
280
+ * Where `node_id` is right now, in viewport pixels.
281
+ *
282
+ * The pair of the call above: read it before a rebuild, hand
283
+ * it back after, and the node has not moved on screen even
284
+ * though the layout under it changed.
285
+ ************************************************************/
286
+ export function yui_graph_viewport_of(graph, node_id)
287
+ {
288
+ if(!graph || !node_id) {
289
+ return null;
290
+ }
291
+ try {
292
+ return graph.getViewportByCanvas(graph.getElementPosition(node_id));
293
+ } catch(e) {
294
+ return null;
295
+ }
296
+ }
297
+
238
298
  export async function yui_graph_center_on(graph, node_id)
239
299
  {
240
300
  if(!graph || !node_id) {