pgwidgets-python 0.2.0__py3-none-any.whl → 0.2.3__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 +13 -0
- pgwidgets/_json.py +63 -0
- pgwidgets/async_/application.py +110 -23
- pgwidgets/async_/widget.py +48 -11
- pgwidgets/method_types.py +15 -0
- pgwidgets/sync/application.py +115 -24
- pgwidgets/sync/widget.py +52 -11
- {pgwidgets_python-0.2.0.dist-info → pgwidgets_python-0.2.3.dist-info}/METADATA +2 -2
- pgwidgets_python-0.2.3.dist-info/RECORD +20 -0
- pgwidgets_python-0.2.0.dist-info/RECORD +0 -19
- {pgwidgets_python-0.2.0.dist-info → pgwidgets_python-0.2.3.dist-info}/WHEEL +0 -0
- {pgwidgets_python-0.2.0.dist-info → pgwidgets_python-0.2.3.dist-info}/licenses/LICENSE.md +0 -0
- {pgwidgets_python-0.2.0.dist-info → pgwidgets_python-0.2.3.dist-info}/top_level.txt +0 -0
pgwidgets/__init__.py
CHANGED
|
@@ -16,4 +16,17 @@ Usage (asynchronous):
|
|
|
16
16
|
top = await W.TopLevel(title="Hello", resizable=True)
|
|
17
17
|
...
|
|
18
18
|
await app.run()
|
|
19
|
+
|
|
20
|
+
Version:
|
|
21
|
+
import pgwidgets
|
|
22
|
+
print(pgwidgets.__version__)
|
|
19
23
|
"""
|
|
24
|
+
|
|
25
|
+
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
__version__ = _pkg_version("pgwidgets")
|
|
29
|
+
except PackageNotFoundError:
|
|
30
|
+
# Package not installed (e.g. running from a source checkout
|
|
31
|
+
# without `pip install -e .`). Fall back to a sentinel.
|
|
32
|
+
__version__ = "0.0.0+unknown"
|
pgwidgets/_json.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""JSON encoding helper for the remote protocol.
|
|
2
|
+
|
|
3
|
+
The wire format between Python and the browser is JSON. The stdlib
|
|
4
|
+
encoder has two issues for scientific data:
|
|
5
|
+
|
|
6
|
+
1. It rejects numpy scalars other than ``np.float64`` (which inherits
|
|
7
|
+
from float), numpy arrays, and similar buffer-protocol objects —
|
|
8
|
+
silently dropping a TreeView payload that happens to contain
|
|
9
|
+
``np.int64`` cell values.
|
|
10
|
+
|
|
11
|
+
2. It writes the literal tokens ``NaN`` / ``Infinity`` / ``-Infinity``
|
|
12
|
+
for non-finite floats, which browsers' ``JSON.parse`` reject — a
|
|
13
|
+
single masked / missing cell in a float column makes the whole
|
|
14
|
+
payload silently fail to parse on the JS side.
|
|
15
|
+
|
|
16
|
+
``JsonEncoder`` handles both: ``.item()`` / ``.tolist()`` fall-backs
|
|
17
|
+
for numpy/pandas types, and a pre-walk that replaces non-finite floats
|
|
18
|
+
with ``None`` (encoded as JSON ``null``). Use it via
|
|
19
|
+
``json.dumps(obj, cls=JsonEncoder)``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _scrub_nan(obj):
|
|
27
|
+
"""Recursively replace non-finite floats with None so the result
|
|
28
|
+
is RFC-8259-valid JSON when re-encoded. Cheap walk: O(n) and
|
|
29
|
+
only visits dicts/lists/tuples/floats."""
|
|
30
|
+
if isinstance(obj, float):
|
|
31
|
+
return obj if math.isfinite(obj) else None
|
|
32
|
+
if isinstance(obj, dict):
|
|
33
|
+
return {k: _scrub_nan(v) for k, v in obj.items()}
|
|
34
|
+
if isinstance(obj, (list, tuple)):
|
|
35
|
+
return [_scrub_nan(v) for v in obj]
|
|
36
|
+
return obj
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _coerce_scalar(v):
|
|
40
|
+
"""If *v* is a float, replace non-finite values with None."""
|
|
41
|
+
if isinstance(v, float) and not math.isfinite(v):
|
|
42
|
+
return None
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JsonEncoder(json.JSONEncoder):
|
|
47
|
+
def default(self, obj):
|
|
48
|
+
item = getattr(obj, "item", None)
|
|
49
|
+
if callable(item):
|
|
50
|
+
try:
|
|
51
|
+
return _coerce_scalar(item())
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
pass
|
|
54
|
+
tolist = getattr(obj, "tolist", None)
|
|
55
|
+
if callable(tolist):
|
|
56
|
+
try:
|
|
57
|
+
return _scrub_nan(tolist())
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
pass
|
|
60
|
+
return super().default(obj)
|
|
61
|
+
|
|
62
|
+
def iterencode(self, o, _one_shot=False):
|
|
63
|
+
return super().iterencode(_scrub_nan(o), _one_shot=_one_shot)
|
pgwidgets/async_/application.py
CHANGED
|
@@ -23,6 +23,7 @@ from pathlib import Path
|
|
|
23
23
|
import websockets
|
|
24
24
|
|
|
25
25
|
from pgwidgets_js import get_static_path, get_remote_html
|
|
26
|
+
from pgwidgets._json import JsonEncoder
|
|
26
27
|
from pgwidgets.defs import WIDGETS
|
|
27
28
|
from pgwidgets.method_types import (
|
|
28
29
|
SPECIAL_SETTERS, FIXED_SETTERS, CHILD_METHODS as CHILD_METHOD_NAMES,
|
|
@@ -90,6 +91,13 @@ class Session:
|
|
|
90
91
|
_STATE_KEY_TO_SETTER = {v: k for k, v in SPECIAL_SETTERS.items()}
|
|
91
92
|
# e.g. {"size": "resize"}
|
|
92
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
|
+
|
|
93
101
|
# State keys handled by fixed-value methods (show/hide)
|
|
94
102
|
_FIXED_STATE_KEYS = {}
|
|
95
103
|
for _mname, (_key, _val) in FIXED_SETTERS.items():
|
|
@@ -299,30 +307,46 @@ class Session:
|
|
|
299
307
|
|
|
300
308
|
def _dispatch_callback(self, wid, action, *args):
|
|
301
309
|
"""Dispatch a callback through the configured concurrency mode."""
|
|
302
|
-
|
|
303
|
-
|
|
310
|
+
# Suppress callbacks during reconstruction — they are side
|
|
311
|
+
# effects of state replay, not user actions. Exception: 'map'
|
|
312
|
+
# is a one-shot lifecycle event that the JS-side observers fire
|
|
313
|
+
# when a widget first gains a visible layout box; the timing
|
|
314
|
+
# can land inside the reconstruction window, and dropping it
|
|
315
|
+
# means the user's map handler never runs until a later layout
|
|
316
|
+
# change (e.g. window resize) triggers a re-fire.
|
|
317
|
+
if self._reconstructing and action != 'map':
|
|
318
|
+
return
|
|
304
319
|
|
|
305
320
|
# Auto-sync: some callbacks carry state that should be reflected
|
|
306
321
|
# in the Python-side widget (e.g. move -> position, resize -> size).
|
|
322
|
+
# We always *capture* the value so get_size()/get_position() return
|
|
323
|
+
# current values, but only *push* it to other browsers (and replay
|
|
324
|
+
# it on reconstruction) when the widget opted in via auto-sync.
|
|
325
|
+
# Otherwise a layout-determined size would replay as a literal
|
|
326
|
+
# resize(W, H) — pinning the widget to pixel dimensions and
|
|
327
|
+
# killing flex growth.
|
|
307
328
|
state_key = STATE_SYNC_CALLBACKS.get(action)
|
|
308
329
|
if state_key is not None:
|
|
309
330
|
widget = self._widget_map.get(wid)
|
|
310
331
|
if widget is not None:
|
|
332
|
+
auto = action in widget._auto_sync_actions
|
|
311
333
|
if len(args) == 1 and isinstance(args[0], dict):
|
|
312
334
|
d = args[0]
|
|
313
335
|
if "width" in d and "height" in d:
|
|
314
336
|
new_val = (d["width"], d["height"])
|
|
315
337
|
if widget._state.get(state_key) != new_val:
|
|
316
338
|
widget._state[state_key] = new_val
|
|
317
|
-
|
|
318
|
-
|
|
339
|
+
if auto:
|
|
340
|
+
self._push(wid, "resize",
|
|
341
|
+
d["width"], d["height"])
|
|
319
342
|
else:
|
|
320
343
|
new_val = tuple(args)
|
|
321
344
|
if widget._state.get(state_key) != new_val:
|
|
322
345
|
widget._state[state_key] = new_val
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
346
|
+
if auto:
|
|
347
|
+
setter = (self._STATE_KEY_TO_SETTER.get(state_key)
|
|
348
|
+
or f"set_{state_key}")
|
|
349
|
+
self._push(wid, setter, *args)
|
|
326
350
|
# If this widget wraps a child (e.g. MDISubWindow),
|
|
327
351
|
# propagate geometry into the parent's children options
|
|
328
352
|
# so reconstruction replays with the current pos/size.
|
|
@@ -492,7 +516,7 @@ class Session:
|
|
|
492
516
|
self._next_id += 1
|
|
493
517
|
msg["id"] = msg_id
|
|
494
518
|
try:
|
|
495
|
-
payload = json.dumps(msg)
|
|
519
|
+
payload = json.dumps(msg, cls=JsonEncoder)
|
|
496
520
|
except Exception as e:
|
|
497
521
|
self._logger.error(
|
|
498
522
|
f"JSON encode failed for "
|
|
@@ -510,7 +534,7 @@ class Session:
|
|
|
510
534
|
ff_id = self._next_id
|
|
511
535
|
self._next_id += 1
|
|
512
536
|
msg_copy = dict(msg, id=ff_id)
|
|
513
|
-
ff_payload = json.dumps(msg_copy)
|
|
537
|
+
ff_payload = json.dumps(msg_copy, cls=JsonEncoder)
|
|
514
538
|
for ws in self._connections[1:]:
|
|
515
539
|
_schedule_ws_send(ws, ff_payload)
|
|
516
540
|
result = await future
|
|
@@ -541,7 +565,7 @@ class Session:
|
|
|
541
565
|
"method": method,
|
|
542
566
|
"args": list(args),
|
|
543
567
|
"silent": True,
|
|
544
|
-
})
|
|
568
|
+
}, cls=JsonEncoder)
|
|
545
569
|
except Exception as e:
|
|
546
570
|
self._logger.error(
|
|
547
571
|
f"JSON encode failed for push {method} "
|
|
@@ -567,7 +591,7 @@ class Session:
|
|
|
567
591
|
"id": msg_id,
|
|
568
592
|
"wid": wid,
|
|
569
593
|
"action": action,
|
|
570
|
-
})
|
|
594
|
+
}, cls=JsonEncoder)
|
|
571
595
|
for ws in self._connections:
|
|
572
596
|
_schedule_ws_send(ws, payload)
|
|
573
597
|
|
|
@@ -630,7 +654,7 @@ class Session:
|
|
|
630
654
|
"wid": wid,
|
|
631
655
|
"method": method,
|
|
632
656
|
"args": list(args),
|
|
633
|
-
})
|
|
657
|
+
}, cls=JsonEncoder)
|
|
634
658
|
async def _pair(ws):
|
|
635
659
|
await ws.send(header)
|
|
636
660
|
await ws.send(data)
|
|
@@ -734,17 +758,37 @@ class Session:
|
|
|
734
758
|
cls = self._widget_classes.get(cls_name, Widget) if cls_name else Widget
|
|
735
759
|
widget = cls._from_existing(self, wid, cls_name or "Widget")
|
|
736
760
|
self._widget_map[wid] = widget
|
|
737
|
-
# Auto-listen for state-syncing callbacks (move, resize)
|
|
738
|
-
# so position/size changes are tracked for reconstruction.
|
|
761
|
+
# Auto-listen for state-syncing callbacks (move, resize).
|
|
739
762
|
# Register locally (sync) and send the listen message as
|
|
740
763
|
# true fire-and-forget (no result awaited) since
|
|
741
|
-
# _resolve_return is not async.
|
|
764
|
+
# _resolve_return is not async. For visual widgets we
|
|
765
|
+
# always listen for 'resize' so get_size() can return a
|
|
766
|
+
# current value, but we only mark the action as
|
|
767
|
+
# auto-syncing (which triggers push-to-peers and
|
|
768
|
+
# replay-on-reconstruction) when the widget defn opts in.
|
|
769
|
+
# Non-visual Callback-base objects (Timer, TextBufferRef,
|
|
770
|
+
# …) get nothing.
|
|
771
|
+
defn = WIDGETS.get(cls_name, {}) if cls_name else {}
|
|
772
|
+
opt_names_set = set(defn.get("options", []))
|
|
773
|
+
all_callbacks = defn.get("callbacks", [])
|
|
774
|
+
is_visual = defn.get("base") != "callback"
|
|
742
775
|
for action in STATE_SYNC_CALLBACKS:
|
|
743
|
-
|
|
744
|
-
if
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
776
|
+
req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
|
|
777
|
+
if req_opt is not None:
|
|
778
|
+
opted_in = req_opt in opt_names_set
|
|
779
|
+
else:
|
|
780
|
+
opted_in = action in all_callbacks
|
|
781
|
+
if not is_visual:
|
|
782
|
+
continue
|
|
783
|
+
if action == "resize" or opted_in:
|
|
784
|
+
key = f"{wid}:{action}"
|
|
785
|
+
if key not in self._callbacks:
|
|
786
|
+
self._callbacks[key] = [lambda wid, *a: None]
|
|
787
|
+
self._fire_and_forget_listen(wid, action)
|
|
788
|
+
if opted_in:
|
|
789
|
+
widget._auto_sync_actions.add(action)
|
|
790
|
+
elif action == "resize":
|
|
791
|
+
widget._passive_sync_actions.add(action)
|
|
748
792
|
return widget
|
|
749
793
|
if isinstance(val, list):
|
|
750
794
|
return [self._resolve_return(v) for v in val]
|
|
@@ -838,12 +882,23 @@ class Session:
|
|
|
838
882
|
await self._listen(new_widget._wid, act,
|
|
839
883
|
lambda wid, *a: None)
|
|
840
884
|
new_widget._auto_sync_actions.add(act)
|
|
841
|
-
# Replay any state the proxy accumulated (e.g. set_tooltip)
|
|
885
|
+
# Replay any state the proxy accumulated (e.g. set_tooltip).
|
|
886
|
+
# Skip passively-captured auto-sync state (size, position): we
|
|
887
|
+
# capture those from callbacks so getters work, but replaying
|
|
888
|
+
# would pin the widget to layout-determined pixel dimensions
|
|
889
|
+
# (same logic as _reconstruct_widget).
|
|
890
|
+
user_set = getattr(old_widget, "_user_set_state", set())
|
|
891
|
+
auto = getattr(old_widget, "_auto_sync_actions", set())
|
|
842
892
|
for key, value in old_widget._state.items():
|
|
843
893
|
if key.startswith("_"):
|
|
844
894
|
continue
|
|
845
895
|
if key in sync_keys:
|
|
846
896
|
continue
|
|
897
|
+
sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
|
|
898
|
+
if (sync_action is not None
|
|
899
|
+
and key not in user_set
|
|
900
|
+
and sync_action not in auto):
|
|
901
|
+
continue
|
|
847
902
|
method_name = (self._STATE_KEY_TO_SETTER.get(key)
|
|
848
903
|
or f"set_{key}")
|
|
849
904
|
if isinstance(value, tuple):
|
|
@@ -851,6 +906,13 @@ class Session:
|
|
|
851
906
|
else:
|
|
852
907
|
await self._call(new_widget._wid, method_name, value)
|
|
853
908
|
new_widget._state[key] = value
|
|
909
|
+
# Propagate user-set / auto-sync membership to the new
|
|
910
|
+
# widget so subsequent reconstructions replay consistently.
|
|
911
|
+
if sync_action is not None:
|
|
912
|
+
if key in user_set:
|
|
913
|
+
new_widget._user_set_state.add(key)
|
|
914
|
+
if sync_action in auto:
|
|
915
|
+
new_widget._auto_sync_actions.add(sync_action)
|
|
854
916
|
|
|
855
917
|
async def _ensure_reconstructed(self, widget):
|
|
856
918
|
"""Ensure a widget has been created on the JS side."""
|
|
@@ -1002,6 +1064,20 @@ class Session:
|
|
|
1002
1064
|
if key.startswith("_"):
|
|
1003
1065
|
continue
|
|
1004
1066
|
|
|
1067
|
+
# Skip auto-sync state (size, position) that came in
|
|
1068
|
+
# passively via a callback (e.g. layout-determined size).
|
|
1069
|
+
# We capture those so getters like get_size()/
|
|
1070
|
+
# get_position() work, but replaying them would pin the
|
|
1071
|
+
# widget to pixel dimensions and override flex/expanding
|
|
1072
|
+
# layout. Replay only if the user explicitly set the
|
|
1073
|
+
# value, or if the widget opted into the sync action
|
|
1074
|
+
# (e.g. an interactively-resizable widget).
|
|
1075
|
+
sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
|
|
1076
|
+
if (sync_action is not None
|
|
1077
|
+
and key not in widget._user_set_state
|
|
1078
|
+
and sync_action not in widget._auto_sync_actions):
|
|
1079
|
+
continue
|
|
1080
|
+
|
|
1005
1081
|
# Binary-payload state (e.g. set_binary_image) replays via
|
|
1006
1082
|
# _send_binary so the bytes go in a raw frame, not embedded
|
|
1007
1083
|
# as base64 in JSON.
|
|
@@ -1046,10 +1122,13 @@ class Session:
|
|
|
1046
1122
|
# _listen calls treat them as first-time registrations and
|
|
1047
1123
|
# actually send the "listen" message to the browser.
|
|
1048
1124
|
wid = widget._wid
|
|
1125
|
+
passive = getattr(widget, "_passive_sync_actions", set())
|
|
1049
1126
|
for action in list(saved_cbs.keys()):
|
|
1050
1127
|
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1051
1128
|
for action in widget._auto_sync_actions:
|
|
1052
1129
|
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1130
|
+
for action in passive:
|
|
1131
|
+
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1053
1132
|
|
|
1054
1133
|
for action, entries in saved_cbs.items():
|
|
1055
1134
|
for handler, extra_args, extra_kwargs, style in entries:
|
|
@@ -1066,6 +1145,13 @@ class Session:
|
|
|
1066
1145
|
if action not in widget._registered_callbacks:
|
|
1067
1146
|
await self._listen(widget._wid, action,
|
|
1068
1147
|
lambda wid, *a: None)
|
|
1148
|
+
# Passive listeners (e.g. 'resize' for getter support on
|
|
1149
|
+
# widgets that didn't opt into auto-sync).
|
|
1150
|
+
for action in passive:
|
|
1151
|
+
if (action not in widget._registered_callbacks
|
|
1152
|
+
and action not in widget._auto_sync_actions):
|
|
1153
|
+
await self._listen(widget._wid, action,
|
|
1154
|
+
lambda wid, *a: None)
|
|
1069
1155
|
|
|
1070
1156
|
async def reconstruct(self):
|
|
1071
1157
|
"""Replay the entire widget tree to all connected browsers.
|
|
@@ -1332,7 +1418,8 @@ class Application:
|
|
|
1332
1418
|
async def _ws_handler(self, ws):
|
|
1333
1419
|
# Init handshake: send init, receive ack which may contain
|
|
1334
1420
|
# session_id + token for reconnection.
|
|
1335
|
-
await ws.send(json.dumps({"type": "init", "id": 0}
|
|
1421
|
+
await ws.send(json.dumps({"type": "init", "id": 0},
|
|
1422
|
+
cls=JsonEncoder))
|
|
1336
1423
|
ack_data = await ws.recv()
|
|
1337
1424
|
ack = json.loads(ack_data)
|
|
1338
1425
|
reconnect_sid = ack.get("session_id")
|
|
@@ -1399,7 +1486,7 @@ class Application:
|
|
|
1399
1486
|
"type": "session-info",
|
|
1400
1487
|
"session_id": session.id,
|
|
1401
1488
|
"token": session.token,
|
|
1402
|
-
}))
|
|
1489
|
+
}, cls=JsonEncoder))
|
|
1403
1490
|
|
|
1404
1491
|
if is_reconnect:
|
|
1405
1492
|
async def do_reconstruct():
|
pgwidgets/async_/widget.py
CHANGED
|
@@ -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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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}"
|
|
@@ -776,14 +808,19 @@ def _add_tree_view_methods(attrs, all_methods):
|
|
|
776
808
|
|
|
777
809
|
|
|
778
810
|
def _init_params(pos_names, opt_names):
|
|
779
|
-
"""Build the parameter string for the generated __init__.
|
|
811
|
+
"""Build the parameter string for the generated __init__.
|
|
812
|
+
|
|
813
|
+
Options are exposed as positional-or-keyword (no ``*`` separator)
|
|
814
|
+
so that single-option widgets like Frame accept ``Frame("Title")``
|
|
815
|
+
in addition to ``Frame(title="Title")``, matching the pyodide
|
|
816
|
+
bridge's behavior. Each option still defaults to ``None`` so
|
|
817
|
+
omitted ones don't override state from prior calls.
|
|
818
|
+
"""
|
|
780
819
|
params = ["self", "session"]
|
|
781
820
|
for name in pos_names:
|
|
782
821
|
params.append(f"{name}=None")
|
|
783
|
-
|
|
784
|
-
params.append("
|
|
785
|
-
for name in opt_names:
|
|
786
|
-
params.append(f"{name}=None")
|
|
822
|
+
for name in opt_names:
|
|
823
|
+
params.append(f"{name}=None")
|
|
787
824
|
params.append("**kwargs")
|
|
788
825
|
return ", ".join(params)
|
|
789
826
|
|
pgwidgets/method_types.py
CHANGED
|
@@ -235,6 +235,10 @@ STATE_DEFAULTS = {
|
|
|
235
235
|
"TabWidget": {"index": -1},
|
|
236
236
|
"StackWidget": {"index": -1},
|
|
237
237
|
"MDIWidget": {"index": -1},
|
|
238
|
+
# ScrollBar uses 1-arg set_scroll_percent / set_thumb_percent,
|
|
239
|
+
# so a scalar default is correct (not the (0.0, 0.0) tuple used
|
|
240
|
+
# by the 2-arg scroll widgets in STATE_KEY_DEFAULTS).
|
|
241
|
+
"ScrollBar": {"scroll_percent": 0.0, "thumb_percent": 0.0},
|
|
238
242
|
}
|
|
239
243
|
|
|
240
244
|
# Cross-widget default values for state keys, used as a fallback when
|
|
@@ -245,6 +249,17 @@ STATE_KEY_DEFAULTS = {
|
|
|
245
249
|
"size": (0, 0),
|
|
246
250
|
"position": (0, 0),
|
|
247
251
|
"index": -1,
|
|
252
|
+
# Scroll widgets that take (h_pct, v_pct). ScrollBar overrides
|
|
253
|
+
# these in STATE_DEFAULTS with scalar 0.0.
|
|
254
|
+
"scroll_position": (0.0, 0.0),
|
|
255
|
+
"scroll_percent": (0.0, 0.0),
|
|
256
|
+
"thumb_percent": (0.0, 0.0),
|
|
257
|
+
# set_expanding(horizontal, vertical) → tuple of bools
|
|
258
|
+
"expanding": (False, False),
|
|
259
|
+
"enabled": True,
|
|
260
|
+
"state": False,
|
|
261
|
+
# HTMLMediaElement convention: 0.0 (muted) to 1.0 (full).
|
|
262
|
+
"volume": 1.0,
|
|
248
263
|
}
|
|
249
264
|
|
|
250
265
|
# Widgets with incrementally-built item lists.
|
pgwidgets/sync/application.py
CHANGED
|
@@ -25,6 +25,7 @@ from pathlib import Path
|
|
|
25
25
|
import websockets
|
|
26
26
|
|
|
27
27
|
from pgwidgets_js import get_static_path, get_remote_html
|
|
28
|
+
from pgwidgets._json import JsonEncoder
|
|
28
29
|
from pgwidgets.defs import WIDGETS
|
|
29
30
|
from pgwidgets.sync.widget import Widget, build_all_widget_classes
|
|
30
31
|
from pgwidgets.method_types import (
|
|
@@ -260,6 +261,15 @@ class Session:
|
|
|
260
261
|
if threading.current_thread() is not self._cb_thread:
|
|
261
262
|
self._cb_thread.join(timeout=2)
|
|
262
263
|
|
|
264
|
+
def get_session_thread(self):
|
|
265
|
+
mode = self._app._concurrency
|
|
266
|
+
if mode == "serialized":
|
|
267
|
+
return threading.get_ident()
|
|
268
|
+
elif mode == "per_session":
|
|
269
|
+
return self._cb_thread
|
|
270
|
+
else:
|
|
271
|
+
return None
|
|
272
|
+
|
|
263
273
|
# -- Message handling --
|
|
264
274
|
|
|
265
275
|
def _handle_message(self, data):
|
|
@@ -374,18 +384,28 @@ class Session:
|
|
|
374
384
|
|
|
375
385
|
def _dispatch_callback(self, wid, action, *args):
|
|
376
386
|
"""Dispatch a callback through the configured concurrency mode."""
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
|
|
382
|
-
|
|
387
|
+
# Suppress callbacks during reconstruction — they are side
|
|
388
|
+
# effects of state replay, not user actions. Exception: 'map'
|
|
389
|
+
# is a one-shot lifecycle event that fires when a widget first
|
|
390
|
+
# gains a visible layout box; the JS-side observers may fire
|
|
391
|
+
# it during the reconstruction window and we MUST forward it
|
|
392
|
+
# or the user's map handler never runs until something later
|
|
393
|
+
# triggers a re-fire (e.g. a window resize).
|
|
394
|
+
if self._reconstructing and action != 'map':
|
|
395
|
+
return
|
|
383
396
|
# Auto-sync: some callbacks carry state that should be reflected
|
|
384
397
|
# in the Python-side widget (e.g. move -> position, resize -> size).
|
|
398
|
+
# We always *capture* the value so get_size()/get_position() return
|
|
399
|
+
# current values, but only *push* it to other browsers (and replay
|
|
400
|
+
# it on reconstruction) when the widget opted in via auto-sync.
|
|
401
|
+
# Otherwise a layout-determined size would replay as a literal
|
|
402
|
+
# resize(W, H) — pinning the widget to pixel dimensions and
|
|
403
|
+
# killing flex growth.
|
|
385
404
|
state_key = STATE_SYNC_CALLBACKS.get(action)
|
|
386
405
|
if state_key is not None:
|
|
387
406
|
widget = self._widget_map.get(wid)
|
|
388
407
|
if widget is not None:
|
|
408
|
+
auto = action in widget._auto_sync_actions
|
|
389
409
|
# Normalize: resize sends {width, height} dict, move
|
|
390
410
|
# sends (x, y) as separate args. Store as a flat tuple
|
|
391
411
|
# matching the corresponding setter's signature.
|
|
@@ -395,15 +415,17 @@ class Session:
|
|
|
395
415
|
new_val = (d["width"], d["height"])
|
|
396
416
|
if widget._state.get(state_key) != new_val:
|
|
397
417
|
widget._state[state_key] = new_val
|
|
398
|
-
|
|
399
|
-
|
|
418
|
+
if auto:
|
|
419
|
+
self._push(wid, "resize",
|
|
420
|
+
d["width"], d["height"])
|
|
400
421
|
else:
|
|
401
422
|
new_val = tuple(args)
|
|
402
423
|
if widget._state.get(state_key) != new_val:
|
|
403
424
|
widget._state[state_key] = new_val
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
425
|
+
if auto:
|
|
426
|
+
setter = (self._STATE_KEY_TO_SETTER.get(state_key)
|
|
427
|
+
or f"set_{state_key}")
|
|
428
|
+
self._push(wid, setter, *args)
|
|
407
429
|
# If this widget wraps a child (e.g. MDISubWindow),
|
|
408
430
|
# propagate geometry into the parent's children options
|
|
409
431
|
# so reconstruction replays with the current pos/size.
|
|
@@ -541,7 +563,7 @@ class Session:
|
|
|
541
563
|
self._next_id += 1
|
|
542
564
|
msg["id"] = msg_id
|
|
543
565
|
try:
|
|
544
|
-
payload = json.dumps(msg)
|
|
566
|
+
payload = json.dumps(msg, cls=JsonEncoder)
|
|
545
567
|
except Exception as e:
|
|
546
568
|
self._logger.error(
|
|
547
569
|
f"JSON encode failed for "
|
|
@@ -565,7 +587,7 @@ class Session:
|
|
|
565
587
|
ff_id = self._next_id
|
|
566
588
|
self._next_id += 1
|
|
567
589
|
msg_copy = dict(msg, id=ff_id)
|
|
568
|
-
ff_payload = json.dumps(msg_copy)
|
|
590
|
+
ff_payload = json.dumps(msg_copy, cls=JsonEncoder)
|
|
569
591
|
for ws in self._connections[1:]:
|
|
570
592
|
_schedule_ws_send(self._app._loop, ws, ff_payload)
|
|
571
593
|
event.wait()
|
|
@@ -605,7 +627,7 @@ class Session:
|
|
|
605
627
|
"method": method,
|
|
606
628
|
"args": list(args),
|
|
607
629
|
"silent": True,
|
|
608
|
-
})
|
|
630
|
+
}, cls=JsonEncoder)
|
|
609
631
|
except Exception as e:
|
|
610
632
|
self._logger.error(
|
|
611
633
|
f"JSON encode failed for push {method} "
|
|
@@ -677,7 +699,7 @@ class Session:
|
|
|
677
699
|
"wid": wid,
|
|
678
700
|
"method": method,
|
|
679
701
|
"args": list(args),
|
|
680
|
-
})
|
|
702
|
+
}, cls=JsonEncoder)
|
|
681
703
|
# Send header + binary as an atomic pair on each connection so
|
|
682
704
|
# they can't be interleaved with other sends from another
|
|
683
705
|
# thread. The pair is wrapped in a single coroutine.
|
|
@@ -794,13 +816,33 @@ class Session:
|
|
|
794
816
|
cls = self._widget_classes.get(cls_name, Widget) if cls_name else Widget
|
|
795
817
|
widget = cls._from_existing(self, wid, cls_name or "Widget")
|
|
796
818
|
self._widget_map[wid] = widget
|
|
797
|
-
# Auto-listen for state-syncing callbacks (move, resize)
|
|
798
|
-
#
|
|
819
|
+
# Auto-listen for state-syncing callbacks (move, resize).
|
|
820
|
+
# For visual widgets we always listen for 'resize' so that
|
|
821
|
+
# get_size() can return a current value, but we only mark
|
|
822
|
+
# the action as auto-syncing (which triggers push-to-peers
|
|
823
|
+
# and replay-on-reconstruction) when the widget defn
|
|
824
|
+
# actually opts in. Non-visual Callback-base objects (e.g.
|
|
825
|
+
# TextBufferRef, Timer) get nothing.
|
|
826
|
+
defn = WIDGETS.get(cls_name, {}) if cls_name else {}
|
|
827
|
+
opt_names_set = set(defn.get("options", []))
|
|
828
|
+
all_callbacks = defn.get("callbacks", [])
|
|
829
|
+
is_visual = defn.get("base") != "callback"
|
|
799
830
|
for action in STATE_SYNC_CALLBACKS:
|
|
800
|
-
|
|
801
|
-
if
|
|
802
|
-
|
|
803
|
-
|
|
831
|
+
req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
|
|
832
|
+
if req_opt is not None:
|
|
833
|
+
opted_in = req_opt in opt_names_set
|
|
834
|
+
else:
|
|
835
|
+
opted_in = action in all_callbacks
|
|
836
|
+
if not is_visual:
|
|
837
|
+
continue
|
|
838
|
+
if action == "resize" or opted_in:
|
|
839
|
+
key = f"{wid}:{action}"
|
|
840
|
+
if key not in self._callbacks:
|
|
841
|
+
self._listen(wid, action, lambda wid, *a: None)
|
|
842
|
+
if opted_in:
|
|
843
|
+
widget._auto_sync_actions.add(action)
|
|
844
|
+
elif action == "resize":
|
|
845
|
+
widget._passive_sync_actions.add(action)
|
|
804
846
|
return widget
|
|
805
847
|
if isinstance(val, list):
|
|
806
848
|
return [self._resolve_return(v) for v in val]
|
|
@@ -897,6 +939,13 @@ class Session:
|
|
|
897
939
|
_STATE_KEY_TO_SETTER = {v: k for k, v in SPECIAL_SETTERS.items()}
|
|
898
940
|
# e.g. {"size": "resize"}
|
|
899
941
|
|
|
942
|
+
# Reverse map: state_key -> callback action that auto-syncs it
|
|
943
|
+
# (e.g. "size" -> "resize", "position" -> "move"). Used during
|
|
944
|
+
# state replay to skip keys that weren't actively opted into via
|
|
945
|
+
# _auto_sync_actions — those were captured passively for getter
|
|
946
|
+
# support but must not be replayed (would pin layout).
|
|
947
|
+
_STATE_KEY_TO_SYNC_ACTION = {v: k for k, v in STATE_SYNC_CALLBACKS.items()}
|
|
948
|
+
|
|
900
949
|
# State keys handled by fixed-value methods (show/hide)
|
|
901
950
|
_FIXED_STATE_KEYS = {}
|
|
902
951
|
for _mname, (_key, _val) in FIXED_SETTERS.items():
|
|
@@ -942,12 +991,23 @@ class Session:
|
|
|
942
991
|
self._listen(new_widget._wid, act,
|
|
943
992
|
lambda wid, *a: None)
|
|
944
993
|
new_widget._auto_sync_actions.add(act)
|
|
945
|
-
# Replay any state the proxy accumulated (e.g. set_tooltip)
|
|
994
|
+
# Replay any state the proxy accumulated (e.g. set_tooltip).
|
|
995
|
+
# Skip passively-captured auto-sync state (size, position): we
|
|
996
|
+
# capture those from callbacks so getters work, but replaying
|
|
997
|
+
# would pin the widget to layout-determined pixel dimensions
|
|
998
|
+
# (same logic as _reconstruct_widget).
|
|
999
|
+
user_set = getattr(old_widget, "_user_set_state", set())
|
|
1000
|
+
auto = getattr(old_widget, "_auto_sync_actions", set())
|
|
946
1001
|
for key, value in old_widget._state.items():
|
|
947
1002
|
if key.startswith("_"):
|
|
948
1003
|
continue
|
|
949
1004
|
if key in sync_keys:
|
|
950
1005
|
continue
|
|
1006
|
+
sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
|
|
1007
|
+
if (sync_action is not None
|
|
1008
|
+
and key not in user_set
|
|
1009
|
+
and sync_action not in auto):
|
|
1010
|
+
continue
|
|
951
1011
|
method_name = (self._STATE_KEY_TO_SETTER.get(key)
|
|
952
1012
|
or f"set_{key}")
|
|
953
1013
|
if isinstance(value, tuple):
|
|
@@ -955,6 +1015,13 @@ class Session:
|
|
|
955
1015
|
else:
|
|
956
1016
|
self._call(new_widget._wid, method_name, value)
|
|
957
1017
|
new_widget._state[key] = value
|
|
1018
|
+
# Propagate user-set / auto-sync membership to the new
|
|
1019
|
+
# widget so subsequent reconstructions replay consistently.
|
|
1020
|
+
if sync_action is not None:
|
|
1021
|
+
if key in user_set:
|
|
1022
|
+
new_widget._user_set_state.add(key)
|
|
1023
|
+
if sync_action in auto:
|
|
1024
|
+
new_widget._auto_sync_actions.add(sync_action)
|
|
958
1025
|
|
|
959
1026
|
def _ensure_reconstructed(self, widget):
|
|
960
1027
|
"""Ensure a widget has been created on the JS side.
|
|
@@ -1137,6 +1204,20 @@ class Session:
|
|
|
1137
1204
|
if key.startswith("_"):
|
|
1138
1205
|
continue
|
|
1139
1206
|
|
|
1207
|
+
# Skip auto-sync state (size, position) that came in
|
|
1208
|
+
# passively via a callback (e.g. layout-determined size).
|
|
1209
|
+
# We capture those so getters like get_size()/
|
|
1210
|
+
# get_position() work, but replaying them would pin the
|
|
1211
|
+
# widget to pixel dimensions and override flex/expanding
|
|
1212
|
+
# layout. Replay only if the user explicitly set the
|
|
1213
|
+
# value, or if the widget opted into the sync action
|
|
1214
|
+
# (e.g. an interactively-resizable widget).
|
|
1215
|
+
sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
|
|
1216
|
+
if (sync_action is not None
|
|
1217
|
+
and key not in widget._user_set_state
|
|
1218
|
+
and sync_action not in widget._auto_sync_actions):
|
|
1219
|
+
continue
|
|
1220
|
+
|
|
1140
1221
|
# Binary-payload state (e.g. set_binary_image) replays via
|
|
1141
1222
|
# _send_binary so the bytes go in a raw frame, not embedded
|
|
1142
1223
|
# as base64 in JSON.
|
|
@@ -1183,10 +1264,13 @@ class Session:
|
|
|
1183
1264
|
# _listen calls treat them as first-time registrations and
|
|
1184
1265
|
# actually send the "listen" message to the browser.
|
|
1185
1266
|
wid = widget._wid
|
|
1267
|
+
passive = getattr(widget, "_passive_sync_actions", set())
|
|
1186
1268
|
for action in list(saved_cbs.keys()):
|
|
1187
1269
|
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1188
1270
|
for action in widget._auto_sync_actions:
|
|
1189
1271
|
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1272
|
+
for action in passive:
|
|
1273
|
+
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1190
1274
|
|
|
1191
1275
|
for action, entries in saved_cbs.items():
|
|
1192
1276
|
for handler, extra_args, extra_kwargs, style in entries:
|
|
@@ -1201,6 +1285,12 @@ class Session:
|
|
|
1201
1285
|
for action in widget._auto_sync_actions:
|
|
1202
1286
|
if action not in widget._registered_callbacks:
|
|
1203
1287
|
self._listen(widget._wid, action, lambda wid, *a: None)
|
|
1288
|
+
# Passive listeners (e.g. 'resize' for getter support on
|
|
1289
|
+
# widgets that didn't opt into auto-sync).
|
|
1290
|
+
for action in passive:
|
|
1291
|
+
if (action not in widget._registered_callbacks
|
|
1292
|
+
and action not in widget._auto_sync_actions):
|
|
1293
|
+
self._listen(widget._wid, action, lambda wid, *a: None)
|
|
1204
1294
|
|
|
1205
1295
|
def _child_method_for(self, parent):
|
|
1206
1296
|
"""Determine which child method a container uses."""
|
|
@@ -1512,7 +1602,8 @@ class Application:
|
|
|
1512
1602
|
async def _ws_handler(self, ws):
|
|
1513
1603
|
# Init handshake: send init, receive ack which may contain
|
|
1514
1604
|
# session_id + token for reconnection.
|
|
1515
|
-
await ws.send(json.dumps({"type": "init", "id": 0}
|
|
1605
|
+
await ws.send(json.dumps({"type": "init", "id": 0},
|
|
1606
|
+
cls=JsonEncoder))
|
|
1516
1607
|
ack_data = await ws.recv()
|
|
1517
1608
|
ack = json.loads(ack_data)
|
|
1518
1609
|
reconnect_sid = ack.get("session_id")
|
|
@@ -1582,7 +1673,7 @@ class Application:
|
|
|
1582
1673
|
"type": "session-info",
|
|
1583
1674
|
"session_id": session.id,
|
|
1584
1675
|
"token": session.token,
|
|
1585
|
-
}))
|
|
1676
|
+
}, cls=JsonEncoder))
|
|
1586
1677
|
|
|
1587
1678
|
if is_reconnect:
|
|
1588
1679
|
# Dispatch reconstruction to the session thread — calling
|
pgwidgets/sync/widget.py
CHANGED
|
@@ -75,6 +75,16 @@ class Widget:
|
|
|
75
75
|
self._constructor_options = {}
|
|
76
76
|
self._registered_callbacks = {}
|
|
77
77
|
self._auto_sync_actions = set()
|
|
78
|
+
# Actions we listen to passively for getter support
|
|
79
|
+
# (e.g. 'resize' on every visual widget so get_size() returns
|
|
80
|
+
# a current value), but that aren't in _auto_sync_actions and
|
|
81
|
+
# therefore don't push to peers or replay on reconstruction.
|
|
82
|
+
self._passive_sync_actions = set()
|
|
83
|
+
# State keys the user explicitly set via a setter call.
|
|
84
|
+
# Used during reconstruction to decide whether a state key
|
|
85
|
+
# should be replayed: passively-captured callback state
|
|
86
|
+
# (e.g. layout-determined size) is NOT in this set.
|
|
87
|
+
self._user_set_state = set()
|
|
78
88
|
self._replay_calls = []
|
|
79
89
|
self._add_seq = 0 # insertion order across _children + _replay_calls
|
|
80
90
|
|
|
@@ -160,6 +170,8 @@ class Widget:
|
|
|
160
170
|
obj._constructor_options = {}
|
|
161
171
|
obj._registered_callbacks = {}
|
|
162
172
|
obj._auto_sync_actions = set()
|
|
173
|
+
obj._passive_sync_actions = set()
|
|
174
|
+
obj._user_set_state = set()
|
|
163
175
|
obj._replay_calls = []
|
|
164
176
|
obj._add_seq = 0
|
|
165
177
|
obj._stale = False
|
|
@@ -177,15 +189,35 @@ class Widget:
|
|
|
177
189
|
opt_names_set = set(defn.get("options", []))
|
|
178
190
|
all_callbacks = defn.get("callbacks", [])
|
|
179
191
|
|
|
180
|
-
# State-sync callbacks (move -> position, resize -> size)
|
|
192
|
+
# State-sync callbacks (move -> position, resize -> size).
|
|
193
|
+
# For visual widgets we always *listen* so getters like
|
|
194
|
+
# get_size() / get_position() can return current values.
|
|
195
|
+
# But we only add the action to _auto_sync_actions — which
|
|
196
|
+
# controls push-to-peers and replay-on-reconstruction — when
|
|
197
|
+
# the widget actually opted in (e.g. via the 'resizable' option
|
|
198
|
+
# or by declaring the callback in its defn). This keeps
|
|
199
|
+
# layout-determined sizes from being replayed as literal
|
|
200
|
+
# resize() calls that would pin flex/expanding widgets.
|
|
201
|
+
is_visual = defn.get("base") != "callback"
|
|
181
202
|
for action in STATE_SYNC_CALLBACKS:
|
|
182
203
|
req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
204
|
+
opted_in = False
|
|
205
|
+
if req_opt is not None:
|
|
206
|
+
opted_in = req_opt in opt_names_set
|
|
207
|
+
else:
|
|
208
|
+
opted_in = action in all_callbacks
|
|
209
|
+
# Visual widgets get the listen unconditionally for resize
|
|
210
|
+
# (it's a universal base-class callback) and for any move
|
|
211
|
+
# they declare. Non-visual Callback-base objects get
|
|
212
|
+
# nothing here.
|
|
213
|
+
if not is_visual:
|
|
186
214
|
continue
|
|
187
|
-
|
|
188
|
-
|
|
215
|
+
if action == "resize" or opted_in:
|
|
216
|
+
session._listen(wid, action, lambda wid, *a: None)
|
|
217
|
+
if opted_in:
|
|
218
|
+
self._auto_sync_actions.add(action)
|
|
219
|
+
elif action == "resize":
|
|
220
|
+
self._passive_sync_actions.add(action)
|
|
189
221
|
|
|
190
222
|
# Per-widget-class state sync (e.g. Slider "activated" -> value)
|
|
191
223
|
cls_sync = WIDGET_CALLBACK_SYNC.get(js_class, {})
|
|
@@ -432,6 +464,9 @@ def _make_setter(method_name, param_names, state_key):
|
|
|
432
464
|
self._state[state_key] = args[0]
|
|
433
465
|
else:
|
|
434
466
|
self._state[state_key] = args
|
|
467
|
+
# Mark as user-set so reconstruction knows to replay this key
|
|
468
|
+
# (callback-captured values for the same key don't get marked).
|
|
469
|
+
self._user_set_state.add(state_key)
|
|
435
470
|
return self._call(method_name, *args)
|
|
436
471
|
method.__name__ = method_name
|
|
437
472
|
method.__qualname__ = f"Widget.{method_name}"
|
|
@@ -444,6 +479,7 @@ def _make_fixed_setter(method_name, state_key, fixed_value):
|
|
|
444
479
|
"""Create a no-arg method that sets a fixed state value (show/hide)."""
|
|
445
480
|
def method(self):
|
|
446
481
|
self._state[state_key] = fixed_value
|
|
482
|
+
self._user_set_state.add(state_key)
|
|
447
483
|
return self._call(method_name)
|
|
448
484
|
method.__name__ = method_name
|
|
449
485
|
method.__qualname__ = f"Widget.{method_name}"
|
|
@@ -842,14 +878,19 @@ def __init__({_init_params(pos_names, opt_names)}):
|
|
|
842
878
|
|
|
843
879
|
|
|
844
880
|
def _init_params(pos_names, opt_names):
|
|
845
|
-
"""Build the parameter string for the generated __init__.
|
|
881
|
+
"""Build the parameter string for the generated __init__.
|
|
882
|
+
|
|
883
|
+
Options are exposed as positional-or-keyword (no ``*`` separator)
|
|
884
|
+
so that single-option widgets like Frame accept ``Frame("Title")``
|
|
885
|
+
in addition to ``Frame(title="Title")``, matching the pyodide
|
|
886
|
+
bridge's behavior. Each option still defaults to ``None`` so
|
|
887
|
+
omitted ones don't override state from prior calls.
|
|
888
|
+
"""
|
|
846
889
|
params = ["self", "session"]
|
|
847
890
|
for name in pos_names:
|
|
848
891
|
params.append(f"{name}=None")
|
|
849
|
-
|
|
850
|
-
params.append("
|
|
851
|
-
for name in opt_names:
|
|
852
|
-
params.append(f"{name}=None")
|
|
892
|
+
for name in opt_names:
|
|
893
|
+
params.append(f"{name}=None")
|
|
853
894
|
params.append("**kwargs")
|
|
854
895
|
return ", ".join(params)
|
|
855
896
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgwidgets-python
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Python bindings for the pgwidgets JavaScript widget library
|
|
5
5
|
Author: PGWidgets Developers
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -17,7 +17,7 @@ Classifier: Topic :: Software Development :: User Interfaces
|
|
|
17
17
|
Requires-Python: >=3.12
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE.md
|
|
20
|
-
Requires-Dist: pgwidgets-js
|
|
20
|
+
Requires-Dist: pgwidgets-js>=0.2.0
|
|
21
21
|
Requires-Dist: websockets>=12
|
|
22
22
|
Provides-Extra: dev
|
|
23
23
|
Requires-Dist: sphinx; extra == "dev"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
pgwidgets/__init__.py,sha256=fKKHAt9GxLbficihVyImWsqrnQvdQZQAmeGaRUF8cBE,857
|
|
2
|
+
pgwidgets/_json.py,sha256=o21qywJ6yAldbqxTq3nLwgK9O67r3a8JxhvI_WAJnMY,2184
|
|
3
|
+
pgwidgets/callbacks.py,sha256=gA2FnX0N5BmbmOMyEV35yHySgAfVjfAZcZhZbo7j4-c,3314
|
|
4
|
+
pgwidgets/defs.py,sha256=Q8qhvTeansFU1Av6j5ncdwF2xSNHrpXuKErdHWQ3HiA,436
|
|
5
|
+
pgwidgets/method_types.py,sha256=S5V6EvJ9DZnSQaiaEhPkAuCtqrlHde5wj16TZd0vHvQ,16327
|
|
6
|
+
pgwidgets/async_/Widgets.py,sha256=vld2xkvusBBEVbhbezaJsjBRRj3L7c17Up0pOVs8PGo,723
|
|
7
|
+
pgwidgets/async_/__init__.py,sha256=rXB-v9XRrYt1imYuYikhkzIyiRqaYhlTpQei2LHaQ18,564
|
|
8
|
+
pgwidgets/async_/application.py,sha256=mab8nRl7dNwnvNx64F0RapIJvJXy4HW_eJ1Bbuhw83w,69612
|
|
9
|
+
pgwidgets/async_/widget.py,sha256=H27v7AzHNg5obzr2e8UyOyj2lbGfWhEhZUrIDqrZNbg,37612
|
|
10
|
+
pgwidgets/extras/__init__.py,sha256=AXUmFtnn4RSlp6pJX6z55j-m_29VmfzpYIjQvAy7pO0,325
|
|
11
|
+
pgwidgets/extras/file_browser.py,sha256=PWzJltjQZbmsD1ykbbC2xJ6ErEkJj7KafhmVkd9q9Ac,16963
|
|
12
|
+
pgwidgets/sync/Widgets.py,sha256=7SaocMVMzFHhi51pjuEAr47Wez90b_a61kDbiv0jOfM,706
|
|
13
|
+
pgwidgets/sync/__init__.py,sha256=SF5RTAvtu8BbYBWzpiCPipy6DJzNXf6nqk9xdvwxUhQ,542
|
|
14
|
+
pgwidgets/sync/application.py,sha256=a1Xu5VRif9WKHxMWvPN3oyTqjPsrdw54Smp4Vz16ObY,80309
|
|
15
|
+
pgwidgets/sync/widget.py,sha256=tQDkCOkcevDrGnG4LhBf69law82-UB1fF-r08GUZPSE,37913
|
|
16
|
+
pgwidgets_python-0.2.3.dist-info/licenses/LICENSE.md,sha256=LoM3fMTiMnQuHRCJghdjOtjnCrL8soBpu2PFk24Xvyg,1528
|
|
17
|
+
pgwidgets_python-0.2.3.dist-info/METADATA,sha256=e5O9EkVS7VKRrgwxNwtKgLOcs6emH7hpkFqyfJOKAUE,4568
|
|
18
|
+
pgwidgets_python-0.2.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
19
|
+
pgwidgets_python-0.2.3.dist-info/top_level.txt,sha256=wwL6fBq0gU-JwzlM6TdduY1qYpu39ysqnnbQT-1bqAs,10
|
|
20
|
+
pgwidgets_python-0.2.3.dist-info/RECORD,,
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
pgwidgets/__init__.py,sha256=365yX_Iq5Uh8ZYPVUDmdTWJa0E8PfLQG82LffdZJi88,476
|
|
2
|
-
pgwidgets/callbacks.py,sha256=gA2FnX0N5BmbmOMyEV35yHySgAfVjfAZcZhZbo7j4-c,3314
|
|
3
|
-
pgwidgets/defs.py,sha256=Q8qhvTeansFU1Av6j5ncdwF2xSNHrpXuKErdHWQ3HiA,436
|
|
4
|
-
pgwidgets/method_types.py,sha256=AoNXYY7iU-ttAWPqL104Dpl4AtUvyF6I2OmzpRw2HWQ,15637
|
|
5
|
-
pgwidgets/async_/Widgets.py,sha256=vld2xkvusBBEVbhbezaJsjBRRj3L7c17Up0pOVs8PGo,723
|
|
6
|
-
pgwidgets/async_/__init__.py,sha256=rXB-v9XRrYt1imYuYikhkzIyiRqaYhlTpQei2LHaQ18,564
|
|
7
|
-
pgwidgets/async_/application.py,sha256=hKsa6PBh49n7AxwwXqdWjBfL_cL6eixX-MeU_lkD9CY,64651
|
|
8
|
-
pgwidgets/async_/widget.py,sha256=bL-wL7T9j6Ic5xsAchw5BdJQtgHX1GuCOF732cJEpbQ,35589
|
|
9
|
-
pgwidgets/extras/__init__.py,sha256=AXUmFtnn4RSlp6pJX6z55j-m_29VmfzpYIjQvAy7pO0,325
|
|
10
|
-
pgwidgets/extras/file_browser.py,sha256=PWzJltjQZbmsD1ykbbC2xJ6ErEkJj7KafhmVkd9q9Ac,16963
|
|
11
|
-
pgwidgets/sync/Widgets.py,sha256=7SaocMVMzFHhi51pjuEAr47Wez90b_a61kDbiv0jOfM,706
|
|
12
|
-
pgwidgets/sync/__init__.py,sha256=SF5RTAvtu8BbYBWzpiCPipy6DJzNXf6nqk9xdvwxUhQ,542
|
|
13
|
-
pgwidgets/sync/application.py,sha256=WNT96gG6BhRJug7Dt-AerAjV_AZFq-BE--1XFj6ObgI,75392
|
|
14
|
-
pgwidgets/sync/widget.py,sha256=UkMgM7dKA1g-CtjukL27vjCZD7FAHyMOpJVD9mpN5-w,35655
|
|
15
|
-
pgwidgets_python-0.2.0.dist-info/licenses/LICENSE.md,sha256=LoM3fMTiMnQuHRCJghdjOtjnCrL8soBpu2PFk24Xvyg,1528
|
|
16
|
-
pgwidgets_python-0.2.0.dist-info/METADATA,sha256=NDMZH7W7Va2LzqYHF_kDK3ryykos_aM5rO5IczPT-uE,4561
|
|
17
|
-
pgwidgets_python-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
18
|
-
pgwidgets_python-0.2.0.dist-info/top_level.txt,sha256=wwL6fBq0gU-JwzlM6TdduY1qYpu39ysqnnbQT-1bqAs,10
|
|
19
|
-
pgwidgets_python-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|