pgwidgets-python 0.3.5__py3-none-any.whl → 0.4.1__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.
@@ -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
@@ -31,6 +32,8 @@ from pgwidgets.method_types import (
31
32
  STATE_SYNC_CALLBACKS, STATE_SYNC_REQUIRES_OPTION,
32
33
  WIDGET_CALLBACK_SYNC, POST_CHILDREN_STATE_KEYS, ITEM_LIST_CONFIG,
33
34
  CHILD_CLOSE_CALLBACKS, REPLAY_METHODS, TREE_VIEW_WIDGETS,
35
+ TREE_OVERRIDE_REPLAY, colour_batches, JS_ONLY_METHODS,
36
+ coalesce_colour_calls,
34
37
  BINARY_STATE_KEYS, _send_binary_auto,
35
38
  )
36
39
  from pgwidgets.async_.widget import Widget, build_all_widget_classes
@@ -142,6 +145,14 @@ class Session:
142
145
 
143
146
  self._reconstructing = False # suppress callbacks during reconstruction
144
147
 
148
+ # Batching (see batch()). While _batch_depth is non-zero, calls
149
+ # are buffered here instead of being sent one at a time.
150
+ self._batch_depth = 0
151
+ self._batch_calls = []
152
+ # cleared if a browser rejects the 'batch' message; a new
153
+ # connection may be a newer client, so add_connection resets it
154
+ self._batch_supported = True
155
+
145
156
  # Browser viewport size (updated by 'viewport' messages from JS).
146
157
  self._screen_size = (0, 0)
147
158
 
@@ -198,6 +209,9 @@ class Session:
198
209
  """Add a browser connection to this session."""
199
210
  if ws not in self._connections:
200
211
  self._connections.append(ws)
212
+ # a reconnecting browser may be a newer client than the one
213
+ # that made us give up on batching
214
+ self._batch_supported = True
201
215
 
202
216
  def remove_connection(self, ws):
203
217
  """Remove a browser connection from this session."""
@@ -459,7 +473,8 @@ class Session:
459
473
  key_path = tuple(path) if isinstance(path, list) else path
460
474
  expanded = widget._state.setdefault(
461
475
  "_expanded_paths", set())
462
- expanded.add(key_path)
476
+ if expanded != "_all":
477
+ expanded.add(key_path)
463
478
  collapsed = widget._state.get("_collapsed_paths")
464
479
  if collapsed is not None and collapsed != "_all":
465
480
  collapsed.discard(key_path)
@@ -468,7 +483,7 @@ class Session:
468
483
  path = args[1]
469
484
  key_path = tuple(path) if isinstance(path, list) else path
470
485
  expanded = widget._state.get("_expanded_paths")
471
- if expanded is not None:
486
+ if expanded is not None and expanded != "_all":
472
487
  expanded.discard(key_path)
473
488
  collapsed = widget._state.setdefault(
474
489
  "_collapsed_paths", set())
@@ -678,11 +693,76 @@ class Session:
678
693
  })
679
694
  return wid
680
695
 
