better-rtplot 0.4.16__tar.gz → 0.6.0__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 (40) hide show
  1. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/PKG-INFO +5 -2
  2. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/README.md +2 -0
  3. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/pyproject.toml +1 -1
  4. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/client.py +169 -13
  5. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/server_browser.py +208 -18
  6. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/index.html +177 -81
  7. better_rtplot-0.6.0/rtplot/static/presentation.css +168 -0
  8. better_rtplot-0.6.0/rtplot/static/ui-view.css +24 -0
  9. better_rtplot-0.6.0/rtplot/static/ui-view.js +180 -0
  10. better_rtplot-0.6.0/rtplot/ui_state.py +163 -0
  11. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/LICENSE +0 -0
  12. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/interactive_test.py +0 -0
  13. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/saved_plots/.gitignore +0 -0
  14. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/server.py +0 -0
  15. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/server_browser_gui.py +0 -0
  16. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/auto-render.min.js +0 -0
  17. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  18. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  19. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  20. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  21. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  22. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  23. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  24. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  25. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  26. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  27. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  28. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  29. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  30. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  33. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  34. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  35. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  36. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  37. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/katex.min.css +0 -0
  38. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/katex/katex.min.js +0 -0
  39. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/uPlot.iife.min.js +0 -0
  40. {better_rtplot-0.4.16 → better_rtplot-0.6.0}/rtplot/static/uPlot.min.css +0 -0
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: better-rtplot
3
- Version: 0.4.16
3
+ Version: 0.6.0
4
4
  Summary:
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Author: jmontp
7
8
  Author-email: jmontp@umich.edu
8
9
  Requires-Python: >=3.9,<3.13
@@ -131,6 +132,8 @@ A rising line now draws itself in the browser tab.
131
132
  - **Remote-friendly.** Sender or plot host can bind. Live Bind /
132
133
  Connect buttons retarget without restart.
133
134
  - **Config lives with the data.** The sender declares plot layout.
135
+ - **Responsive presentation.** Native phone sizing, essential actions, compact navigation, and explicit semantic styles. See the [presentation guide](docs/presentation.md) and [before/after screenshots](docs/presentation-screenshots/README.md).
136
+ - **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
134
137
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
135
138
  from your loop, no threads, no callbacks.
136
139
  - **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
@@ -107,6 +107,8 @@ A rising line now draws itself in the browser tab.
107
107
  - **Remote-friendly.** Sender or plot host can bind. Live Bind /
108
108
  Connect buttons retarget without restart.
109
109
  - **Config lives with the data.** The sender declares plot layout.
110
+ - **Responsive presentation.** Native phone sizing, essential actions, compact navigation, and explicit semantic styles. See the [presentation guide](docs/presentation.md) and [before/after screenshots](docs/presentation-screenshots/README.md).
111
+ - **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
110
112
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
111
113
  from your loop, no threads, no callbacks.
112
114
  - **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.4.16"
3
+ version = "0.6.0"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "MIT"
@@ -1,6 +1,9 @@
1
1
  import zmq
2
2
  import numpy as np
3
3
  import time
4
+ import uuid
5
+ import warnings
6
+ from .ui_state import META_KEY, declarations, merge_state, presentation_requested
4
7
  from collections import OrderedDict, namedtuple
5
8
  from dataclasses import dataclass, field
6
9
  from typing import List, Optional, Tuple, Union
@@ -24,6 +27,7 @@ control_socket.setsockopt(zmq.RCVHWM, 1000)
24
27
  #Non-blocking local state for controls, drained on each poll_controls() call
25
28
  _control_values = {}
26
29
  _control_button_events = []
30
+ _last_control_poll = time.monotonic()
27
31
 
28
32
  # Cached payload of the most recent initialize_plots() call. The server
29
33
  # uses this to recover from its own crashes: when its tab loses the
@@ -91,6 +95,16 @@ SENDING_PLOT_UPDATE = "0"
91
95
  SENDING_DATA = "1"
92
96
  SENDING_DISPLAY = "4"
93
97
  SENDING_TEXT_INPUT = "5"
98
+ SENDING_UI_STATE = "6"
99
+ _session = uuid.uuid4().hex
100
+ _generation = 0
101
+ _ui_revision = 0
102
+ _ui_state = {"controls": {}, "sections": {}}
103
+ _view = {}
104
+ _control_registry = {}
105
+ _server_capabilities = set()
106
+ _source_epoch = None
107
+ _last_ui_publish = 0.0
94
108
 
