@yuneta/gobj-ui 7.23.29 → 7.23.31

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.
@@ -26,22 +26,33 @@ import {
26
26
  kw_set_local_storage_value,
27
27
  gobj_write_str_attr,
28
28
  gobj_read_str_attr,
29
- gobj_publish_event,
30
29
  gobj_yuno,
31
- gobj_gclass_name,
30
+ gobj_yuno_name,
32
31
  gobj_short_name,
33
- gobj_full_name,
34
- gobj_current_state,
35
- gobj_is_running,
36
- gobj_is_playing,
37
- gobj_is_service,
38
- gobj_is_pure_child,
39
- gobj_is_volatil,
32
+ gobj_is_destroying,
33
+ gobj_unsubscribe_event,
40
34
  createElement2,
41
35
  escapeHtml,
36
+ log_warning,
42
37
  refresh_language,
43
38
  } from "@yuneta/gobj-js";
44
39
 
40
+ import {
41
+ describe_js_gobj,
42
+ node_badge_keys,
43
+ node_info_rows,
44
+ node_labels,
45
+ node_role,
46
+ node_status,
47
+ node_status_key,
48
+ } from "./gobj_tree_model.js";
49
+ import {
50
+ close_gclass_view,
51
+ gclass_view_available,
52
+ open_gclass_view,
53
+ } from "./yui_gclass_view.js";
54
+ import {yui_shell_of} from "./c_yui_shell.js";
55
+
45
56
  import {yui_toolbar} from "./yui_toolbar.js";
