better-rtplot 0.4.10__tar.gz → 0.4.14__tar.gz

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 (37) hide show
  1. better_rtplot-0.4.14/LICENSE +21 -0
  2. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/PKG-INFO +6 -4
  3. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/README.md +3 -1
  4. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/pyproject.toml +2 -2
  5. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/client.py +47 -10
  6. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/interactive_test.py +0 -0
  7. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/saved_plots/.gitignore +0 -0
  8. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/server_browser.py +135 -4
  9. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/server_browser_gui.py +0 -0
  10. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/index.html +68 -1
  11. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/auto-render.min.js +0 -0
  12. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  13. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  14. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  15. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  16. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  17. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  18. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  19. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  20. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  21. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  22. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  23. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  24. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  25. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  26. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  27. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  28. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  29. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  30. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/katex.min.css +0 -0
  33. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/katex/katex.min.js +0 -0
  34. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/uPlot.iife.min.js +0 -0
  35. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/static/uPlot.min.css +0 -0
  36. better_rtplot-0.4.10/LICENSE +0 -674
  37. {better_rtplot-0.4.10 → better_rtplot-0.4.14}/rtplot/server.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jmontp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-rtplot
3
- Version: 0.4.10
3
+ Version: 0.4.14
4
4
  Summary:
5
- License: GPL V3.0
5
+ License: MIT
6
6
  Author: jmontp
7
7
  Author-email: jmontp@umich.edu
8
8
  Requires-Python: >=3.9,<3.13
9
- Classifier: License :: Other/Proprietary License
9
+ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.9
12
12
  Classifier: Programming Language :: Python :: 3.10
@@ -127,8 +127,10 @@ A rising line now draws itself in the browser tab.
127
127
  - **Config lives with the data.** The sender declares plot layout.
128
128
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
129
129
  from your loop, no threads, no callbacks.
130
+ - **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
131
+ the server bundles KaTeX and works offline.
130
132
  - **Static HTML snapshots.** `save_snapshot("out.html")` writes a
131
- self-contained ~65 KB file with the current trace embedded.
133
+ self-contained file with the current trace and math rendering embedded.
132
134
 
133
135
  ---
134
136
 
@@ -103,8 +103,10 @@ A rising line now draws itself in the browser tab.
103
103
  - **Config lives with the data.** The sender declares plot layout.
104
104
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
105
105
  from your loop, no threads, no callbacks.
106
+ - **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
107
+ the server bundles KaTeX and works offline.
106
108
  - **Static HTML snapshots.** `save_snapshot("out.html")` writes a
107
- self-contained ~65 KB file with the current trace embedded.
109
+ self-contained file with the current trace and math rendering embedded.
108
110
 
109
111
  ---
110
112
 
@@ -1,9 +1,9 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.4.10"
3
+ version = "0.4.14"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
- license = "GPL V3.0"
6
+ license = "MIT"
7
7
  readme = "README.md"
8
8
  packages = [{include = "rtplot"}]
9
9
  include = ["rtplot/static/*", "rtplot/static/katex/*", "rtplot/static/katex/fonts/*"]
@@ -22,7 +22,7 @@ socket = context.socket(zmq.PUB)
22
22
  control_socket = context.socket(zmq.PULL)
23
23
  control_socket.setsockopt(zmq.RCVHWM, 1000)
24
24
  #Non-blocking local state for controls, drained on each poll_controls() call
25
- _control_slider_values = {}
25
+ _control_values = {}
26
26
  _control_button_events = []
27
27
 
28
28
  # Cached payload of the most recent initialize_plots() call. The server
@@ -90,6 +90,7 @@ else:
90
90
  SENDING_PLOT_UPDATE = "0"
91
91
  SENDING_DATA = "1"
92
92
  SENDING_DISPLAY = "4"
93
+ SENDING_TEXT_INPUT = "5"
93
94
 
94
95
  #Lightweight result type returned by poll_controls()
95
96
  ControlState = namedtuple("ControlState", ["values", "buttons"])
@@ -138,6 +139,7 @@ class Plot:
138
139
  names: List[str]