696
+ @contextlib.asynccontextmanager
697
+ async def batch(self):
698
+ """Apply many updates as one message.
699
+
700
+ The async counterpart of the sync ``Session.batch``; see that
701
+ docstring for the semantics. Used as::
702
+
703
+ async with tree.batch():
704
+ for path, col, value in changes:
705
+ await tree.set_cell(path, col, value)
706
+ """
707
+ self._batch_depth += 1
708
+ try:
709
+ yield self
710
+ finally:
711
+ self._batch_depth -= 1
712
+ if self._batch_depth == 0:
713
+ await self._flush_batch()
714
+
715
+ async def _flush_batch(self):
716
+ """Send everything buffered by batch() as one message.
717
+
718
+ A browser older than this server won't know the ``batch``
719
+ message -- a page that was loaded before the server was
720
+ upgraded, most commonly. Rather than failing the caller's
721
+ update, fall back to sending the calls individually and stop
722
+ trying to batch for this connection. Reloading the page picks
723
+ up the current client and re-enables it.
724
+ """
725
+ calls, self._batch_calls = self._batch_calls, []
726
+ if not calls:
727
+ return None
728
+ # _send handles the no-browser case (and is the single place
729
+ # that policy lives)
730
+ # Fold runs of colour calls into set_colors: one re-render in
731
+ # the browser instead of one per cell.
732
+ calls = coalesce_colour_calls(calls)
733
+ if self._batch_supported:
734
+ self._logger.debug("flushing a batch of %d call(s)", len(calls))
735
+ try:
736
+ return await self._send({"type": "batch", "calls": calls})
737
+ except RuntimeError as e:
738
+ if "Unknown message type" not in str(e):
739
+ raise
740
+ self._batch_supported = False
741
+ self._logger.warning(
742
+ "browser does not understand batched updates (it is "
743
+ "running an older pgwidgets-js); sending calls "
744
+ "individually. Reload the page to re-enable batching.")
745
+ for call in calls:
746
+ await self._send({"type": "call", **call})
747
+ return None
748
+
749
+ def _needs_result(self, method):
750
+ """True if `method` has to reach the browser now to be useful."""
751
+ return method.startswith("get_") or method in JS_ONLY_METHODS
752
+
681
753
  async def _call(self, wid, method, *args):
682
754
  """Call a method on a JS widget.
683
755
 
684
756
  Returns None if no browser is connected.
685
757
  """
758
+ if self._batch_depth > 0:
759
+ if not self._needs_result(method):
760
+ self._batch_calls.append({"wid": wid, "method": method,
761
+ "args": list(args)})
762
+ return None
763
+ # has to go now -- flush what's queued so it stays ordered
764
+ await self._flush_batch()
765
+
686
766
  result = await self._send({
687
767
  "type": "call",
688
768
  "wid": wid,
@@ -1335,6 +1415,13 @@ class Session:
1335
1415
  list(path))
1336
1416
  continue
1337
1417
 
1418
+ # Tree/table colour overrides are replayed together as
1419
+ # batches once the whole state has been walked (see
1420
+ # below) -- one round-trip and one browser re-render per
1421
+ # batch, rather than per coloured cell.
1422
+ if key in TREE_OVERRIDE_REPLAY or key == "_table_style":
1423
+ continue
1424
+
1338
1425
  # Tree/table sort
1339
1426
  if key == "_sort":
1340
1427
  col, asc = value
@@ -1350,6 +1437,10 @@ class Session:
1350
1437
  else:
1351
1438
  await self._call(widget._wid, method_name, value)
1352
1439
 
1440
+ # Colour overrides, batched
1441
+ for spec in colour_batches(widget):
1442
+ await self._call(widget._wid, "set_colors", spec)
1443
+
1353
1444
  # Show/hide
1354
1445
  for key, value in widget._state.items():
1355
1446
  if key in self._FIXED_STATE_KEYS:
@@ -11,6 +11,7 @@ import base64
11
11
  import mimetypes
12
12
  import os
13
13
 
14
+ from pgwidgets import tree_model
14
15
  from pgwidgets.defs import WIDGETS, CALLBACK_METHODS, WIDGET_METHODS, CONTAINER_METHODS
15
16
  from pgwidgets.method_types import (
16
17
  classify_method, SETTER, GETTER, CHILD, ACTION, JS_ONLY,
@@ -279,6 +280,15 @@ class Widget:
279
280
  """The Session this widget belongs to."""
280
281
  return self._session
281
282
 
283
+ def batch(self):
284
+ """Convenience for ``widget.session.batch()``.
285
+
286
+ Batching is per session, not per widget, so updates to other
287
+ widgets made inside the block ride along in the same message.
288
+ Used with ``async with``.
289
+ """
290
+ return self._session.batch()
291
+
282
292
  @property
283
293
  def app(self):
284
294
  """The Application this widget belongs to."""
@@ -773,7 +783,8 @@ def _add_tree_view_methods(attrs, all_methods):
773
783
  if path is not None:
774
784
  key = tuple(path) if isinstance(path, list) else path
775
785
  expanded = self._state.setdefault("_expanded_paths", set())
776
- expanded.add(key)
786
+ if expanded != "_all":
787
+ expanded.add(key)
777
788
  collapsed = self._state.get("_collapsed_paths")
778
789
  if collapsed is not None and collapsed != "_all":
779
790
  collapsed.discard(key)
@@ -789,7 +800,7 @@ def _add_tree_view_methods(attrs, all_methods):
789
800
  if path is not None:
790
801
  key = tuple(path) if isinstance(path, list) else path
791
802
  expanded = self._state.get("_expanded_paths")
792
- if expanded is not None:
803
+ if expanded is not None and expanded != "_all":
793
804
  expanded.discard(key)
794
805
  collapsed = self._state.setdefault("_collapsed_paths", set())
795
806
  if collapsed != "_all":
@@ -814,6 +825,429 @@ def _add_tree_view_methods(attrs, all_methods):
814
825
  collapse_all_method.__name__ = "collapse_all"
815
826
  attrs["collapse_all"] = collapse_all_method
816
827
 
828
+ _add_tree_model_methods(attrs, all_methods)
829
+
830
+
831
+ # ---- generated from sync/widget.py by tools/sync_async_tree_model.py ----
832
+ def _pad(args, n):
833
+ """Pad a call's positional args out to `n` with None, so a partially
834
+ specified call still lands in the right state slots."""
835
+ args = list(args)
836
+ while len(args) < n:
837
+ args.append(None)
838
+ return tuple(args)
839
+
840
+
841
+ def _style_path(path):
842
+ """Hashable form of a path, for use as a style-map key."""
843
+ return tuple(path) if isinstance(path, list) else path
844
+
845
+
846
+ def _model_rows(widget):
847
+ """The flat row model in _state, whichever bulk setter filled it."""
848
+ for key in ("rows", "data"):
849
+ rows = widget._state.get(key)
850
+ if isinstance(rows, list):
851
+ return rows
852
+ return None
853
+
854
+
855
+ def _drop_row_styles(widget):
856
+ """The JS clear() that precedes a bulk load empties the per-cell and
857
+ per-row style maps but keeps the column / table layers."""
858
+ widget._state.pop("_cell_styles", None)
859
+ widget._state.pop("_row_styles", None)
860
+
861
+
862
+ def _add_tree_model_methods(attrs, all_methods):
863
+ """Override the tree/table mutators so they maintain the Python-side
864
+ model (see :mod:`pgwidgets.tree_model`).
865
+
866
+ Python is the source of truth: the browser can be rebuilt at any time
867
+ from ``_state``, so every mutation has to be reflected there or it is
868
+ lost on the next reconnect. The bulk setters additionally deep-copy,
869
+ so a caller that keeps editing the structure it passed in cannot
870
+ corrupt the model behind our back.
871
+ """
872
+ # --- bulk setters: store a private copy, reset row-level styles ---
873
+ for name, state_key in (("set_tree", "tree"), ("set_data", "data"),
874
+ ("set_rows", "rows")):
875
+ if name not in all_methods:
876
+ continue
877
+
878
+ def make_bulk(mn, key, pn):
879
+ async def method(self, *args, **kwargs):
880
+ args = _resolve_kwargs(mn, pn, args, kwargs)
881
+ self._state[key] = tree_model.copy_tree(
882
+ args[0] if args else None)
883
+ _drop_row_styles(self)
884
+ return await self._call(mn, *args)
885
+ method.__name__ = mn
886
+ return method
887
+ attrs[name] = make_bulk(name, state_key, all_methods[name])
888
+
889
+ # --- per-cell writes fold into the model ---
890
+ if "set_cell" in all_methods:
891
+ param_names = all_methods["set_cell"]
892
+
893
+ async def set_cell_method(self, *args, **kwargs):
894
+ args = _resolve_kwargs("set_cell", param_names, args, kwargs)
895
+ if len(args) >= 3:
896
+ where, col, value = args[0], args[1], args[2]
897
+ tree = self._state.get("tree")
898
+ if isinstance(tree, dict):
899
+ tree_model.set_cell(tree, where, col, value)
900
+ else:
901
+ rows = _model_rows(self)
902
+ if rows is not None:
903
+ tree_model.row_set_cell(
904
+ rows, self._state.get("columns"),
905
+ where, col, value)
906
+ return await self._call("set_cell", *args)
907
+ set_cell_method.__name__ = "set_cell"
908
+ attrs["set_cell"] = set_cell_method
909
+
910
+ # --- structural tree edits ---
911
+ if "add_item" in all_methods:
912
+ param_names = all_methods["add_item"]
913
+
914
+ async def add_item_method(self, *args, **kwargs):
915
+ args = _resolve_kwargs("add_item", param_names, args, kwargs)
916
+ tree = self._state.get("tree")
917
+ if isinstance(tree, dict) and len(args) >= 3:
918
+ tree_model.add_item(tree, args[0], args[1], args[2])
919
+ return await self._call("add_item", *args)
920
+ add_item_method.__name__ = "add_item"
921
+ attrs["add_item"] = add_item_method
922
+
923
+ if "remove_item" in all_methods:
924
+ param_names = all_methods["remove_item"]
925
+
926
+ async def remove_item_method(self, *args, **kwargs):
927
+ args = _resolve_kwargs("remove_item", param_names, args, kwargs)
928
+ tree = self._state.get("tree")
929
+ if isinstance(tree, dict) and args:
930
+ tree_model.remove_item(tree, args[0])
931
+ return await self._call("remove_item", *args)
932
+ remove_item_method.__name__ = "remove_item"
933
+ attrs["remove_item"] = remove_item_method
934
+
935
+ if "remove_items" in all_methods:
936
+ param_names = all_methods["remove_items"]
937
+
938
+ async def remove_items_method(self, *args, **kwargs):
939
+ args = _resolve_kwargs("remove_items", param_names, args, kwargs)
940
+ tree = self._state.get("tree")
941
+ if isinstance(tree, dict) and args:
942
+ tree_model.remove_items(tree, args[0])
943
+ return await self._call("remove_items", *args)
944
+ remove_items_method.__name__ = "remove_items"
945
+ attrs["remove_items"] = remove_items_method
946
+
947
+ if "add_tree" in all_methods:
948
+ param_names = all_methods["add_tree"]
949
+
950
+ async def add_tree_method(self, *args, **kwargs):
951
+ args = _resolve_kwargs("add_tree", param_names, args, kwargs)
952
+ args = _pad(args, 2)
953
+ tree = self._state.get("tree")
954
+ if isinstance(tree, dict):
955
+ tree_model.merge_tree(tree, args[0], args[1])
956
+ elif isinstance(args[0], dict) and args[1] in (None, [], ()):
957
+ # merging into an empty widget: the merge *is* the tree
958
+ self._state["tree"] = tree_model.copy_tree(args[0])
959
+ return await self._call("add_tree", *args)
960
+ add_tree_method.__name__ = "add_tree"
961
+ attrs["add_tree"] = add_tree_method
962
+
963
+ if "delete_tree" in all_methods:
964
+ param_names = all_methods["delete_tree"]
965
+
966
+ async def delete_tree_method(self, *args, **kwargs):
967
+ args = _resolve_kwargs("delete_tree", param_names, args, kwargs)
968
+ args = _pad(args, 2)
969
+ tree = self._state.get("tree")
970
+ prune = True if args[1] is None else bool(args[1])
971
+ if isinstance(tree, dict):
972
+ tree_model.delete_tree(tree, args[0], prune)
973
+ return await self._call("delete_tree", *args)
974
+ delete_tree_method.__name__ = "delete_tree"
975
+ attrs["delete_tree"] = delete_tree_method
976
+
977
+ if "update_tree" in all_methods:
978
+ param_names = all_methods["update_tree"]
979
+
980
+ async def update_tree_method(self, *args, **kwargs):
981
+ """Bring the tree to `tree`, sending only what changed.
982
+
983
+ The browser's own ``update_tree`` is a full replacement (it
984
+ rebuilds every row, dropping expansion state, cell styles and
985
+ any open editor). Because the model here is authoritative we
986
+ can diff against it instead and send just the deltas. A diff
987
+ broader than a wholesale replacement falls back to
988
+ ``set_tree``.
989
+ """
990
+ args = _resolve_kwargs("update_tree", param_names, args, kwargs)
991
+ new_tree = args[0] if args else {}
992
+ old_tree = self._state.get("tree")
993
+
994
+ if not isinstance(old_tree, dict) or not isinstance(new_tree,
995
+ dict):
996
+ self._state["tree"] = tree_model.copy_tree(new_tree)
997
+ _drop_row_styles(self)
998
+ return await self._call("set_tree", new_tree)
999
+
1000
+ ops = tree_model.diff_tree(old_tree, new_tree)
1001
+ self._state["tree"] = tree_model.copy_tree(new_tree)
1002
+ if ops is None:
1003
+ # wholesale replacement -- the browser clears, so the
1004
+ # row-level style layers go with it
1005
+ _drop_row_styles(self)
1006
+ return await self._call("set_tree", new_tree)
1007
+ result = None
1008
+ for op in ops:
1009
+ result = await self._call(op[0], *op[1:])
1010
+ return result
1011
+ update_tree_method.__name__ = "update_tree"
1012
+ attrs["update_tree"] = update_tree_method
1013
+
1014
+ # --- incremental flat-data update ---
1015
+ #
1016
+ # The browser diffs these against what it is showing, so the whole
1017
+ # array goes over the wire but only the rows that differ are
1018
+ # touched (selection, colours and scroll position survive). The
1019
+ # model still has to record the new contents for reconnection.
1020
+ for name, default_key in (("update_data", "data"),
1021
+ ("update_rows", "rows")):
1022
+ if name not in all_methods:
1023
+ continue
1024
+
1025
+ def make_update_rows(mn, dk, pn):
1026
+ async def method(self, *args, **kwargs):
1027
+ args = _resolve_kwargs(mn, pn, args, kwargs)
1028
+ key = dk
1029
+ for candidate in ("rows", "data"):
1030
+ if isinstance(self._state.get(candidate), list):
1031
+ key = candidate # keep filling whichever
1032
+ break # bulk setter was used
1033
+ self._state[key] = tree_model.copy_tree(
1034
+ args[0] if args else None)
1035
+ return await self._call(mn, *args)
1036
+ method.__name__ = mn
1037
+ return method
1038
+ attrs[name] = make_update_rows(name, default_key, all_methods[name])
1039
+
1040
+ # --- flat row edits ---
1041
+ for name, fn in (("insert_row", tree_model.insert_row),
1042
+ ("append_row", tree_model.append_row),
1043
+ ("delete_row", tree_model.delete_row)):
1044
+ if name not in all_methods:
1045
+ continue
1046
+
1047
+ def make_row_op(mn, func, pn):
1048
+ async def method(self, *args, **kwargs):
1049
+ args = _resolve_kwargs(mn, pn, args, kwargs)
1050
+ rows = _model_rows(self)
1051
+ if rows is not None:
1052
+ if mn == "append_row":
1053
+ func(rows, args[0] if args else None)
1054
+ elif mn == "delete_row":
1055
+ func(rows, args[0] if args else None)
1056
+ else:
1057
+ args2 = _pad(args, 2)
1058
+ func(rows, args2[0], args2[1])
1059
+ return await self._call(mn, *args)
1060
+ method.__name__ = mn
1061
+ return method
1062
+ attrs[name] = make_row_op(name, fn, all_methods[name])
1063
+
1064
+ # --- column edits ---
1065
+ if "insert_column" in all_methods:
1066
+ param_names = all_methods["insert_column"]
1067
+ # TreeView is insert_column(column, before); TableView is
1068
+ # insert_column(index, column) -- tell them apart by the defn
1069
+ by_key = param_names and param_names[0] == "column"
1070
+
1071
+ async def insert_column_method(self, *args, **kwargs):
1072
+ args = _resolve_kwargs("insert_column", param_names,
1073
+ args, kwargs)
1074
+ args = _pad(args, 2)
1075
+ columns = self._state.get("columns")
1076
+ if isinstance(columns, list):
1077
+ if by_key:
1078
+ tree_model.insert_column(columns, args[0],
1079
+ before=args[1])
1080
+ else:
1081
+ tree_model.insert_column(columns, args[1],
1082
+ index=args[0])
1083
+ return await self._call("insert_column", *args)
1084
+ insert_column_method.__name__ = "insert_column"
1085
+ attrs["insert_column"] = insert_column_method
1086
+
1087
+ if "append_column" in all_methods:
1088
+ param_names = all_methods["append_column"]
1089
+
1090
+ async def append_column_method(self, *args, **kwargs):
1091
+ args = _resolve_kwargs("append_column", param_names,
1092
+ args, kwargs)
1093
+ columns = self._state.get("columns")
1094
+ if isinstance(columns, list) and args:
1095
+ tree_model.append_column(columns, args[0])
1096
+ return await self._call("append_column", *args)
1097
+ append_column_method.__name__ = "append_column"
1098
+ attrs["append_column"] = append_column_method
1099
+
1100
+ if "delete_column" in all_methods:
1101
+ param_names = all_methods["delete_column"]
1102
+
1103
+ async def delete_column_method(self, *args, **kwargs):
1104
+ args = _resolve_kwargs("delete_column", param_names,
1105
+ args, kwargs)
1106
+ columns = self._state.get("columns")
1107
+ if isinstance(columns, list) and args:
1108
+ # TreeView deletes by column key, TableView by index;
1109
+ # tree_model.delete_column accepts either
1110
+ tree_model.delete_column(columns, args[0])
1111
+ return await self._call("delete_column", *args)
1112
+ delete_column_method.__name__ = "delete_column"
1113
+ attrs["delete_column"] = delete_column_method
1114
+
1115
+ _add_colour_override_methods(attrs, all_methods)
1116
+
1117
+
1118
+ def _record_style(widget, state_key, map_key, call_args, nkeys):
1119
+ """Record (or, when every colour channel is None, drop) one override.
1120
+
1121
+ ``call_args`` is the equivalent single-call argument tuple, so the
1122
+ stored entry can be replayed by re-dispatch regardless of whether it
1123
+ arrived singly or as part of a batch.
1124
+ """
1125
+ if all(v is None for v in call_args[nkeys:]):
1126
+ widget._state.get(state_key, {}).pop(map_key, None)
1127
+ else:
1128
+ widget._state.setdefault(state_key, {})[map_key] = call_args
1129
+
1130
+
1131
+ def _add_colour_override_methods(attrs, all_methods):
1132
+ """Accumulate the per-cell / row / column / table colour overrides.
1133
+
1134
+ These have no bulk setter to fold into, so they are kept as maps in
1135
+ _state and replayed on top of the data during reconstruction. An
1136
+ all-null call clears that layer, matching the JS.
1137
+ """
1138
+ # method, total args, state key, number of leading key args
1139
+ # (the remaining args are the fg / bg / bold channels)
1140
+ specs = [
1141
+ ("set_cell_color", 5, "_cell_styles", 2),
1142
+ ("set_row_color", 4, "_row_styles", 1),
1143
+ ("set_column_color", 4, "_column_styles", 1),
1144
+ ("set_table_color", 3, "_table_style", 0),
1145
+ ]
1146
+ for name, nargs, state_key, nkeys in specs:
1147
+ if name not in all_methods:
1148
+ continue
1149
+
1150
+ def make_setter(mn, n, sk, nk, pn):
1151
+ async def method(self, *args, **kwargs):
1152
+ args = _resolve_kwargs(mn, pn, args, kwargs)
1153
+ args = _pad(args, n)
1154
+ cleared = all(v is None for v in args[nk:])
1155
+ if nk == 0: # table-wide: one slot
1156
+ if cleared:
1157
+ self._state.pop(sk, None)
1158
+ else:
1159
+ self._state[sk] = tuple(args)
1160
+ else:
1161
+ map_key = ((_style_path(args[0]), args[1]) if nk == 2
1162
+ else _style_path(args[0]))
1163
+ if cleared:
1164
+ self._state.get(sk, {}).pop(map_key, None)
1165
+ else:
1166
+ # keep the whole call, so replaying is a
1167
+ # straight re-dispatch
1168
+ self._state.setdefault(sk, {})[map_key] = tuple(args)
1169
+ return await self._call(mn, *args)
1170
+ method.__name__ = mn
1171
+ return method
1172
+ attrs[name] = make_setter(name, nargs, state_key, nkeys,
1173
+ all_methods[name])
1174
+
1175
+ clears = [
1176
+ ("clear_cell_color", "_cell_styles",
1177
+ lambda a: (_style_path(a[0]), a[1])),
1178
+ ("clear_row_color", "_row_styles", lambda a: _style_path(a[0])),
1179
+ ("clear_column_color", "_column_styles", lambda a: a[0]),
1180
+ ]
1181
+ for name, state_key, keyfn in clears:
1182
+ if name not in all_methods:
1183
+ continue
1184
+
1185
+ def make_clear_one(mn, sk, kf, pn):
1186
+ async def method(self, *args, **kwargs):
1187
+ args = _resolve_kwargs(mn, pn, args, kwargs)
1188
+ args = _pad(args, 2)
1189
+ self._state.get(sk, {}).pop(kf(args), None)
1190
+ return await self._call(mn, *args)
1191
+ method.__name__ = mn
1192
+ return method
1193
+ attrs[name] = make_clear_one(name, state_key, keyfn,
1194
+ all_methods[name])
1195
+
1196
+ if "set_colors" in all_methods:
1197
+ param_names = all_methods["set_colors"]
1198
+
1199
+ async def set_colors_method(self, *args, **kwargs):
1200
+ """Apply many colour overrides in one call.
1201
+
1202
+ Folds the batch into the same style maps the single-cell
1203
+ setters use, so the model stays accurate for reconnection
1204
+ while costing one round-trip and one browser re-render
1205
+ instead of one of each per cell.
1206
+ """
1207
+ args = _resolve_kwargs("set_colors", param_names, args, kwargs)
1208
+ spec = args[0] if args else None
1209
+ if isinstance(spec, dict):
1210
+ if spec.get("clear"):
1211
+ for key in ("_cell_styles", "_row_styles",
1212
+ "_column_styles", "_table_style"):
1213
+ self._state.pop(key, None)
1214
+ for e in (spec.get("cells") or []):
1215
+ _record_style(
1216
+ self, "_cell_styles",
1217
+ (_style_path(e.get("path")), e.get("col_key")),
1218
+ (e.get("path"), e.get("col_key"), e.get("fg"),
1219
+ e.get("bg"), e.get("bold")), 2)
1220
+ for e in (spec.get("rows") or []):
1221
+ _record_style(
1222
+ self, "_row_styles", _style_path(e.get("path")),
1223
+ (e.get("path"), e.get("fg"), e.get("bg"),
1224
+ e.get("bold")), 1)
1225
+ for e in (spec.get("columns") or []):
1226
+ _record_style(
1227
+ self, "_column_styles", e.get("col_key"),
1228
+ (e.get("col_key"), e.get("fg"), e.get("bg"),
1229
+ e.get("bold")), 1)
1230
+ if "table" in spec:
1231
+ t = spec.get("table") or {}
1232
+ channels = (t.get("fg"), t.get("bg"), t.get("bold"))
1233
+ if all(v is None for v in channels):
1234
+ self._state.pop("_table_style", None)
1235
+ else:
1236
+ self._state["_table_style"] = channels
1237
+ return await self._call("set_colors", *args)
1238
+ set_colors_method.__name__ = "set_colors"
1239
+ attrs["set_colors"] = set_colors_method
1240
+
1241
+ if "clear_all_colors" in all_methods:
1242
+ async def clear_all_colors_method(self):
1243
+ for key in ("_cell_styles", "_row_styles", "_column_styles",
1244
+ "_table_style"):
1245
+ self._state.pop(key, None)
1246
+ return await self._call("clear_all_colors")
1247
+ clear_all_colors_method.__name__ = "clear_all_colors"
1248
+ attrs["clear_all_colors"] = clear_all_colors_method
1249
+
1250
+
817
1251
 
818
1252
  def _init_params(pos_names, opt_names):
819
1253
  """Build the parameter string for the generated __init__.