better-rtplot 0.6.0__tar.gz → 0.6.1__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.6.0 → better_rtplot-0.6.1}/PKG-INFO +3 -1
  2. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/README.md +2 -0
  3. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/pyproject.toml +1 -1
  4. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/client.py +31 -2
  5. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/server_browser.py +18 -5
  6. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/index.html +25 -2
  7. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/presentation.css +5 -0
  8. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/ui_state.py +26 -1
  9. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/LICENSE +0 -0
  10. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/interactive_test.py +0 -0
  11. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/saved_plots/.gitignore +0 -0
  12. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/server.py +0 -0
  13. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/server_browser_gui.py +0 -0
  14. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/auto-render.min.js +0 -0
  15. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  16. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  17. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  18. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  19. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  20. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  21. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  22. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  23. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  24. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  25. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  26. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  27. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  28. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  29. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  30. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  31. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  32. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  33. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  34. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  35. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/katex.min.css +0 -0
  36. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/katex/katex.min.js +0 -0
  37. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/uPlot.iife.min.js +0 -0
  38. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/uPlot.min.css +0 -0
  39. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/ui-view.css +0 -0
  40. {better_rtplot-0.6.0 → better_rtplot-0.6.1}/rtplot/static/ui-view.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: better-rtplot
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary:
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -159,3 +159,5 @@ A rising line now draws itself in the browser tab.
159
159
  Issues and feature requests:
160
160
  [github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
161
161
 
162
+ Dropdown controls: [API and examples](docs/dropdowns.md).
163
+
@@ -133,3 +133,5 @@ A rising line now draws itself in the browser tab.
133
133
 
134
134
  Issues and feature requests:
135
135
  [github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
136
+
137
+ Dropdown controls: [API and examples](docs/dropdowns.md).
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.6.0"
3
+ version = "0.6.1"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "MIT"
@@ -310,10 +310,24 @@ class TextInput:
310
310
  })
311
311
 
312
312
 
313
+ @dataclass
314
+ class Dropdown:
315
+ """Single choice control. Omitted value selects the first option."""
316
+ id: str
317
+ label: str
318
+ options: List[Union[str, dict]]
319
+ value: Optional[str] = None
320
+ height: Optional[float] = None
321
+
322
+ def to_dict(self):
323
+ return _drop_none({"type": "dropdown", "id": self.id, "label": self.label,
324
+ "options": self.options, "value": self.value, "height": self.height})
325
+
326
+
313
327
  @dataclass
314
328
  class ControlsRow:
315
329
  """A row of control widgets, rendered in place of a plot."""
316
- controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, dict]] = field(default_factory=list)
330
+ controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, Dropdown, dict]] = field(default_factory=list)
317
331
 
318
332
  section: Optional[str] = None
319
333
  title: Optional[str] = None
@@ -610,6 +624,10 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0, *, view=None, u
610
624
  "confirm support: layout may be flat and disabled states are NOT enforced.",
611
625
  RuntimeWarning, stacklevel=2)
612
626
 
627
+ if any(c["type"] == "dropdown" for c in _control_registry.values()) and "dropdown_v1" not in _server_capabilities:
628
+ warnings.warn("Dropdowns require rtplot browser server >= 0.6.1. This server did not "
629
+ "confirm support; dropdown controls may be missing or unenforced.",
630
+ RuntimeWarning, stacklevel=2)
613
631
  if presentation_requested(plot_desc_dict, _view) and "presentation_v1" not in _server_capabilities:
614
632
  warnings.warn("Presentation options require rtplot browser server >= 0.6.0. This server did not "
615
633
  "confirm support; essential placement, groups, and styling may be ignored.",
@@ -767,12 +785,23 @@ def set_text_input(input_id: str, value):
767
785
  socket.send_json({"id": str(input_id), "value": str(value)})
768
786
 
769
787
 
788
+ def set_dropdown(control_id: str, value: str):
789
+ """Select a declared option without rebuilding the layout (server >= 0.6.1)."""
790
+ control = _control_registry.get(control_id, {})
791
+ if control.get("type") != "dropdown" or value not in control["options"]:
792
+ raise ValueError("set_dropdown requires a dropdown ID and a declared string value")
793
+ if "dropdown_v1" not in _server_capabilities:
794
+ raise RuntimeError("set_dropdown requires rtplot browser server >= 0.6.1")
795
+ socket.send_string(SENDING_TEXT_INPUT, zmq.SNDMORE)
796
+ socket.send_json({"id": control_id, "value": value, "session": _session, "generation": _generation})
797
+
798
+
770
799
  def poll_controls():
771
800
  """Drain the return channel non-blocking and return current control state.
772
801
 
773
802
  Returns a ControlState(values, buttons) where:
774
803
  - values: dict of latest control values keyed by id. Sliders/dials
775
- remain floats; text_input controls remain strings.
804
+ remain floats; text_input and dropdown controls remain strings.
776
805
  - buttons: list of button ids that fired since the previous poll
777
806
  (cleared after this call).
778
807
 
@@ -676,8 +676,9 @@ def parse_config(tab: Tab, json_config):
676
676
  for element in row:
677
677
  if element.get("type") in ("slider", "dial") and "value" in element:
678
678
  slider_values[element["id"]] = float(element["value"])
679
- elif element.get("type") == "text_input":
680
- text_values[element["id"]] = str(element.get("value", ""))
679
+ elif element.get("type") in ("text_input", "dropdown"):
680
+ default = registry[element["id"]]["options"][0] if element["type"] == "dropdown" else ""
681
+ text_values[element["id"]] = str(element.get("value", default))
681
682
 
682
683
  for plot_counter, (_, plot_description) in enumerate(plot_entries):
683
684
  trace_names = plot_description["names"]
@@ -717,10 +718,11 @@ def parse_config(tab: Tab, json_config):
717
718
  el["id"]
718
719
  for row in control_rows
719
720
  for el in row
720
- if el.get("type") == "text_input"
721
+ if el.get("type") in ("text_input", "dropdown")
721
722
  }
722
723
  tab.text_values = {
723
- text_id: tab.text_values.get(text_id, default_value)
724
+ text_id: (default_value if registry[text_id]["type"] == "dropdown"
725
+ else tab.text_values.get(text_id, default_value))
724
726
  for text_id, default_value in text_values.items()
725
727
  if text_id in active_text_ids
726
728
  }
@@ -1264,6 +1266,12 @@ async def zmq_receiver(tab: Tab):
1264
1266
  if text_id is None:
1265
1267
  continue
1266
1268
  value = str(payload.get("value", ""))
1269
+ control = tab.control_registry.get(text_id, {})
1270
+ if control.get("type") == "dropdown":
1271
+ if (payload.get("session"), payload.get("generation")) != (tab.session, tab.generation):
1272
+ continue
1273
+ if payload.get("value") not in control["options"]:
1274
+ continue
1267
1275
  if tab.text_values.get(text_id) != value:
1268
1276
  tab.text_values[text_id] = value
1269
1277
  tab.text_dirty.add(text_id)
@@ -1701,8 +1709,13 @@ async def handle_ws(request):
1701
1709
  text_id = payload.get("id")
1702
1710
  tid = ws_tab.get(ws, BIND_ME_ID)
1703
1711
  t = tabs.get(tid)
1704
- if text_id and t is not None and control_allowed(t, payload, {"text_input"}):
1712
+ if text_id and t is not None and control_allowed(t, payload, {"text_input", "dropdown"}):
1705
1713
  value = str(payload.get("value", ""))
1714
+ control = t.control_registry.get(text_id, {})
1715
+ if control.get("type") == "dropdown":
1716
+ if payload.get("value") not in control["options"]:
1717
+ continue
1718
+ t.text_dirty.add(text_id)
1706
1719
  t.text_values[text_id] = value
1707
1720
  refresh_config_message(t)
1708
1721
  await send_control_event(
@@ -742,6 +742,28 @@
742
742
  item.appendChild(val);
743
743
  controlElements.displays[el.id] = val;
744
744
  controlElements.displayKinds[el.id] = 'text';
745
+ } else if (el.type === 'dropdown') {
746
+ item.classList.add('ctrl-dropdown', 'flex');
747
+ const label = document.createElement('label');
748
+ label.textContent = el.label || el.id;
749
+ const select = document.createElement('select');
750
+ select.className = 'ctrl-textinput ctrl-select';
751
+ for (const choice of el.options) {
752
+ const option = document.createElement('option');
753
+ option.value = typeof choice === 'string' ? choice : choice.value;
754
+ option.textContent = typeof choice === 'string' ? choice : (choice.label ?? choice.value);
755
+ select.appendChild(option);
756
+ }
757
+ select.value = el.value ?? select.options[0].value;
758
+ let committed = select.value;
759
+ select.addEventListener('change', () => {
760
+ if (sendCtrl({type: 'control_text', id: el.id, value: select.value})) committed = select.value;
761
+ else select.value = committed;
762
+ });
763
+ select.setServerValue = value => { select.value = value; committed = value; };
764
+ label.appendChild(select);
765
+ item.appendChild(label);
766
+ controlElements.textInputs[el.id] = select;
745
767
  } else if (el.type === 'text_input') {
746
768
  item.classList.add('ctrl-text-input', 'flex');
747
769
  if (el.label) {
@@ -809,7 +831,7 @@
809
831
  applyElementSize(item, el);
810
832
  item.dataset.appearance = el.appearance || 'default';
811
833
  if (el.appearance === 'state') item.setAttribute('role', 'status');
812
- item.querySelectorAll('input,svg.ctrl-dial').forEach(input => input.setAttribute('aria-label', el.label || el.id));
834
+ item.querySelectorAll('input,select,svg.ctrl-dial').forEach(input => input.setAttribute('aria-label', el.label || el.id));
813
835
  div.appendChild(item);
814
836
  viewManager.register(el.id, item, section);
815
837
  });
@@ -828,7 +850,8 @@
828
850
  if (!values) return;
829
851
  for (const [id, val] of Object.entries(values)) {
830
852
  const input = controlElements.textInputs[id];
831
- if (input) input.value = (val !== undefined && val !== null) ? String(val) : '';
853
+ if (input?.setServerValue) input.setServerValue(String(val));
854
+ else if (input) input.value = (val !== undefined && val !== null) ? String(val) : '';
832
855
  }
833
856
  }
834
857
 
@@ -166,3 +166,8 @@ body { height: auto; min-height: 100%; }
166
166
  .ctrl-textinput, .ctrl-numinput, .ctrl-nudgebtn { border-color: #738799; }
167
167
 
168
168
  @media (max-height: 480px) and (min-width: 640px) { .ui-source-status { padding-block: 2px; font-size: 12px; line-height: 1.2; } }
169
+
170
+ /* Native choices retain platform keyboard and touch behavior. */
171
+ .ctrl-dropdown { min-width: 0; flex: 1 1 180px; }
172
+ .ctrl-dropdown > label { display: flex; flex-direction: column; gap: 6px; width: 100%; min-width: 0; }
173
+ .ctrl-select { flex: none; min-height: 44px; padding: 8px 12px; font: inherit; font-size: 16px; color: #18334b; background: #fff; border: 1px solid #738799; }
@@ -2,12 +2,35 @@
2
2
  from copy import deepcopy
3
3
 
4
4
  META_KEY = "__rtplot_view__"
5
- CAPABILITIES = ["sections", "ui_state_v1", "presentation_v1"]
5
+ CAPABILITIES = ["sections", "ui_state_v1", "presentation_v1", "dropdown_v1"]
6
6
  CONTROL_DEFAULTS = {"enabled": True, "visible": True, "busy": False, "selected": False, "reason": ""}
7
7
  SECTION_DEFAULTS = {"visible": True, "status": "idle", "message": ""}
8
8
  STATUSES = {"idle", "ready", "busy", "complete", "warning", "error"}
9
9
 
10
10
 
11
+ def dropdown_options(control):
12
+ """Return validated (value, label) choices; values are always strings."""
13
+ options = control.get("options")
14
+ if not isinstance(options, list) or not options:
15
+ raise ValueError("Dropdown options must be a non-empty list")
16
+ choices = []
17
+ for option in options:
18
+ if isinstance(option, str):
19
+ value, label = option, option
20
+ elif isinstance(option, dict):
21
+ value, label = option.get("value"), option.get("label", option.get("value"))
22
+ else:
23
+ raise ValueError("Dropdown options must be strings or value/label dictionaries")
24
+ if not isinstance(value, str) or not isinstance(label, str):
25
+ raise ValueError("Dropdown option values and labels must be strings")
26
+ if value in [v for v, _ in choices]:
27
+ raise ValueError("Dropdown option values must be unique")
28
+ choices.append((value, label))
29
+ if control.get("value", choices[0][0]) not in [v for v, _ in choices]:
30
+ raise ValueError("Dropdown value must match an option")
31
+ return choices
32
+
33
+
11
34
  def declarations(config, view=None):
12
35
  """Validate optional view and return normalized view plus control registry."""
13
36
  if hasattr(view, "to_dict"):
@@ -78,6 +101,8 @@ def declarations(config, view=None):
78
101
  if row.get("exclusive") and control.get("type") != "button":
79
102
  raise ValueError("Exclusive groups may contain only buttons")
80
103
  controls[cid] = {"type": control.get("type"), "section": sid}
104
+ if control.get("type") == "dropdown":
105
+ controls[cid]["options"] = [value for value, _ in dropdown_options(control)]
81
106
  if row.get("exclusive"):
82
107
  controls[cid]["exclusive_group"] = key
83
108
  essentials = view.get("essential_controls", [])
File without changes