139
140
  colors: Optional[List[str]] = None
140
141
  line_style: Optional[List[str]] = None
142
+ show: Optional[List[bool]] = None
141
143
  line_width: Optional[float] = None
142
144
  title: Optional[str] = None
143
145
  xlabel: Optional[str] = None
@@ -151,6 +153,7 @@ class Plot:
151
153
  "names": list(self.names),
152
154
  "colors": list(self.colors) if self.colors is not None else None,
153
155
  "line_style": list(self.line_style) if self.line_style is not None else None,
156
+ "show": list(self.show) if self.show is not None else None,
154
157
  "line_width": self.line_width,
155
158
  "title": self.title,
156
159
  "xlabel": self.xlabel,
@@ -247,10 +250,27 @@ class Text:
247
250
  })
248
251
 
249
252
 
253
+ @dataclass
254
+ class TextInput:
255
+ id: str
256
+ label: str
257
+ value: str = ""
258
+ placeholder: Optional[str] = None
259
+ max_length: Optional[int] = None
260
+ height: Optional[float] = None
261
+
262
+ def to_dict(self):
263
+ return _drop_none({
264
+ "type": "text_input", "id": self.id, "label": self.label,
265
+ "value": self.value, "placeholder": self.placeholder,
266
+ "max_length": self.max_length, "height": self.height,
267
+ })
268
+
269
+
250
270
  @dataclass
251
271
  class ControlsRow:
252
272
  """A row of control widgets, rendered in place of a plot."""
253
- controls: List[Union[Button, Slider, Dial, Display, Text, dict]] = field(default_factory=list)
273
+ controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, dict]] = field(default_factory=list)
254
274
 
255
275
  def to_dict(self):
256
276
  return {"controls": [
@@ -536,7 +556,9 @@ def _send_initialize_with_handshake(cfg, timeout):
536
556
  elif evtype == "button":
537
557
  _control_button_events.append(event.get("id"))
538
558
  elif evtype == "slider":
539
- _control_slider_values[event.get("id")] = float(event.get("value", 0.0))
559
+ _control_values[event.get("id")] = float(event.get("value", 0.0))
560
+ elif evtype == "text":
561
+ _control_values[event.get("id")] = str(event.get("value", ""))
540
562
  # Unknown / resend_config: ignore during handshake.
541
563
  # Resend if the window elapsed without an ack; PUB/SUB will
542
564
  # drop the first send during slow-join but retries land.
@@ -571,12 +593,24 @@ def set_display(display_id: str, value):
571
593
  socket.send_json({"id": str(display_id), "value": payload_value})
572
594
 
573
595
 
596
+ def set_text_input(input_id: str, value):
597
+ """Push a text_input control value to the browser and server state.
598
+
599
+ This is the editable-control companion to set_display(). It updates
600
+ declared 'text_input' controls without rebuilding the full plot config,
601
+ and the server keeps the value as the current control state returned by
602
+ poll_controls().
603
+ """
604
+ socket.send_string(SENDING_TEXT_INPUT, zmq.SNDMORE)
605
+ socket.send_json({"id": str(input_id), "value": str(value)})
606
+
607
+
574
608
  def poll_controls():
575
609
  """Drain the return channel non-blocking and return current control state.
576
610
 
577
611
  Returns a ControlState(values, buttons) where:
578
- - values: dict of {slider_id: float} with the latest value of every
579
- slider the server has told us about since process start.
612
+ - values: dict of latest control values keyed by id. Sliders/dials
613
+ remain floats; text_input controls remain strings.
580
614
  - buttons: list of button ids that fired since the previous poll
581
615
  (cleared after this call).
582
616
 
@@ -587,7 +621,7 @@ def poll_controls():
587
621
  Reconnect button on a tab), it asks us to re-publish the cached
588
622
  initialize_plots() payload so the plot rewires without the user
589
623
  restarting their script. The request is consumed silently — the
590
- returned ControlState only ever contains slider/button events.
624
+ returned ControlState only ever contains control/button events.
591
625
  """
592
626
  global _control_button_events
593
627
  while True:
@@ -601,7 +635,9 @@ def poll_controls():
601
635
  if evtype == "button":
602
636
  _control_button_events.append(event.get("id"))
603
637
  elif evtype == "slider":
604
- _control_slider_values[event.get("id")] = float(event.get("value", 0.0))
638
+ _control_values[event.get("id")] = float(event.get("value", 0.0))
639
+ elif evtype == "text":
640
+ _control_values[event.get("id")] = str(event.get("value", ""))
605
641
  elif evtype == "resend_config":
606
642
  if plot_desc_dict is not None:
607
643
  socket.send_string(SENDING_PLOT_UPDATE)
@@ -615,7 +651,7 @@ def poll_controls():
615
651
 
616
652
  buttons = _control_button_events
617
653
  _control_button_events = []
618
- return ControlState(values=dict(_control_slider_values), buttons=buttons)
654
+ return ControlState(values=dict(_control_values), buttons=buttons)
619
655
 
620
656
 
621
657
  def save_snapshot(path, server_url=None, animate=False, timeout=5.0):
@@ -623,7 +659,8 @@ def save_snapshot(path, server_url=None, animate=False, timeout=5.0):
623
659
 
624
660
  The server exposes a ``/snapshot.html`` endpoint that renders the
625
661
  current plot state as a self-contained HTML file with uPlot's JS +
626
- CSS inlined plus the most recent window of trace data. The result
662
+ CSS inlined plus the most recent window of trace data. Vendored KaTeX
663
+ assets are also inlined, so LaTeX plot labels render offline. The result
627
664
  opens in any browser offline and looks pixel-identical to what the
628
665
  live tab was showing. Ideal for committing reproducible example
629
666
  previews to a repo or emailing a static "here's what I was seeing"
@@ -664,4 +701,4 @@ def save_snapshot(path, server_url=None, animate=False, timeout=5.0):
664
701
  abs_path = _os.path.abspath(path)
665
702
  with open(abs_path, "wb") as fh:
666
703
  fh.write(body)
667
- return abs_path
704
+ return abs_path
@@ -20,7 +20,9 @@ subscribed tab's PUSH socket.
20
20
 
21
21
  import argparse
22
22
  import asyncio
23
+ import base64
23
24
  import dataclasses
25
+ import hashlib
24
26
  import json
25
27
  import os
26
28
  import socket
@@ -215,6 +217,7 @@ RECEIVED_PLOT_UPDATE = 0
215
217
  RECEIVED_DATA = 1
216
218
  SAVE_PLOT = 3
217
219
  RECEIVED_DISPLAY = 4
220
+ RECEIVED_TEXT_INPUT = 5
218
221
 
219
222
  BIND_ME_ID = "bind_me"
220
223
  BIND_ME_NAME = "Shared - Bind to me"
@@ -375,6 +378,8 @@ class Tab:
375
378
  layout: list = field(default_factory=list)
376
379
  control_rows: list = field(default_factory=list)
377
380
  slider_values: dict = field(default_factory=dict)
381
+ text_values: dict = field(default_factory=dict)
382
+ text_dirty: set = field(default_factory=set)
378
383
  display_values: dict = field(default_factory=dict)
379
384
  display_dirty: set = field(default_factory=set)
380
385
  fps: float = 0.0
@@ -499,6 +504,7 @@ def parse_config(tab: Tab, json_config):
499
504
  trace_info = []
500
505
  control_rows = []
501
506
  slider_values = {}
507
+ text_values = {}
502
508
  layout = []
503
509
  num_datapoints_in_plot = DEFAULT_NUM_DATAPOINTS_IN_PLOT
504
510
 
@@ -514,6 +520,8 @@ def parse_config(tab: Tab, json_config):
514
520
  for element in row:
515
521
  if element.get("type") in ("slider", "dial") and "value" in element:
516
522
  slider_values[element["id"]] = float(element["value"])
523
+ elif element.get("type") == "text_input":
524
+ text_values[element["id"]] = str(element.get("value", ""))
517
525
  layout.append({"kind": "controls", "index": len(control_rows)})
518
526
  control_rows.append(row)
519
527
  continue
@@ -546,6 +554,18 @@ def parse_config(tab: Tab, json_config):
546
554
  }
547
555
  tab.display_dirty = {d for d in tab.display_dirty if d in active_display_ids}
548
556
  tab.slider_values = slider_values
557
+ active_text_ids = {
558
+ el["id"]
559
+ for row in control_rows
560
+ for el in row
561
+ if el.get("type") == "text_input"
562
+ }
563
+ tab.text_values = {
564
+ text_id: tab.text_values.get(text_id, default_value)
565
+ for text_id, default_value in text_values.items()
566
+ if text_id in active_text_ids
567
+ }
568
+ tab.text_dirty = {text_id for text_id in tab.text_dirty if text_id in active_text_ids}
549
569
  tab.layout = layout
550
570
 
551
571
  num_traces = sum(traces_per_plot)
@@ -585,11 +605,20 @@ def build_config_message(tab: Tab, config_dict) -> dict:
585
605
  "controls": tab.control_rows,
586
606
  "layout": tab.layout,
587
607
  "slider_values": dict(tab.slider_values),
608
+ "text_values": dict(tab.text_values),
588
609
  "display_values": dict(tab.display_values),
589
610
  "row_layout": bool(NEW_SUBPLOT_IN_ROW),
590
611
  }
