pgwidgets-python 0.3.4__py3-none-any.whl → 0.4.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.
pgwidgets/method_types.py CHANGED
@@ -87,9 +87,13 @@ ACTION_METHODS = {
87
87
  # Per-cell / row / column / table colour overrides — the
88
88
  # ``set_*`` naming would otherwise classify them as SETTERs,
89
89
  # whose single-state-slot semantics can't represent the
90
- # accumulated dict of overrides we actually keep. ACTION
91
- # dispatch sends each call straight through to the JS side,
92
- # which holds the canonical state in its own maps.
90
+ # accumulated dict of overrides we actually keep. Listed here
91
+ # so no SETTER is generated; _add_tree_view_methods() then
92
+ # supplies generators that accumulate them into the _cell_styles
93
+ # / _row_styles / _column_styles / _table_style dicts, which
94
+ # reconstruct() replays (see TREE_OVERRIDE_REPLAY). Same
95
+ # arrangement as expand_item / sort_by_column above.
96
+ "set_colors",
93
97
  "set_cell_color", "set_row_color", "set_column_color",
94
98
  "set_table_color",
95
99
  "clear_cell_color", "clear_row_color", "clear_column_color",
@@ -107,8 +111,14 @@ ACTION_METHODS = {
107
111
  "raise_", "lower",
108
112
  # Timer
109
113
  "start", "cancel", "set", "cond_set",
110
- # Table/Tree row-level modifications (tracked via bulk set_data)
111
- "add_item", "remove_item", "update_tree", "remove_items",
114
+ # Tree / table / column mutations. Listed here so no SETTER is
115
+ # generated (none of them sets a single state slot);
116
+ # _add_tree_view_methods() then supplies generators that apply each
117
+ # mutation to the tree / rows / columns model in _state, so the bulk
118
+ # state replayed on reconnect reflects every change since, not just
119
+ # the last wholesale set. See pgwidgets.tree_model.
120
+ "add_item", "remove_item", "update_tree", "remove_items", "delete_tree",
121
+ "update_data", "update_rows",
112
122
  "insert_row", "append_row", "delete_row",
113
123
  "insert_column", "append_column", "delete_column",
114
124
  "set_cell",
@@ -232,7 +242,123 @@ CHILD_SELECT_METHODS = {
232
242
  # (e.g. Splitter.set_sizes needs panes to already exist).
233
243
  POST_CHILDREN_STATE_KEYS = {"sizes", "index", "_collapsed_paths",
234
244
  "_expanded_paths", "_sort", "scroll_position",
235
- "scroll_percent"}
245
+ "scroll_percent",
246
+ # Tree/table colour overrides. Unlike the row
247
+ # data -- which folds into the tree/rows model
248
+ # and is replayed by the bulk setter -- these
249
+ # have no bulk setter, so they are accumulated
250
+ # and replayed on top of the data.
251
+ "_cell_styles", "_row_styles",
252
+ "_column_styles", "_table_style"}
253
+
254
+ # Maps a colour-override state key to the method that replays one stored
255
+ # entry during reconstruction. Each entry holds the original call's
256
+ # argument tuple, so replaying is a straight re-dispatch. _table_style
257
+ # holds a single tuple rather than a dict of them.
258
+ TREE_OVERRIDE_REPLAY = {
259
+ "_cell_styles": "set_cell_color",
260
+ "_row_styles": "set_row_color",
261
+ "_column_styles": "set_column_color",
262
+ }
263
+
264
+ # Colour overrides are replayed in batches rather than one call per
265
+ # cell: each single call is a blocking websocket round-trip *and* a full
266
+ # re-render in the browser, so restoring a few hundred coloured cells
267
+ # one at a time is slow and visibly iterative. Cells are chunked so no
268
+ # single message grows unreasonably large.
269
+ COLOUR_BATCH_SIZE = 500
270
+
271
+
272
+ # Colour calls that can be folded into a single ``set_colors``, mapped
273
+ # to the spec key they contribute to and the names of their arguments.
274
+ _COLOUR_CALL_SPEC = {
275
+ "set_cell_color": ("cells", ("path", "col_key", "fg", "bg", "bold")),
276
+ "set_row_color": ("rows", ("path", "fg", "bg", "bold")),
277
+ "set_column_color": ("columns", ("col_key", "fg", "bg", "bold")),
278
+ "set_table_color": ("table", ("fg", "bg", "bold")),
279
+ }
280
+
281
+
282
+ def coalesce_colour_calls(calls):
283
+ """Fold runs of colour calls in a batch into single ``set_colors``.
284
+
285
+ Each ``set_*_color`` re-renders the widget, so a few hundred of them
286
+ are slow in the browser even when they arrive in one message -- the
287
+ reason the reconnect path uses ``set_colors`` instead. Doing the
288
+ same for batched calls means an app can keep writing the simple
289
+ per-cell loop and still get one render.
290
+
291
+ Only *consecutive* calls on the same widget are merged, so ordering
292
+ against any interleaved non-colour call is preserved. Runs of one
293
+ are left alone. ``set_colors`` and the batch message arrived
294
+ together, so a browser that understands the batch understands the
295
+ merged call.
296
+ """
297
+ out = []
298
+ i = 0
299
+ n = len(calls)
300
+ while i < n:
301
+ call = calls[i]
302
+ if call.get("method") not in _COLOUR_CALL_SPEC:
303
+ out.append(call)
304
+ i += 1
305
+ continue
306
+
307
+ wid = call.get("wid")
308
+ run = []
309
+ while (i < n and calls[i].get("method") in _COLOUR_CALL_SPEC
310
+ and calls[i].get("wid") == wid):
311
+ run.append(calls[i])
312
+ i += 1
313
+
314
+ if len(run) == 1:
315
+ out.append(run[0])
316
+ continue
317
+
318
+ spec = {}
319
+ for entry in run:
320
+ key, names = _COLOUR_CALL_SPEC[entry["method"]]
321
+ values = dict(zip(names, entry.get("args", [])))
322
+ if key == "table":
323
+ spec["table"] = values
324
+ else:
325
+ spec.setdefault(key, []).append(values)
326
+ out.append({"wid": wid, "method": "set_colors", "args": [spec]})
327
+ return out
328
+
329
+
330
+ def colour_batches(widget, chunk=COLOUR_BATCH_SIZE):
331
+ """Return the ``set_colors`` specs that restore a widget's colours.
332
+
333
+ The row, column and table layers ride along in the first batch; the
334
+ per-cell overrides are split across as many batches as needed.
335
+ """
336
+ state = getattr(widget, "_state", None) or {}
337
+ cells = [dict(zip(("path", "col_key", "fg", "bg", "bold"), args))
338
+ for args in state.get("_cell_styles", {}).values()]
339
+ rows = [dict(zip(("path", "fg", "bg", "bold"), args))
340
+ for args in state.get("_row_styles", {}).values()]
341
+ columns = [dict(zip(("col_key", "fg", "bg", "bold"), args))
342
+ for args in state.get("_column_styles", {}).values()]
343
+ table = state.get("_table_style")
344
+
345
+ if not (cells or rows or columns or table):
346
+ return []
347
+
348
+ first = {}
349
+ if rows:
350
+ first["rows"] = rows
351
+ if columns:
352
+ first["columns"] = columns
353
+ if table is not None:
354
+ first["table"] = dict(zip(("fg", "bg", "bold"), table))
355
+ if cells[:chunk]:
356
+ first["cells"] = cells[:chunk]
357
+
358
+ batches = [first]
359
+ for i in range(chunk, len(cells), chunk):
360
+ batches.append({"cells": cells[i:i + chunk]})
361
+ return batches
236
362
 
237
363
  # Default state values applied when a widget is created without
238
364
  # explicitly setting the key (e.g. TextEntry() with no text arg).
@@ -297,8 +423,12 @@ CLEAR_RESETS = {
297
423
  "TextArea": ["text"],
298
424
  "TextSource": ["text"],
299
425
  "ComboBox": ["text", "index", "_items"],
300
- "TreeView": ["tree", "data", "_collapsed_paths", "_sort"],
301
- "TableView": ["rows", "data", "_collapsed_paths", "_sort"],
426
+ # The JS clear() empties its per-cell and per-row style maps but
427
+ # keeps the column / table layers, so mirror exactly that.
428
+ "TreeView": ["tree", "data", "_collapsed_paths", "_sort",
429
+ "_cell_styles", "_row_styles"],
430
+ "TableView": ["rows", "data", "_collapsed_paths", "_sort",
431
+ "_cell_styles", "_row_styles"],
302
432
  "HtmlView": ["html"],
303
433
  "ExternalWidget": ["content"],
304
434
  }
@@ -419,7 +549,34 @@ BINARY_STATE_KEYS = {
419
549
  }
420
550
 
421
551
 
552
+ def _combobox_get_index(self):
553
+ """Index of the item matching the current text, or -1 when the text is
554
+ not one of the offerings (e.g. a value typed into an editable combo box
555
+ but not yet committed with Enter). Mirrors the JS get_index(), which
556
+ is items.indexOf(input value), and stays consistent with get_text()
557
+ (the cached entry text, kept live by the 'modified' callback)."""
558
+ items = self._state.get("_items", [])
559
+ try:
560
+ return items.index(self._state.get("text", ""))
561
+ except ValueError:
562
+ return -1
563
+
564
+
565
+ def _combobox_set_index(self, idx):
566
+ """Select the item at *idx*, keeping the cached text consistent with
567
+ the index so get_text() agrees with get_index() after a programmatic
568
+ change (the generic setter would leave 'text' stale)."""
569
+ items = self._state.get("_items", [])
570
+ if 0 <= idx < len(items):
571
+ self._state["text"] = items[idx]
572
+ self._state["index"] = idx
573
+ self._user_set_state.add("index")
574
+ return self._call("set_index", idx)
575
+
576
+
422
577
  CUSTOM_METHODS = {
578
+ ("ComboBox", "get_index"): _combobox_get_index,
579
+ ("ComboBox", "set_index"): _combobox_set_index,
423
580
  ("TabWidget", "index_to_widget"): _index_to_widget,
424
581
  ("TabWidget", "index_of"): _index_of,
425
582
  ("StackWidget", "index_to_widget"): _index_to_widget,
@@ -11,6 +11,7 @@ the UI can be reconstructed when a browser reconnects.
11
11
  """
12
12
 
13
13
  import asyncio
14
+ import contextlib
14
15
  import json
15
16
  import logging
16
17
  import mimetypes
@@ -33,6 +34,8 @@ from pgwidgets.method_types import (
33
34
  STATE_SYNC_CALLBACKS, STATE_SYNC_REQUIRES_OPTION,
34
35
  WIDGET_CALLBACK_SYNC, POST_CHILDREN_STATE_KEYS, ITEM_LIST_CONFIG,
35
36
  CHILD_CLOSE_CALLBACKS, REPLAY_METHODS, TREE_VIEW_WIDGETS,
37
+ TREE_OVERRIDE_REPLAY, colour_batches, JS_ONLY_METHODS,
38
+ coalesce_colour_calls,
36
39
  BINARY_STATE_KEYS, _send_binary_auto,
37
40
  )
38
41
 
@@ -189,6 +192,14 @@ class Session:
189
192
 
190
193
  self._reconstructing = False # suppress callbacks during reconstruction
191
194
 
195
+ # Batching (see batch()). While _batch_depth is non-zero, calls
196
+ # are buffered here instead of being sent one at a time.
197
+ self._batch_depth = 0
198
+ self._batch_calls = []
199
+ # cleared if a browser rejects the 'batch' message; a new
200
+ # connection may be a newer client, so add_connection resets it
201
+ self._batch_supported = True
202
+
192
203
  # Browser viewport size (updated by 'viewport' messages from JS).
193
204
  self._screen_size = (0, 0)
194
205
 
@@ -247,6 +258,9 @@ class Session:
247
258
  """Add a browser connection to this session."""
248
259
  if ws not in self._connections:
249
260
  self._connections.append(ws)
261
+ # a reconnecting browser may be a newer client than the one
262
+ # that made us give up on batching
263
+ self._batch_supported = True
250
264
 
251
265
  def remove_connection(self, ws):
252
266
  """Remove a browser connection from this session."""
@@ -524,6 +538,14 @@ class Session:
524
538
  widget._state[spec[0]] = tuple(args)
525
539
  else:
526
540
  widget._state[spec] = args[0]
541
+ # ComboBox: a value committed with Enter in the browser may be
542
+ # a new entry the browser appended to its list; mirror that
543
+ # append so our _items (and thus get_index) stays consistent.
544
+ if (widget._js_class == "ComboBox" and action == "activated"
545
+ and len(args) >= 2):
546
+ items = widget._state.setdefault("_items", [])
547
+ if args[1] not in items:
548
+ items.append(args[1])
527
549
  # Dialog autoclose: the JS side auto-hides when a button is
528
550
  # clicked. Sync that to Python so reconstruction doesn't
529
551
  # re-show a dialog that was autoclosed.
@@ -540,7 +562,8 @@ class Session:
540
562
  key_path = tuple(path) if isinstance(path, list) else path
541
563
  expanded = widget._state.setdefault(
542
564
  "_expanded_paths", set())
543
- expanded.add(key_path)
565
+ if expanded != "_all":
566
+ expanded.add(key_path)
544
567
  collapsed = widget._state.get("_collapsed_paths")
545
568
  if collapsed is not None and collapsed != "_all":
546
569
  collapsed.discard(key_path)
@@ -549,7 +572,7 @@ class Session:
549
572
  path = args[1]
550
573
  key_path = tuple(path) if isinstance(path, list) else path
551
574
  expanded = widget._state.get("_expanded_paths")
552
- if expanded is not None:
575
+ if expanded is not None and expanded != "_all":
553
576
  expanded.discard(key_path)
554
577
  collapsed = widget._state.setdefault(
555
578
  "_collapsed_paths", set())
@@ -725,11 +748,101 @@ class Session:
725
748
  })
726
749
  return wid
727
750
 
751
+ @contextlib.contextmanager
752
+ def batch(self):
753
+ """Apply many updates as one message.
754
+
755
+ Every call normally goes to the browser on its own and blocks
756
+ for the reply, and widgets like TreeView re-render on each one.
757
+ For a burst of updates -- a refresh rewriting a few hundred
758
+ cells, say -- that is slow and paints in visible stages. Inside
759
+ this context the calls are buffered and sent as a single batch
760
+ on exit, which the browser applies with rendering suspended, so
761
+ each widget draws once::
762
+
763
+ with tree.get_session().batch():
764
+ for path, col, value in changes:
765
+ tree.set_cell(path, col, value)
766
+
767
+ Widget state is still updated as each call is made -- only the
768
+ traffic is deferred -- so the Python-side model stays correct
769
+ throughout, and a reconnect landing mid-batch reconstructs from
770
+ it correctly.
771
+
772
+ Calls made inside a batch return None. Methods that genuinely
773
+ need an answer from the browser (getters, and the browser-only
774
+ queries) flush the batch first and execute immediately, so
775
+ ordering is preserved.
776
+
777
+ Nests: only the outermost block flushes. The buffer is flushed
778
+ on the way out even if the body raises, because those calls have
779
+ already been applied to the model and dropping them would leave
780
+ the browser diverged from it.
781
+ """
782
+ self._batch_depth += 1
783
+ try:
784
+ yield self
785
+ finally:
786
+ self._batch_depth -= 1
787
+ if self._batch_depth == 0:
788
+ self._flush_batch()
789
+
790
+ def _flush_batch(self):
791
+ """Send everything buffered by batch() as one message.
792
+
793
+ A browser older than this server won't know the ``batch``
794
+ message -- a page that was loaded before the server was
795
+ upgraded, most commonly. Rather than failing the caller's
796
+ update, fall back to sending the calls individually and stop
797
+ trying to batch for this connection. Reloading the page picks
798
+ up the current client and re-enables it.
799
+ """
800
+ calls, self._batch_calls = self._batch_calls, []
801
+ if not calls:
802
+ return None
803
+ # _send handles the no-browser case (and is the single place
804
+ # that policy lives)
805
+ # Fold runs of colour calls into set_colors: one re-render in
806
+ # the browser instead of one per cell.
807
+ calls = coalesce_colour_calls(calls)
808
+ if self._batch_supported:
809
+ self._logger.debug("flushing a batch of %d call(s)", len(calls))
810
+ try:
811
+ return self._send({"type": "batch", "calls": calls})
812
+ except RuntimeError as e:
813
+ if "Unknown message type" not in str(e):
814
+ raise
815
+ self._batch_supported = False
816
+ self._logger.warning(
817
+ "browser does not understand batched updates (it is "
818
+ "running an older pgwidgets-js); sending calls "
819
+ "individually. Reload the page to re-enable batching.")
820
+ for call in calls:
821
+ self._send({"type": "call", **call})
822
+ return None
823
+
824
+ def _needs_result(self, method):
825
+ """True if `method` has to reach the browser now to be useful.
826
+
827
+ Getters answer from local state where they can; the ones that
828
+ can't (and the browser-only queries) must not be deferred, or
829
+ the caller gets None where it expected a value.
830
+ """
831
+ return method.startswith("get_") or method in JS_ONLY_METHODS
832
+
728
833
  def _call(self, wid, method, *args):
729
834
  """Call a method on a JS widget.
730
835
 
731
836
  Returns None if no browser is connected.
732
837
  """
838
+ if self._batch_depth > 0:
839
+ if not self._needs_result(method):
840
+ self._batch_calls.append({"wid": wid, "method": method,
841
+ "args": list(args)})
842
+ return None
843
+ # has to go now -- flush what's queued so it stays ordered
844
+ self._flush_batch()
845
+
733
846
  result = self._send({
734
847
  "type": "call",
735
848
  "wid": wid,
@@ -1524,6 +1637,13 @@ class Session:
1524
1637
  list(path))
1525
1638
  continue
1526
1639
 
1640
+ # Tree/table colour overrides are replayed together as
1641
+ # batches once the whole state has been walked (see
1642
+ # below) -- one round-trip and one browser re-render per
1643
+ # batch, rather than per coloured cell.
1644
+ if key in TREE_OVERRIDE_REPLAY or key == "_table_style":
1645
+ continue
1646
+
1527
1647
  # Tree/table sort
1528
1648
  if key == "_sort":
1529
1649
  col, asc = value
@@ -1539,6 +1659,10 @@ class Session:
1539
1659
  else:
1540
1660
  self._call(widget._wid, method_name, value)
1541
1661
 
1662
+ # Colour overrides, batched
1663
+ for spec in colour_batches(widget):
1664
+ self._call(widget._wid, "set_colors", spec)
1665
+
1542
1666
  # Show/hide
1543
1667
  for key, value in widget._state.items():
1544
1668
  if key in self._FIXED_STATE_KEYS: