bytesim 0.1.0__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.
Files changed (48) hide show
  1. bytesim/__init__.py +14 -0
  2. bytesim/__main__.py +3 -0
  3. bytesim/cli.py +45 -0
  4. bytesim/constants.py +48 -0
  5. bytesim/demos.py +74 -0
  6. bytesim/model/__init__.py +5 -0
  7. bytesim/model/addressing.py +160 -0
  8. bytesim/model/base.py +57 -0
  9. bytesim/model/cloning.py +249 -0
  10. bytesim/model/equivalence.py +86 -0
  11. bytesim/model/groups.py +133 -0
  12. bytesim/model/ha.py +93 -0
  13. bytesim/model/layer2.py +106 -0
  14. bytesim/model/layout.py +203 -0
  15. bytesim/model/naming.py +114 -0
  16. bytesim/model/network.py +138 -0
  17. bytesim/model/routing.py +117 -0
  18. bytesim/model/stp.py +315 -0
  19. bytesim/model/types.py +107 -0
  20. bytesim/py.typed +0 -0
  21. bytesim/scripting.py +108 -0
  22. bytesim/synthesis.py +644 -0
  23. bytesim/topology.py +517 -0
  24. bytesim/tui/__init__.py +4 -0
  25. bytesim/tui/app.py +161 -0
  26. bytesim/tui/cables.py +58 -0
  27. bytesim/tui/canvas.py +489 -0
  28. bytesim/tui/command_input.py +29 -0
  29. bytesim/tui/commands/__init__.py +1 -0
  30. bytesim/tui/commands/addressing.py +235 -0
  31. bytesim/tui/commands/base.py +43 -0
  32. bytesim/tui/commands/groups.py +215 -0
  33. bytesim/tui/commands/history.py +134 -0
  34. bytesim/tui/commands/interpreter.py +85 -0
  35. bytesim/tui/commands/naming.py +68 -0
  36. bytesim/tui/commands/registry.py +28 -0
  37. bytesim/tui/commands/scripting.py +54 -0
  38. bytesim/tui/commands/switching.py +192 -0
  39. bytesim/tui/commands/topology.py +255 -0
  40. bytesim/tui/commands/traffic.py +312 -0
  41. bytesim/tui/help.py +50 -0
  42. bytesim/tui/inspector.py +115 -0
  43. bytesim/tui/theme.py +12 -0
  44. bytesim-0.1.0.dist-info/METADATA +178 -0
  45. bytesim-0.1.0.dist-info/RECORD +48 -0
  46. bytesim-0.1.0.dist-info/WHEEL +4 -0
  47. bytesim-0.1.0.dist-info/entry_points.txt +2 -0
  48. bytesim-0.1.0.dist-info/licenses/LICENSE +21 -0