591
612
 
592
613
 
614
+ def refresh_config_message(tab: Tab):
615
+ """Rebuild the browser config payload using the tab's latest control state."""
616
+ if tab.config_dict is None:
617
+ tab.config_message = None
618
+ else:
619
+ tab.config_message = build_config_message(tab, tab.config_dict)
620
+
621
+
593
622
  ###############################
594
623
  # Binary payload construction #
595
624
  ###############################
@@ -952,7 +981,7 @@ async def zmq_receiver(tab: Tab):
952
981
 
953
982
  tab.last_config_error = None # clear: this one was good
954
983
  tab.config_dict = cfg
955
- tab.config_message = build_config_message(tab, cfg)
984
+ refresh_config_message(tab)
956
985
  tab.initialized = True
957
986
  fps = None
958
987
  tab.fps = 0.0
@@ -976,6 +1005,10 @@ async def zmq_receiver(tab: Tab):
976
1005
  await send_control_event(
977
1006
  tab, {"type": "slider", "id": sid, "value": svalue}
978
1007
  )
1008
+ for text_id, text_value in tab.text_values.items():
1009
+ await send_control_event(
1010
+ tab, {"type": "text", "id": text_id, "value": text_value}
1011
+ )
979
1012
 
980
1013
  elif category == RECEIVED_DATA:
981
1014
  arr = await _recv_array_async(sock)
@@ -1028,6 +1061,20 @@ async def zmq_receiver(tab: Tab):
1028
1061
  tab.display_values[display_id] = value
1029
1062
  tab.display_dirty.add(display_id)
1030
1063
 
1064
+ elif category == RECEIVED_TEXT_INPUT:
1065
+ payload = await sock.recv_json()
1066
+ text_id = payload.get("id")
1067
+ if text_id is None:
1068
+ continue
1069
+ value = str(payload.get("value", ""))
1070
+ if tab.text_values.get(text_id) != value:
1071
+ tab.text_values[text_id] = value
1072
+ tab.text_dirty.add(text_id)
1073
+ refresh_config_message(tab)
1074
+ await send_control_event(
1075
+ tab, {"type": "text", "id": text_id, "value": value}
1076
+ )
1077
+
1031
1078
 
1032
1079
  ###############################
1033
1080
  # Pusher tasks #
@@ -1050,6 +1097,23 @@ async def display_pusher():
1050
1097
  )
1051
1098
 
1052
1099
 
1100
+ async def text_input_pusher():
1101
+ """Push dirty text_input values to viewers at ~30 Hz, per tab."""
1102
+ target_dt = 1.0 / 30.0
1103
+ while True:
1104
+ await asyncio.sleep(target_dt)
1105
+ if not ws_clients:
1106
+ continue
1107
+ for t in list(tabs.values()):
1108
+ if not t.text_dirty:
1109
+ continue
1110
+ values = {k: t.text_values[k] for k in t.text_dirty}
1111
+ t.text_dirty = set()
1112
+ await broadcast_text_tab(
1113
+ t.id, {"type": "text_update", "tab": t.id, "values": values}
1114
+ )
1115
+
1116
+
1053
1117
  async def ws_pusher():
