@yuneta/gobj-ui 7.23.38 → 7.23.40

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.
@@ -45,6 +45,7 @@ import {
45
45
  node_role,
46
46
  node_status,
47
47
  node_status_key,
48
+ node_status_symbol,
48
49
  } from "./gobj_tree_model.js";
49
50
  import {
50
51
  close_gclass_view,
@@ -54,6 +55,7 @@ import {
54
55
  import {yui_shell_of} from "./c_yui_shell.js";
55
56
 
56
57
  import {yui_toolbar} from "./yui_toolbar.js";
58
+ import {attach_clear} from "./yui_inputs.js";
57
59
  import {
58
60
  yui_graph_camera_items,
59
61
  yui_graph_anchor_item,
@@ -257,6 +259,15 @@ let PRIVATE_DATA = {
257
259
  theme: null,
258
260
  theme_observer: null, // MutationObserver on <html data-theme>
259
261
  $layout_select: null,
262
+
263
+ /*---------------- find ----------------*/
264
+ search: "", // current find term, lower-cased
265
+ match_count: 0, // how many cards it matched, for the chip
266
+ find_timer: null, // rate-limits the find box
267
+ $find_input: null,
268
+ $find_result: null,
269
+ $find_count: null,
270
+
260
271
  $popover: null,
261
272
  $popover_title: null,
262
273
  $popover_body: null,
@@ -351,6 +362,13 @@ const NODE_SIZES = {
351
362
  /***************************************************************
352
363
  * Shared card typography.
353
364
  ***************************************************************/
365
+ /*
366
+ * The ring a find match wears. Amber, which in this library means
367
+ * "here" -- the same colour the gclass viewer lights the current
368
+ * state with -- and not one of the role colours, which are taken.
369
+ */
370
+ const FIND_MATCH_COLOR = "#D9A23F";
371
+
354
372
  const GT_FONT =
355
373
  "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, " +
356
374
  "Helvetica, Arial, sans-serif";
@@ -836,6 +854,129 @@ function destroy_ui(gobj)
836
854
  /************************************************************
837
855
  * Toolbar
838
856
  ************************************************************/
857
+ /************************************************************
858
+ * The find box and its count.
859
+ *
860
+ * A tree of a real yuno is a hundred cards, and the only way
861
+ * to locate one was to read them all. The COUNT is what makes
862
+ * it honest: a graph that did not move looks the same whether
863
+ * nothing matched or the match was already on screen -- the
864
+ * same reason the JSON graph carries one, and the same
865
+ * drawing, because the two sit in the same console.
866
+ ************************************************************/
867
+ function make_find_items(gobj)
868
+ {
869
+ let priv = gobj.priv;
870
+
871
+ /* Materialised, not a spec: attach_clear() hangs the NORM clear (✕)
872
+ * on a real element. Clearing dispatches a synthetic `input`, which
873
+ * goes through the same rate-limited handler and fires
874
+ * EV_FIND_NODES with an empty term -- box and highlight clear
875
+ * together. */
876
+ let $find_input = createElement2(
877
+ ['input', {
878
+ class: 'GOBJ_TREE_FIND_INPUT input',
879
+ type: 'text',
880
+ /* A placeholder is not a text node, so the data-i18n walk
881
+ * cannot reach it: it needs its own key. */
882
+ placeholder: t('search'),
883
+ 'data-i18n-placeholder': 'search',
884
+ 'aria-label': t('search'),
885
+ 'data-i18n-aria-label': 'search'
886
+ }, [], {
887
+ /* Rate-limited, not delayed for effect: every keystroke
888
+ * REBUILDS the cards -- an html node paints no G6 state, so
889
+ * the highlight has to live in the card's own markup -- and
890
+ * the first letter typed matches most of a tree. */
891
+ input: (evt) => {
892
+ evt.stopPropagation();
893
+ let text = evt.target.value.trim();
894
+ if(priv.find_timer) {
895
+ clearTimeout(priv.find_timer);
896
+ }
897
+ priv.find_timer = setTimeout(function() {
898
+ priv.find_timer = null;
899
+ gobj_send_event(gobj, "EV_FIND_NODES", {text: text}, gobj);
900
+ }, 250);
901
+ }
902
+ }]);
903
+
904
+ let $find_control = createElement2(
905
+ ['div', {class: 'GOBJ_TREE_FIND control has-icons-left',
906
+ style: 'margin-left:.5rem; margin-right:.5rem; ' +
907
+ 'max-width:12rem; min-width:7rem;'}, [
908
+ $find_input,
909
+ ['span', {class: 'icon is-left'}, [['i', {class: 'yi-magnifying-glass'}]]]
910
+ ]]);
911
+ attach_clear($find_control, $find_input);
912
+
913
+ priv.$find_input = $find_input;
914
+
915
+ return [
916
+ $find_control,
917
+ /* ONE counted span, not a number beside a word: "1 matches"
918
+ * is what two spans always produce, because nothing there can
919
+ * see the count. `t(key, {count})` picks `matches_one` for
920
+ * one and falls back to the base key for the rest, in both
921
+ * languages.
922
+ *
923
+ * And NO `i18n` attribute on it: refresh_language()
924
+ * re-translates whatever carries one by calling t() WITHOUT
925
+ * the count, which would put the plural back over the
926
+ * singular. A counted word is re-rendered by its owner
927
+ * instead -- which is why the language action below repaints
928
+ * this chip. */
929
+ /* `display:flex` inline and NOT the `is-flex` helper -- both
930
+ * Bulma helpers carry !important, so `is-hidden is-flex` on one
931
+ * element is decided by stylesheet order. */
932
+ ['div', {class: 'GOBJ_TREE_FIND_RESULT is-hidden',
933
+ style: 'display:flex; align-items:center; gap:.3rem; ' +
934
+ 'margin-right:.5rem; font-size:.85rem;'}, [
935
+ ['span', {class: 'GOBJ_TREE_FIND_COUNT'}, '']
936
+ ]]
937
+ ];
938
+ }
939
+
940
+ /************************************************************
941
+ * Does this node answer the current find term?
942
+ *
943
+ * What a reader types is a gclass, a name or a state -- the
944
+ * three things a card shows. The full name is searched too,
945
+ * because it is what the popover shows and what a log line
946
+ * carries.
947
+ ************************************************************/
948
+ function node_matches(d, term)
949
+ {
950
+ if(!term) {
951
+ return false;
952
+ }
953
+ let hay = [d.gclass, d.name, d.fullname, d.state]
954
+ .filter((x) => typeof x === "string")
955
+ .join(" ")
956
+ .toLowerCase();
957
+ return hay.indexOf(term) >= 0;
958
+ }
959
+
960
+ /************************************************************
961
+ * Repaint the count chip. Hidden while nothing is typed:
962
+ * "0 matches" beside an empty box says nothing.
963
+ ************************************************************/
964
+ function update_find_result(gobj)
965
+ {
966
+ let priv = gobj.priv;
967
+
968
+ if(!priv.$find_result || !priv.$find_count) {
969
+ return;
970
+ }
971
+ if(!priv.search) {
972
+ priv.$find_result.classList.add("is-hidden");
973
+ priv.$find_count.textContent = "";
974
+ return;
975
+ }
976
+ priv.$find_count.textContent = t("matches", {count: priv.match_count});
977
+ priv.$find_result.classList.remove("is-hidden");
978
+ }
979
+
839
980
  function make_toolbar(gobj)
840
981
  {
841
982
  let priv = gobj.priv;
@@ -846,7 +987,9 @@ function make_toolbar(gobj)
846
987
  * and not a per-node one, so it sits on its own at the left — ahead
847
988
  * of a find box where the toolbar has one. The per-node handles
848
989
  * live inside the cards, on the right of each header. */
849
- let left_items = yui_graph_fold_items(gobj, toolbar_wide);
990
+ let left_items = yui_graph_fold_items(gobj, toolbar_wide).concat(
991
+ make_find_items(gobj)
992
+ );
850
993
  let center_items = [];
851
994
  let right_items = [];
852
995
 
@@ -899,6 +1042,11 @@ function make_toolbar(gobj)
899
1042
  ['div', {class: 'yui-horizontal-toolbar-section right'}, right_items],
900
1043
  ]);
901
1044
 
1045
+ /* The count chip is built as a spec, so its elements only exist
1046
+ * once yui_toolbar() has materialised the row. */
1047
+ priv.$find_result = $toolbar.querySelector('.GOBJ_TREE_FIND_RESULT');
1048
+ priv.$find_count = $toolbar.querySelector('.GOBJ_TREE_FIND_COUNT');
1049
+
902
1050
  refresh_language($toolbar, t);
903
1051
  return $toolbar;
904
1052
  }
@@ -1159,9 +1307,14 @@ function render_toggle_html(node_id, collapsed, num_children, cs)
1159
1307
  /************************************************************
1160
1308
  * HTML for the status pill: what the gobj is DOING.
1161
1309
  *
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.
1310
+ * A SYMBOL plus a word, and the word is dropped in the
1311
+ * compact card where there is no room for it.
1312
+ *
1313
+ * The symbol carries the state on its own, which a dot never
1314
+ * did: a dot has one shape, so telling running from stopped
1315
+ * meant telling green from red at 10 pixels -- and stopped is
1316
+ * the one that has to be seen. Play, pause and stop are also
1317
+ * the framework's own three verbs, not a borrowed metaphor.
1165
1318
  ************************************************************/
1166
1319
  function render_status_html(d, ss, labels, with_label)
1167
1320
  {
@@ -1171,15 +1324,16 @@ function render_status_html(d, ss, labels, with_label)
1171
1324
  let label = d.disabled?
1172
1325
  labels.field.disabled:
1173
1326
  (labels.status_short[node_status(d)] || node_status(d));
1327
+ let symbol = node_status_symbol(d);
1174
1328
 
1175
1329
  if(!with_label) {
1176
1330
  return `<span title="${escapeHtml(full)}" style="
1177
1331
  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
1332
  margin-left: 6px;
1182
- "></span>`;
1333
+ color: ${ss.dot};
1334
+ font-size: 11px;
1335
+ line-height: 1;
1336
+ ">${escapeHtml(symbol)}</span>`;
1183
1337
  }
1184
1338
 
1185
1339
  return `<span title="${escapeHtml(full)}" style="
@@ -1187,7 +1341,7 @@ function render_status_html(d, ss, labels, with_label)
1187
1341
  display: inline-flex;
1188
1342
  align-items: center;
1189
1343
  gap: 4px;
1190
- padding: 1px 7px 1px 5px;
1344
+ padding: 1px 7px 1px 6px;
1191
1345
  background: ${ss.bg};
1192
1346
  color: ${ss.fg};
1193
1347
  border-radius: 9px;
@@ -1195,7 +1349,8 @@ function render_status_html(d, ss, labels, with_label)
1195
1349
  font-weight: 700;
1196
1350
  line-height: 1.45;
1197
1351
  white-space: nowrap;
1198
- "><span style="width:7px; height:7px; border-radius:50%; background:${ss.dot};"></span>${escapeHtml(label)}</span>`;
1352
+ "><span style="color:${ss.dot}; font-size:11px; line-height:1;"
1353
+ >${escapeHtml(symbol)}</span>${escapeHtml(label)}</span>`;
1199
1354
  }
1200
1355
 
1201
1356
  /************************************************************
@@ -1257,6 +1412,20 @@ function build_gobj_nodes(gobj, d, nodes, edges, parent_id, compact, labels)
1257
1412
  * "this branch is out of the game" at any zoom. */
1258
1413
  let border_style = d.disabled? "dashed": "solid";
1259
1414
 
1415
+ /*
1416
+ * A card the find box matched. The ring is drawn OVER the role
1417
+ * colour and not instead of it -- the card still has to say what
1418
+ * it is -- and it is counted here, while every card passes
1419
+ * through, so the chip and the drawing can never disagree.
1420
+ */
1421
+ let matched = node_matches(d, priv.search);
1422
+ if(matched) {
1423
+ priv.match_count++;
1424
+ border_width = 3;
1425
+ border_style = "solid";
1426
+ cs = Object.assign({}, cs, {border: FIND_MATCH_COLOR});
1427
+ }
1428
+
1260
1429
  let children = d.children || [];
1261
1430
  let num_children = children.length;
1262
1431
  let has_children = num_children > 0;
@@ -1442,6 +1611,11 @@ function load_tree(gobj)
1442
1611
  let layout_cfg = get_layout_cfg(gobj_read_str_attr(gobj, "layout"));
1443
1612
  let labels = node_labels(t);
1444
1613
 
1614
+ /* Counted as the cards are built, so it is reset HERE and nowhere
1615
+ * else: a count kept by the action would drift the moment the tree
1616
+ * was rebuilt for any other reason. */
1617
+ priv.match_count = 0;
1618
+
1445
1619
  let anchor = priv.pending_anchor || null;
1446
1620
  let preserve_view = !!priv.pending_preserve_view;
1447
1621
  priv.pending_anchor = null;
@@ -2074,6 +2248,29 @@ class GobjLanesHLayout extends BaseLayout {
2074
2248
  /************************************************************
2075
2249
  *
2076
2250
  ************************************************************/
2251
+ /************************************************************
2252
+ * EV_FIND_NODES {text}
2253
+ *
2254
+ * The cards carry the highlight in their own markup (an html
2255
+ * node paints no G6 state), so finding is a REBUILD -- which
2256
+ * is also why the box is rate-limited. The view is preserved:
2257
+ * a reader who typed a letter did not ask to be moved.
2258
+ ************************************************************/
2259
+ function ac_find_nodes(gobj, event, kw, src)
2260
+ {
2261
+ let priv = gobj.priv;
2262
+ let text = String((kw && kw.text) || "").trim().toLowerCase();
2263
+
2264
+ if(text === priv.search) {
2265
+ return 0;
2266
+ }
2267
+
2268
+ priv.search = text;
2269
+ refresh_tree(gobj, {preserve_view: true});
2270
+ update_find_result(gobj);
2271
+ return 0;
2272
+ }
2273
+
2077
2274
  function ac_refresh(gobj, event, kw, src)
2078
2275
  {
2079
2276
  save_view_state(gobj);
@@ -2646,6 +2843,9 @@ function ac_language_changed(gobj, event, kw, src)
2646
2843
  }
2647
2844
  hide_popover(gobj);
2648
2845
  refresh_tree(gobj);
2846
+ /* The count chip carries a COUNTED word, which has no key for
2847
+ * refresh_language() to re-translate: its owner has to repaint it. */
2848
+ update_find_result(gobj);
2649
2849
  return 0;
2650
2850
  }
2651
2851
 
@@ -2684,6 +2884,7 @@ function create_gclass(gclass_name)
2684
2884
  *---------------------------------------------*/
2685
2885
  const states = [
2686
2886
  ["ST_IDLE", [
2887
+ ["EV_FIND_NODES", ac_find_nodes, null],
2687
2888
  ["EV_REFRESH", ac_refresh, null],
2688
2889
  ["EV_THEME", ac_theme, null],
2689
2890
  ["EV_CHANGE_LAYOUT", ac_change_layout, null],
@@ -2710,6 +2911,7 @@ function create_gclass(gclass_name)
2710
2911
  * Events
2711
2912
  *---------------------------------------------*/
2712
2913
  const event_types = [
2914
+ ["EV_FIND_NODES", 0],
2713
2915
  ["EV_REFRESH", 0],
2714
2916
  ["EV_THEME", 0],
2715
2917
  ["EV_CHANGE_LAYOUT", 0],
@@ -599,17 +599,25 @@ function make_toolbar(gobj)
599
599
  */
600
600
  let left_items = yui_graph_fold_items(gobj, toolbar_wide).concat([
601
601
  $find_control,
602
- /* Two spans, not one string: the number is DATA and "matches"
603
- * is the word, so a language switch re-translates the half that
604
- * is a word. `display:flex` inline and NOT the `is-flex`
605
- * helper — both Bulma helpers carry !important, so
606
- * `is-hidden is-flex` on one element is decided by stylesheet
607
- * order. */
602
+ /* ONE counted span, not a number beside a word: "1 matches"
603
+ * is what two spans always produce, because nothing there can
604
+ * see the count. `t(key, {count})` picks `matches_one` for
605
+ * one and falls back to the base key for the rest, in both
606
+ * languages.
607
+ *
608
+ * And NO `i18n` attribute on it: refresh_language()
609
+ * re-translates whatever carries one by calling t() WITHOUT
610
+ * the count, which would put the plural back over the
611
+ * singular. Nothing re-translates it, so it is repainted on
612
+ * the next find -- which is the next keystroke, and it is
613
+ * hidden while the box is empty. */
614
+ /* `display:flex` inline and NOT the `is-flex` helper — both
615
+ * Bulma helpers carry !important, so `is-hidden is-flex` on one
616
+ * element is decided by stylesheet order. */
608
617
  ['div', {class: 'JSON_GRAPH_FIND_RESULT is-hidden',
609
618
  style: 'display:flex; align-items:center; gap:.3rem; ' +
610
619
  'margin-right:.5rem; font-size:.85rem;'}, [
611
- ['span', {class: 'JSON_GRAPH_FIND_COUNT'}, ''],
612
- ['span', {i18n: 'matches'}, 'matches']
620
+ ['span', {class: 'JSON_GRAPH_FIND_COUNT'}, '']
613
621
  ]]
614
622
  ]);
615
623
  let center_items = [];
@@ -1537,7 +1545,7 @@ function update_find_result(gobj)
1537
1545
  priv.$find_count.textContent = "";
1538
1546
  return;
1539
1547
  }
1540
- priv.$find_count.textContent = String(priv.match_count);
1548
+ priv.$find_count.textContent = t("matches", {count: priv.match_count});
1541
1549
  priv.$find_result.classList.remove("is-hidden");
1542
1550
  }
1543
1551
 
@@ -649,9 +649,14 @@ function make_toolbar(gobj)
649
649
 
650
650
  let center_items = [
651
651
  $find_control,
652
- /* Two spans, not one string: the number is DATA and "matches" is
653
- * the word, so a language switch re-translates the half that is a
654
- * word. Spaced with CSS because createElement2 trims text nodes. */
652
+ /* ONE counted span, not a number beside a word: "1 matches" is
653
+ * what two spans always produce, because nothing there can see
654
+ * the count. `t(key, {count})` picks `matches_one` for one and
655
+ * falls back to the base key for the rest, in both languages.
656
+ * No `i18n` attribute on it: refresh_language() would call t()
657
+ * WITHOUT the count and put the plural back over the singular.
658
+ * Nothing re-translates it, so it is repainted on the next
659
+ * find, and it is hidden while the box is empty. */
655
660
  /* `display:flex` inline and NOT the `is-flex` helper: both Bulma
656
661
  * helpers carry !important, so `is-hidden is-flex` on one element
657
662
  * is decided by whichever lands later in the stylesheet — and the
@@ -661,8 +666,7 @@ function make_toolbar(gobj)
661
666
  ['div', {class: 'GRAPH_FIND_RESULT is-hidden',
662
667
  style: 'display:flex; align-items:center; gap:.3rem; ' +
663
668
  'margin-right:.5rem; font-size:.85rem;'}, [
664
- ['span', {class: 'GRAPH_FIND_COUNT'}, ''],
665
- ['span', {i18n: 'matches'}, 'matches']
669
+ ['span', {class: 'GRAPH_FIND_COUNT'}, '']
666
670
  ]]
667
671
  ];
668
672
 
@@ -2485,7 +2489,7 @@ function ac_find_result(gobj, event, kw, src)
2485
2489
  return 0;
2486
2490
  }
2487
2491
 
2488
- $count.textContent = String((kw && kw.matches) || 0);
2492
+ $count.textContent = t("matches", {count: (kw && kw.matches) || 0});
2489
2493
  $result.classList.remove("is-hidden");
2490
2494
  return 0;
2491
2495
  }
@@ -88,6 +88,43 @@ function node_status(d)
88
88
  return d.playing? "playing": "running";
89
89
  }
90
90
 
91
+ /*
92
+ * What the status LOOKS like, before any word is read.
93
+ *
94
+ * The transport vocabulary, because it is Yuneta's own: a yuno is RUN
95
+ * (the process), PLAYED (the services) and PAUSED, so play / pause /
96
+ * stop are not a metaphor here, they are the three verbs.
97
+ *
98
+ * A coloured dot was the whole encoding before, and a dot has one
99
+ * SHAPE: telling "running" from "stopped" meant telling green from
100
+ * red, at 10px, which is no distinction at all for a reader who does
101
+ * not see colour -- and stopped is the state that matters.
102
+ *
103
+ * U+FE0E asks for the TEXT presentation: bare, several of these
104
+ * render as colour emoji on some platforms, which would make the
105
+ * glyph the loudest thing on a card.
106
+ */
107
+ const STATUS_SYMBOLS = {
108
+ "playing": "\u25B6\uFE0E", /* play */
109
+ "running": "\u2016", /* paused: two bars */
110
+ "stopped": "\u25A0\uFE0E", /* stop */
111
+ };
112
+
113
+ const DISABLED_SYMBOL = "\u2298"; /* a slashed circle */
114
+
115
+ /************************************************************
116
+ * The symbol of a node's status, which is what the reader
117
+ * actually sees first. `disabled` overrides the rest: a
118
+ * disabled gobj is not going to run whatever it is doing now.
119
+ ************************************************************/
120
+ function node_status_symbol(d)
121
+ {
122
+ if(d && d.disabled) {
123
+ return DISABLED_SYMBOL;
124
+ }
125
+ return STATUS_SYMBOLS[node_status(d)] || STATUS_SYMBOLS.stopped;
126
+ }
127
+
91
128
  /************************************************************
92
129
  * The i18n key of the status, which is NOT the status itself:
93
130
  * a gobj that runs and does not play is PAUSED to the reader,
@@ -349,7 +386,7 @@ function node_labels(t)
349
386
  full_name: t("full name"),
350
387
  role: t("role"),
351
388
  status: t("status"),
352
- state: t("state"),
389
+ fsm_state: t("fsm state"),
353
390
  disabled: t("disabled"),
354
391
  parent: t("parent"),
355
392
  bottom_gobj: t("bottom gobj"),
@@ -387,8 +424,14 @@ function node_info_rows(d, t)
387
424
  }
388
425
 
389
426
  rows.push([f.role, L.role[role_i18n_key(node_role(d))]]);
390
- rows.push([f.status, L.status[node_status_key(d)]]);
391
- rows.push([f.state, d.state || "\u2014"]);
427
+ rows.push([
428
+ f.status,
429
+ node_status_symbol(d) + " " + L.status[node_status_key(d)]
430
+ ]);
431
+ /* NOT `state`: both rows read "Estado" in Spanish, side by side,
432
+ * one saying "Parado" and the other "ST_IDLE". They are different
433
+ * questions -- what the gobj is DOING, and where its machine IS. */
434
+ rows.push([f.fsm_state, d.state || "\u2014"]);
392
435
 
393
436
  if(d.disabled) {
394
437
  rows.push([f.disabled, f.yes]);
@@ -423,9 +466,11 @@ function node_info_rows(d, t)
423
466
  export {
424
467
  ROLES,
425
468
  STATUSES,
469
+ STATUS_SYMBOLS,
426
470
  node_role,
427
471
  node_status,
428
472
  node_status_key,
473
+ node_status_symbol,
429
474
  node_badge_keys,
430
475
  node_labels,
431
476
  node_info_rows,
@@ -2,6 +2,7 @@ import {describe, it, expect} from "vitest";
2
2
  import {
3
3
  node_role,
4
4
  node_status,
5
+ node_status_symbol,
5
6
  node_status_key,
6
7
  node_badge_keys,
7
8
  node_info_rows,
@@ -148,8 +149,10 @@ describe("node_info_rows", () => {
148
149
  let timer = root.children.find(c => c.gclass === "C_TIMER");
149
150
  let r = rows_of(timer);
150
151
  expect(r["role"]).toBe("pure child");
151
- expect(r["status"]).toBe("running (paused)");
152
- expect(r["state"]).toBe("ST_IDLE");
152
+ /* The symbol travels with the word: the popover is where a
153
+ * reader learns which glyph means what. */
154
+ expect(r["status"]).toBe("\u2016 running (paused)");
155
+ expect(r["fsm state"]).toBe("ST_IDLE");
153
156
  });
154
157
 
155
158
  it("shows the facts a card has no room for", () => {
@@ -158,7 +161,7 @@ describe("node_info_rows", () => {
158
161
  let r = rows_of(tcp);
159
162
  expect(r["disabled"]).toBe("yes");
160
163
  expect(r["bottom gobj"]).toBe("C_TCP^raw");
161
- expect(r["status"]).toBe("stopped");
164
+ expect(r["status"]).toBe("\u2298 stopped");
162
165
  });
163
166
 
164
167
  it("carries the traces and the flags the backend reports", () => {
@@ -182,3 +185,21 @@ describe("node_info_rows", () => {
182
185
  expect(rows_of(root)["children"]).toBe("2 (collapsed)");
183
186
  });
184
187
  });
188
+
189
+ describe("node_status_symbol", () => {
190
+ it("gives each status its own SHAPE, not only a colour", () => {
191
+ expect(node_status_symbol({running: true, playing: true})).toBe("\u25B6\uFE0E");
192
+ expect(node_status_symbol({running: true, playing: false})).toBe("\u2016");
193
+ expect(node_status_symbol({running: false})).toBe("\u25A0\uFE0E");
194
+ });
195
+
196
+ it("says disabled over anything else", () => {
197
+ expect(node_status_symbol({running: true, playing: true, disabled: true}))
198
+ .toBe("\u2298");
199
+ });
200
+
201
+ it("falls back to stopped for nothing at all", () => {
202
+ expect(node_status_symbol(null)).toBe("\u25A0\uFE0E");
203
+ expect(node_status_symbol(undefined)).toBe("\u25A0\uFE0E");
204
+ });
205
+ });
@@ -257,11 +257,19 @@ function build_body(shell, t)
257
257
  );
258
258
  attach_clear($search_ctrl, $search);
259
259
 
260
- /* Live match counter next to the filter. */
260
+ /* Live match counter next to the filter.
261
+ *
262
+ * ONE counted span, not a number beside a word: "1 matches" is what
263
+ * two spans always produce, because nothing there can see the
264
+ * count. `t(key, {count})` picks `matches_one` for one and falls
265
+ * back to the base key for the rest. No `i18n` attribute on it --
266
+ * refresh_language() would call t() WITHOUT the count and put the
267
+ * plural back over the singular -- so the filter, which runs on
268
+ * every keystroke, is what repaints it. */
261
269
  let $count_n = createElement2(["span", {class: "ROUTEMAP_COUNT_N"}, ""]);
262
270
  let $count = createElement2(
263
271
  ["span", {class: "ROUTEMAP_COUNT is-size-7 has-text-grey is-hidden"},
264
- [$count_n, ["span", {i18n: "matches"}, "matches"]]]
272
+ [$count_n]]
265
273
  );
266
274
  /* References are hidden by default and revealed by this toggle.
267
275
  * Remembered for the page session (not localStorage): it is a
@@ -294,7 +302,9 @@ function build_body(shell, t)
294
302
  if(q === "") {
295
303
  $count.classList.add("is-hidden");
296
304
  } else {
297
- $count_n.textContent = $tree.querySelectorAll(".ROUTEMAP_MATCH").length;
305
+ $count_n.textContent = t("matches", {
306
+ count: $tree.querySelectorAll(".ROUTEMAP_MATCH").length
307
+ });
298
308
  $count.classList.remove("is-hidden");
299
309
  }
300
310
  };