95
109
  #Lightweight result type returned by poll_controls()
96
110
  ControlState = namedtuple("ControlState", ["values", "buttons"])
@@ -147,6 +161,8 @@ class Plot:
147
161
  yrange: Optional[Tuple[float, float]] = None
148
162
  xrange: Optional[int] = None
149
163
  height: Optional[float] = None
164
+ section: Optional[str] = None
165
+ min_height: Optional[int] = None
150
166
 
151
167
  def to_dict(self):
152
168
  return _drop_none({
@@ -161,6 +177,7 @@ class Plot:
161
177
  "yrange": _range_to_list(self.yrange),
162
178
  "xrange": self.xrange,
163
179
  "height": self.height,
180
+ "section": self.section, "min_height": self.min_height,
164
181
  })
165
182
 
166
183
 
@@ -169,6 +186,7 @@ class PlotRow:
169
186
  """A group of plots with its own column count in the browser."""
170
187
  plots: List[Union[Plot, dict]]
171
188
  columns: int
189
+ section: Optional[str] = None
172
190
 
173
191
  def to_dict(self):
174
192
  if isinstance(self.columns, bool) or not isinstance(self.columns, int) or self.columns < 1:
@@ -183,7 +201,7 @@ class PlotRow:
183
201
  or any(k in p for k in ("plots", "controls", "non_plot_labels"))
184
202
  for p in plots):
185
203
  raise ValueError("PlotRow can contain only plot descriptions")
186
- return {"columns": self.columns, "plots": plots}
204
+ return _drop_none({"columns": self.columns, "plots": plots, "section": self.section})
187
205
 
188
206
 
189
207
  @dataclass
@@ -192,11 +210,12 @@ class Button:
192
210
  label: str
193
211
  color: Optional[str] = None
194
212
  height: Optional[float] = None
213
+ appearance: Optional[str] = None
195
214
 
196
215
  def to_dict(self):
197
216
  return _drop_none({
198
217
  "type": "button", "id": self.id, "label": self.label,
199
- "color": self.color, "height": self.height,
218
+ "color": self.color, "height": self.height, "appearance": self.appearance,
200
219
  })
201
220
 
202
221
 
@@ -250,11 +269,12 @@ class Display:
250
269
  label: str
251
270
  format: Optional[str] = None
252
271
  height: Optional[float] = None
272
+ appearance: Optional[str] = None
253
273
 
254
274
  def to_dict(self):
255
275
  return _drop_none({
256
276
  "type": "display", "id": self.id, "label": self.label,
257
- "format": self.format, "height": self.height,
277
+ "format": self.format, "height": self.height, "appearance": self.appearance,
258
278
  })
259
279
 
260
280
 
@@ -264,11 +284,12 @@ class Text:
264
284
  label: str
265
285
  value: str = ""
266
286
  height: Optional[float] = None
287
+ appearance: Optional[str] = None
267
288
 
268
289
  def to_dict(self):
269
290
  return _drop_none({
270
291
  "type": "text", "id": self.id, "label": self.label,
271
- "value": self.value, "height": self.height,
292
+ "value": self.value, "height": self.height, "appearance": self.appearance,
272
293
  })
273
294
 
274
295
 
@@ -294,11 +315,43 @@ class ControlsRow:
294
315
  """A row of control widgets, rendered in place of a plot."""
295
316
  controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, dict]] = field(default_factory=list)
296
317
 
318
+ section: Optional[str] = None
319
+ title: Optional[str] = None
320
+ exclusive: Optional[bool] = None
321
+
297
322
  def to_dict(self):
298
- return {"controls": [
323
+ return _drop_none({"controls": [
299
324
  c.to_dict() if hasattr(c, "to_dict") else c
300
325
  for c in self.controls
301
- ]}
326
+ ], "section": self.section, "title": self.title, "exclusive": self.exclusive})
327
+
328
+
329
+ @dataclass
330
+ class Section:
331
+ id: str
332
+ title: str
333
+ description: str = ""
334
+ collapsible: bool = False
335
+ collapsed: bool = False
336
+ visible: bool = True
337
+ navigation: str = "primary"
338
+ density: str = "normal"
339
+ show_heading: bool = True
340
+
341
+ def to_dict(self):
342
+ return dict(vars(self))
343
+
344
+
345
+ @dataclass
346
+ class View:
347
+ sections: List[Union[Section, dict]]
348
+ persistent_section: Optional[str] = None
349
+ essential_controls: Optional[List[str]] = None
350
+
351
+ def to_dict(self):
352
+ return _drop_none({"sections": [s.to_dict() if hasattr(s, "to_dict") else s
353
+ for s in self.sections],
354
+ "persistent_section": self.persistent_section, "essential_controls": self.essential_controls})
302
355
 