1054
1118
  """Push new samples to viewers as binary delta frames, per tab."""
1055
1119
  target_dt = 1.0 / max(1, args.rate)
@@ -1296,8 +1360,21 @@ def _read_static_asset(name: str) -> str:
1296
1360
  return fh.read()
1297
1361
 
1298
1362
 
1299
- # Loaded once at import time so per-request cost is just the send.
1363
+ # Loaded once at import time so per-request cost is just the send. Version the
1364
+ # asset URLs by their contents: browsers can otherwise keep an old or damaged
1365
+ # KaTeX script after the executable is updated while fetching the new HTML.
1300
1366
  _INDEX_HTML = _read_static_asset("index.html")
1367
+ for _asset in (
1368
+ "uPlot.min.css",
1369
+ "uPlot.iife.min.js",
1370
+ "katex/katex.min.css",
1371
+ "katex/katex.min.js",
1372
+ "katex/auto-render.min.js",
1373
+ ):
1374
+ _digest = hashlib.sha256(_read_static_asset(_asset).encode("utf-8")).hexdigest()[:12]
1375
+ _INDEX_HTML = _INDEX_HTML.replace(
1376
+ f'"/static/{_asset}"', f'"/static/{_asset}?v={_digest}"'
1377
+ )
1301
1378
 
1302
1379
 
1303
1380
  async def handle_index(request):
@@ -1342,6 +1419,7 @@ async def handle_ws(request):
1342
1419
  },
1343
1420
  )
1344
1421
  if t.config_message is not None:
1422
+ refresh_config_message(t)
1345
1423
  await ws_send_text(ws, t.config_message)
1346
1424
  if t.display_values:
1347
1425
  await ws_send_text(
@@ -1402,9 +1480,21 @@ async def handle_ws(request):
1402
1480
  t = tabs.get(tid)
1403
1481
  if sid and t is not None:
1404
1482
  t.slider_values[sid] = value
1483
+ refresh_config_message(t)
1405
1484
  await send_control_event(
1406
1485
  t, {"type": "slider", "id": sid, "value": value}
1407
1486
  )
1487
+ elif ptype == "control_text":
1488
+ text_id = payload.get("id")
1489
+ tid = ws_tab.get(ws, BIND_ME_ID)
1490
+ t = tabs.get(tid)
1491
+ if text_id and t is not None:
1492
+ value = str(payload.get("value", ""))
1493
+ t.text_values[text_id] = value
1494
+ refresh_config_message(t)
1495
+ await send_control_event(
1496
+ t, {"type": "text", "id": text_id, "value": value}
1497
+ )
1408
1498
  elif msg.type == WSMsgType.ERROR:
1409
1499
  break
1410
1500
  finally:
@@ -1423,6 +1513,7 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1423
1513
  <meta charset="utf-8" />
1424
1514
  <title>rtplot snapshot</title>
1425
1515
  <style>__UPLOT_CSS__</style>
1516
+ <style>__KATEX_CSS__</style>
1426
1517
  <style>
1427
1518
  html, body { margin: 0; padding: 0; background: #fafafa; color: #222;
1428
1519
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
@@ -1442,6 +1533,8 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1442
1533
  &middot; drag to zoom, double-click to reset
1443
1534
  </p>
1444
1535
  <script>__UPLOT_JS__</script>
1536
+ <script>__KATEX_JS__</script>
1537
+ <script>__KATEX_AUTO_RENDER_JS__</script>
1445
1538
  <script>
1446
1539
  (function () {
1447
1540
  const SNAP = __SNAPSHOT_JSON__;
@@ -1502,6 +1595,15 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1502
1595
  traceCount: traceCount, startIdx: traceOffset });
1503
1596
  traceOffset += traceCount;
1504
1597
  });