bytesim/tui/cables.py ADDED
@@ -0,0 +1,58 @@
1
+ """Where cables are drawn: orthogonal paths between ports, and the line glyphs."""
2
+ from __future__ import annotations
3
+
4
+ from ..constants import BOX_H
5
+ from ..model import Device, Link, Network
6
+
7
+ N, E, S, W = 1, 2, 4, 8
8
+
9
+ LINE = {
10
+ 0: " ", N: "│", S: "│", N | S: "│", E: "─", W: "─", E | W: "─",
11
+ N | E: "└", N | W: "┘", S | E: "┌", S | W: "┐",
12
+ N | S | E: "├", N | S | W: "┤", E | W | S: "┬", E | W | N: "┴",
13
+ N | E | S | W: "┼",
14
+ }
15
+
16
+ LINE2 = {m: c.translate(str.maketrans("│─└┘┌┐├┤┬┴┼", "║═╚╝╔╗╠╣╦╩╬")) for m, c in LINE.items()}
17
+
18
+ LINE_HEAVY = {m: c.translate(str.maketrans("│─└┘┌┐├┤┬┴┼", "┃━┗┛┏┓┣┫┳┻╋")) for m, c in LINE.items()}
19
+ LINE_DASH = {m: c.translate(str.maketrans("│─", "┆┄")) for m, c in LINE.items()} # STP-blocked
20
+
21
+
22
+ def dirbit(dx: int, dy: int) -> int:
23
+ return {(0, -1): N, (1, 0): E, (0, 1): S, (-1, 0): W}[(dx, dy)]
24
+
25
+
26
+ def _polyline(corners: list[tuple[int, int]]) -> list[tuple[int, int]]:
27
+ pts = [corners[0]]
28
+ for x2, y2 in corners[1:]:
29
+ x, y = pts[-1]
30
+ while (x, y) != (x2, y2):
31
+ x += (x2 > x) - (x2 < x)
32
+ y += (y2 > y) - (y2 < y)
33
+ pts.append((x, y))
34
+ return pts
35
+
36
+ def _zpath(s: tuple[int, int], e: tuple[int, int], idx: int) -> list[tuple[int, int]]:
37
+ (sx, sy), (ex, ey) = s, e
38
+ gap = ey - sy
39
+ mid = sy + 1 + idx % (gap - 1) if gap >= 2 else sy
40
+ return _polyline([(sx, sy), (sx, mid), (ex, mid), (ex, ey)])
41
+
42
+ def _bus_slot(net: Network, dev: Device) -> int:
43
+ row = sorted(d.x for d in net.devices.values() if d.y == dev.y)
44
+ return row.index(dev.x)
45
+
46
+ def route(net: Network, link: Link, idx: int) -> list[tuple[int, int]]:
47
+ """Cells a cable occupies, ordered from link.a to link.b."""
48
+ A, B = net.devices[link.a], net.devices[link.b]
49
+ ax, bx = A.port_x(link.pa), B.port_x(link.pb)
50
+ # Cables leaving the same upper device share a horizontal "bus" row;
51
+ # neighbouring devices use different rows so their buses don't merge.
52
+ if B.y >= A.y + BOX_H + 1: # B below A
53
+ return _zpath((ax, A.y + BOX_H), (bx, B.y - 1), _bus_slot(net, A))
54
+ if A.y >= B.y + BOX_H + 1: # B above A
55
+ return _zpath((bx, B.y + BOX_H), (ax, A.y - 1), _bus_slot(net, B))[::-1]
56
+ # Overlapping rows: loop underneath both boxes.
57
+ y = max(A.y, B.y) + BOX_H + 1 + idx % 2
58
+ return _polyline([(ax, A.y + BOX_H), (ax, y), (bx, y), (bx, B.y + BOX_H)])
bytesim/tui/canvas.py ADDED
@@ -0,0 +1,489 @@
1
+ """The network canvas: draws devices, cables, frames and packets; mouse and keys."""
2
+ from __future__ import annotations
3
+
4
+ from collections import Counter
5
+ from dataclasses import dataclass
6
+ from typing import Callable
7
+
8
+ from rich.text import Text
9
+ from textual import events
10
+ from textual.binding import Binding
11
+ from textual.widget import Widget
12
+
13
+ from ..constants import BOX_H
14
+ from ..model import Device, Network, fmt
15
+ from .cables import LINE, LINE2, LINE_DASH, LINE_HEAVY, N, S, dirbit, route
16
+ from .theme import HA_COLOR, ROLE_COLOR, group_color
17
+
18
+ @dataclass
19
+ class Packet:
20
+ cells: list[tuple[int, int]]
21
+ color: str
22
+ on_done: Callable[[], None] | None = None
23
+ pos: int = 0
24
+
25
+
26
+ TRAIL = 4
27
+
28
+
29
+ class NetCanvas(Widget, can_focus=True):
30
+ BINDINGS = [
31
+ Binding("up", "move(0,-1)", "Move", show=False),
32
+ Binding("down", "move(0,1)", "Move", show=False),
33
+ Binding("left", "move(-1,0)", "Move", show=False),
34
+ Binding("right", "move(1,0)", "Move", show=False),
35
+ Binding("shift+up", "move(0,-4)", show=False),
36
+ Binding("shift+down", "move(0,4)", show=False),
37
+ Binding("shift+left", "move(-4,0)", show=False),
38
+ Binding("shift+right", "move(4,0)", show=False),
39
+ Binding("tab", "cycle(1)", "Next dev"),
40
+ Binding("shift+tab", "cycle(-1)", "Prev dev", show=False),
41
+ Binding("a", "arrange", "Auto-arrange"),
42
+ Binding("u", "undo", "Undo"),
43
+ Binding("U", "redo", "Redo", show=False),
44
+ Binding("v", "toggle_addr", "Addresses"),
45
+ Binding("x,delete", "delete", "Delete"),
46
+ Binding("h", "pan(-4,0)", "Pan hjkl", show=False),
47
+ Binding("j", "pan(0,2)", show=False),
48
+ Binding("k", "pan(0,-2)", show=False),
49
+ Binding("l", "pan(4,0)", show=False),
50
+ Binding("enter,colon,slash,i", "app.focus_cmd", "Command"),
51
+ Binding("escape", "deselect", show=False),
52
+ ]
53
+
54
+ def __init__(self, **kw) -> None:
55
+ super().__init__(**kw)
56
+ self.selected: str | None = None
57
+ self.selected_group: str | None = None
58
+ self.show_addr = True
59
+ self.pan = (0, 0)
60
+ self.packets: list[Packet] = []
61
+ self.hot_links: Counter[int] = Counter()
62
+ self.flash: dict[str, list] = {} # device -> [colour, ticks left]
63
+ self._timer = None
64
+ self._drag: tuple | None = None
65
+ self.frame = 0 # animation clock (storms crawl along their loop)
66
+
67
+ def reset(self) -> None:
68
+ """Forget selection and anything in flight (the network was replaced)."""
69
+ self.selected = self.selected_group = None
70
+ self.packets.clear()
71
+ self.flash.clear()
72
+ self.hot_links.clear()
73
+ self.pan = (0, 0)
74
+
75
+ @property
76
+ def busy(self) -> bool:
77
+ """True while packets are animating."""
78
+ return bool(self.packets)
79
+
80
+ @property
81
+ def net(self) -> Network:
82
+ return self.app.net # type: ignore[attr-defined]
83
+
84
+ # ---- selection / movement -----------------------------------------
85
+ def select(self, dev_id: str | None) -> None:
86
+ self.selected = dev_id
87
+ self.selected_group = None
88
+ if dev_id:
89
+ d = self.net.devices[dev_id]
90
+ self.scroll_into_view(d.x, d.y, d.x + d.width - 1, d.y + BOX_H - 1)
91
+ self.app.refresh_sidebar() # type: ignore[attr-defined]
92
+ self.refresh()
93
+
94
+ def select_group(self, name: str) -> None:
95
+ self.selected = None
96
+ self.selected_group = name
97
+ self.scroll_into_view(*self.net.group_bounds(name))
98
+ self.app.refresh_sidebar() # type: ignore[attr-defined]
99
+ self.refresh()
100
+
101
+ def scroll_into_view(self, x0: int, y0: int, x1: int, y1: int) -> None:
102
+ ox, oy = self.pan
103
+ w, h = self.size.width or 80, self.size.height or 24
104
+ if x0 < ox:
105
+ ox = x0 - 2
106
+ elif x1 >= ox + w:
107
+ ox = x1 - w + 3
108
+ if y0 < oy:
109
+ oy = y0 - 1
110
+ elif y1 >= oy + h:
111
+ oy = y1 - h + 3
112
+ self.pan = (ox, oy)
113
+
114
+ def _moving(self) -> list[Device]:
115
+ if self.selected_group:
116
+ return [self.net.devices[d] for d in self.net.devices_in(self.selected_group)]
117
+ return [self.net.devices[self.selected]] if self.selected else []
118
+
119
+ def _shift(self, devs: list[Device], dx: int, dy: int, what: str = "") -> bool:
120
+ """Move devices together without letting any go off the top/left edge."""
121
+ dx = max(dx, -min(d.x for d in devs))
122
+ dy = max(dy, -min(d.y for d in devs))
123
+ if dx or dy:
124
+ self.app.before_move(what or " ".join(d.id for d in devs)) # type: ignore[attr-defined]
125
+ for d in devs:
126
+ d.x, d.y = d.x + dx, d.y + dy
127
+ if dx or dy:
128
+ self.app.manual_moved() # type: ignore[attr-defined]
129
+ return bool(dx or dy)
130
+
131
+ def action_move(self, dx: int, dy: int) -> None:
132
+ devs = self._moving()
133
+ if not devs:
134
+ self.app.log_msg("[dim]select a device or group first (tab, click, or type its name)[/]") # type: ignore[attr-defined]
135
+ return
136
+ self._shift(devs, dx, dy, self.selected_group or self.selected or "")
137
+ if self.selected_group:
138
+ self.scroll_into_view(*self.net.group_bounds(self.selected_group))
139
+ else:
140
+ d = devs[0]
141
+ self.scroll_into_view(d.x, d.y, d.x + d.width - 1, d.y + BOX_H - 1)
142
+ self.app.refresh_sidebar() # type: ignore[attr-defined]
143
+ self.refresh()
144
+
145
+ def action_cycle(self, step: int) -> None:
146
+ devs = [d.id for d in self.net.sorted_devices()]
147
+ if not devs:
148
+ return
149
+ i = devs.index(self.selected) + step if self.selected in devs else (0 if step > 0 else -1)
150
+ self.select(devs[i % len(devs)])
151
+
152
+ def action_deselect(self) -> None:
153
+ self.select(None)
154
+
155
+ def action_arrange(self) -> None:
156
+ self.app.run_command("auto") # type: ignore[attr-defined] (so it can be undone)
157
+
158
+ def action_delete(self) -> None:
159
+ if self.selected_group:
160
+ self.app.run_command(f"ungroup {self.selected_group}") # type: ignore[attr-defined]
161
+ elif self.selected:
162
+ self.app.run_command(f"del {self.selected}") # type: ignore[attr-defined]
163
+
164
+ def action_undo(self) -> None:
165
+ self.app.run_command("undo") # type: ignore[attr-defined]
166
+
167
+ def action_redo(self) -> None:
168
+ self.app.run_command("redo") # type: ignore[attr-defined]
169
+
170
+ def action_toggle_addr(self) -> None:
171
+ self.show_addr = not self.show_addr
172
+ self.refresh()
173
+
174
+ def action_pan(self, dx: int, dy: int) -> None:
175
+ self.pan = (self.pan[0] + dx, self.pan[1] + dy)
176
+ self.refresh()
177
+
178
+ # ---- mouse ---------------------------------------------------------
179
+ def _hit(self, wx: int, wy: int) -> Device | None:
180
+ for d in reversed(self._draw_order()):
181
+ if d.contains(wx, wy):
182
+ return d
183
+ return None
184
+
185
+ def _hit_group(self, wx: int, wy: int) -> str | None:
186
+ """A group is hit by clicking its frame (border or label); inner frames win."""
187
+ for g in sorted(self.net.groups, key=lambda g: -len(self.net.chain(g))):
188
+ x0, y0, x1, y1 = self.net.group_bounds(g)
189
+ on_x = wx in (x0, x1) and y0 <= wy <= y1
190
+ on_y = wy in (y0, y1) and x0 <= wx <= x1
191
+ if on_x or on_y:
192
+ return g
193
+ return None
194
+
195
+ def on_mouse_down(self, event: events.MouseDown) -> None:
196
+ off = event.get_content_offset(self)
197
+ if off is None:
198
+ return
199
+ self.focus()
200
+ wx, wy = off.x + self.pan[0], off.y + self.pan[1]
201
+ hit = self._hit(wx, wy)
202
+ if hit:
203
+ self.select(hit.id)
204
+ self._drag = ("dev", hit.id, wx, wy)
205
+ elif g := self._hit_group(wx, wy):
206
+ self.select_group(g)
207
+ self._drag = ("group", g, wx, wy)
208
+ else:
209
+ self._drag = ("pan", off.x, off.y, *self.pan)
210
+ self.capture_mouse()
211
+
212
+ def on_mouse_move(self, event: events.MouseMove) -> None:
213
+ if not self._drag:
214
+ return
215
+ off = event.get_content_offset_capture(self)
216
+ if self._drag[0] in ("dev", "group"):
217
+ kind, name, lx, ly = self._drag
218
+ if kind == "dev":
219
+ devs = [self.net.devices[name]] if name in self.net.devices else []
220
+ else:
221
+ devs = [self.net.devices[d] for d in self.net.devices_in(name)]
222
+ if not devs:
223
+ return
224
+ wx, wy = off.x + self.pan[0], off.y + self.pan[1]
225
+ if self._shift(devs, wx - lx, wy - ly, name):
226
+ self._drag = (kind, name, wx, wy)
227
+ self.refresh()
228
+ else:
229
+ _, sx, sy, ox, oy = self._drag
230
+ self.pan = (ox - (off.x - sx), oy - (off.y - sy))
231
+ self.refresh()
232
+
233
+ def on_mouse_up(self, event: events.MouseUp) -> None:
234
+ if self._drag:
235
+ self._drag = None
236
+ self.release_mouse()
237
+ self.app.refresh_sidebar() # type: ignore[attr-defined]
238
+
239
+ def on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
240
+ self.action_pan(*((4, 0) if event.shift else (0, 2)))
241
+
242
+ def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
243
+ self.action_pan(*((-4, 0) if event.shift else (0, -2)))
244
+
245
+ # ---- packets -------------------------------------------------------
246
+ def send_packet(self, cells: list[tuple[int, int]], color: str,
247
+ on_done: Callable[[], None] | None = None) -> None:
248
+ self.packets.append(Packet(cells, color, on_done))
249
+ if self._timer is None:
250
+ self._timer = self.set_interval(1 / 45, self._tick)
251
+
252
+ def heat(self, links) -> None:
253
+ self.hot_links.update(links)
254
+
255
+ def cool(self, links) -> None:
256
+ self.hot_links.subtract(links)
257
+ self.hot_links += Counter() # drop zero counts
258
+ self.refresh()
259
+
260
+ def flash_device(self, dev_id: str, color: str, ticks: int = 30) -> None:
261
+ self.flash[dev_id] = [color, ticks]
262
+ self.animate()
263
+
264
+ def animate(self) -> None:
265
+ """Make sure the animation clock is running (it stops itself when idle)."""
266
+ if self._timer is None:
267
+ self._timer = self.set_interval(1 / 45, self._tick)
268
+
269
+ def _tick(self) -> None:
270
+ for pk in list(self.packets):
271
+ pk.pos += 1
272
+ if pk.pos - TRAIL >= len(pk.cells):
273
+ self.packets.remove(pk)
274
+ if pk.on_done:
275
+ pk.on_done()
276
+ for dev_id in list(self.flash):
277
+ self.flash[dev_id][1] -= 1
278
+ if self.flash[dev_id][1] <= 0:
279
+ del self.flash[dev_id]
280
+ self.frame += 1
281
+ if not self.packets and not self.flash and not self.net.storming and self._timer is not None:
282
+ self._timer.stop()
283
+ self._timer = None
284
+ self.refresh()
285
+
286
+ # ---- rendering -----------------------------------------------------
287
+ def _draw_order(self) -> list[Device]:
288
+ return sorted(self.net.devices.values(), key=lambda d: d.id == self.selected)
289
+
290
+ def render(self) -> Text:
291
+ w, h = self.size.width, self.size.height
292
+ ox, oy = self.pan
293
+ chars = [[" "] * w for _ in range(h)]
294
+ styles = [[""] * w for _ in range(h)]
295
+
296
+ def put(x: int, y: int, ch: str, st: str) -> None:
297
+ sx, sy = x - ox, y - oy
298
+ if 0 <= sx < w and 0 <= sy < h:
299
+ chars[sy][sx] = ch
300
+ styles[sy][sx] = st
301
+
302
+ net = self.net
303
+ masks: dict[tuple[int, int], int] = {}
304
+ cell_style: dict[tuple[int, int], str] = {}
305
+ sides: dict[tuple[str, int], str] = {}
306
+ hot = set(self.hot_links)
307
+ if self.selected:
308
+ hot |= {i for i, l in enumerate(net.links) if l.touches(self.selected)}
309
+ if self.selected_group:
310
+ members = set(net.devices_in(self.selected_group))
311
+ hot |= {i for i, l in enumerate(net.links) if l.a in members or l.b in members}
312
+
313
+ kinds: dict[tuple[int, int], set[str]] = {}
314
+ broken: list[tuple[int, int]] = []
315
+ crawl: list[list[tuple[int, int]]] = [] # storm loops: dots run along them
316
+ looping = net.loop_links() if net.storming else set()
317
+ for i, l in enumerate(net.links):
318
+ pts = route(net, l, i)
319
+ alive = l.up and net.devices[l.a].up and net.devices[l.b].up
320
+ blocked = (l.a, l.pa) in net.stp_blocked or (l.b, l.pb) in net.stp_blocked
321
+ storm = l.a in net.storming or l.b in net.storming
322
+ if storm and i in looping:
323
+ crawl.append(pts)
324
+ st = ("red" if not alive or storm else "grey35" if blocked
325
+ else "bold yellow" if i in hot else ROLE_COLOR[l.role])
326
+ kind = "control" if l.control else "blocked" if blocked else l.role
327
+ for pt in pts:
328
+ kinds.setdefault(pt, set()).add(kind)
329
+ if not l.up:
330
+ broken.append(pts[len(pts) // 2])
331
+ for (x1, y1), (x2, y2) in zip(pts, pts[1:]):
332
+ masks[(x1, y1)] = masks.get((x1, y1), 0) | dirbit(x2 - x1, y2 - y1)
333
+ masks[(x2, y2)] = masks.get((x2, y2), 0) | dirbit(x1 - x2, y1 - y2)
334
+ for dev_id, port, pt in ((l.a, l.pa, pts[0]), (l.b, l.pb, pts[-1])):
335
+ below = pt[1] > net.devices[dev_id].y
336
+ masks[pt] = masks.get(pt, 0) | (N if below else S)
337
+ sides[(dev_id, port)] = "bottom" if below else "top"
338
+ for pt in pts:
339
+ if i in hot or pt not in cell_style:
340
+ cell_style[pt] = st
341
+ frames = []
342
+ for g in sorted(net.groups, key=lambda g: len(net.chain(g))): # outer frames first
343
+ sel = g == self.selected_group
344
+ label = f" {g} "
345
+ st = group_color(g)
346
+ if g in net.clusters and not self.app.graph: # type: ignore[attr-defined]
347
+ ha = net.ha_state(g)
348
+ label = f" {g} · HA {ha['level']} " if ha["level"] != "ok" else f" {g} · HA "
349
+ if ha["level"] != "ok":
350
+ st = HA_COLOR[ha["level"]]
351
+ st = "bold yellow" if sel else st
352
+ x0, y0, x1, y1 = net.group_bounds(g)
353
+ frames.append((g, st, x0, y0, x1, label))
354
+ hz, vt = ("━", "┃") if sel else ("┄", "┆")
355
+ for x in range(x0 + 1, x1):
356
+ put(x, y0, hz, st)
357
+ put(x, y1, hz, st)
358
+ for y in range(y0 + 1, y1):
359
+ put(x0, y, vt, st)
360
+ put(x1, y, vt, st)
361
+ for x, y, ch in ((x0, y0, "╭"), (x1, y0, "╮"), (x0, y1, "╰"), (x1, y1, "╯")):
362
+ put(x, y, ch, st)
363
+
364
+ for (x, y), m in masks.items():
365
+ k = kinds.get((x, y), {"data"})
366
+ table = (LINE2 if k == {"control"} else LINE_HEAVY if k == {"fabric"}
367
+ else LINE_DASH if k == {"blocked"} else LINE)
368
+ put(x, y, table[m], cell_style[(x, y)])
369
+ for x, y in broken:
370
+ put(x, y, "✕", "bold red")
371
+ for pts in crawl:
372
+ for k, (x, y) in enumerate(pts):
373
+ if (k - self.frame // 2) % 4 == 0:
374
+ put(x, y, "•", "bold bright_red")
375
+
376
+ # Group labels sit on the top edge, in the first gap no cable passes through.
377
+ for g, st, x0, y0, x1, label in frames:
378
+ starts = range(x0 + 2, max(x0 + 3, x1 - len(label)))
379
+ lx = next((sx for sx in starts
380
+ if not any((sx + k, y0) in masks for k in range(len(label)))), x0 + 2)
381
+ for k, ch in enumerate(label):
382
+ if (lx + k, y0) not in masks:
383
+ put(lx + k, y0, ch, f"bold reverse {st}" if 0 < k < len(label) - 1 else st)
384
+
385
+ for d in self._draw_order():
386
+ self._draw_device(d, put, sides, d.id == self.selected, self.flash.get(d.id))
387
+
388
+ if self.show_addr:
389
+ # Labels wider than their box drop to a lower line instead of colliding.
390
+ ends: dict[tuple[int, int], int] = {}
391
+ for d in sorted(net.devices.values(), key=lambda d: d.x):
392
+ text, st, y = self._addr_label(d)
393
+ if not text:
394
+ continue
395
+ step = 1 if d.kind == "c" else -1
396
+
397
+ def blocked(lv: int, dx: int) -> bool:
398
+ return (ends.get((y, lv), -10**6) >= d.x + dx
399
+ or any((d.x + dx + k, y + lv * step) in masks for k in range(len(text))))
400
+
401
+ level, dx = next(((lv, dx) for dx in range(d.width) for lv in range(3)
402
+ if not blocked(lv, dx)), (0, 0))
403
+ ends[(y, level)] = d.x + dx + len(text)
404
+ y += level * step
405
+ for k, ch in enumerate(text):
406
+ if (d.x + dx + k, y) not in masks:
407
+ put(d.x + dx + k, y, ch, st)
408
+
409
+ for pk in self.packets:
410
+ for k in range(TRAIL):
411
+ idx = pk.pos - k
412
+ if 0 <= idx < len(pk.cells):
413
+ x, y = pk.cells[idx]
414
+ put(x, y, "●" if k == 0 else "•", f"bold {pk.color}" if k == 0 else pk.color)
415
+
416
+ text = Text(no_wrap=True, overflow="crop")
417
+ for row in range(h):
418
+ start = 0
419
+ for col in range(1, w + 1):
420
+ if col == w or styles[row][col] != styles[row][start]:
421
+ text.append("".join(chars[row][start:col]), styles[row][start] or None)
422
+ start = col
423
+ if row < h - 1:
424
+ text.append("\n")
425
+ return text
426
+
427
+ def _addr_label(self, d: Device) -> tuple[str, str, int]:
428
+ """Short address caption drawn under computers and above gateways."""
429
+ net = self.net
430
+ if d.kind == "g":
431
+ return "☁ internet", "magenta", d.y - 1
432
+ if d.kind != "c" or self.app.graph: # type: ignore[attr-defined]
433
+ return "", "", 0
434
+ y = d.y + BOX_H
435
+ for g in net.chain(d.id): # step below any frame edges under the box
436
+ bottom = net.group_bounds(g)[3]
437
+ if y <= bottom <= d.y + BOX_H + 3:
438
+ y = bottom + 1
439
+ addrs = net.addressed(d.id)
440
+ if not addrs:
441
+ cabled = any(net.link_index(d.id, p) is not None for p in range(1, d.nports + 1))
442
+ return ("no addr", "red", y) if cabled else ("", "", 0)
443
+ _, ifc = addrs[0]
444
+ st = {"adhoc": "yellow", "static": "cyan"}.get(ifc.via, "grey70")
445
+ return fmt(ifc.addr) + (f"+{len(addrs) - 1}" if len(addrs) > 1 else ""), st, y
446
+
447
+ def _draw_device(self, d: Device, put, sides: dict, selected: bool, flash=None) -> None:
448
+ if selected:
449
+ tl, tr, bl, br, hz, vt, dn, up = "╔╗╚╝═║╤╧"
450
+ bst = "bold yellow"
451
+ else:
452
+ tl, tr, bl, br, hz, vt, dn, up = "┌┐└┘─│┬┴"
453
+ bst = d.color
454
+ if not d.up:
455
+ bst = "red"
456
+ elif d.id in self.net.storming: # pulse while drowning in a storm
457
+ bst = "bold bright_red" if (self.frame // 12) % 2 else "red"
458
+ if flash and flash[1] % 10 >= 3: # blink on arrival
459
+ tl, tr, bl, br, hz, vt, dn, up = "┏┓┗┛━┃┳┻"
460
+ bst = f"bold {flash[0]}"
461
+ x, y, w = d.x, d.y, d.width
462
+ rows = [tl + hz * (w - 2) + tr, None, None, bl + hz * (w - 2) + br]
463
+ for i, ch in enumerate(rows[0]):
464
+ put(x + i, y, ch, bst)
465
+ for i, ch in enumerate(rows[3]):
466
+ put(x + i, y + 3, ch, bst)
467
+ for r in (1, 2):
468
+ put(x, y + r, vt, bst)
469
+ put(x + w - 1, y + r, vt, bst)
470
+ for i in range(1, w - 1):
471
+ put(x + i, y + r, " ", "")
472
+ label = (d.id if d.up else d.id + "✕").center(w - 2)
473
+ lst = f"bold reverse {d.color}" if selected else f"bold {d.color}"
474
+ if not d.up:
475
+ lst = "bold red strike"
476
+ lpad = len(label) - len(label.lstrip())
477
+ for i, ch in enumerate(label):
478
+ put(x + 1 + i, y + 1, ch, lst if lpad - 1 <= i <= lpad + len(d.id) else "")
479
+ for p in range(1, d.nports + 1):
480
+ px = d.port_x(p)
481
+ side = sides.get((d.id, p))
482
+ if side and (d.id, p) in self.net.stp_blocked:
483
+ put(px, y + 2, "⊘", "bold yellow") # held blocking by STP
484
+ put(px, y + 3 if side == "bottom" else y, dn if side == "bottom" else up, bst)
485
+ elif side:
486
+ put(px, y + 2, "●", f"bold {d.color}")
487
+ put(px, y + 3 if side == "bottom" else y, dn if side == "bottom" else up, bst)
488
+ else:
489
+ put(px, y + 2, "○", "grey42")
@@ -0,0 +1,29 @@
1
+ """The command line, with history on up/down."""
2
+ from __future__ import annotations
3
+
4
+ from textual.binding import Binding
5
+ from textual.widgets import Input
6
+
7
+ class CommandInput(Input):
8
+ BINDINGS = [
9
+ Binding("escape", "app.focus_canvas", "Canvas", show=False),
10
+ Binding("up", "history(-1)", show=False),
11
+ Binding("down", "history(1)", show=False),
12
+ ]
13
+
14
+ def __init__(self, **kw) -> None:
15
+ super().__init__(**kw)
16
+ self.history: list[str] = []
17
+ self.hpos = 0
18
+
19
+ def remember(self, line: str) -> None:
20
+ if line and (not self.history or self.history[-1] != line):
21
+ self.history.append(line)
22
+ self.hpos = len(self.history)
23
+
24
+ def action_history(self, step: int) -> None:
25
+ if not self.history:
26
+ return
27
+ self.hpos = max(0, min(len(self.history), self.hpos + step))
28
+ self.value = self.history[self.hpos] if self.hpos < len(self.history) else ""
29
+ self.cursor_position = len(self.value)
@@ -0,0 +1 @@
1
+ """Console commands, grouped by topic. Each handler is registered with @command."""