46
57
  import {
47
58
  yui_graph_camera_items,
@@ -249,6 +260,9 @@ let PRIVATE_DATA = {
249
260
  $popover: null,
250
261
  $popover_title: null,
251
262
  $popover_body: null,
263
+ $popover_gclass: null, // the "gclass" button, or null when unavailable
264
+ popover_node: null, // the descriptor the popover is showing
265
+ gclass_view: null, // handle of the open gclass window (or null)
252
266
  node_by_id: null, // map of node_id -> node_data (for popover)
253
267
  collapse_state: null, // map of full_name -> "collapsed" | "expanded"
254
268
  resize_observer: null, // ResizeObserver on the canvas mount element
@@ -274,20 +288,46 @@ let __gclass__ = null;
274
288
  let __layout_registered__ = false;
275
289
 
276
290
  /***************************************************************
277
- * Node colors by role
291
+ * Node colours by role.
292
+ *
293
+ * Saturated hues, one family apart from the next, and the same
294
+ * five everywhere the reader meets a gobj. `stroke` draws the
295
+ * border and the edge; `accent` is the brighter sibling used
296
+ * for the bar down the left of the card and the toggle badge,
297
+ * so a card carries its colour twice at two intensities and is
298
+ * readable at the zoom where the text is not.
278
299
  ***************************************************************/
279
300
  const ROLE_COLORS = {
280
- yuno: {fill: "#E0F2FE", stroke: "#075985"}, // blue
281
- service: {fill: "#DCFCE7", stroke: "#166534"}, // green
282
- pure: {fill: "#FEF9C3", stroke: "#854D0E"}, // yellow
283
- volatil: {fill: "#FCE7F3", stroke: "#9D174D"}, // pink
284
- default: {fill: "#F3F4F6", stroke: "#374151"}, // gray
301
+ yuno: {stroke: "#0284c7", accent: "#38bdf8"}, // sky
302
+ service: {stroke: "#059669", accent: "#34d399"}, // emerald
303
+ pure: {stroke: "#d97706", accent: "#fbbf24"}, // amber
304
+ volatil: {stroke: "#db2777", accent: "#f472b6"}, // pink
305
+ child: {stroke: "#7c3aed", accent: "#a78bfa"}, // violet
285
306
  };
286
307
 
287
- const STATE_COLORS = {
288
- running_playing: "#15803D", // green
289
- running: "#B45309", // amber
290
- stopped: "#B91C1C", // red
308
+ /***************************************************************
309
+ * Status colours: what the gobj is DOING.
310
+ *
311
+ * A pill and not a word: three states of one gobj are told
312
+ * apart across a whole tree by colour long before anybody
313
+ * reads them, and the dot alone never said which was which.
314
+ * `disabled` is a fourth entry and not a status -- a disabled
315
+ * gobj is still stopped or running -- but it takes the pill,
316
+ * because it is the fact that explains the other one.
317
+ ***************************************************************/
318
+ const STATUS_COLORS = {
319
+ light: {
320
+ playing: {fg: "#065f46", bg: "#a7f3d0", dot: "#10b981"},
321
+ running: {fg: "#92400e", bg: "#fde68a", dot: "#f59e0b"},
322
+ stopped: {fg: "#991b1b", bg: "#fecaca", dot: "#ef4444"},
323
+ disabled: {fg: "#334155", bg: "#e2e8f0", dot: "#94a3b8"},
324
+ },
325
+ dark: {
326
+ playing: {fg: "#6ee7b7", bg: "rgba(16,185,129,0.28)", dot: "#34d399"},
327
+ running: {fg: "#fcd34d", bg: "rgba(245,158,11,0.28)", dot: "#fbbf24"},
328
+ stopped: {fg: "#fca5a5", bg: "rgba(239,68,68,0.28)", dot: "#f87171"},
329
+ disabled: {fg: "#cbd5e1", bg: "rgba(148,163,184,0.28)", dot: "#94a3b8"},
330
+ },
291
331
  };
292
332
 
293
333
  /***************************************************************
@@ -297,9 +337,9 @@ const STATE_COLORS = {
297
337
  ***************************************************************/
298
338
  const NODE_SIZES = {
299
339
  full: {
300
- yuno: {w: 240, h: 72},
301
- service: {w: 210, h: 68},
302
- child: {w: 160, h: 60},
340
+ yuno: {w: 260, h: 96},
341
+ service: {w: 232, h: 92},
342
+ child: {w: 196, h: 86},
303
343
  },
304
344
  compact: {
305
345
  yuno: {w: 210, h: 30},
@@ -316,45 +356,64 @@ const GT_FONT =
316
356
  "Helvetica, Arial, sans-serif";
317
357
 
318
358
  /***************************************************************
319
- * Soft, theme-aware card palette derived from the role colour
320
- * (same visual language as the treedb graph cards): light tint
321
- * fill + role-colour border, brightened on dark.
359
+ * Theme-aware card palette derived from the role colour.
360
+ *
361
+ * Brighter than a tint: at 9% of the role colour every card
362
+ * was the same near-white rectangle and the tree read as a
363
+ * wireframe. The fill carries enough of the hue to be named
364
+ * from across the canvas, and the border and the accent bar
365
+ * carry the rest.
322
366
  ***************************************************************/
323
- function role_card_style(stroke, dark)
367
+ function role_card_style(colors, dark)
324
368
  {
325
- // On dark, lift the card off the near-black canvas with a
326
- // clearly-lighter slate base + vivid border (a faint tint on
327
- // the canvas colour makes the cards invisible).
369
+ let stroke = colors.stroke;
370
+ let accent = colors.accent;
371
+
328
372
  return {
329
373
  bg: dark
330
- ? `color-mix(in srgb, ${stroke} 30%, #2c3542)`
331
- : `color-mix(in srgb, ${stroke} 9%, #ffffff)`,
332
- border: dark
333
- ? `color-mix(in srgb, ${stroke} 85%, #ffffff)`
334
- : stroke,
335
- title: dark ? "#e8eaed" : "#0f172a",
336
- sub: dark ? "#9aa4b2" : "#64748b",
374
+ ? `color-mix(in srgb, ${stroke} 34%, #253044)`
375
+ : `color-mix(in srgb, ${stroke} 13%, #ffffff)`,
376
+ border: dark? accent: stroke,
377
+ accent: dark? accent: stroke,
378
+ title: dark ? "#f1f5f9" : "#0f172a",
379
+ sub: dark ? "#a8b3c2" : "#475569",
337
380
  tagbg: dark
338
- ? `color-mix(in srgb, ${stroke} 42%, #2c3542)`
339
- : `color-mix(in srgb, ${stroke} 14%, #ffffff)`,
381
+ ? `color-mix(in srgb, ${stroke} 48%, #253044)`
382
+ : `color-mix(in srgb, ${stroke} 20%, #ffffff)`,
383
+ tagfg: dark? "#f1f5f9": stroke,
340
384
  shadow: dark
341
- ? "0 1px 3px rgba(0,0,0,0.45)"
342
- : "0 1px 3px rgba(15,23,42,0.12)",
385
+ ? "0 2px 6px rgba(0,0,0,0.50)"
386
+ : "0 2px 5px rgba(15,23,42,0.16)",
343
387
  };
344
388
  }
345
389
 
346
390
  /***************************************************************
347
- * Map a gobj to its visual category.
391
+ * The pill colours of a node, for the theme in force.
348
392
  ***************************************************************/
349
- function get_node_category(target_gobj, is_root)
393
+ function status_card_style(d, dark)
350
394
  {
351
- if(is_root) {
352
- return "yuno";
395
+ let table = dark? STATUS_COLORS.dark: STATUS_COLORS.light;
396
+ if(d.disabled) {
397
+ return table.disabled;
353
398
  }
354
- if(gobj_is_service(target_gobj)) {
355
- return "service";
399
+ return table[node_status(d)] || table.stopped;
400
+ }
401
+
402
+ /***************************************************************
403
+ * Map a node to its visual size class. Services are drawn
404
+ * bigger than plain children so the service boundary of the
405
+ * hierarchy is visible before anything is read.
406
+ ***************************************************************/
407
+ function get_node_category(d)
408
+ {
409
+ switch(node_role(d)) {
410
+ case "yuno":
411
+ return "yuno";
412
+ case "service":
413
+ return "service";
414
+ default:
415
+ return "child";
356
416
  }
357
- return "child";
358
417
  }
359
418
 
360
419
  /***************************************************************
@@ -498,6 +557,17 @@ function mt_start(gobj)
498
557
  */
499
558
  restore_view_shape(gobj);
500
559
 
560
+ /* The cards carry translated text now (the status pill, the
561
+ * badges), and G6 draws them as innerHTML inside its own
562
+ * canvas: refresh_language() cannot reach in there, so a
563
+ * language switch is a REBUILD. Subscribe to the shell
564
+ * directly, like C_YUI_PERIOD -- a host that mounts a bare
565
+ * tree gets the repaint without having to forward anything. */
566
+ let shell = yui_shell_of(gobj);
567
+ if(shell) {
568
+ gobj_subscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
569
+ }
570
+
501
571
  build_graph(gobj);
502
572
  load_tree(gobj);
503
573
  }
@@ -507,6 +577,18 @@ function mt_start(gobj)
507
577
  ***************************************************************/
508
578
  function mt_stop(gobj)
509
579
  {
580
+ let priv = gobj.priv;
581
+
582
+ let shell = yui_shell_of(gobj);
583
+ if(shell) {
584
+ gobj_unsubscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
585
+ }
586
+
587
+ /* In mt_stop and NOT in mt_destroy: gobj_destroy() destroys the
588
+ * children BEFORE calling mt_destroy, so a hosted gobj retired
589
+ * there is retired after the framework already took it down. */
590
+ close_gclass_view(priv.gclass_view);
591
+ priv.gclass_view = null;
510
592
  }
511
593
 
512
594
  /***************************************************************
@@ -539,6 +621,8 @@ function mt_destroy(gobj)
539
621
  priv.$popover = null;
540
622
  priv.$popover_title = null;
541
623
  priv.$popover_body = null;
624
+ priv.$popover_gclass = null;
625
+ priv.popover_node = null;
542
626
  priv.$layout_select = null;
543
627
  priv.node_by_id = null;
544
628
  priv.collapse_state = null;
@@ -657,6 +741,42 @@ function build_popover(gobj, $container)
657
741
  `;
658
742
  $header.appendChild($title);
659
743
 
744
+ /* What this gobj IS: its gclass, opened in the gclass viewer.
745
+ *
746
+ * Built only where the app registers C_YUI_JSON -- the viewer
747
+ * the window hosts. An app that does not carry it gets no
748
+ * button rather than a button that fails, and the reason is
749
+ * logged once, at build time. */
750
+ if(gclass_view_available()) {
751
+ let $gclass = document.createElement("button");
752
+ $gclass.type = "button";
753
+ $gclass.className = "GOBJ_TREE_POPOVER_GCLASS button is-small";
754
+ $gclass.style.cssText = `
755
+ margin-right: 6px;
756
+ padding: 0 8px;
757
+ height: 22px;
758
+ font-size: 11px;
759
+ white-space: nowrap;
760
+ `;
761
+ $gclass.textContent = t("gclass");
762
+ $gclass.setAttribute("i18n", "gclass");
763
+ $gclass.title = t("view gclass");
764
+ $gclass.setAttribute("data-i18n-title", "view gclass");
765
+ $gclass.setAttribute("aria-label", t("view gclass"));
766
+ $gclass.setAttribute("data-i18n-aria-label", "view gclass");
767
+ $gclass.addEventListener("click", (evt) => {
768
+ evt.stopPropagation();
769
+ gobj_send_event(gobj, "EV_OPEN_GCLASS", {}, gobj);
770
+ });
771
+ $header.appendChild($gclass);
772
+ priv.$popover_gclass = $gclass;
773
+ } else {
774
+ log_warning(
775
+ `${gobj_short_name(gobj)}: no C_YUI_JSON registered, ` +
776
+ `the gclass viewer is not offered`
777
+ );
778
+ }
779
+
660
780
  let $close = document.createElement("button");
661
781
  $close.type = "button";
662
782
  /* Named so a stylesheet can reach it: the size a FINGER needs is a
@@ -783,6 +903,30 @@ function make_toolbar(gobj)
783
903
  return $toolbar;
784
904
  }
785
905
 
906
+ /************************************************************
907
+ * The element G6 mounts its canvas in.
908
+ *
909
+ * Read from THIS gclass's own $container, never with
910
+ * `document.getElementById()`: a view is not always in the
911
+ * document when it is started -- a host that builds the
912
+ * viewer and mounts it into a window afterwards starts it
913
+ * DETACHED -- and a global lookup answers null there. It cost
914
+ * a resize that never worked: the ResizeObserver below was
915
+ * simply never attached, silently, and the canvas kept the
916
+ * size it was born with for the life of the window.
917
+ *
918
+ * Null only if the UI was never built; that is a bug, and it
919
+ * is logged where it matters rather than skipped.
920
+ ************************************************************/
921
+ function canvas_mount(gobj)
922
+ {
923
+ let $container = gobj_read_attr(gobj, "$container");
924
+ if(!$container) {
925
+ return null;
926
+ }
927
+ return $container.querySelector("#" + gobj.priv.canvas_id);
928
+ }
929
+
786
930
  /************************************************************
787
931
  * Build a G6 graph instance
788
932
  ************************************************************/
@@ -871,8 +1015,10 @@ function build_graph(gobj)
871
1015
  * the observed box so there is no feedback loop.
872
1016
  */
873
1017
  if(typeof ResizeObserver !== "undefined") {
874
- let $canvas = document.getElementById(priv.canvas_id);
875
- if($canvas) {
1018
+ let $canvas = canvas_mount(gobj);
1019
+ if(!$canvas) {
1020
+ log_error(`${gobj_short_name(gobj)}: no canvas mount, the graph will never resize`);
1021
+ } else {
876
1022
  let ro = new ResizeObserver(() => {
877
1023
  if(priv._resize_raf) {
878
1024
  cancelAnimationFrame(priv._resize_raf);
@@ -917,37 +1063,26 @@ function apply_layout(gobj)
917
1063
  }
918
1064
 
919
1065
  /************************************************************
920
- * Resolve role colors for a gobj
921
- ************************************************************/
922
- function get_role_colors(target_gobj, is_root)
923
- {
924
- if(is_root) {
925
- return ROLE_COLORS.yuno;
926
- }
927
- if(gobj_is_volatil(target_gobj)) {
928
- return ROLE_COLORS.volatil;
929
- }
930
- if(gobj_is_service(target_gobj)) {
931
- return ROLE_COLORS.service;
932
- }
933
- if(gobj_is_pure_child(target_gobj)) {
934
- return ROLE_COLORS.pure;
935
- }
936
- return ROLE_COLORS.default;
937
- }
938
-
939
- /************************************************************
940
- * Resolve state color
1066
+ * The role colours of a node.
1067
+ *
1068
+ * Note the ORDER: volatil before service. A volatil gobj that
1069
+ * is also a service is drawn volatil, because that is the fact
1070
+ * that decides how long it lives.
941
1071
  ************************************************************/
942
- function get_state_color(target_gobj)
1072
+ function get_role_colors(d)
943
1073
  {
944
- if(!gobj_is_running(target_gobj)) {
945
- return STATE_COLORS.stopped;
946
- }
947
- if(gobj_is_playing(target_gobj)) {
948
- return STATE_COLORS.running_playing;
1074
+ switch(node_role(d)) {
1075
+ case "yuno":
1076
+ return ROLE_COLORS.yuno;
1077
+ case "volatil":
1078
+ return ROLE_COLORS.volatil;
1079
+ case "service":
1080
+ return ROLE_COLORS.service;
1081
+ case "pure":
1082
+ return ROLE_COLORS.pure;
1083
+ default:
1084
+ return ROLE_COLORS.child;
949
1085
  }
950
- return STATE_COLORS.running;
951
1086
  }
952
1087
 
953
1088
  /************************************************************
@@ -994,27 +1129,27 @@ function is_node_collapsed(gobj, full_name, num_children)
994
1129
  ************************************************************/
995
1130
  function render_toggle_html(node_id, collapsed, num_children, cs)
996
1131
  {
997
- let label = collapsed ? ("+" + num_children) : "−";
998
- let title = collapsed ? "Expand children" : "Collapse children";
1132
+ let label = collapsed ? ("+" + num_children) : "\u2212";
1133
+ let title = collapsed ? t("expand children") : t("collapse children");
999
1134
  return `<span
1000
1135
  data-gobj-toggle="true"
1001
1136
  data-node-id="${node_id}"
1002
- title="${title}"
1137
+ title="${escapeHtml(title)}"
1003
1138
  style="
1004
1139
  flex: 0 0 auto;
1005
1140
  display: inline-flex;
1006
1141
  align-items: center;
1007
1142
  justify-content: center;
1008
- min-width: 22px;
1009
- height: 16px;
1010
- padding: 0 5px;
1143
+ min-width: 24px;
1144
+ height: 18px;
1145
+ padding: 0 6px;
1011
1146
  margin-left: 4px;
1012
- background: ${cs.tagbg};
1013
- color: ${cs.border};
1014
- border: 1px solid ${cs.border};
1015
- border-radius: 6px;
1147
+ background: ${cs.accent};
1148
+ color: #ffffff;
1149
+ border: 1px solid ${cs.accent};
1150
+ border-radius: 9px;
1016
1151
  font-size: 10px;
1017
- font-weight: 600;
1152
+ font-weight: 700;
1018
1153
  line-height: 1;
1019
1154
  cursor: pointer;
1020
1155
  user-select: none;
@@ -1022,27 +1157,92 @@ function render_toggle_html(node_id, collapsed, num_children, cs)
1022
1157
  }
1023
1158
 
1024
1159
  /************************************************************
1025
- * Recursively build nodes and edges from the gobj tree
1160
+ * HTML for the status pill: what the gobj is DOING.
1161
+ *
1162
+ * A dot plus a word, and the word is dropped in the compact
1163
+ * card where there is no room for it -- the colour still
1164
+ * carries it, and the `title` says it in full either way.
1026
1165
  ************************************************************/
1027
- function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, compact)
1166
+ function render_status_html(d, ss, labels, with_label)
1167
+ {
1168
+ let full = d.disabled?
1169
+ labels.field.disabled:
1170
+ (labels.status[node_status_key(d)] || node_status_key(d));
1171
+ let label = d.disabled?
1172
+ labels.field.disabled:
1173
+ (labels.status_short[node_status(d)] || node_status(d));
1174
+
1175
+ if(!with_label) {
1176
+ return `<span title="${escapeHtml(full)}" style="
1177
+ flex: 0 0 auto;
1178
+ width: 10px; height: 10px; border-radius: 50%;
1179
+ background: ${ss.dot};
1180
+ box-shadow: 0 0 0 2px ${ss.bg};
1181
+ margin-left: 6px;
1182
+ "></span>`;
1183
+ }
1184
+
1185
+ return `<span title="${escapeHtml(full)}" style="
1186
+ flex: 0 0 auto;
1187
+ display: inline-flex;
1188
+ align-items: center;
1189
+ gap: 4px;
1190
+ padding: 1px 7px 1px 5px;
1191
+ background: ${ss.bg};
1192
+ color: ${ss.fg};
1193
+ border-radius: 9px;
1194
+ font-size: 10px;
1195
+ font-weight: 700;
1196
+ line-height: 1.45;
1197
+ white-space: nowrap;
1198
+ "><span style="width:7px; height:7px; border-radius:50%; background:${ss.dot};"></span>${escapeHtml(label)}</span>`;
1199
+ }
1200
+
1201
+ /************************************************************
1202
+ * HTML for the badges: what the gobj IS, besides its colour.
1203
+ ************************************************************/
1204
+ function render_badges_html(d, cs, labels)
1205
+ {
1206
+ let html = "";
1207
+ for(let key of node_badge_keys(d)) {
1208
+ let label = labels.badge[key] || key;
1209
+ html += `<span style="
1210
+ font-size: 10px;
1211
+ font-weight: 600;
1212
+ padding: 0 6px;
1213
+ margin-right: 4px;
1214
+ background: ${cs.tagbg};
1215
+ color: ${cs.tagfg};
1216
+ border: 1px solid ${cs.accent};
1217
+ border-radius: 9px;
1218
+ white-space: nowrap;
1219
+ ">${escapeHtml(label)}</span>`;
1220
+ }
1221
+ return html;
1222
+ }
1223
+
1224
+ /************************************************************
1225
+ * Recursively build nodes and edges from a NODE DESCRIPTOR
1226
+ * tree (gobj_tree_model.js), never from gobjs: the same
1227
+ * builder draws the browser yuno and a backend one.
1228
+ *
1229
+ * The descriptor is also what the popover reads, so the view
1230
+ * fields it needs are written onto it here (`full_name`,
1231
+ * `short_name`, `num_children`, `is_collapsed`,
1232
+ * `direct_children`) instead of copied into a second object
1233
+ * that can then disagree with the first.
1234
+ ************************************************************/
1235
+ function build_gobj_nodes(gobj, d, nodes, edges, parent_id, compact, labels)
1028
1236
  {
1029
1237
  let priv = gobj.priv;
1030
1238
  let node_id = gen_node_id(gobj);
1031
1239
 
1032
- let gclass_name = gobj_gclass_name(target_gobj) || "";
1033
- let instance_name = gobj_name(target_gobj) || "";
1034
- let state = gobj_current_state(target_gobj) || "";
1035
- let full_name = gobj_full_name(target_gobj);
1036
- let colors = get_role_colors(target_gobj, is_root);
1037
1240
  let dark = yui_is_dark();
1038
- let cs = role_card_style(colors.stroke, dark);
1039
- let state_color = get_state_color(target_gobj);
1040
-
1041
- let running = gobj_is_running(target_gobj);
1042
- let playing = gobj_is_playing(target_gobj);
1043
- let status_text = !running ? "stopped" : (playing ? "playing" : "running");
1241
+ let colors = get_role_colors(d);
1242
+ let cs = role_card_style(colors, dark);
1243
+ let ss = status_card_style(d, dark);
1044
1244
 
1045
- let category = get_node_category(target_gobj, is_root);
1245
+ let category = get_node_category(d);
1046
1246
  let size = NODE_SIZES[compact ? "compact" : "full"][category];
1047
1247
  let width = size.w;
1048
1248
  let height = size.h;
@@ -1053,10 +1253,15 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
1053
1253
  */
1054
1254
  let border_width = (category === "yuno" || category === "service") ? 2 : 1;
1055
1255
 
1056
- let children = target_gobj.dl_children || [];
1256
+ /* A disabled gobj is drawn as one: a dashed border says
1257
+ * "this branch is out of the game" at any zoom. */
1258
+ let border_style = d.disabled? "dashed": "solid";
1259
+
1260
+ let children = d.children || [];
1057
1261
  let num_children = children.length;
1058
1262
  let has_children = num_children > 0;
1059
- let collapsed = has_children && is_node_collapsed(gobj, full_name, num_children);
1263
+ let collapsed = has_children &&
1264
+ is_node_collapsed(gobj, d.fullname, num_children);
1060
1265
 
1061
1266
  /*
1062
1267
  * Direct-children index (collected even when this node is
@@ -1066,33 +1271,41 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
1066
1271
  let direct_children = [];
1067
1272
  for(let child of children) {
1068
1273
  direct_children.push({
1069
- full_name: gobj_full_name(child),
1070
- has_grandchildren: (child.dl_children || []).length > 0,
1274
+ full_name: child.fullname,
1275
+ has_grandchildren: (child.children || []).length > 0,
1071
1276
  });
1072
1277
  }
1073
1278
 
1279
+ /* What the VIEW needs on top of the descriptor. Written onto
1280
+ * it, because the popover reads the same object. */
1281
+ d.full_name = d.fullname;
1282
+ d.short_name = d.shortname;
1283
+ d.num_children = num_children;
1284
+ d.is_collapsed = collapsed;
1285
+ d.direct_children = direct_children;
1286
+ d.node_id = node_id;
1287
+
1074
1288
  let toggle_html = has_children
1075
1289
  ? render_toggle_html(node_id, collapsed, num_children, cs)
1076
1290
  : "";
1077
1291
 
1292
+ let card_title = d.gclass + (d.name? ("^" + d.name): "");
1078
1293
  let node_html;
1079
1294
 
1080
1295
  if(compact) {
1081
1296
  /*
1082
- * Compact one-line node: "gclass^name" with a left color bar,
1083
- * state dot and optional +/- toggle. Name truncates with "...".
1297
+ * Compact one-line node: "gclass^name" with a left colour bar
1298
+ * and the status dot. Name truncates with "...".
1084
1299
  */
1085
- let compact_label = escapeHtml(
1086
- instance_name ? (gclass_name + "^" + instance_name) : gclass_name
1087
- );
1300
+ let compact_label = escapeHtml(card_title);
1088
1301
 
1089
1302
  node_html = `
1090
- <div class="GOBJ_CARD" data-gobj-name="${escapeHtml(full_name)}" style="
1303
+ <div class="GOBJ_CARD" data-gobj-name="${escapeHtml(d.fullname)}" style="
1091
1304
  width: ${width}px;
1092
1305
  height: ${height}px;
1093
1306
  background: ${cs.bg};
1094
- border: ${border_width}px solid ${cs.border};
1095
- border-left: 5px solid ${cs.border};
1307
+ border: ${border_width}px ${border_style} ${cs.border};
1308
+ border-left: 6px solid ${cs.accent};
1096
1309
  border-radius: 7px;
1097
1310
  box-shadow: ${cs.shadow};
1098
1311
  font-family: ${GT_FONT};
@@ -1101,49 +1314,30 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
1101
1314
  padding: 0 8px;
1102
1315
  box-sizing: border-box;
1103
1316
  overflow: hidden;
1317
+ opacity: ${d.disabled? "0.72": "1"};
1104
1318
  cursor: pointer;
1105
1319
  " title="${compact_label}">
1106
1320
  <span style="flex:1 1 auto; min-width:0; font-size:11px; font-weight:600; color:${cs.title}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${compact_label}</span>
1107
- <span title="${escapeHtml(state || status_text)}" style="
1108
- flex: 0 0 auto;
1109
- width: 8px; height: 8px; border-radius: 50%;
1110
- background: ${state_color}; margin-left: 6px;
1111
- "></span>
1321
+ ${render_status_html(d, ss, labels, false)}
1112
1322
  ${toggle_html}
1113
1323
  </div>`;
1114
1324
  } else {
1115
1325
  /*
1116
- * Full node: header (gclass + optional toggle), name, badges + state.
1117
- * All rows truncate with "..." — width is fixed.
1326
+ * Full node: header (gclass + optional toggle), name, state,
1327
+ * badges + status pill. All rows truncate with "..." -- the
1328
+ * width is fixed.
1118
1329
  */
1119
- let header_label = escapeHtml(gclass_name);
1120
- let name_label = escapeHtml(instance_name);
1121
- let state_label = escapeHtml(state);
1122
-
1123
- let badges = [];
1124
- if(is_root) {
1125
- badges.push("yuno");
1126
- } else {
1127
- if(gobj_is_service(target_gobj)) {
1128
- badges.push("service");
1129
- }
1130
- if(gobj_is_pure_child(target_gobj)) {
1131
- badges.push("pure");
1132
- }
1133
- if(gobj_is_volatil(target_gobj)) {
1134
- badges.push("volatil");
1135
- }
1136
- }
1137
- let badges_html = badges.map(b =>
1138
- `<span style="font-size:10px; padding:0 5px; margin-right:4px; background:${cs.tagbg}; color:${cs.border}; border:1px solid ${cs.border}; border-radius:6px; white-space:nowrap;">${b}</span>`
1139
- ).join("");
1330
+ let header_label = escapeHtml(d.gclass);
1331
+ let name_label = escapeHtml(d.name);
1332
+ let state_label = escapeHtml(d.state);
1140
1333
 
1141
1334
  node_html = `
1142
- <div class="GOBJ_CARD" data-gobj-name="${escapeHtml(full_name)}" style="
1335
+ <div class="GOBJ_CARD" data-gobj-name="${escapeHtml(d.fullname)}" style="
1143
1336
  width: ${width}px;
1144
1337
  height: ${height}px;
1145
1338
  background: ${cs.bg};
1146
- border: ${border_width}px solid ${cs.border};
1339
+ border: ${border_width}px ${border_style} ${cs.border};
1340
+ border-left: 6px solid ${cs.accent};
1147
1341
  border-radius: 9px;
1148
1342
  box-shadow: ${cs.shadow};
1149
1343
  overflow: hidden;
@@ -1152,59 +1346,33 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
1152
1346
  display: flex;
1153
1347
  flex-direction: column;
1154
1348
  justify-content: center;
1155
- padding: 5px 9px;
1156
- gap: 1px;
1349
+ padding: 6px 9px;
1350
+ gap: 2px;
1351
+ opacity: ${d.disabled? "0.72": "1"};
1157
1352
  cursor: pointer;
1158
- " title="${escapeHtml(gclass_name + (instance_name ? "^" + instance_name : ""))}">
1353
+ " title="${escapeHtml(card_title)}">
1159
1354
  <div style="flex:0 0 auto; display:flex; align-items:center; gap:6px; overflow:hidden;">
1160
- <span style="flex:1 1 auto; min-width:0; font-size:12px; line-height:1.25; font-weight:600; color:${cs.title}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${header_label}</span>
1355
+ <span style="flex:1 1 auto; min-width:0; font-size:12px; line-height:1.25; font-weight:700; color:${cs.title}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${header_label}</span>
1161
1356
  ${toggle_html}
1162
1357
  </div>
1163
1358
  <div style="flex:0 0 auto; font-size:12px; line-height:1.25; color:${cs.sub}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${name_label || "&nbsp;"}</div>
1164
1359
  <div style="flex:0 0 auto; display:flex; justify-content:space-between; align-items:center; gap:6px; overflow:hidden;">
1165
- <span style="flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${badges_html}</span>
1166
- <span style="flex:0 0 auto; display:flex; align-items:center; gap:4px; color:${state_color}; font-weight:600; font-size:11px; white-space:nowrap;">
1167
- <span style="width:7px; height:7px; border-radius:50%; background:${state_color};"></span>${state_label || status_text}
1168
- </span>
1360
+ <span style="flex:1 1 auto; min-width:0; font-size:10px; line-height:1.25; color:${cs.sub}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${state_label || "&nbsp;"}</span>
1361
+ ${render_status_html(d, ss, labels, true)}
1169
1362
  </div>
1363
+ <div style="flex:0 0 auto; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;">${render_badges_html(d, cs, labels)}</div>
1170
1364
  </div>`;
1171
1365
  }
1172
1366
 
1173
- let parent_gobj = gobj_parent(target_gobj);
1174
-
1175
- /*
1176
- * Node data kept for the popover. Extend this object when
1177
- * you want to surface more attributes — then add a row in
1178
- * get_node_info_rows() with the same key.
1179
- */
1180
- let node_data = {
1181
- gclass: gclass_name,
1182
- name: instance_name,
1183
- short_name: gobj_short_name(target_gobj),
1184
- full_name: full_name,
1185
- parent_short_name: parent_gobj ? gobj_short_name(parent_gobj) : "",
1186
- state: state,
1187
- category: category,
1188
- is_yuno: is_root,
1189
- is_service: gobj_is_service(target_gobj),
1190
- is_pure_child: gobj_is_pure_child(target_gobj),
1191
- is_volatil: gobj_is_volatil(target_gobj),
1192
- is_running: running,
1193
- is_playing: playing,
1194
- num_children: num_children,
1195
- is_collapsed: collapsed,
1196
- direct_children: direct_children,
1197
- };
1198
-
1199
1367
  if(!priv.node_by_id) {
1200
1368
  priv.node_by_id = {};
1201
1369
  }
1202
- priv.node_by_id[node_id] = node_data;
1370
+ priv.node_by_id[node_id] = d;
1203
1371
 
1204
1372
  let node = {
1205
1373
  id: node_id,
1206
1374
  type: 'html',
1207
- data: node_data,
1375
+ data: d,
1208
1376
  style: {
1209
1377
  innerHTML: node_html,
1210
1378
  size: [width, height],
@@ -1219,8 +1387,8 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
1219
1387
  source: parent_id,
1220
1388
  target: node_id,
1221
1389
  style: {
1222
- stroke: colors.stroke,
1223
- lineWidth: 1,
1390
+ stroke: colors.accent,
1391
+ lineWidth: 1.4,
1224
1392
  }
1225
1393
  });
1226
1394
  }
@@ -1230,7 +1398,7 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
1230
1398
  */
1231
1399
  if(!collapsed) {
1232
1400
  for(let child of children) {
1233
- build_gobj_nodes(gobj, child, nodes, edges, node_id, false, compact);
1401
+ build_gobj_nodes(gobj, child, nodes, edges, node_id, compact, labels);
1234
1402
  }
1235
1403
  }
1236
1404
 
@@ -1256,12 +1424,23 @@ function load_tree(gobj)
1256
1424
  return;
1257
1425
  }
1258
1426
 
1259
- let yuno = gobj_yuno();
1260
- if(!yuno) {
1427
+ /*
1428
+ * The tree, as data.
1429
+ *
1430
+ * The graph draws DESCRIPTORS, so this is the one line that
1431
+ * says which yuno is on screen. A backend yuno enters here:
1432
+ * describe_backend_tree(<the `view-gobj-tree` answer>) in
1433
+ * place of describe_js_gobj(gobj_yuno(), true), and nothing
1434
+ * below this point changes.
1435
+ */
1436
+ let root = describe_js_gobj(gobj_yuno(), true);
1437
+ if(!root) {
1438
+ log_error(`${gobj_short_name(gobj)}: no yuno to draw`);
1261
1439
  return;
1262
1440
  }
1263
1441
 
1264
1442
  let layout_cfg = get_layout_cfg(gobj_read_str_attr(gobj, "layout"));
1443
+ let labels = node_labels(t);
1265
1444
 
1266
1445
  let anchor = priv.pending_anchor || null;
1267
1446
  let preserve_view = !!priv.pending_preserve_view;
@@ -1274,7 +1453,7 @@ function load_tree(gobj)
1274
1453
  let nodes = [];
1275
1454
  let edges = [];
1276
1455
 
1277
- build_gobj_nodes(gobj, yuno, nodes, edges, null, true, layout_cfg.compact);
1456
+ build_gobj_nodes(gobj, root, nodes, edges, null, layout_cfg.compact, labels);
1278
1457
 
1279
1458
  if(nodes.length > 0) {
1280
1459
  let root_id = nodes[0].id; /* built before its children */
@@ -1434,67 +1613,12 @@ function refresh_tree(gobj, opts)
1434
1613
  }
1435
1614
  }
1436
1615
 
1437
- /************************************************************
1438
- * Rows to show in the popover for a given node data.
1439
- *
1440
- * This is the extension point: to surface a new attribute,
1441
- * collect it into `node_data` in build_gobj_nodes(), then
1442
- * append a `[label, value]` pair here.
1443
- ************************************************************/
1444
- function get_node_info_rows(node_data)
1445
- {
1446
- let rows = [];
1447
-
1448
- rows.push([t("gclass"), node_data.gclass || "—"]);
1449
- rows.push([t("name"), node_data.name || "—"]);
1450
-
1451
- if(node_data.full_name && node_data.full_name !== node_data.short_name) {
1452
- rows.push([t("full name"), node_data.full_name]);
1453
- }
1454
-
1455
- let role;
1456
- if(node_data.is_yuno) {
1457
- role = "yuno";
1458
- } else if(node_data.is_service) {
1459
- role = "service";
1460
- } else if(node_data.is_volatil) {
1461
- role = "volatil child";
1462
- } else if(node_data.is_pure_child) {
1463
- role = "pure child";
1464
- } else {
1465
- role = "child";
1466
- }
1467
- rows.push([t("role"), role]);
1468
-
1469
- let status;
1470
- if(!node_data.is_running) {
1471
- status = "stopped";
1472
- } else if(node_data.is_playing) {
1473
- status = "running + playing";
1474
- } else {
1475
- status = "running";
1476
- }
1477
- rows.push([t("status"), status]);
1478
- rows.push([t("state"), node_data.state || "—"]);
1479
-
1480
- if(node_data.parent_short_name) {
1481
- rows.push([t("parent"), node_data.parent_short_name]);
1482
- }
1483
-
1484
- if(typeof node_data.num_children === "number") {
1485
- let kids = node_data.num_children;
1486
- if(kids > 0 && node_data.is_collapsed) {
1487
- rows.push([t("children"), kids + " " + t("(collapsed)")]);
1488
- } else if(kids > 0) {
1489
- rows.push([t("children"), String(kids)]);
1490
- }
1491
- }
1492
-
1493
- return rows;
1494
- }
1495
-
1496
1616
  /************************************************************
1497
1617
  * Render popover body from the info rows of a node.
1618
+ *
1619
+ * The rows come from gobj_tree_model.js, so a node of the
1620
+ * browser yuno and a node of a backend one are read out with
1621
+ * the same words in the same order.
1498
1622
  ************************************************************/
1499
1623
  function render_popover_body(gobj, node_data)
1500
1624
  {
@@ -1504,14 +1628,14 @@ function render_popover_body(gobj, node_data)
1504
1628
  return;
1505
1629
  }
1506
1630
 
1507
- let rows = get_node_info_rows(node_data);
1631
+ let rows = node_info_rows(node_data, t);
1508
1632
 
1509
1633
  /* Bulma scheme vars, like the popover chrome around it (build_popover):
1510
1634
  * they flip with <html data-theme>, so the popover follows the theme
1511
1635
  * with no redraw. Hardcoding them is what made the value near-black
1512
1636
  * (#1A1A1A) on the near-black dark card background — invisible. */
1513
1637
  let rows_html = rows.map(([label, value]) => `
1514
- <div style="display:grid; grid-template-columns: 90px 1fr; column-gap:10px; padding: 2px 0;">
1638
+ <div style="display:grid; grid-template-columns: 96px 1fr; column-gap:10px; padding: 2px 0;">
1515
1639
  <span style="color:var(--bulma-text-weak, #6B7280); font-weight:500;">${escapeHtml(String(label))}</span>
1516
1640
  <span style="color:var(--bulma-text-strong, #1A1A1A); word-break:break-all;">${escapeHtml(String(value))}</span>
1517
1641
  </div>
@@ -1535,6 +1659,8 @@ function show_popover(gobj, node_data, client_x, client_y)
1535
1659
  return;
1536
1660
  }
1537
1661
 
1662
+ priv.popover_node = node_data;
1663
+
1538
1664
  $title.textContent = node_data.short_name ||
1539
1665
  (node_data.gclass + (node_data.name ? "^" + node_data.name : ""));
1540
1666
  $title.title = $title.textContent;
@@ -1588,6 +1714,7 @@ function hide_popover(gobj)
1588
1714
  if(priv.$popover_title) {
1589
1715
  priv.$popover_title.textContent = "";
1590
1716
  }
1717
+ priv.popover_node = null;
1591
1718
  }
1592
1719
 
1593
1720
  /************************************************************
@@ -2400,7 +2527,7 @@ function ac_show(gobj, event, kw, src)
2400
2527
  refresh_tree(gobj);
2401
2528
 
2402
2529
  if(graph) {
2403
- let $canvas = document.getElementById(priv.canvas_id);
2530
+ let $canvas = canvas_mount(gobj);
2404
2531
  if($canvas) {
2405
2532
  /*
2406
2533
  * Content box (clientWidth/Height), not getBoundingClientRect:
@@ -2433,7 +2560,7 @@ function ac_resize(gobj, event, kw, src)
2433
2560
  let graph = priv.graph;
2434
2561
 
2435
2562
  if(graph) {
2436
- let $canvas = document.getElementById(priv.canvas_id);
2563
+ let $canvas = canvas_mount(gobj);
2437
2564
  if($canvas) {
2438
2565
  let cw = $canvas.clientWidth;
2439
2566
  let ch = $canvas.clientHeight;
@@ -2455,6 +2582,87 @@ function ac_hide(gobj, event, kw, src)
2455
2582
  return 0;
2456
2583
  }
2457
2584
 
2585
+ /************************************************************
2586
+ * Open the gclass of the node the popover is showing.
2587
+ *
2588
+ * One window at a time: opening a second gclass replaces the
2589
+ * first rather than stacking windows nobody closes.
2590
+ ************************************************************/
2591
+ function ac_open_gclass(gobj, event, kw, src)
2592
+ {
2593
+ let priv = gobj.priv;
2594
+ let node_data = priv.popover_node;
2595
+
2596
+ if(!node_data || !node_data.gclass) {
2597
+ log_error(`${gobj_short_name(gobj)}: EV_OPEN_GCLASS with no node`);
2598
+ return -1;
2599
+ }
2600
+
2601
+ close_gclass_view(priv.gclass_view);
2602
+ priv.gclass_view = null;
2603
+
2604
+ priv.gclass_view = open_gclass_view(gobj, node_data.gclass, {
2605
+ title_prefix: gobj_yuno_name() || "",
2606
+ on_close: () => {
2607
+ if(gobj_is_destroying(gobj)) {
2608
+ return;
2609
+ }
2610
+ gobj_send_event(gobj, "EV_GCLASS_CLOSED", {}, gobj);
2611
+ },
2612
+ });
2613
+
2614
+ return 0;
2615
+ }
2616
+
2617
+ /************************************************************
2618
+ * The reader dismissed the gclass window (its own ✕).
2619
+ *
2620
+ * C_YUI_WINDOW.close_window() calls on_close and THEN destroys
2621
+ * itself, so here we only drop our reference -- destroying it
2622
+ * again would destroy it twice.
2623
+ ************************************************************/
2624
+ function ac_gclass_closed(gobj, event, kw, src)
2625
+ {
2626
+ gobj.priv.gclass_view = null;
2627
+ return 0;
2628
+ }
2629
+
2630
+ /************************************************************
2631
+ * The gclass viewer asked for a subtree.
2632
+ *
2633
+ * It cannot: the gclass description is a COMPLETE document,
2634
+ * it carries no `__collapsed__` stub, so nothing in it is
2635
+ * fetchable. The event is declared because the viewer is
2636
+ * hosted as a child and subscribes its host to everything it
2637
+ * publishes -- and it is logged, because arriving here means
2638
+ * the document was not the one we built.
2639
+ ************************************************************/
2640
+ function ac_gclass_expand_path(gobj, event, kw, src)
2641
+ {
2642
+ log_error(
2643
+ `${gobj_short_name(gobj)}: EV_EXPAND_PATH on a complete document: ` +
2644
+ `${kw && kw.path}`
2645
+ );
2646
+ return -1;
2647
+ }
2648
+
2649
+ /************************************************************
2650
+ * The app switched language.
2651
+ *
2652
+ * A rebuild, not a re-translation: the words live inside the
2653
+ * innerHTML of G6 nodes, where refresh_language() cannot go.
2654
+ ************************************************************/
2655
+ function ac_language_changed(gobj, event, kw, src)
2656
+ {
2657
+ let $container = gobj_read_attr(gobj, "$container");
2658
+ if($container) {
2659
+ refresh_language($container, t);
2660
+ }
2661
+ hide_popover(gobj);
2662
+ refresh_tree(gobj);
2663
+ return 0;
2664
+ }
2665
+
2458
2666
 
2459
2667
 
2460
2668
 
@@ -2506,6 +2714,10 @@ function create_gclass(gclass_name)
2506
2714
  ["EV_RESIZE", ac_resize, null],
2507
2715
  ["EV_SHOW", ac_show, null],
2508
2716
  ["EV_HIDE", ac_hide, null],
2717
+ ["EV_OPEN_GCLASS", ac_open_gclass, null],
2718
+ ["EV_GCLASS_CLOSED", ac_gclass_closed, null],
2719
+ ["EV_EXPAND_PATH", ac_gclass_expand_path, null],
2720
+ ["EV_LANGUAGE_CHANGED", ac_language_changed, null],
2509
2721
  ]]
2510
2722
  ];
2511
2723
 
@@ -2529,6 +2741,10 @@ function create_gclass(gclass_name)
2529
2741
  ["EV_RESIZE", 0],
2530
2742
  ["EV_SHOW", 0],
2531
2743
  ["EV_HIDE", 0],
2744
+ ["EV_OPEN_GCLASS", 0],
2745
+ ["EV_GCLASS_CLOSED", 0],
2746
+ ["EV_EXPAND_PATH", 0],
2747
+ ["EV_LANGUAGE_CHANGED", 0],
2532
2748
  ];
2533
2749
 
2534
2750
  __gclass__ = gclass_create(