303
356
 
304
357
  def local_plot():
@@ -448,6 +501,8 @@ def send_array(A, flags=0, copy=True, track=False):
448
501
  shape = A.shape,
449
502
  )
450
503
 
504
+ _publish_ui_if_due()
505
+
451
506
  #Send category
452
507
  socket.send_string(SENDING_DATA)
453
508
  #Send json description
@@ -456,7 +511,7 @@ def send_array(A, flags=0, copy=True, track=False):
456
511
  socket.send(A, flags, copy=copy, track=track)
457
512
 
458
513
 
459
- def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
514
+ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0, *, view=None, ui_state=None):
460
515
  """Send a json description of desired plot and block until the
461
516
  server acknowledges it.
462
517
 
@@ -483,8 +538,15 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
483
538
  warning and return (so an old server without the handshake
484
539
  feature, or a server on a flaky network, doesn't block the
485
540
  caller forever). Set to 0 to skip the handshake entirely.
541
+ view:
542
+ Optional View or dictionary declaring sections and a persistent section.
543
+ Requires a browser server >= 0.5.0; unconfirmed support emits a warning.
544
+ ui_state:
545
+ Optional initial controls/sections presentation overrides. Subsequent
546
+ set_ui_state() patches preserve plots and values; None resets overrides.
486
547
  """
487
- global plot_desc_dict
548
+ global plot_desc_dict, _generation, _ui_revision, _ui_state, _view
549
+ global _control_registry, _server_capabilities, _source_epoch, _control_button_events, _last_control_poll
488
550
 
489
551
  #Process int inputs
490
552
  if isinstance(plot_descriptions,int):
@@ -534,7 +596,24 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
534
596
  else:
535
597
  raise TypeError("Incorrect usage of initialize_plots, verify github for usage")
536
598
 
599
+ _view, _control_registry = declarations(plot_desc_dict, view)
600
+ _ui_state = merge_state({"controls": {}, "sections": {}}, ui_state or {}, _control_registry, _view)
601
+ _generation += 1
602
+ _ui_revision = 0
603
+ _source_epoch = None
604
+ _server_capabilities = set()
605
+ _control_button_events = []
606
+ _last_control_poll = time.monotonic()
537
607
  _send_initialize_with_handshake(plot_desc_dict, handshake_timeout)
608
+ if (view is not None or ui_state is not None) and "ui_state_v1" not in _server_capabilities:
609
+ warnings.warn("Sections/UI state require rtplot server >= 0.5.0. This server did not "
610
+ "confirm support: layout may be flat and disabled states are NOT enforced.",
611
+ RuntimeWarning, stacklevel=2)
612
+
613
+ if presentation_requested(plot_desc_dict, _view) and "presentation_v1" not in _server_capabilities:
614
+ warnings.warn("Presentation options require rtplot browser server >= 0.6.0. This server did not "
615
+ "confirm support; essential placement, groups, and styling may be ignored.",
616
+ RuntimeWarning, stacklevel=2)
538
617
 
539
618
 
540
619
  def _send_initialize_with_handshake(cfg, timeout):
@@ -549,7 +628,7 @@ def _send_initialize_with_handshake(cfg, timeout):
549
628
 
550
629
  def _send_once():
551
630
  socket.send_string(SENDING_PLOT_UPDATE)
552
- socket.send_json(cfg)
631
+ socket.send_json(_wire_config(cfg))
553
632
 
554
633
  _send_once()
555
634
 
@@ -575,8 +654,9 @@ def _send_initialize_with_handshake(cfg, timeout):
575
654
  continue
576
655
  evtype = event.get("type")
577
656
  if evtype == "config_ack":
578
- return
579
- elif evtype == "button":
657
+ if _accept_ack(event):
658
+ return
659
+ elif evtype == "button" and _current_event(event):
580
660
  _control_button_events.append(event.get("id"))
581
661
  elif evtype == "slider":
582
662
  _control_values[event.get("id")] = float(event.get("value", 0.0))
@@ -597,6 +677,65 @@ def _send_initialize_with_handshake(cfg, timeout):
597
677
  )
598
678
 
599
679
 
