pgwidgets-python 0.2.0__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/PKG-INFO +2 -2
  2. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/WhatsNew.rst +75 -8
  3. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/widgets.rst +55 -10
  4. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/__init__.py +13 -0
  5. pgwidgets_python-0.2.3/pgwidgets/_json.py +63 -0
  6. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/async_/application.py +110 -23
  7. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/async_/widget.py +48 -11
  8. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/method_types.py +15 -0
  9. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/sync/application.py +115 -24
  10. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/sync/widget.py +52 -11
  11. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/PKG-INFO +2 -2
  12. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/SOURCES.txt +1 -0
  13. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/requires.txt +1 -1
  14. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pyproject.toml +5 -1
  15. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/.flake8 +0 -0
  16. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/.github/workflows/tests.yml +0 -0
  17. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/.gitignore +0 -0
  18. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/.readthedocs.yaml +0 -0
  19. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/LICENSE.md +0 -0
  20. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/README.md +0 -0
  21. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/Makefile +0 -0
  22. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/api/async.rst +0 -0
  23. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/api/index.rst +0 -0
  24. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/api/sync.rst +0 -0
  25. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/architecture.rst +0 -0
  26. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/async.rst +0 -0
  27. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/callbacks.rst +0 -0
  28. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/conf.py +0 -0
  29. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/extras.rst +0 -0
  30. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/getting-started.rst +0 -0
  31. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/index.rst +0 -0
  32. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/subclassing.rst +0 -0
  33. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/sync.rst +0 -0
  34. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/utilities.rst +0 -0
  35. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/docs/web-servers.rst +0 -0
  36. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/examples/README.md +0 -0
  37. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/examples/all_widgets.py +0 -0
  38. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/examples/all_widgets_async.py +0 -0
  39. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/examples/demo_async.py +0 -0
  40. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/examples/demo_sync.py +0 -0
  41. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/examples/demo_treeview.py +0 -0
  42. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/async_/Widgets.py +0 -0
  43. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/async_/__init__.py +0 -0
  44. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/callbacks.py +0 -0
  45. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/defs.py +0 -0
  46. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/extras/__init__.py +0 -0
  47. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/extras/file_browser.py +0 -0
  48. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/sync/Widgets.py +0 -0
  49. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets/sync/__init__.py +0 -0
  50. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/dependency_links.txt +0 -0
  51. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/top_level.txt +0 -0
  52. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/setup.cfg +0 -0
  53. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/test_pg2.py +0 -0
  54. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/tests/__init__.py +0 -0
  55. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/tests/test_defs.py +0 -0
  56. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/tests/test_extras.py +0 -0
  57. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/tests/test_protocol.py +0 -0
  58. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/tests/test_reconstruct.py +0 -0
  59. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/tests/test_session.py +0 -0
  60. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/tests/test_stateful.py +0 -0
  61. {pgwidgets_python-0.2.0 → pgwidgets_python-0.2.3}/tests/test_widget_classes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgwidgets-python
3
- Version: 0.2.0
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"
@@ -1,13 +1,76 @@
1
1
  What's New
2
2
  ==========
3
3
 
