pgwidgets-python 0.2.1__py3-none-any.whl → 0.3.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/__init__.py CHANGED
@@ -24,6 +24,8 @@ Version:
24
24
 
25
25
  from importlib.metadata import version as _pkg_version, PackageNotFoundError
26
26
 
27
+ from pgwidgets.buffer import Buffer, is_buffer # noqa: F401
28
+
27
29
  try:
28
30
  __version__ = _pkg_version("pgwidgets")
29
31
  except PackageNotFoundError:
@@ -30,7 +30,7 @@ from pgwidgets.method_types import (
30
30
  STATE_SYNC_CALLBACKS, STATE_SYNC_REQUIRES_OPTION,
31
31
  WIDGET_CALLBACK_SYNC, POST_CHILDREN_STATE_KEYS, ITEM_LIST_CONFIG,
32
32
  CHILD_CLOSE_CALLBACKS, REPLAY_METHODS, TREE_VIEW_WIDGETS,
33
- BINARY_STATE_KEYS,
33
+ BINARY_STATE_KEYS, _send_binary_auto,
34
34
  )
35
35
  from pgwidgets.async_.widget import Widget, build_all_widget_classes
36
36
 
@@ -91,6 +91,13 @@ class Session:
91
91
  _STATE_KEY_TO_SETTER = {v: k for k, v in SPECIAL_SETTERS.items()}
92
92
  # e.g. {"size": "resize"}
93
93
 
94
+ # Reverse map: state_key -> callback action that auto-syncs it
95
+ # (e.g. "size" -> "resize", "position" -> "move"). Used during
96
+ # state replay to skip keys that weren't actively opted into via
97
+ # _auto_sync_actions — those were captured passively for getter
98
+ # support but must not be replayed (would pin layout).
99
+ _STATE_KEY_TO_SYNC_ACTION = {v: k for k, v in STATE_SYNC_CALLBACKS.items()}
100
+
94
101
  # State keys handled by fixed-value methods (show/hide)
95
102
  _FIXED_STATE_KEYS = {}
96
103
  for _mname, (_key, _val) in FIXED_SETTERS.items():
@@ -118,6 +125,9 @@ class Session:
118
125
 
119
126
  self._widget_classes = app._widget_classes
120
127
  self._transfers = {} # transfer_id -> transfer state dict
128
+ # FIFO of binary-chunk JSON headers (encoding="binary") still
129
+ # awaiting their paired raw binary frame.
130
+ self._pending_binary_headers = []
121
131
  self._callback_source_ws = None # ws that sent current callback
122
132
 
123
133
  self._reconstructing = False # suppress callbacks during reconstruction
@@ -187,6 +197,19 @@ class Session:
187
197
  # -- Message handling --
188
198
 
189
199
  def _handle_message(self, data):
200
+ # Raw binary frames pair with the head of the binary-chunk
201
+ # JSON header FIFO (encoding="binary"). See the sync
202
+ # equivalent for the rationale.
203
+ if isinstance(data, (bytes, bytearray, memoryview)):
204
+ queue = self._pending_binary_headers
205
+ if not queue:
206
+ self._logger.warning(
207
+ "Session %s: unexpected binary frame with no "
208
+ "queued header (ignored).", self.id)
209
+ return
210
+ header = queue.pop(0)
211
+ self._handle_binary_chunk(header, bytes(data))
212
+ return
190
213
  msg = json.loads(data)
191
214
  if isinstance(msg, list):
192
215
  for m in msg:
@@ -209,8 +232,18 @@ class Session:
209
232
  elif msg_type == "viewport":
210
233
  self._screen_size = (msg.get("width", 0), msg.get("height", 0))
211
234
 
212
- elif msg_type == "file-chunk":
213
- self._handle_file_chunk(msg)
235
+ elif msg_type == "binary-chunk":
236
+ encoding = msg.get("encoding", "binary")
237
+ if encoding == "binary":
238
+ self._pending_binary_headers.append(msg)
239
+ elif encoding == "base64":
240
+ import base64 as _b64
241
+ payload = _b64.b64decode(msg.get("data") or "")
242
+ self._handle_binary_chunk(msg, payload)
243
+ else:
244
+ self._logger.warning(
245
+ "Session %s: unknown binary-chunk encoding %r "
246
+ "(ignored).", self.id, encoding)
214
247
 
215
248
  elif msg_type == "callback":
216
249
  # If the payload has a transfer_id, stash the metadata —
@@ -237,25 +270,40 @@ class Session:
237
270
  self._dispatch_callback(
238
271
  msg["wid"], msg["action"], *msg.get("args", []))
239
272
 
240
- def _handle_file_chunk(self, msg):
241
- """Handle a file-chunk message: buffer data and fire callbacks."""
242
- tid = msg["transfer_id"]
273
+ def _handle_binary_chunk(self, header, data):
274
+ """Buffer one chunk of an in-flight transfer.
275
+
276
+ ``header`` is the parsed binary-chunk JSON; ``data`` is the
277
+ chunk's raw bytes (paired binary frame, or decoded inline
278
+ base64). See the sync equivalent for the full description.
279
+ """
280
+ tid = header["transfer_id"]
243
281
  transfer = self._transfers.get(tid)
244
282
  if transfer is None:
245
283
  return
246
284
 
247
- fi = msg["file_index"]
248
- fc = msg["file_count"]
285
+ fi = header.get("file_index", 0)
286
+ fc = header.get("file_count", 1)
287
+ ci = header["chunk_index"]
288
+ nc = header["num_chunks"]
249
289
  if fi not in transfer["file_data"]:
250
- transfer["file_data"][fi] = []
251
- transfer["num_chunks"][fi] = msg["num_chunks"]
252
- transfer["file_data"][fi].append(msg["data"])
290
+ transfer["file_data"][fi] = [None] * nc
291
+ transfer["num_chunks"][fi] = nc
292
+ slot = transfer["file_data"][fi]
293
+ if 0 <= ci < len(slot):
294
+ slot[ci] = data
295
+ else:
296
+ self._logger.warning(
297
+ "Session %s: chunk_index %d out of range for "
298
+ "transfer_id %s (num_chunks=%d).",
299
+ self.id, ci, tid, nc)
300
+ return
253
301
 
254
302
  # Check if all files have received all their chunks.
255
303
  all_complete = (
256
304
  len(transfer["num_chunks"]) == fc
257
305
  and all(
258
- len(transfer["file_data"][i]) >= transfer["num_chunks"][i]
306
+ None not in transfer["file_data"][i]
259
307
  for i in range(fc)
260
308
  )
261
309
  )
@@ -267,16 +315,17 @@ class Session:
267
315
  for i, fmeta in enumerate(files_meta):
268
316
  fsize = fmeta.get("size", 0)
269
317
  total_bytes += fsize
270
- nc = transfer["num_chunks"].get(i)
271
- if nc:
272
- received = len(transfer["file_data"].get(i, []))
273
- transferred_bytes += fsize * received // nc
318
+ n = transfer["num_chunks"].get(i)
319
+ if n:
320
+ slots = transfer["file_data"].get(i, [])
321
+ received = sum(1 for s in slots if s is not None)
322
+ transferred_bytes += fsize * received // n
274
323
 
