zettcode-commands 0.4.2__py3-none-any.whl

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,736 @@
1
+ """The picture on ``/hub``'s first tab: sessions as boxes, messages as lines, one colour per state.
2
+
3
+ Nothing here reads anything. :func:`build` turns the broker's graph into :class:`Node` and :class:`Link` values;
4
+ :func:`layout` ranks the nodes by how much they talk, keeps as many of the busiest as fit the rectangle without
5
+ touching, puts the reader's own session in the middle with the rest on a ring, and routes every line around the boxes
6
+ it does not belong to; :func:`paint` draws the result, and may be called again with another node selected without
7
+ routing anything again.
8
+
9
+ How a line is drawn:
10
+
11
+ * It leaves one box at the middle of a side and enters the other the same way, in right angles with rounded corners,
12
+ and joins the lines it meets (``├ ┬ ┼ ...``). It is found by A* over the character grid: a step down costs twice a
13
+ step across (a cell is about twice as tall as it is wide), a turn costs, and a cell another line already uses costs
14
+ more, so lines keep apart where they can.
15
+ * Its number is the messages the two exchanged, both ways, written beside the end it is drawn from: the other end
16
+ than the reader's own session, otherwise the busier one. The line has the colour of that end's state, so the number
17
+ and the colour say whose line it is.
18
+ * With a node selected its lines stay as they are and everything else is dimmed.
19
+
20
+ A state has one colour; the legend says which. They are the theme's own, so a palette change recolours the picture, and
21
+ the two lists use the same colours for the same words.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import heapq
27
+ import math
28
+ from collections.abc import Mapping, Sequence
29
+ from dataclasses import dataclass, field
30
+ from typing import Any, Literal
31
+
32
+ from zettcode.tui import Canvas, Theme
33
+ from zettcode.tui.render import Style, display_width, truncate
34
+
35
+ __all__ = [
36
+ "BOX_HEIGHT",
37
+ "MAX_NODES",
38
+ "STATES",
39
+ "Link",
40
+ "Node",
41
+ "Picture",
42
+ "State",
43
+ "build",
44
+ "colours",
45
+ "glyph",
46
+ "layout",
47
+ "legend",
48
+ "nearest",
49
+ "paint",
50
+ "state_of",
51
+ "summary",
52
+ ]
53
+
54
+ State = Literal["working", "idle", "external", "offline"]
55
+
56
+ #: The states a node can be in, in the order the legend lists them.
57
+ STATES: tuple[State, ...] = ("working", "idle", "external", "offline")
58
+
59
+ #: How many nodes the picture draws at most, and how many lines; more is a smear.
60
+ MAX_NODES = 16
61
+ MAX_LINKS = 40
62
+
63
+ #: A box is this many rows tall, and at most this many columns wide, label included.
64
+ BOX_HEIGHT = 3
65
+ MAX_BOX = 24
66
+
67
+ # A cell of line is the set of sides it joins: these bits, and the glyph each set is drawn as.
68
+ _N, _E, _S, _W = 1, 2, 4, 8
69
+ _DIRS = ((0, -1), (1, 0), (0, 1), (-1, 0))
70
+ _BITS = (_N, _E, _S, _W)
71
+ _JOINS = {
72
+ _N: "\u2502",
73
+ _S: "\u2502",
74
+ _N | _S: "\u2502",
75
+ _E: "\u2500",
76
+ _W: "\u2500",
77
+ _E | _W: "\u2500",
78
+ _N | _E: "\u2570",
79
+ _N | _W: "\u256f",
80
+ _S | _E: "\u256d",
81
+ _S | _W: "\u256e",
82
+ _N | _E | _S: "\u251c",
83
+ _N | _S | _W: "\u2524",
84
+ _E | _S | _W: "\u252c",
85
+ _N | _E | _W: "\u2534",
86
+ _N | _E | _S | _W: "\u253c",
87
+ }
88
+
89
+ Cell = tuple[int, int]
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class Node:
94
+ """One session, daemon or outsider the picture shows."""
95
+
96
+ key: str
97
+ label: str
98
+ state: State
99
+ me: bool = False
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class Link:
104
+ """Two nodes that exchanged messages, and how many."""
105
+
106
+ a: str
107
+ b: str
108
+ count: int
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class Route:
113
+ """One line: its link, the end it is numbered at, and the cells it runs through."""
114
+
115
+ link: Link
116
+ origin: str
117
+ cells: tuple[Cell, ...]
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class Picture:
122
+ """A laid-out graph: what is drawn, where, and what was left out.
123
+
124
+ Attributes:
125
+ nodes: The nodes drawn, busiest first and the reader's own at the head.
126
+ boxes: Each drawn node's box as ``(left, top, width)``.
127
+ routes: The lines, strongest first.
128
+ cells: For each cell a line runs through, which routes and which sides of the cell each joins.
129
+ labels: ``(x, y, text, route index)`` for each number that found a place.
130
+ hidden_nodes: How many nodes did not fit.
131
+ undrawn: How many lines join two drawn nodes and are not drawn: only the strongest :data:`MAX_LINKS` are
132
+ routed, and a line with no way round the boxes is not drawn either.
133
+ unnumbered: How many drawn lines found no place to write their number.
134
+ """
135
+
136
+ nodes: tuple[Node, ...] = ()
137
+ boxes: Mapping[str, tuple[int, int, int]] = field(default_factory=dict)
138
+ routes: tuple[Route, ...] = ()
139
+ cells: Mapping[Cell, tuple[tuple[int, int], ...]] = field(default_factory=dict)
140
+ labels: tuple[tuple[int, int, str, int], ...] = ()
141
+ hidden_nodes: int = 0
142
+ undrawn: int = 0
143
+ unnumbered: int = 0
144
+
145
+
146
+ def state_of(status: str, kind: str) -> State:
147
+ """Return the state a roster entry (or a daemon's status) is drawn in."""
148
+ if kind == "external":
149
+ return "external"
150
+ return "working" if status == "working" else "idle"
151
+
152
+
153
+ def glyph(state: str) -> str:
154
+ """Return the mark in front of a state: a diamond for an outsider, a dot for the rest."""
155
+ return "\u25c7" if state == "external" else "\u25cf"
156
+
157
+
158
+ def colours(theme: Theme) -> dict[str, str]:
159
+ """Return the colour of each state, from the theme: working is the accent, one with no process the muted colour."""
160
+ return {
161
+ "working": theme.accent,
162
+ "idle": theme.subtle,
163
+ "external": theme.warning,
164
+ "offline": theme.muted,
165
+ }
166
+
167
+
168
+ def build(
169
+ graph: Mapping[str, Any],
170
+ *,
171
+ me: str = "",
172
+ titles: Mapping[str, str] | None = None,
173
+ offline: Sequence[str] = (),
174
+ ) -> tuple[tuple[Node, ...], tuple[Link, ...]]:
175
+ """Return the nodes and links of the broker's graph.
176
+
177
+ A session the graph has a line to that is not in its roster is drawn ``offline``, as is every id in ``offline``:
178
+ nothing runs it, whether it was a daemon that was stopped or a session that was only stored.
179
+
180
+ Args:
181
+ graph: What ``Client.traffic_graph`` returned: ``sessions`` and ``edges``.
182
+ me: The reader's own session id, drawn in the middle.
183
+ titles: Names for sessions that are stored but not registered, by id.
184
+ offline: Ids of daemons of this workspace whose process is gone, which get a node even with no messages.
185
+ """
186
+ names = dict(titles or {})
187
+ gone = set(offline)
188
+ nodes: dict[str, Node] = {}
189
+ for entry in graph.get("sessions", ()):
190
+ key = str(entry.get("session_id", ""))
191
+ if not key:
192
+ continue
193
+ label = str(entry.get("handle") or entry.get("title") or key[:8])
194
+ nodes[key] = Node(key, label, state_of(str(entry.get("status", "idle")), str(entry.get("kind", ""))), key == me)
195
+ pairs: dict[frozenset[str], Link] = {}
196
+ for edge in graph.get("edges", ()):
197
+ a, b = str(edge.get("a", "")), str(edge.get("b", ""))
198
+ if not a or not b or a == b:
199
+ continue
200
+ for key in (a, b):
201
+ if key not in nodes:
202
+ nodes[key] = Node(key, names.get(key) or key[:8], "offline", key == me)
203
+ # A pair is one line whichever way the messages went: two entries for it are added, not drawn twice.
204
+ count = int(edge.get("count", 1))
205
+ seen = pairs.get(frozenset((a, b)))
206
+ pairs[frozenset((a, b))] = Link(a, b, count + (seen.count if seen else 0))
207
+ links = list(pairs.values())
208
+ for key in gone:
209
+ if key not in nodes and key in names:
210
+ nodes[key] = Node(key, names[key], "offline", key == me)
211
+ return tuple(nodes.values()), tuple(links)
212
+
213
+
214
+ # -- ranking and placing ----------------------------------------------------------------------------
215
+
216
+
217
+ def _text(node: Node) -> str:
218
+ """Return what a box says: the label, and ``(you)`` after the reader's own."""
219
+ return node.label + (" (you)" if node.me else "")
220
+
221
+
222
+ def _box(node: Node) -> int:
223
+ """Return the width of a node's box: its glyph, its label and a column of air on both sides."""
224
+ return min(MAX_BOX, display_width(_text(node)) + 6)
225
+
226
+
227
+ def _totals(links: Sequence[Link]) -> dict[str, int]:
228
+ """Return the messages each node is part of."""
229
+ total: dict[str, int] = {}
230
+ for link in links:
231
+ total[link.a] = total.get(link.a, 0) + link.count
232
+ total[link.b] = total.get(link.b, 0) + link.count
233
+ return total
234
+
235
+
236
+ def rank(nodes: Sequence[Node], links: Sequence[Link]) -> list[Node]:
237
+ """Return the nodes busiest first, the reader's own at the head whatever it has said."""
238
+ total = _totals(links)
239
+ order = sorted(range(len(nodes)), key=lambda i: (not nodes[i].me, -total.get(nodes[i].key, 0), i))
240
+ return [nodes[i] for i in order]
241
+
242
+
243
+ def _ring_order(nodes: Sequence[Node], links: Sequence[Link]) -> list[Node]:
244
+ """Return the nodes around the ring so that talkers sit next to each other where they can."""
245
+ weight: dict[str, dict[str, int]] = {node.key: {} for node in nodes}
246
+ for link in links:
247
+ if link.a in weight and link.b in weight:
248
+ weight[link.a][link.b] = weight[link.a].get(link.b, 0) + link.count
249
+ weight[link.b][link.a] = weight[link.b].get(link.a, 0) + link.count
250
+ left = {node.key: node for node in nodes}
251
+ total = {key: sum(neighbours.values()) for key, neighbours in weight.items()}
252
+ out: list[Node] = []
253
+ current: str | None = None
254
+ while left:
255
+ pool = weight[current] if current else {}
256
+ key = max(left, key=lambda k: (pool.get(k, 0), total[k]))
257
+ out.append(left.pop(key))
258
+ current = key
259
+ return out
260
+
261
+
262
+ def _spots(
263
+ shown: Sequence[Node], links: Sequence[Link], width: int, height: int, *, whole_rows: bool = False
264
+ ) -> dict[str, Cell]:
265
+ """Return the centre of each node's box: the reader's own in the middle, the rest on an ellipse round it.
266
+
267
+ The ellipse's vertical radius is half a row when the height is even, and rounding a ring box onto a half row can
268
+ push its last row onto the edge; ``whole_rows`` takes the radius down to a whole number of rows instead.
269
+ """
270
+ cx, cy = width // 2, height // 2
271
+ if len(shown) == 1:
272
+ return {shown[0].key: (cx, cy)}
273
+ if len(shown) == 2:
274
+ first, second = shown
275
+ w1, w2 = _box(first), _box(second)
276
+ # Each box is centred in its own half by its own width, then pushed inside the edge: two wide boxes in a
277
+ # narrow area sit against the two edges, one cell from each, with whatever lane is left between them.
278
+ left1 = max(1, width // 4 - w1 // 2)
279
+ left2 = min(width - 1 - w2, width - width // 4 - w2 // 2)
280
+ return {first.key: (left1 + w1 // 2, cy), second.key: (left2 + w2 // 2, cy)}
281
+ centre = next((node for node in shown if node.me), None)
282
+ ring = _ring_order([node for node in shown if node is not centre], links)
283
+ widest = max(_box(node) for node in shown)
284
+ rx = max(1.0, (width - widest) / 2 - 1)
285
+ ry = max(1.0, (height - BOX_HEIGHT) / 2 - 1)
286
+ if whole_rows:
287
+ ry = float(max(1, int(ry)))
288
+ spots: dict[str, Cell] = {}
289
+ if centre is not None:
290
+ spots[centre.key] = (cx, cy)
291
+ for index, node in enumerate(ring):
292
+ angle = -math.pi / 2 + 2 * math.pi * index / len(ring)
293
+ spots[node.key] = (round(cx + rx * math.cos(angle)), round(cy + ry * math.sin(angle)))
294
+ return spots
295
+
296
+
297
+ def _rects(shown: Sequence[Node], spots: Mapping[str, Cell]) -> dict[str, tuple[int, int, int]]:
298
+ """Return each box as ``(left, top, width)`` from its centre."""
299
+ return {
300
+ node.key: (spots[node.key][0] - _box(node) // 2, spots[node.key][1] - BOX_HEIGHT // 2, _box(node))
301
+ for node in shown
302
+ }
303
+
304
+
305
+ def _clear(boxes: Mapping[str, tuple[int, int, int]], width: int, height: int) -> bool:
306
+ """Return whether every box is inside the rectangle with a cell to spare and a free lane to the next one."""
307
+ rects = list(boxes.values())
308
+ for left, top, w in rects:
309
+ if left < 1 or top < 1 or left + w > width - 1 or top + BOX_HEIGHT > height - 1:
310
+ return False
311
+ for index, (al, at, aw) in enumerate(rects):
312
+ for bl, bt, bw in rects[index + 1 :]:
313
+ if al - 1 < bl + bw and bl < al + aw + 1 and at - 1 < bt + BOX_HEIGHT and bt < at + BOX_HEIGHT + 1:
314
+ return False
315
+ return True
316
+
317
+
318
+ # -- routing ----------------------------------------------------------------------------------------
319
+
320
+
321
+ def _ports(box: tuple[int, int, int], toward: Cell) -> list[tuple[Cell, int, int]]:
322
+ """Return the cells a line may start from or end in: the cell, the side that points into the box, and a penalty.
323
+
324
+ A side that faces away from ``toward`` costs extra, so a line leaves by the side nearest the box it goes to and
325
+ only goes round when that side is taken.
326
+ """
327
+ left, top, w = box
328
+ middle = top + BOX_HEIGHT // 2
329
+ centre = left + w // 2
330
+ dx, dy = toward[0] - centre, toward[1] - middle
331
+ # A cell is about twice as tall as wide, so a vertical offset counts double.
332
+ horizontal = abs(dx) >= 2 * abs(dy)
333
+ return [
334
+ ((left - 1, middle), _E, 0 if dx < 0 and horizontal else 6 if dx < 0 else 14),
335
+ ((left + w, middle), _W, 0 if dx > 0 and horizontal else 6 if dx > 0 else 14),
336
+ ((centre, top - 1), _S, 0 if dy < 0 and not horizontal else 6 if dy < 0 else 14),
337
+ ((centre, top + BOX_HEIGHT), _N, 0 if dy > 0 and not horizontal else 6 if dy > 0 else 14),
338
+ ]
339
+
340
+
341
+ def _search(
342
+ starts: list[tuple[Cell, int, int]],
343
+ ends: list[tuple[Cell, int, int]],
344
+ blocked: set[Cell],
345
+ used: Mapping[Cell, int],
346
+ halo: Mapping[Cell, frozenset[str]],
347
+ allowed: frozenset[str],
348
+ width: int,
349
+ height: int,
350
+ ) -> tuple[list[Cell], int, int] | None:
351
+ """Return the cheapest path between two boxes as its cells and the two sides that point into the boxes.
352
+
353
+ A step across costs 1 and a step down 2, a turn 4, a cell a parallel line already runs through 6 and a crossing
354
+ 2, and a cell beside a box that is not one of the two 2, which keeps a line off the borders it passes.
355
+ """
356
+ goals = {cell: (bit, penalty) for cell, bit, penalty in ends}
357
+
358
+ def heuristic(x: int, y: int) -> int:
359
+ return min(abs(x - gx) + 2 * abs(y - gy) for gx, gy in goals)
360
+
361
+ heap: list[tuple[int, int, int, int, int, int]] = []
362
+ best: dict[tuple[int, int, int], int] = {}
363
+ parent: dict[tuple[int, int, int], tuple[int, int, int] | None] = {}
364
+ first: dict[tuple[int, int, int], int] = {}
365
+ tick = 0
366
+ for (x, y), bit, penalty in starts:
367
+ if not (0 <= x < width and 0 <= y < height) or (x, y) in blocked:
368
+ continue
369
+ outward = (_BITS.index(bit) + 2) % 4
370
+ state = (x, y, outward)
371
+ best[state] = penalty
372
+ parent[state] = None
373
+ first[state] = bit
374
+ heapq.heappush(heap, (penalty + heuristic(x, y), penalty, tick, x, y, outward))
375
+ tick += 1
376
+ expanded = 0
377
+ while heap:
378
+ _, cost, _, x, y, d = heapq.heappop(heap)
379
+ if cost > best.get((x, y, d), cost):
380
+ continue
381
+ if (x, y) in goals:
382
+ cells: list[Cell] = []
383
+ walk: tuple[int, int, int] | None = (x, y, d)
384
+ start_bit = 0
385
+ while walk is not None:
386
+ cells.append((walk[0], walk[1]))
387
+ start_bit = first.get(walk, start_bit)
388
+ walk = parent[walk]
389
+ cells.reverse()
390
+ return cells, start_bit, goals[(x, y)][0]
391
+ expanded += 1
392
+ if expanded > 30000:
393
+ return None
394
+ for nd, (dx, dy) in enumerate(_DIRS):
395
+ nx, ny = x + dx, y + dy
396
+ if not (0 <= nx < width and 0 <= ny < height) or (nx, ny) in blocked:
397
+ continue
398
+ axis = 1 if nd in (1, 3) else 2
399
+ step = 1 if axis == 1 else 2
400
+ step += 0 if nd == d else 4
401
+ seen = used.get((nx, ny), 0)
402
+ if seen & axis:
403
+ step += 6
404
+ elif seen:
405
+ step += 2
406
+ if halo.get((nx, ny), frozenset()) - allowed:
407
+ step += 2
408
+ if (nx, ny) in goals:
409
+ step += goals[(nx, ny)][1]
410
+ total = cost + step
411
+ state = (nx, ny, nd)
412
+ if total < best.get(state, 1 << 30):
413
+ best[state] = total
414
+ parent[state] = (x, y, d)
415
+ heapq.heappush(heap, (total + heuristic(nx, ny), total, tick, nx, ny, nd))
416
+ tick += 1
417
+ return None
418
+
419
+
420
+ def _centre(box: tuple[int, int, int]) -> Cell:
421
+ """Return the middle cell of a box."""
422
+ return box[0] + box[2] // 2, box[1] + BOX_HEIGHT // 2
423
+
424
+
425
+ def _sides(cells: Sequence[Cell], start_bit: int, end_bit: int) -> list[int]:
426
+ """Return which sides of each cell the path joins, the first and last also reaching into their boxes."""
427
+ out = []
428
+ for index, here in enumerate(cells):
429
+ bits = 0
430
+ for other in (cells[index - 1] if index else None, cells[index + 1] if index + 1 < len(cells) else None):
431
+ if other is None:
432
+ continue
433
+ dx, dy = other[0] - here[0], other[1] - here[1]
434
+ bits |= _E if dx > 0 else _W if dx < 0 else _S if dy > 0 else _N
435
+ if index == 0:
436
+ bits |= start_bit
437
+ if index == len(cells) - 1:
438
+ bits |= end_bit
439
+ out.append(bits)
440
+ return out
441
+
442
+
443
+ def _origin(link: Link, nodes: Mapping[str, Node], total: Mapping[str, int]) -> str:
444
+ """Return the end a line is numbered at: not the reader's own session, else the busier one."""
445
+ a, b = nodes[link.a], nodes[link.b]
446
+ if a.me != b.me:
447
+ return b.key if a.me else a.key
448
+ return a.key if total.get(a.key, 0) >= total.get(b.key, 0) else b.key
449
+
450
+
451
+ def layout(nodes: Sequence[Node], links: Sequence[Link], width: int, height: int) -> Picture:
452
+ """Lay the graph out in a ``width`` by ``height`` rectangle.
453
+
454
+ The busiest nodes are kept, as many as fit on one ellipse with a lane left between every two boxes (a count that
455
+ does not fit by a row is dropped, and the next smaller is tried); the rest are counted in ``hidden_nodes``.
456
+ Only the strongest :data:`MAX_LINKS` lines are routed, strongest first so the busy ones get the straightest paths;
457
+ those left out between two drawn nodes are counted in ``undrawn``.
458
+ """
459
+ if not nodes or width <= 0 or height <= 0:
460
+ return Picture(hidden_nodes=len(nodes))
461
+ ranked = rank(nodes, links)
462
+ shown: list[Node] = []
463
+ spots: dict[str, Cell] = {}
464
+ boxes: dict[str, tuple[int, int, int]] = {}
465
+ for count in range(min(MAX_NODES, len(ranked)), 0, -1):
466
+ shown = ranked[:count]
467
+ for whole_rows in (False, True):
468
+ spots = _spots(shown, links, width, height, whole_rows=whole_rows)
469
+ boxes = _rects(shown, spots)
470
+ if _clear(boxes, width, height):
471
+ break
472
+ else:
473
+ continue
474
+ break
475
+ else:
476
+ return Picture(hidden_nodes=len(nodes))
477
+
478
+ by_key = {node.key: node for node in shown}
479
+ total = _totals(links)
480
+ inside = [link for link in links if link.a in by_key and link.b in by_key]
481
+ inside.sort(key=lambda link: -link.count)
482
+ owner = {
483
+ (x, y): key
484
+ for key, (left, top, w) in boxes.items()
485
+ for x in range(left, left + w)
486
+ for y in range(top, top + BOX_HEIGHT)
487
+ }
488
+ blocked = set(owner)
489
+ halo: dict[Cell, frozenset[str]] = {}
490
+ for key, (left, top, w) in boxes.items():
491
+ for x in range(left - 1, left + w + 1):
492
+ for y in range(top - 1, top + BOX_HEIGHT + 1):
493
+ if (x, y) not in blocked:
494
+ halo[(x, y)] = halo.get((x, y), frozenset()) | {key}
495
+
496
+ used: dict[Cell, int] = {}
497
+ routes: list[Route] = []
498
+ cells: dict[Cell, list[tuple[int, int]]] = {}
499
+ for link in inside[:MAX_LINKS]:
500
+ origin = _origin(link, by_key, total)
501
+ other = link.b if origin == link.a else link.a
502
+ found = _search(
503
+ _ports(boxes[origin], _centre(boxes[other])),
504
+ _ports(boxes[other], _centre(boxes[origin])),
505
+ blocked,
506
+ used,
507
+ halo,
508
+ frozenset((link.a, link.b)),
509
+ width,
510
+ height,
511
+ )
512
+ if found is None:
513
+ continue
514
+ path, start_bit, end_bit = found
515
+ index = len(routes)
516
+ routes.append(Route(link, origin, tuple(path)))
517
+ for cell, sides in zip(path, _sides(path, start_bit, end_bit), strict=True):
518
+ cells.setdefault(cell, []).append((index, sides))
519
+ used[cell] = used.get(cell, 0) | (1 if sides & (_E | _W) else 0) | (2 if sides & (_N | _S) else 0)
520
+ labels = _place_labels(routes, cells, owner, width, height)
521
+ return Picture(
522
+ nodes=tuple(shown),
523
+ boxes=boxes,
524
+ routes=tuple(routes),
525
+ cells={cell: tuple(parts) for cell, parts in cells.items()},
526
+ labels=tuple(labels),
527
+ hidden_nodes=len(nodes) - len(shown),
528
+ undrawn=len(inside) - len(routes),
529
+ unnumbered=len(routes) - len(labels),
530
+ )
531
+
532
+
533
+ def _place_labels(
534
+ routes: Sequence[Route],
535
+ cells: Mapping[Cell, Sequence[tuple[int, int]]],
536
+ owner: Mapping[Cell, str],
537
+ width: int,
538
+ height: int,
539
+ ) -> list[tuple[int, int, str, int]]:
540
+ """Put each line's number on its own line close to the end it is drawn from, or beside it, where nothing else is.
541
+
542
+ On the line it replaces a straight stretch only (never a corner, never the cell that joins the box at the far
543
+ end). Beside it, any side of any of the first cells will do, including the first, so a line of one cell still gets
544
+ its number. A number is never written over a box, over a cell any line runs through or over another number, and
545
+ it never touches a box other than the two its line joins, where it would read as that box's own label.
546
+ """
547
+ taken: set[Cell] = set()
548
+ out: list[tuple[int, int, str, int]] = []
549
+
550
+ def free(spot: set[Cell], ends: tuple[str, str]) -> bool:
551
+ for x, y in spot:
552
+ if not (0 <= x < width and 0 <= y < height) or (x, y) in owner or (x, y) in cells or (x, y) in taken:
553
+ return False
554
+ if any(owner.get(near, ends[0]) not in ends for near in ((x - 1, y), (x, y - 1), (x + 1, y), (x, y + 1))):
555
+ return False
556
+ return True
557
+
558
+ for index, route in enumerate(routes):
559
+ text = f" {route.link.count} "
560
+ path = route.cells
561
+ placed = False
562
+ for start in range(1, len(path) - len(text)):
563
+ run = path[start : start + len(text)]
564
+ straight = all(cell[1] == run[0][1] for cell in run) and all(
565
+ abs(run[i + 1][0] - run[i][0]) == 1 for i in range(len(run) - 1)
566
+ )
567
+ if not straight or any(cell in taken or dict(cells[cell])[index] != _E | _W for cell in run):
568
+ continue
569
+ if any(len(cells[cell]) != 1 for cell in run):
570
+ continue
571
+ out.append((min(cell[0] for cell in run), run[0][1], text, index))
572
+ taken.update(run)
573
+ placed = True
574
+ break
575
+ if placed:
576
+ continue
577
+ number = str(route.link.count)
578
+ for cell_x, cell_y in path[:12]:
579
+ for left, top in (
580
+ (cell_x + 1, cell_y),
581
+ (cell_x - len(number), cell_y),
582
+ (cell_x, cell_y - 1),
583
+ (cell_x, cell_y + 1),
584
+ ):
585
+ spot = {(left + i, top) for i in range(len(number))}
586
+ if free(spot, (route.link.a, route.link.b)):
587
+ out.append((left, top, number, index))
588
+ taken |= spot
589
+ placed = True
590
+ break
591
+ if placed:
592
+ break
593
+ return out
594
+
595
+
596
+ # -- painting ---------------------------------------------------------------------------------------
597
+
598
+
599
+ def paint(canvas: Canvas, theme: Theme, x: int, y: int, picture: Picture, selected: str | None = None) -> None:
600
+ """Paint a laid-out graph with its top left corner at ``(x, y)``; ``selected`` keeps its lines and dims the rest."""
601
+ if not picture.nodes:
602
+ return
603
+ states = colours(theme)
604
+ by_key = {node.key: node for node in picture.nodes}
605
+ page = theme.surface_alt
606
+
607
+ def lit(route: Route) -> bool:
608
+ return selected is None or selected in (route.link.a, route.link.b)
609
+
610
+ def colour_of(route: Route) -> str:
611
+ return states[by_key[route.origin].state] if lit(route) else theme.border
612
+
613
+ for (cell_x, cell_y), parts in picture.cells.items():
614
+ # The glyph keeps every line through the cell, so a dimmed line is not cut where a bright one crosses it; the
615
+ # colour is a bright line's when there is one.
616
+ bits = 0
617
+ for _, sides in parts:
618
+ bits |= sides
619
+ owner_route = next((index for index, _ in parts if lit(picture.routes[index])), parts[0][0])
620
+ if bits in _JOINS:
621
+ style = Style(foreground=colour_of(picture.routes[owner_route]), background=page)
622
+ canvas.set_cell(x + cell_x, y + cell_y, _JOINS[bits], style)
623
+ for left, top, text, index in picture.labels:
624
+ route = picture.routes[index]
625
+ canvas.draw_text(x + left, y + top, text, Style(foreground=colour_of(route), background=page, bold=lit(route)))
626
+
627
+ near = {selected} if selected is not None else set()
628
+ for route in picture.routes:
629
+ if selected in (route.link.a, route.link.b):
630
+ near.update((route.link.a, route.link.b))
631
+ for node in picture.nodes:
632
+ left, top, w = picture.boxes[node.key]
633
+ colour = states[node.state]
634
+ chosen = node.key == selected
635
+ dim = selected is not None and node.key not in near
636
+ fill = theme.selection if chosen else theme.surface
637
+ edge = Style(
638
+ foreground=theme.border if dim else colour,
639
+ background=fill,
640
+ bold=chosen or node.me or node.state == "working",
641
+ )
642
+ body = Style(foreground=theme.muted if dim else theme.text, background=fill, bold=node.me or chosen)
643
+ canvas.fill(x + left, y + top, w, BOX_HEIGHT, Style(background=fill))
644
+ if node.me or chosen:
645
+ tl, tr, bl, br, bar, side = "\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"
646
+ else:
647
+ tl, tr, bl, br, bar, side = "\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"
648
+ canvas.draw_text(x + left, y + top, tl + bar * (w - 2) + tr, edge)
649
+ canvas.draw_text(x + left, y + top + 2, bl + bar * (w - 2) + br, edge)
650
+ canvas.draw_text(x + left, y + top + 1, side, edge)
651
+ canvas.draw_text(x + left + w - 1, y + top + 1, side, edge)
652
+ canvas.draw_text(
653
+ x + left + 2, y + top + 1, glyph(node.state), Style(foreground=colour, background=fill, bold=True)
654
+ )
655
+ canvas.draw_text(x + left + 4, y + top + 1, truncate(_text(node), w - 6), body, max_width=w - 6)
656
+
657
+
658
+ #: The step an arrow key takes on the picture, as (columns, rows).
659
+ _ARROWS = {"left": (-1, 0), "right": (1, 0), "up": (0, -1), "down": (0, 1)}
660
+
661
+
662
+ def nearest(picture: Picture, selected: str | None, direction: str) -> str | None:
663
+ """Return the node to select when an arrow key is pressed, or ``None`` when there is none that way.
664
+
665
+ Every node has a place, so an arrow goes to the node nearest in that direction: of those ahead of the selected
666
+ one it takes the one with the least distance along the arrow plus twice the distance across it, preferring
667
+ those inside a cone of about 60 degrees either side, and falling back to any that lies ahead. A row is about
668
+ twice as tall as a column is wide, so rows count double. With nothing selected the first arrow selects the
669
+ reader's own session, which comes first.
670
+ """
671
+ if not picture.nodes:
672
+ return None
673
+ if selected is None or selected not in picture.boxes:
674
+ return picture.nodes[0].key
675
+ step_x, step_y = _ARROWS[direction]
676
+ # Everything below is in columns: a row is about twice as tall as a column is wide, so a row is two units. That
677
+ # one unit is used for the cone and for the score, which is what makes up and down behave like left and right.
678
+ left, top, width = picture.boxes[selected]
679
+ here_x, here_y = left + width / 2, (top + BOX_HEIGHT / 2) * 2
680
+ best: dict[bool, tuple[float, str]] = {}
681
+ for node in picture.nodes:
682
+ if node.key == selected:
683
+ continue
684
+ left, top, width = picture.boxes[node.key]
685
+ dx, dy = left + width / 2 - here_x, (top + BOX_HEIGHT / 2) * 2 - here_y
686
+ ahead = dx * step_x + dy * step_y
687
+ across = abs(dx * step_y) + abs(dy * step_x)
688
+ if ahead <= 0:
689
+ continue
690
+ inside = across <= 1.7 * ahead
691
+ score = (ahead + 2 * across, node.key)
692
+ if inside not in best or score < best[inside]:
693
+ best[inside] = score
694
+ chosen = best.get(True) or best.get(False)
695
+ return chosen[1] if chosen else None
696
+
697
+
698
+ def summary(nodes: Sequence[Node], links: Sequence[Link], picture: Picture, selected: str | None = None) -> str:
699
+ """Return the line over the picture: what the whole graph holds, or what the selected node is part of."""
700
+ chosen = next((node for node in nodes if node.key == selected), None)
701
+ if chosen is not None:
702
+ mine = [link for link in links if selected in (link.a, link.b)]
703
+ drawn = sum(1 for route in picture.routes if selected in (route.link.a, route.link.b))
704
+ shown = f"{drawn}" if drawn == len(mine) else f"{drawn} of {len(mine)}"
705
+ return (
706
+ f"{glyph(chosen.state)} {_text(chosen)} \u00b7 {chosen.state} \u00b7 "
707
+ f"{shown} {'line' if len(mine) == 1 else 'lines'} \u00b7 {sum(link.count for link in mine)} messages"
708
+ )
709
+ working = sum(1 for node in nodes if node.state == "working")
710
+ # What was left out comes before the count of working ones, so a narrow page that cuts the line cuts that.
711
+ text = f"{len(nodes)} nodes \u00b7 {sum(link.count for link in links)} messages"
712
+ if picture.hidden_nodes:
713
+ text += f" \u00b7 {picture.hidden_nodes} quieter not drawn"
714
+ if picture.undrawn:
715
+ text += f" \u00b7 {picture.undrawn} lines not drawn"
716
+ if picture.unnumbered:
717
+ text += f" \u00b7 {picture.unnumbered} without a number"
718
+ return f"{text} \u00b7 {working} working"
719
+
720
+
721
+ def legend(theme: Theme) -> list[list[tuple[str, Style]]]:
722
+ """Return the legend as groups of styled text, what a number means first.
723
+
724
+ A group is drawn whole or not at all, so a narrow page drops the last state rather than half of it.
725
+ """
726
+ states = colours(theme)
727
+ muted = Style(foreground=theme.muted, background=theme.surface_alt)
728
+ groups = [[("n = messages ", muted)]]
729
+ for state in STATES:
730
+ groups.append(
731
+ [
732
+ (glyph(state) + " ", Style(foreground=states[state], background=theme.surface_alt, bold=True)),
733
+ (state + " ", muted),
734
+ ]
735
+ )
736
+ return groups