4
- Significant changes since the last tagged release (``v0.1.3``).
4
+ Recent changes since ``v0.2.1``
5
+ ---------------------------------
6
+
7
+ Reliable ``get_size()`` / ``get_position()`` on every visual widget
8
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
9
+
10
+ The auto-sync layer that backs widget getters has been reworked so
11
+ ``widget.get_size()`` (and ``get_position()`` where supported)
12
+ returns the current layout-determined value for any visual widget,
13
+ not just ones that opted into the ``resizable`` / ``moveable``
14
+ options. The binding now installs a *passive* resize listener on
15
+ every visual widget — the value is captured into local state for
16
+ the getter, but it is **not** pushed to other connected browsers or
17
+ replayed on reconstruction. Explicit ``widget.resize(w, h)`` calls
18
+ are still replayed, as is interactive resize on widgets that opted
19
+ into active sync.
20
+
21
+ The user-visible upshot: code that calls ``image.get_size()`` on an
22
+ ``Image`` placed in a flex container now returns the real pixel
23
+ size the browser laid the widget out at, instead of ``None`` or a
24
+ stale value. And widgets like ``Image`` with
25
+ ``set_expanding(True, True)`` no longer get pinned to pixel
26
+ dimensions on reconnect (a regression in earlier auto-sync work).
27
+
28
+ The same guard now applies in both reconstruction paths
29
+ (top-level state replay AND ``_transfer_proxy`` for factory-created
30
+ widgets like ``ToolBarAction``), which fixes a "toolbar items
31
+ drift farther apart after each reconnect" bug.
32
+
33
+ ``map`` callback survives reconstruction
34
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
35
+
36
+ The Python side now forwards ``map`` callbacks during a reconstruct
37
+ window instead of suppressing them along with the rest of the
38
+ state-replay echoes. ``map`` is a one-shot lifecycle event tied to
39
+ the widget first becoming visually present; missing it on the
40
+ Python side meant a user's map handler stayed un-fired until the
41
+ window happened to be resized. Pairs with the JS-side reliability
42
+ work for the same callback.
43
+
44
+ Sensible defaults for more getters
45
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
46
+
47
+ Getters now return useful zero-value defaults before any state has
48
+ been set or reported from the browser, instead of ``None``:
49
+
50
+ - ``get_scroll_position()`` → ``(0.0, 0.0)``
51
+ - ``get_scroll_percent()`` → ``(0.0, 0.0)`` (or ``0.0`` on
52
+ ``ScrollBar``, which uses a single-axis API)
53
+ - ``get_thumb_percent()`` → ``(0.0, 0.0)`` (or ``0.0`` on
54
+ ``ScrollBar``)
55
+ - ``get_expanding()`` → ``(False, False)``
56
+ - ``get_enabled()`` → ``True``
57
+ - ``get_state()`` → ``False``
58
+ - ``get_volume()`` → ``1.0`` (HTMLMediaElement convention: 0.0
59
+ muted, 1.0 full).
60
+
61
+ Configured in ``STATE_KEY_DEFAULTS`` / ``STATE_DEFAULTS`` in
62
+ ``method_types.py``.
63
+
64
+ ----
65
+
66
+ Earlier — since ``v0.1.3``
67
+ --------------------------
5
68
 
6
69
  Major changes
7
- -------------
70
+ ~~~~~~~~~~~~~
8
71
 
9
72
  TreeView / TableView: dict-tree model
10
- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
73
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
11
74
 
12
75
  Mirroring the JS-side rewrite, ``TreeView`` and ``TableView`` now
13
76
  work with hierarchies of dicts keyed by stable string identifiers.
@@ -63,7 +126,7 @@ Highlights:
63
126
  See :doc:`widgets` for the full reference.
64
127
 
65
128
  Window controls (TopLevel) and shade (MDISubWindow)
66
- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
129
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
67
130
 
68
131
  ``TopLevel`` gains the same window controls that ``MDISubWindow``
69
132
  has, plus a "shade" (roll up to title bar) state on both.
@@ -96,7 +159,7 @@ Maximize, Close). The menu supports both click-release and
96
159
  press-drag-release, like a menubar.
97
160
 
98
161
  Image: binary-frame protocol
99
- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
162
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
100
163
 
101
164
  New method ``Image.set_binary_image(data, format='jpeg')`` sends raw
102
165
  bytes (``bytes`` / ``bytearray`` / ``memoryview``) via a WebSocket
@@ -106,7 +169,7 @@ Useful for animation/streaming. ``format`` is one of ``"jpeg"``,
106
169
  widget state and replayed on reconnect.
107
170
 
108
171
  Callbacks base class