1598
+ renderMathInElement(plotsDiv, {
1599
+ delimiters: [
1600
+ { left: '$$', right: '$$', display: true },
1601
+ { left: '\\\\[', right: '\\\\]', display: true },
1602
+ { left: '\\\\(', right: '\\\\)', display: false },
1603
+ { left: '$', right: '$', display: false },
1604
+ ],
1605
+ throwOnError: false,
1606
+ });
1505
1607
  if (SNAP.animate) {
1506
1608
  let phase = 0;
1507
1609
  setInterval(function () {
@@ -1538,9 +1640,31 @@ _SNAPSHOT_TEMPLATE = """<!doctype html>
1538
1640
  try:
1539
1641
  _UPLOT_JS = _read_static_asset("uPlot.iife.min.js")
1540
1642
  _UPLOT_CSS = _read_static_asset("uPlot.min.css")
1643
+ _KATEX_JS = _read_static_asset(os.path.join("katex", "katex.min.js"))
1644
+ _KATEX_AUTO_RENDER_JS = _read_static_asset(
1645
+ os.path.join("katex", "auto-render.min.js")
1646
+ )
1647
+ _KATEX_CSS = _read_static_asset(os.path.join("katex", "katex.min.css"))
1648
+ # Snapshot files have no sibling font directory. Inline the vendored
1649
+ # WOFF2 faces so equations keep their layout when opened offline.
1650
+ _KATEX_FONT_DIR = os.path.join(
1651
+ os.path.dirname(os.path.abspath(__file__)), "static", "katex", "fonts"
1652
+ )
1653
+ for _font_name in os.listdir(_KATEX_FONT_DIR):
1654
+ if not _font_name.endswith(".woff2"):
1655
+ continue
1656
+ with open(os.path.join(_KATEX_FONT_DIR, _font_name), "rb") as _font_file:
1657
+ _font_data = base64.b64encode(_font_file.read()).decode("ascii")
1658
+ _KATEX_CSS = _KATEX_CSS.replace(
1659
+ f"url(fonts/{_font_name})",
1660
+ f"url(data:font/woff2;base64,{_font_data})",
1661
+ )
1541
1662
  except Exception as _exc: # noqa: BLE001
1542
1663
  _UPLOT_JS = f"/* uPlot load failed: {_exc} */"
1543
1664
  _UPLOT_CSS = ""
1665
+ _KATEX_JS = f"/* KaTeX load failed: {_exc} */"
1666
+ _KATEX_AUTO_RENDER_JS = ""
1667
+ _KATEX_CSS = ""
1544
1668
 
1545
1669
 
1546
1670
  def _build_snapshot_html(tab: Tab, animate: bool) -> str:
@@ -1590,7 +1714,13 @@ def _build_snapshot_html(tab: Tab, animate: bool) -> str:
1590
1714
  html = _SNAPSHOT_TEMPLATE
1591
1715
  html = html.replace("__UPLOT_CSS__", _UPLOT_CSS)
1592
1716
  html = html.replace("__UPLOT_JS__", _UPLOT_JS)
1593
- html = html.replace("__SNAPSHOT_JSON__", json.dumps(payload))
1717
+ html = html.replace("__KATEX_CSS__", _KATEX_CSS)
1718
+ html = html.replace("__KATEX_JS__", _KATEX_JS)
1719
+ html = html.replace("__KATEX_AUTO_RENDER_JS__", _KATEX_AUTO_RENDER_JS)
1720
+ # Keep client-supplied labels from closing the script element while
1721
+ # preserving their exact value when JavaScript parses the payload.
1722
+ snapshot_json = json.dumps(payload).replace("<", "\\u003c")
1723
+ html = html.replace("__SNAPSHOT_JSON__", snapshot_json)
1594
1724
  return html
1595
1725
 
1596
1726
 
@@ -1641,11 +1771,12 @@ async def on_startup(app):
1641
1771
 
1642
1772
  app["ws_task"] = asyncio.create_task(ws_pusher())
1643
1773
  app["display_task"] = asyncio.create_task(display_pusher())
1774
+ app["text_input_task"] = asyncio.create_task(text_input_pusher())
1644
1775
  app["resources_task"] = asyncio.create_task(resources_pusher())
1645
1776
 
1646
1777
 
1647
1778
  async def on_cleanup(app):
1648
- for key in ("ws_task", "display_task", "resources_task"):
1779
+ for key in ("ws_task", "display_task", "text_input_task", "resources_task"):
1649
1780
  await _cancel_task(app.get(key))
1650
1781
  for t in list(tabs.values()):
1651
1782
  await _cancel_task(t.receiver_task)
@@ -95,6 +95,8 @@
95
95
  .ctrl-numinput { width: 72px; font-family: monospace; font-size: calc(13px * var(--ui-scale)); padding: 4px 6px; border: 1px solid #b8b8b8; border-radius: 3px; background: #fff; color: #222; text-align: right; -moz-appearance: textfield; }
96
96
  .ctrl-numinput::-webkit-outer-spin-button,
97
97
  .ctrl-numinput::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
98
+ .ctrl-textinput { min-width: 200px; width: min(320px, 100%); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: calc(13px * var(--ui-scale)); padding: 7px 10px; border: 1px solid #b8b8b8; border-radius: 3px; background: #fff; color: #222; text-align: left; }
99
+ .ctrl-textinput:focus { outline: none; border-color: #2a5db0; box-shadow: 0 0 0 2px rgba(42, 93, 176, 0.14); }
98
100
  .ctrl-nudgebtn { width: 26px; height: 26px; font-size: calc(15px * var(--ui-scale)); font-weight: 600; line-height: 1; padding: 0; border: 1px solid #b8b8b8; background: #f7f7f7; color: #333; cursor: pointer; border-radius: 3px; }
99
101
  .ctrl-nudgebtn:hover { background: #e9e9e9; }
100
102
  .ctrl-nudgebtn:active { background: #dcdcdc; }
@@ -263,6 +265,8 @@
263
265
  // a typo in someone's label can't blow up the whole page.
264
266
  const _latexDelims = [
265
267
  { left: '$$', right: '$$', display: true },
268
+ { left: '\\[', right: '\\]', display: true },
269
+ { left: '\\(', right: '\\)', display: false },
266
270
  { left: '$', right: '$', display: false },
267
271
  ];
268
272
  function renderLatexIn(el) {
@@ -299,6 +303,26 @@
299
303
  const resTabRates = document.getElementById('res-tab-rates');
300
304
  const resUnavail = document.getElementById('res-unavail');
301
305
 
306
+ // A failed KaTeX download can still leave auto-render defined, but its
307
+ // closure then holds an undefined KaTeX reference. Retry both scripts
308
+ // in order with fresh URLs and render labels already on the page.
309
+ if (typeof katex === 'undefined') {
310
+ const retry = Date.now();
311
+ const core = document.createElement('script');
312
+ core.src = `/static/katex/katex.min.js?retry=${retry}`;
313
+ core.onload = () => {
314
+ if (typeof katex === 'undefined') return;
315
+ const autoRender = document.createElement('script');
316
+ autoRender.src = `/static/katex/auto-render.min.js?retry=${retry}`;
317
+ autoRender.onload = () => {
318
+ renderLatexIn(plotsDiv);
319
+ tabbar.querySelectorAll('.tab-name').forEach(renderLatexIn);
320
+ };
321
+ document.head.appendChild(autoRender);
322
+ };
323
+ document.head.appendChild(core);
324
+ }
325
+
302
326
  // ---- Detect the monitor's refresh rate on page load ----
303
327
  let monitorHz = 0;
304
328
  (function measureMonitorHz() {
@@ -415,7 +439,7 @@
415
439
  let socket = null;
416
440
  let pendingFrame = false;
417
441
  let lastStatus = { fps: 0, statusByte: 0, nonPlot: 0, peers: 0, dirty: true };
418
- const controlElements = { displays: {}, displayFormats: {}, displayKinds: {}, sliders: {} };
442
+ const controlElements = { displays: {}, displayFormats: {}, displayKinds: {}, sliders: {}, textInputs: {} };
419
443
 
420
444
  function sendCtrl(msg) {
421
445
  if (socket && socket.readyState === WebSocket.OPEN) {
@@ -684,6 +708,34 @@
684
708
  item.appendChild(val);
685
709
  controlElements.displays[el.id] = val;
686
710
  controlElements.displayKinds[el.id] = 'text';
711
+ } else if (el.type === 'text_input') {
712
+ item.classList.add('ctrl-text-input', 'flex');
713
+ if (el.label) {
714
+ const lbl = document.createElement('label');
715
+ lbl.textContent = el.label;
716
+ item.appendChild(lbl);
717
+ }
718
+ const input = document.createElement('input');
719
+ input.type = 'text';
720
+ input.className = 'ctrl-textinput';
721
+ input.value = (el.value !== undefined && el.value !== null) ? String(el.value) : '';
722
+ if (el.placeholder !== undefined && el.placeholder !== null) {
723
+ input.placeholder = String(el.placeholder);
724
+ }
725
+ if (el.max_length !== undefined && Number.isFinite(Number(el.max_length))) {
726
+ input.maxLength = Number(el.max_length);
727
+ }
728
+ const commit = () => sendCtrl({ type: 'control_text', id: el.id, value: input.value });
729
+ input.addEventListener('change', commit);
730
+ input.addEventListener('blur', commit);
731
+ input.addEventListener('keydown', (e) => {
732
+ if (e.key === 'Enter') {
733
+ e.preventDefault();
734
+ commit();
735
+ }
736
+ });
737
+ item.appendChild(input);
738
+ controlElements.textInputs[el.id] = input;
687
739
  }
688
740
  return item;
689
741
  }
@@ -722,6 +774,14 @@
722
774
  }
723
775
  }
724
776
 
777
+ function applyTextValues(values) {
778
+ if (!values) return;
779
+ for (const [id, val] of Object.entries(values)) {
780
+ const input = controlElements.textInputs[id];
781
+ if (input) input.value = (val !== undefined && val !== null) ? String(val) : '';
782
+ }
783
+ }
784
+
725
785
  function applyDisplayValues(values) {
726
786
  if (!values) return;
727
787
  for (const [id, val] of Object.entries(values)) {
@@ -752,6 +812,7 @@
752
812
  controlElements.displayFormats = {};
753
813
  controlElements.displayKinds = {};
754
814
  controlElements.sliders = {};
815
+ controlElements.textInputs = {};
755
816
  }
756
817
 
757
818
  function showEmptyState(text) {
@@ -771,6 +832,7 @@
771
832
  const colors = pcfg.colors || DEFAULT_COLORS;
772
833
  const widths = pcfg.line_width || [];
773
834
  const styles = pcfg.line_style || [];
835
+ const shows = pcfg.show || [];
774
836
 
775
837
  const series = [{}];
776
838
  for (let t = 0; t < traceCount; t++) {
@@ -782,6 +844,7 @@
782
844
  dash: dash || undefined,
783
845
  points: { show: false },
784
846
  spanGaps: true,
847
+ show: shows[t] !== false,
785
848
  });
786
849
  }
787
850
 
@@ -859,6 +922,7 @@
859
922
  });
860
923
  totalTraces = traceOffset;
861
924
  applySliderValues(cfg.slider_values);
925
+ applyTextValues(cfg.text_values);
862
926
  applyDisplayValues(cfg.display_values);
863
927
  // Render LaTeX in everything we just built: uPlot's title,
864
928
  // x/y axis labels, and legend (all rendered as HTML), plus the
@@ -1280,6 +1344,9 @@
1280
1344
  } else if (msg.type === 'display_update') {
1281
1345
  if (msg.tab && msg.tab !== activeTab) return;
1282
1346
  applyDisplayValues(msg.values);
1347
+ } else if (msg.type === 'text_update') {
1348
+ if (msg.tab && msg.tab !== activeTab) return;
1349
+ applyTextValues(msg.values);
1283
1350
  } else if (msg.type === 'peer_count') {
1284
1351
  const n = Number(msg.count) || 0;
1285
1352
  if (lastStatus.peers !== n) {