275
324
  progress_info = {
276
325
  "transfer_id": tid,
277
326
  "file_index": fi,
278
- "chunk_index": msg["chunk_index"],
279
- "num_chunks": msg["num_chunks"],
327
+ "chunk_index": ci,
328
+ "num_chunks": nc,
280
329
  "transferred_bytes": transferred_bytes,
281
330
  "total_bytes": total_bytes,
282
331
  "complete": all_complete,
@@ -292,38 +341,54 @@ class Session:
292
341
  # Reassemble file data and fire the original callback.
293
342
  payload = transfer["payload"]
294
343
  for i, file_meta in enumerate(payload["files"]):
295
- file_meta["data"] = "".join(
296
- transfer["file_data"].get(i, []))
344
+ slots = transfer["file_data"].get(i, [])
345
+ file_meta["data"] = b"".join(slots)
297
346
  del self._transfers[tid]
298
347
  self._dispatch_callback(
299
348
  transfer["wid"], action, payload)
300
349
 
301
350
  def _dispatch_callback(self, wid, action, *args):
302
351
  """Dispatch a callback through the configured concurrency mode."""
303
- if self._reconstructing:
304
- return # suppress callbacks during reconstruction
352
+ # Suppress callbacks during reconstruction — they are side
353
+ # effects of state replay, not user actions. Exception: 'map'
354
+ # is a one-shot lifecycle event that the JS-side observers fire
355
+ # when a widget first gains a visible layout box; the timing
356
+ # can land inside the reconstruction window, and dropping it
357
+ # means the user's map handler never runs until a later layout
358
+ # change (e.g. window resize) triggers a re-fire.
359
+ if self._reconstructing and action != 'map':
360
+ return
305
361
 
306
362
  # Auto-sync: some callbacks carry state that should be reflected
307
363
  # in the Python-side widget (e.g. move -> position, resize -> size).
364
+ # We always *capture* the value so get_size()/get_position() return
365
+ # current values, but only *push* it to other browsers (and replay
366
+ # it on reconstruction) when the widget opted in via auto-sync.
367
+ # Otherwise a layout-determined size would replay as a literal
368
+ # resize(W, H) — pinning the widget to pixel dimensions and
369
+ # killing flex growth.
308
370
  state_key = STATE_SYNC_CALLBACKS.get(action)
309
371
  if state_key is not None:
310
372
  widget = self._widget_map.get(wid)
311
373
  if widget is not None:
374
+ auto = action in widget._auto_sync_actions
312
375
  if len(args) == 1 and isinstance(args[0], dict):
313
376
  d = args[0]
314
377
  if "width" in d and "height" in d:
315
378
  new_val = (d["width"], d["height"])
316
379
  if widget._state.get(state_key) != new_val:
317
380
  widget._state[state_key] = new_val
318
- self._push(wid, "resize",
319
- d["width"], d["height"])
381
+ if auto:
382
+ self._push(wid, "resize",
383
+ d["width"], d["height"])
320
384
  else:
321
385
  new_val = tuple(args)
322
386
  if widget._state.get(state_key) != new_val:
323
387
  widget._state[state_key] = new_val
324
- setter = (self._STATE_KEY_TO_SETTER.get(state_key)
325
- or f"set_{state_key}")
326
- self._push(wid, setter, *args)
388
+ if auto:
389
+ setter = (self._STATE_KEY_TO_SETTER.get(state_key)
390
+ or f"set_{state_key}")
391
+ self._push(wid, setter, *args)
327
392
  # If this widget wraps a child (e.g. MDISubWindow),
328
393
  # propagate geometry into the parent's children options
329
394
  # so reconstruction replays with the current pos/size.
@@ -639,6 +704,69 @@ class Session:
639
704
  task = asyncio.ensure_future(_pair(ws))
640
705
  task.add_done_callback(_drain_send_exception)
641
706
 
707
+ def _send_binary_chunked(self, wid, method, args, data,
708
+ chunk_size=512 * 1024,
709
+ shape=None, dtype=None):
710
+ """Fire-and-forget chunked binary call (async variant).
711
+
712
+ See the sync :meth:`Session._send_binary_chunked` for the full
713
+ description, including the optional ``shape``/``dtype`` that
714
+ promote the receiver's payload from a raw ``ArrayBuffer`` to
715
+ a typed array. Each connection's chunks are scheduled as a
716
+ single coroutine so they ship atomically.
717
+ """
718
+ if not self._connections:
719
+ return
720
+ if not isinstance(data, (bytes, bytearray, memoryview)):
721
+ raise TypeError(
722
+ "_send_binary_chunked: data must be bytes-like, got "
723
+ + type(data).__name__)
724
+ data = bytes(data)
725
+ n = len(data)
726
+ if chunk_size <= 0:
727
+ raise ValueError("chunk_size must be positive")
728
+ num_chunks = max(1, (n + chunk_size - 1) // chunk_size)
729
+ msg_id = self._next_id
730
+ self._next_id += 1
731
+ transfer_id = self._next_id
732
+ self._next_id += 1
733
+ announce_obj = {
734
+ "type": "binary-call-chunked",
735
+ "id": msg_id,
736
+ "wid": wid,
737
+ "method": method,
738
+ "args": list(args),
739
+ "transfer_id": transfer_id,
740
+ "num_chunks": num_chunks,
741
+ }
742
+ if shape is not None:
743
+ announce_obj["shape"] = list(shape)
744
+ if dtype is not None:
745
+ announce_obj["dtype"] = dtype
746
+ announce = json.dumps(announce_obj, cls=JsonEncoder)
747
+ pairs = []
748
+ for ci in range(num_chunks):
749
+ start = ci * chunk_size
750
+ end = min(start + chunk_size, n)
751
+ header = json.dumps({
752
+ "type": "binary-chunk",
753
+ "transfer_id": transfer_id,
754
+ "chunk_index": ci,
755
+ "num_chunks": num_chunks,
756
+ "encoding": "binary",
757
+ }, cls=JsonEncoder)
758
+ pairs.append((header, data[start:end]))
759
+
760
+ async def _send_all(ws):
761
+ await ws.send(announce)
762
+ for hdr, payload in pairs:
763
+ await ws.send(hdr)
764
+ await ws.send(payload)
765
+
766
+ for ws in self._connections:
767
+ task = asyncio.ensure_future(_send_all(ws))
768
+ task.add_done_callback(_drain_send_exception)
769
+
642
770
  async def _listen(self, wid, action, handler):
643
771
  """Register a callback listener.
644
772
 
@@ -735,17 +863,37 @@ class Session:
735
863
  cls = self._widget_classes.get(cls_name, Widget) if cls_name else Widget
736
864
  widget = cls._from_existing(self, wid, cls_name or "Widget")
737
865
  self._widget_map[wid] = widget
738
- # Auto-listen for state-syncing callbacks (move, resize)
739
- # so position/size changes are tracked for reconstruction.
866
+ # Auto-listen for state-syncing callbacks (move, resize).
740
867
  # Register locally (sync) and send the listen message as
741
868
  # true fire-and-forget (no result awaited) since
742
- # _resolve_return is not async.
869
+ # _resolve_return is not async. For visual widgets we
870
+ # always listen for 'resize' so get_size() can return a
871
+ # current value, but we only mark the action as
872
+ # auto-syncing (which triggers push-to-peers and
873
+ # replay-on-reconstruction) when the widget defn opts in.
874
+ # Non-visual Callback-base objects (Timer, TextBufferRef,
875
+ # …) get nothing.
876
+ defn = WIDGETS.get(cls_name, {}) if cls_name else {}
877
+ opt_names_set = set(defn.get("options", []))
878
+ all_callbacks = defn.get("callbacks", [])
879
+ is_visual = defn.get("base") != "callback"
743
880
  for action in STATE_SYNC_CALLBACKS:
744
- key = f"{wid}:{action}"
745
- if key not in self._callbacks:
746
- self._callbacks[key] = [lambda wid, *a: None]
747
- self._fire_and_forget_listen(wid, action)
748
- widget._auto_sync_actions.add(action)
881
+ req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
882
+ if req_opt is not None:
883
+ opted_in = req_opt in opt_names_set
884
+ else:
885
+ opted_in = action in all_callbacks
886
+ if not is_visual:
887
+ continue
888
+ if action == "resize" or opted_in:
889
+ key = f"{wid}:{action}"
890
+ if key not in self._callbacks:
891
+ self._callbacks[key] = [lambda wid, *a: None]
892
+ self._fire_and_forget_listen(wid, action)
893
+ if opted_in:
894
+ widget._auto_sync_actions.add(action)
895
+ elif action == "resize":
896
+ widget._passive_sync_actions.add(action)
749
897
  return widget
750
898
  if isinstance(val, list):
751
899
  return [self._resolve_return(v) for v in val]
@@ -839,12 +987,23 @@ class Session:
839
987
  await self._listen(new_widget._wid, act,
840
988
  lambda wid, *a: None)
841
989
  new_widget._auto_sync_actions.add(act)
842
- # Replay any state the proxy accumulated (e.g. set_tooltip)
990
+ # Replay any state the proxy accumulated (e.g. set_tooltip).
991
+ # Skip passively-captured auto-sync state (size, position): we
992
+ # capture those from callbacks so getters work, but replaying
993
+ # would pin the widget to layout-determined pixel dimensions
994
+ # (same logic as _reconstruct_widget).
995
+ user_set = getattr(old_widget, "_user_set_state", set())
996
+ auto = getattr(old_widget, "_auto_sync_actions", set())
843
997
  for key, value in old_widget._state.items():
844
998
  if key.startswith("_"):
845
999
  continue
846
1000
  if key in sync_keys:
847
1001
  continue
1002
+ sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
1003
+ if (sync_action is not None
1004
+ and key not in user_set
1005
+ and sync_action not in auto):
1006
+ continue
848
1007
  method_name = (self._STATE_KEY_TO_SETTER.get(key)
849
1008
  or f"set_{key}")
850
1009
  if isinstance(value, tuple):
@@ -852,6 +1011,13 @@ class Session:
852
1011
  else:
853
1012
  await self._call(new_widget._wid, method_name, value)
854
1013
  new_widget._state[key] = value
1014
+ # Propagate user-set / auto-sync membership to the new
1015
+ # widget so subsequent reconstructions replay consistently.
1016
+ if sync_action is not None:
1017
+ if key in user_set:
1018
+ new_widget._user_set_state.add(key)
1019
+ if sync_action in auto:
1020
+ new_widget._auto_sync_actions.add(sync_action)
855
1021
 
856
1022
  async def _ensure_reconstructed(self, widget):
857
1023
  """Ensure a widget has been created on the JS side."""
@@ -1003,13 +1169,28 @@ class Session:
1003
1169
  if key.startswith("_"):
1004
1170
  continue
1005
1171
 
1172
+ # Skip auto-sync state (size, position) that came in
1173
+ # passively via a callback (e.g. layout-determined size).
1174
+ # We capture those so getters like get_size()/
1175
+ # get_position() work, but replaying them would pin the
1176
+ # widget to pixel dimensions and override flex/expanding
1177
+ # layout. Replay only if the user explicitly set the
1178
+ # value, or if the widget opted into the sync action
1179
+ # (e.g. an interactively-resizable widget).
1180
+ sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
1181
+ if (sync_action is not None
1182
+ and key not in widget._user_set_state
1183
+ and sync_action not in widget._auto_sync_actions):
1184
+ continue
1185
+
1006
1186
  # Binary-payload state (e.g. set_binary_image) replays via
1007
- # _send_binary so the bytes go in a raw frame, not embedded
1008
- # as base64 in JSON.
1187
+ # _send_binary / _send_binary_chunked. Large payloads
1188
+ # switch to chunked transport automatically.
1009
1189
  if key in BINARY_STATE_KEYS:
1010
1190
  method_name = BINARY_STATE_KEYS[key]
1011
1191
  fmt, data = value
1012
- self._send_binary(widget._wid, method_name, [fmt], data)
1192
+ _send_binary_auto(self, widget._wid, method_name,
1193
+ [fmt], data)
1013
1194
  continue
1014
1195
 
1015
1196
  if key in self._STATE_KEY_TO_SETTER:
@@ -1047,10 +1228,13 @@ class Session:
1047
1228
  # _listen calls treat them as first-time registrations and
1048
1229
  # actually send the "listen" message to the browser.
1049
1230
  wid = widget._wid
1231
+ passive = getattr(widget, "_passive_sync_actions", set())
1050
1232
  for action in list(saved_cbs.keys()):
1051
1233
  self._callbacks.pop(f"{wid}:{action}", None)
1052
1234
  for action in widget._auto_sync_actions:
1053
1235
  self._callbacks.pop(f"{wid}:{action}", None)
1236
+ for action in passive:
1237
+ self._callbacks.pop(f"{wid}:{action}", None)
1054
1238
 
1055
1239
  for action, entries in saved_cbs.items():
1056
1240
  for handler, extra_args, extra_kwargs, style in entries:
@@ -1067,6 +1251,13 @@ class Session:
1067
1251
  if action not in widget._registered_callbacks:
1068
1252
  await self._listen(widget._wid, action,
1069
1253
  lambda wid, *a: None)
1254
+ # Passive listeners (e.g. 'resize' for getter support on
1255
+ # widgets that didn't opt into auto-sync).
1256
+ for action in passive:
1257
+ if (action not in widget._registered_callbacks
1258
+ and action not in widget._auto_sync_actions):
1259
+ await self._listen(widget._wid, action,
1260
+ lambda wid, *a: None)
1070
1261
 
1071
1262
  async def reconstruct(self):
1072
1263
  """Replay the entire widget tree to all connected browsers.
@@ -1204,13 +1395,20 @@ class Application:
1204
1395
 
1205
1396
  def __init__(self, ws_port=9500, http_port=9501, host="127.0.0.1",
1206
1397
  http_server=True, concurrency_handling="per_session",
1207
- max_sessions=1, logger=None):
1398
+ max_sessions=1, logger=None, ws_sock=None):
1208
1399
  if concurrency_handling not in _CONCURRENCY_MODES:
1209
1400
  raise ValueError(
1210
1401
  f"concurrency_handling must be one of "
1211
1402
  f"{_CONCURRENCY_MODES!r}, got {concurrency_handling!r}")
1212
1403
  self._host = host
1213
- self._ws_port = ws_port
1404
+ # ws_sock, if provided, is a bound TCP socket the WebSocket
1405
+ # server should adopt directly — see the sync :class:`Application`
1406
+ # for the rationale (TOCTOU-free port allocation).
1407
+ self._ws_sock = ws_sock
1408
+ if ws_sock is not None:
1409
+ self._ws_port = ws_sock.getsockname()[1]
1410
+ else:
1411
+ self._ws_port = ws_port
1214
1412
  self._http_port = http_port
1215
1413
  self._use_http_server = http_server
1216
1414
  self._concurrency = concurrency_handling
@@ -1567,8 +1765,12 @@ class Application:
1567
1765
  self._logger.info(
1568
1766
  f"WebSocket on ws://{self._host}:{self._ws_port}")
1569
1767
 
1570
- self._ws_server = await websockets.serve(
1571
- self._ws_handler, self._host, self._ws_port)
1768
+ if self._ws_sock is not None:
1769
+ self._ws_server = await websockets.serve(
1770
+ self._ws_handler, sock=self._ws_sock)
1771
+ else:
1772
+ self._ws_server = await websockets.serve(
1773
+ self._ws_handler, self._host, self._ws_port)
1572
1774
 
1573
1775
  async def close(self):
1574
1776
  """Close all sessions and shut down the application.
@@ -79,6 +79,16 @@ class Widget:
79
79
  self._constructor_options = {}
80
80
  self._registered_callbacks = {}
81
81
  self._auto_sync_actions = set()
82
+ # Actions we listen to passively for getter support
83
+ # (e.g. 'resize' on every visual widget so get_size() returns
84
+ # a current value), but that aren't in _auto_sync_actions and
85
+ # therefore don't push to peers or replay on reconstruction.
86
+ self._passive_sync_actions = set()
87
+ # State keys the user explicitly set via a setter call.
88
+ # Used during reconstruction to decide whether a state key
89
+ # should be replayed: passively-captured callback state
90
+ # (e.g. layout-determined size) is NOT in this set.
91
+ self._user_set_state = set()
82
92
  self._replay_calls = []
83
93
  self._add_seq = 0
84
94
 
@@ -196,6 +206,8 @@ class Widget:
196
206
  obj._constructor_options = {}
197
207
  obj._registered_callbacks = {}
198
208
  obj._auto_sync_actions = set()
209
+ obj._passive_sync_actions = set()
210
+ obj._user_set_state = set()
199
211
  obj._replay_calls = []
200
212
  obj._stale = False
201
213
  return obj
@@ -212,15 +224,31 @@ class Widget:
212
224
  opt_names_set = set(defn.get("options", []))
213
225
  all_callbacks = defn.get("callbacks", [])
214
226
 
215
- # State-sync callbacks (move -> position, resize -> size)
227
+ # State-sync callbacks (move -> position, resize -> size).
228
+ # For visual widgets we always *listen* so getters like
229
+ # get_size() / get_position() can return current values.
230
+ # But we only add the action to _auto_sync_actions — which
231
+ # controls push-to-peers and replay-on-reconstruction — when
232
+ # the widget actually opted in (e.g. via the 'resizable' option
233
+ # or by declaring the callback in its defn). This keeps
234
+ # layout-determined sizes from being replayed as literal
235
+ # resize() calls that would pin flex/expanding widgets.
236
+ is_visual = defn.get("base") != "callback"
216
237
  for action in STATE_SYNC_CALLBACKS:
217
238
  req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
218
- if req_opt and req_opt not in opt_names_set:
219
- continue
220
- if req_opt is None and action not in all_callbacks:
239
+ opted_in = False
240
+ if req_opt is not None:
241
+ opted_in = req_opt in opt_names_set
242
+ else:
243
+ opted_in = action in all_callbacks
244
+ if not is_visual:
221
245
  continue
222
- await session._listen(wid, action, lambda wid, *a: None)
223
- self._auto_sync_actions.add(action)
246
+ if action == "resize" or opted_in:
247
+ await session._listen(wid, action, lambda wid, *a: None)
248
+ if opted_in:
249
+ self._auto_sync_actions.add(action)
250
+ elif action == "resize":
251
+ self._passive_sync_actions.add(action)
224
252
 
225
253
  # Per-widget-class state sync (e.g. Slider "activated" -> value)
226
254
  cls_sync = WIDGET_CALLBACK_SYNC.get(js_class, {})
@@ -461,6 +489,9 @@ def _make_setter(method_name, param_names, state_key):
461
489
  self._state[state_key] = args[0]
462
490
  else:
463
491
  self._state[state_key] = args
492
+ # Mark as user-set so reconstruction knows to replay this key
493
+ # (callback-captured values for the same key don't get marked).
494
+ self._user_set_state.add(state_key)
464
495
  return await self._call(method_name, *args)
465
496
  method.__name__ = method_name
466
497
  method.__qualname__ = f"Widget.{method_name}"
@@ -473,6 +504,7 @@ def _make_fixed_setter(method_name, state_key, fixed_value):
473
504
  """Create a no-arg async method that sets a fixed state value (show/hide)."""
474
505
  async def method(self):
475
506
  self._state[state_key] = fixed_value
507
+ self._user_set_state.add(state_key)
476
508
  return await self._call(method_name)
477
509
  method.__name__ = method_name
478
510
  method.__qualname__ = f"Widget.{method_name}"
pgwidgets/buffer.py ADDED
@@ -0,0 +1,125 @@
1
+ """Typed binary buffer descriptors for the remote interface.
2
+
3
+ When a method on the browser side expects a typed n-dimensional array
4
+ (image pixels, scientific data, vertex buffers, …) the Python side can
5
+ wrap the raw bytes in a :class:`Buffer` so the receiver gets a typed
6
+ view with shape/dtype information without each method re-implementing
7
+ its own ad-hoc convention.
8
+
9
+ Example::
10
+
11
+ from pgwidgets import Buffer
12
+
13
+ pixels = bytes(...) # 2048 * 2048 * 4 bytes
14
+ buf = Buffer(pixels,
15
+ shape=(2048, 2048, 4),
16
+ dtype="uint8")
17
+ viewer.load_buffer(buf, [2048, 2048], cache)
18
+
19
+ A widget method's binding can recognize :class:`Buffer` args and ship
20
+ them via the chunked binary transport, attaching ``shape`` and
21
+ ``dtype`` to the chunk announce so the JavaScript receiver builds the
22
+ correct typed array (``Uint8Array``, ``Float32Array``, …) before
23
+ dispatching to the method.
24
+ """
25
+
26
+ from typing import Sequence, Union
27
+
28
+
29
+ # Subset of NumPy / DLPack dtype names mapped to the JavaScript
30
+ # TypedArray constructor on the receiving side.
31
+ _DTYPES = frozenset({
32
+ "uint8", "uint16", "uint32",
33
+ "int8", "int16", "int32",
34
+ "float32", "float64",
35
+ })
36
+
37
+ _DTYPE_BYTES = {
38
+ "uint8": 1,
39
+ "int8": 1,
40
+ "uint16": 2,
41
+ "int16": 2,
42
+ "uint32": 4,
43
+ "int32": 4,
44
+ "float32": 4,
45
+ "float64": 8,
46
+ }
47
+
48
+
49
+ class Buffer:
50
+ """Raw bytes plus shape + dtype, addressable as a typed array.
51
+
52
+ Parameters
53
+ ----------
54
+ data : bytes-like
55
+ ``bytes``, ``bytearray``, ``memoryview``, or anything that
56
+ exposes ``__bytes__`` / the buffer protocol. Converted to
57
+ ``bytes`` at construction time.
58
+ shape : tuple of int
59
+ Logical dimensions of the array, e.g. ``(height, width, 4)``
60
+ for an RGBA8 image. Each entry must be a positive integer.
61
+ dtype : str
62
+ One of ``"uint8"``, ``"uint16"``, ``"uint32"``, ``"int8"``,
63
+ ``"int16"``, ``"int32"``, ``"float32"``, ``"float64"``.
64
+ Default ``"uint8"``.
65
+
66
+ Raises
67
+ ------
68
+ TypeError
69
+ If ``data`` is not bytes-like.
70
+ ValueError
71
+ If ``dtype`` is unsupported, ``shape`` contains non-positive
72
+ entries, or the byte length disagrees with
73
+ ``prod(shape) * itemsize`` (rounded up for non-evenly divisible
74
+ cases).
75
+ """
76
+
77
+ __slots__ = ("data", "shape", "dtype")
78
+
79
+ def __init__(self,
80
+ data: Union[bytes, bytearray, memoryview],
81
+ shape: Sequence[int],
82
+ dtype: str = "uint8") -> None:
83
+ if dtype not in _DTYPES:
84
+ raise ValueError(
85
+ f"Buffer: unsupported dtype {dtype!r}; "
86
+ f"supported: {sorted(_DTYPES)}")
87
+ try:
88
+ data = bytes(data)
89
+ except TypeError as e:
90
+ raise TypeError(
91
+ f"Buffer: data must be bytes-like, got "
92
+ f"{type(data).__name__}") from e
93
+ shape_tuple = tuple(int(d) for d in shape)
94
+ if not shape_tuple or any(d <= 0 for d in shape_tuple):
95
+ raise ValueError(
96
+ f"Buffer: shape must be a non-empty tuple of "
97
+ f"positive ints, got {shape!r}")
98
+ itemsize = _DTYPE_BYTES[dtype]
99
+ expected = itemsize
100
+ for d in shape_tuple:
101
+ expected *= d
102
+ if len(data) != expected:
103
+ raise ValueError(
104
+ f"Buffer: data length {len(data)} bytes does not "
105
+ f"match shape {shape_tuple} * dtype {dtype} "
106
+ f"(expected {expected} bytes)")
107
+ self.data = data
108
+ self.shape = shape_tuple
109
+ self.dtype = dtype
110
+
111
+ def __len__(self) -> int:
112
+ return len(self.data)
113
+
114
+ def __repr__(self) -> str:
115
+ return (f"Buffer(<{len(self.data)} bytes>, "
116
+ f"shape={self.shape}, dtype={self.dtype!r})")
117
+
118
+
119
+ def is_buffer(obj) -> bool:
120
+ """Return True if *obj* is a :class:`Buffer` instance.
121
+
122
+ Convenience for code that branches on whether an argument carries
123
+ binary-payload metadata.
124
+ """
125
+ return isinstance(obj, Buffer)