109
- ~~~~~~~~~~~~~~~~~~~~
172
+ ^^^^^^^^^^^^^^^^^^^^
110
173
 
111
174
  New module ``pgwidgets.callbacks`` exposes ``Callbacks``, a small
112
175
  base class that provides the same callback API (``add_callback``,
@@ -121,7 +184,7 @@ supports both ``add_callback("activated", ...)`` and
121
184
  See :ref:`callbacks-base`.
122
185
 
123
186
  Robustness improvements
124
- -----------------------
187
+ ~~~~~~~~~~~~~~~~~~~~~~~
125
188
 
126
189
  - ``Session._send`` and ``_send_binary`` no longer hang when the
127
190
  asyncio loop refuses a coroutine (loop closed mid-call,
@@ -139,7 +202,7 @@ Robustness improvements
139
202
  internal ``ScrollBar`` widgets in its constructor.
140
203
 
141
204
  Other notable changes
142
- ---------------------
205
+ ~~~~~~~~~~~~~~~~~~~~~
143
206
 
144
207
  - ``ColorDialog`` now exposes ``popup``, ``set_position``,
145
208
  ``set_modal``, and the ``move`` / ``close`` callbacks (inherited
@@ -160,3 +223,7 @@ Other notable changes
160
223
  though the JS side preserves columns on clear).
161
224
  - ``FileBrowser`` migrated to the new dict-tree ``TableView`` API
162
225
  internally and now subclasses ``Callbacks``.
226
+ - ``FixedLayout`` container added (auto-generated from the JS
227
+ definition). Use ``W.FixedLayout()`` and
228
+ ``layout.add_widget(child, x, y)`` to place children at fixed
229
+ pixel offsets; ``remove(child)`` works as on any other container.
@@ -140,6 +140,17 @@ Grid layout.
140
140
  ``append_column(widgets)``, ``delete_column(index)``
141
141
  - **Callbacks:** ``child-added``, ``child-removed``
142
142
 
143
+ FixedLayout
144
+ ~~~~~~~~~~~
145
+
146
+ Absolute-positioning container. Each child is placed at a fixed
147
+ ``(x, y)`` pixel offset within the container and renders at its
148
+ natural size unless ``resize()`` has been called on it.
149
+
150
+ - **Options:** *(none)*
151
+ - **Methods:** ``add_widget(child, x, y)``
152
+ - **Callbacks:** ``child-added``, ``child-removed``
153
+
143
154
  Splitter
144
155
  ~~~~~~~~
145
156
 
@@ -415,24 +426,58 @@ TextSource
415
426
 
416
427
  Source code editor with line numbers, syntax tags, and gutter icons.
417
428
 
429
+ Positions in the buffer are expressed as ``TextBufferRef`` instances —
430
+ live references that track edits. ``create_ref(offset, gravity)`` is
431
+ the only place a caller deals in raw integer offsets; all other
432
+ position-taking and position-returning methods on the public API use
433
+ refs.
434
+
435
+ .. note::
436
+
437
+ Refs are JS-side live objects. When using ``TextSource`` over the
438
+ remote interface (pgwidgets-python proper), refs cannot currently
439
+ round-trip across the wire — a ref-handle protocol is planned for
440
+ a future release. In the meantime, the ref-based API is fully
441
+ usable from JS-direct and pyodide contexts.
442
+
418
443
  - **Args:** ``text``
419
444
  - **Options:** ``wrap``, ``line_numbers``, ``icon_gutter``, ``editable``,
420
445
  ``font_family``, ``font_size``
421
446
  - **Methods:** ``set_text(text)``, ``get_text()``, ``get_length()``,
422
- ``insert_text(offset, text, tags)``, ``delete_range(start, end)``,
447
+ ``get_text_range(start_ref, end_ref)``,
448
+ ``insert_text(ref, text, tags)``, ``delete_range(start_ref, end_ref)``,
423
449
  ``clear()``, ``set_editable(tf)``, ``set_wrap(mode)``,
424
450
  ``set_line_numbers(tf)``, ``set_icon_gutter(tf)``,
425
- ``set_icon(line, icon_url)``, ``get_cursor()``, ``set_cursor(offset)``,
426
- ``get_selection()``, ``set_selection(start, end)``,
451
+ ``set_icon(ref, icon_url)``, ``get_cursor()``, ``set_cursor(ref)``,
452
+ ``get_selection_range()``, ``set_selection_range(start_ref, end_ref)``,
427
453
  ``create_tag(name, attrs)``, ``remove_tag_def(name)``,
428
- ``apply_tag(name, start, end)``, ``remove_tag(name, start, end)``,
429
- ``get_tags_at(offset)``, ``create_ref(offset, gravity)``,
430
- ``remove_ref(ref)``, ``undo()``, ``redo()``, ``can_undo()``,
431
- ``can_redo()``, ``find(query, opts)``, ``find_all(query, opts)``,
432
- ``replace(query, replacement, opts)``, ``scroll_to(ref_or_offset)``,
454
+ ``has_tag(name)``,
455
+ ``apply_tag(name, start_ref, end_ref)``,
456
+ ``remove_tag(name, start_ref, end_ref)``,
457
+ ``get_tags_at(ref)``, ``get_tags_range(start_ref, end_ref)``,
458
+ ``create_ref(offset, gravity)``, ``remove_ref(ref)``,
459
+ ``create_named_ref(name, offset, gravity)``,
460
+ ``get_named_ref(name)``, ``remove_named_ref(name)``,
461
+ ``get_ref_start()``, ``get_ref_end()``, ``get_ref_bounds()``,
462
+ ``get_ref_line_start(lineno)``, ``get_ref_line_end(lineno)``,
463
+ ``undo()``, ``redo()``, ``can_undo()``, ``can_redo()``,
464
+ ``find(query, opts)``, ``find_all(query, opts)``,
465
+ ``replace(query, replacement, opts)``, ``scroll_to_ref(ref)``,
433
466
  ``scroll_to_cursor()``
434
- - **Callbacks:** ``changed``, ``cursor_moved``, ``line_clicked``,
435
- ``icon_clicked``
467
+ - **Callbacks:** ``changed``, ``cursor_moved`` (fires with a fresh
468
+ ref), ``line_clicked``, ``icon_clicked`` (fires with
469
+ ``(line, ref)``)
470
+
471
+ A ``TextBufferRef`` itself supports the following methods:
472
+
473
+ - **Inspection:** ``get_offset()``, ``get_gravity()``, ``is_valid()``,
474
+ ``get_line()``, ``get_line_column()``
475
+ - **Absolute position:** ``set_offset(offset)``, ``set_line(lineno)``,
476
+ ``to_ref(other)``, ``copy()``
477
+ - **Relative movement:** ``to_line_start()``, ``to_line_end()``,
478
+ ``to_next_line()``, ``to_prev_line()``, ``to_next_char()``,
479
+ ``to_prev_char()`` (movement methods clamp at buffer boundaries
480
+ and are no-ops past them; mutating an invalidated ref raises)
436
481
 
437
482
  Selectors
438
483
  ---------
@@ -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"
@@ -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)
@@ -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
- if self._reconstructing:
303
- return # suppress callbacks during reconstruction
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
- self._push(wid, "resize",
318
- d["width"], d["height"])
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
- setter = (self._STATE_KEY_TO_SETTER.get(state_key)
324
- or f"set_{state_key}")
325
- self._push(wid, setter, *args)
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
- key = f"{wid}:{action}"
744
- if key not in self._callbacks:
745
- self._callbacks[key] = [lambda wid, *a: None]
746
- self._fire_and_forget_listen(wid, action)
747
- widget._auto_sync_actions.add(action)
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():