680
+ def _wire_config(cfg):
681
+ # Old servers already skip non_plot_labels entries. Metadata adds no traces.
682
+ return {**cfg, META_KEY: {"non_plot_labels": [], "rtplot": {
683
+ "session": _session, "generation": _generation, "view": _view,
684
+ "revision": _ui_revision, "state": _ui_state,
685
+ }}}
686
+
687
+
688
+ def _accept_ack(event):
689
+ global _server_capabilities, _source_epoch, _control_button_events
690
+ if event.get("session") is not None and (event.get("session"), event.get("generation")) != (_session, _generation):
691
+ return False
692
+ _server_capabilities = set(event.get("capabilities", []))
693
+ epoch = event.get("source_epoch")
694
+ if epoch != _source_epoch:
695
+ _control_button_events = []
696
+ _source_epoch = epoch
697
+ return True
698
+
699
+
700
+ def _current_event(event):
701
+ if "ui_state_v1" not in _server_capabilities:
702
+ return True
703
+ return (event.get("session"), event.get("generation"), event.get("source_epoch")) == (_session, _generation, _source_epoch)
704
+
705
+
706
+ def _publish_ui_if_due(force=False):
707
+ global _last_ui_publish
708
+ if "ui_state_v1" not in _server_capabilities:
709
+ return
710
+ now = time.monotonic()
711
+ if not force and now - _last_ui_publish < 1.0:
712
+ return
713
+ socket.send_string(SENDING_UI_STATE, zmq.SNDMORE)
714
+ socket.send_json({"session": _session, "generation": _generation,
715
+ "revision": _ui_revision, "state": _ui_state})
716
+ _last_ui_publish = now
717
+
718
+
719
+ def set_ui_state(patch):
720
+ """Patch control/section presentation state without reinitializing plots.
721
+
722
+ None removes a property override (or all overrides for a target ID).
723
+ Returns whether the merged state changed. Call poll_controls regularly
724
+ while idle to maintain liveness and recover dropped presentation updates.
725
+ """
726
+ global _ui_state, _ui_revision
727
+ if "ui_state_v1" not in _server_capabilities:
728
+ raise RuntimeError("set_ui_state requires an acknowledged rtplot server >= 0.5.0; "
729
+ "disabled-state enforcement is unavailable")
730
+ merged = merge_state(_ui_state, patch, _control_registry, _view)
731
+ changed = merged != _ui_state
732
+ if changed:
733
+ _ui_state = merged
734
+ _ui_revision += 1
735
+ _publish_ui_if_due(force=changed)
736
+ return changed
737
+
738
+
600
739
  def set_display(display_id: str, value):
601
740
  """Push a single display box value to the browser.
602
741
 
@@ -646,7 +785,16 @@ def poll_controls():
646
785
  restarting their script. The request is consumed silently — the
647
786
  returned ControlState only ever contains control/button events.
648
787
  """
649
- global _control_button_events
788
+ global _control_button_events, _source_epoch, _last_control_poll
789
+ now = time.monotonic()
790
+ stale_poll = "ui_state_v1" in _server_capabilities and now - _last_control_poll > 5.0
791
+ _last_control_poll = now
792
+ if stale_poll:
793
+ # An idle/disconnected application's PULL socket can still hold actions
794
+ # sent before the server detected the outage. Drain them without replay.
795
+ _control_button_events = []
796
+ _source_epoch = None
797
+ _publish_ui_if_due(force=stale_poll)
650
798
  while True:
651
799
  try:
652
800
  event = control_socket.recv_json(flags=zmq.NOBLOCK)
@@ -655,6 +803,13 @@ def poll_controls():
655
803
  except zmq.ZMQError:
656
804
  break
657
805
  evtype = event.get("type")
806
+ if stale_poll and evtype in ("config_ack", "button", "slider", "text"):
807
+ continue
808
+ if evtype == "config_ack":
809
+ _accept_ack(event)
810
+ continue
811
+ if evtype in ("button", "slider", "text") and not _current_event(event):
812
+ continue
658
813
  if evtype == "button":
659
814
  _control_button_events.append(event.get("id"))
660
815
  elif evtype == "slider":
@@ -664,7 +819,8 @@ def poll_controls():
664
819
  elif evtype == "resend_config":
665
820
  if plot_desc_dict is not None:
666
821
  socket.send_string(SENDING_PLOT_UPDATE)
667
- socket.send_json(plot_desc_dict)
822
+ _control_button_events = []
823
+ socket.send_json(_wire_config(plot_desc_dict))
668
824
  # Surface a one-line confirmation so the user can see in
669
825
  # their script log that the recovery handshake worked.
670
826
  # If they never see this after clicking Reconnect, the