pgwidgets-python 0.2.1__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.1 → pgwidgets_python-0.2.3}/PKG-INFO +1 -1
  2. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/WhatsNew.rst +75 -8
  3. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/widgets.rst +55 -10
  4. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/async_/application.py +101 -16
  5. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/async_/widget.py +38 -6
  6. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/method_types.py +15 -0
  7. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/sync/application.py +98 -18
  8. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/sync/widget.py +42 -6
  9. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/PKG-INFO +1 -1
  10. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/.flake8 +0 -0
  11. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/.github/workflows/tests.yml +0 -0
  12. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/.gitignore +0 -0
  13. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/.readthedocs.yaml +0 -0
  14. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/LICENSE.md +0 -0
  15. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/README.md +0 -0
  16. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/Makefile +0 -0
  17. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/api/async.rst +0 -0
  18. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/api/index.rst +0 -0
  19. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/api/sync.rst +0 -0
  20. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/architecture.rst +0 -0
  21. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/async.rst +0 -0
  22. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/callbacks.rst +0 -0
  23. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/conf.py +0 -0
  24. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/extras.rst +0 -0
  25. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/getting-started.rst +0 -0
  26. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/index.rst +0 -0
  27. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/subclassing.rst +0 -0
  28. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/sync.rst +0 -0
  29. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/utilities.rst +0 -0
  30. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/docs/web-servers.rst +0 -0
  31. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/examples/README.md +0 -0
  32. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/examples/all_widgets.py +0 -0
  33. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/examples/all_widgets_async.py +0 -0
  34. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/examples/demo_async.py +0 -0
  35. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/examples/demo_sync.py +0 -0
  36. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/examples/demo_treeview.py +0 -0
  37. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/__init__.py +0 -0
  38. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/_json.py +0 -0
  39. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/async_/Widgets.py +0 -0
  40. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/async_/__init__.py +0 -0
  41. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/callbacks.py +0 -0
  42. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/defs.py +0 -0
  43. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/extras/__init__.py +0 -0
  44. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/extras/file_browser.py +0 -0
  45. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/sync/Widgets.py +0 -0
  46. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets/sync/__init__.py +0 -0
  47. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/SOURCES.txt +0 -0
  48. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/dependency_links.txt +0 -0
  49. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/requires.txt +0 -0
  50. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pgwidgets_python.egg-info/top_level.txt +0 -0
  51. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/pyproject.toml +0 -0
  52. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/setup.cfg +0 -0
  53. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/test_pg2.py +0 -0
  54. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/tests/__init__.py +0 -0
  55. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/tests/test_defs.py +0 -0
  56. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/tests/test_extras.py +0 -0
  57. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/tests/test_protocol.py +0 -0
  58. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/tests/test_reconstruct.py +0 -0
  59. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/tests/test_session.py +0 -0
  60. {pgwidgets_python-0.2.1 → pgwidgets_python-0.2.3}/tests/test_stateful.py +0 -0
  61. {pgwidgets_python-0.2.1 → 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.1
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
@@ -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
  ---------
@@ -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():
@@ -300,30 +307,46 @@ class Session:
300
307
 
301
308
  def _dispatch_callback(self, wid, action, *args):
302
309
  """Dispatch a callback through the configured concurrency mode."""
303
- if self._reconstructing:
304
- 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
305
319
 
306
320
  # Auto-sync: some callbacks carry state that should be reflected
307
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.
308
328
  state_key = STATE_SYNC_CALLBACKS.get(action)
309
329
  if state_key is not None:
310
330
  widget = self._widget_map.get(wid)
311
331
  if widget is not None:
332
+ auto = action in widget._auto_sync_actions
312
333
  if len(args) == 1 and isinstance(args[0], dict):
313
334
  d = args[0]
314
335
  if "width" in d and "height" in d:
315
336
  new_val = (d["width"], d["height"])
316
337
  if widget._state.get(state_key) != new_val:
317
338
  widget._state[state_key] = new_val
318
- self._push(wid, "resize",
319
- d["width"], d["height"])
339
+ if auto:
340
+ self._push(wid, "resize",
341
+ d["width"], d["height"])
320
342
  else:
321
343
  new_val = tuple(args)
322
344
  if widget._state.get(state_key) != new_val:
323
345
  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)
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)
327
350
  # If this widget wraps a child (e.g. MDISubWindow),
328
351
  # propagate geometry into the parent's children options
329
352
  # so reconstruction replays with the current pos/size.
@@ -735,17 +758,37 @@ class Session:
735
758
  cls = self._widget_classes.get(cls_name, Widget) if cls_name else Widget
736
759
  widget = cls._from_existing(self, wid, cls_name or "Widget")
737
760
  self._widget_map[wid] = widget
738
- # Auto-listen for state-syncing callbacks (move, resize)
739
- # so position/size changes are tracked for reconstruction.
761
+ # Auto-listen for state-syncing callbacks (move, resize).
740
762
  # Register locally (sync) and send the listen message as
741
763
  # true fire-and-forget (no result awaited) since
742
- # _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"
743
775
  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)
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)
749
792
  return widget
750
793
  if isinstance(val, list):
751
794
  return [self._resolve_return(v) for v in val]
@@ -839,12 +882,23 @@ class Session:
839
882
  await self._listen(new_widget._wid, act,
840
883
  lambda wid, *a: None)
841
884
  new_widget._auto_sync_actions.add(act)
842
- # 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())
843
892
  for key, value in old_widget._state.items():
844
893
  if key.startswith("_"):
845
894
  continue
846
895
  if key in sync_keys:
847
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
848
902
  method_name = (self._STATE_KEY_TO_SETTER.get(key)
849
903
  or f"set_{key}")
850
904
  if isinstance(value, tuple):
@@ -852,6 +906,13 @@ class Session:
852
906
  else:
853
907
  await self._call(new_widget._wid, method_name, value)
854
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)
855
916
 
856
917
  async def _ensure_reconstructed(self, widget):
857
918
  """Ensure a widget has been created on the JS side."""
@@ -1003,6 +1064,20 @@ class Session:
1003
1064
  if key.startswith("_"):
1004
1065
  continue
1005
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
+
1006
1081
  # Binary-payload state (e.g. set_binary_image) replays via
1007
1082
  # _send_binary so the bytes go in a raw frame, not embedded
1008
1083
  # as base64 in JSON.
@@ -1047,10 +1122,13 @@ class Session:
1047
1122
  # _listen calls treat them as first-time registrations and
1048
1123
  # actually send the "listen" message to the browser.
1049
1124
  wid = widget._wid
1125
+ passive = getattr(widget, "_passive_sync_actions", set())
1050
1126
  for action in list(saved_cbs.keys()):
1051
1127
  self._callbacks.pop(f"{wid}:{action}", None)
1052
1128
  for action in widget._auto_sync_actions:
1053
1129
  self._callbacks.pop(f"{wid}:{action}", None)
1130
+ for action in passive:
1131
+ self._callbacks.pop(f"{wid}:{action}", None)
1054
1132
 
1055
1133
  for action, entries in saved_cbs.items():
1056
1134
  for handler, extra_args, extra_kwargs, style in entries:
@@ -1067,6 +1145,13 @@ class Session:
1067
1145
  if action not in widget._registered_callbacks:
1068
1146
  await self._listen(widget._wid, action,
1069
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)
1070
1155
 
1071
1156
  async def reconstruct(self):
1072
1157
  """Replay the entire widget tree to all connected browsers.
@@ -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}"
@@ -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.
@@ -384,18 +384,28 @@ class Session:
384
384
 
385
385
  def _dispatch_callback(self, wid, action, *args):
386
386
  """Dispatch a callback through the configured concurrency mode."""
387
- # widget = self._widget_map.get(wid)
388
- # cls_name = widget._js_class if widget is not None else "<missing>"
389
- # print(f"[PY-CB] receive wid={wid} action={action} "
390
- # f"registered_class={cls_name}", flush=True)
391
- if self._reconstructing:
392
- return # suppress callbacks during reconstruction
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
393
396
  # Auto-sync: some callbacks carry state that should be reflected
394
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.
395
404
  state_key = STATE_SYNC_CALLBACKS.get(action)
396
405
  if state_key is not None:
397
406
  widget = self._widget_map.get(wid)
398
407
  if widget is not None:
408
+ auto = action in widget._auto_sync_actions
399
409
  # Normalize: resize sends {width, height} dict, move
400
410
  # sends (x, y) as separate args. Store as a flat tuple
401
411
  # matching the corresponding setter's signature.
@@ -405,15 +415,17 @@ class Session:
405
415
  new_val = (d["width"], d["height"])
406
416
  if widget._state.get(state_key) != new_val:
407
417
  widget._state[state_key] = new_val
408
- self._push(wid, "resize",
409
- d["width"], d["height"])
418
+ if auto:
419
+ self._push(wid, "resize",
420
+ d["width"], d["height"])
410
421
  else:
411
422
  new_val = tuple(args)
412
423
  if widget._state.get(state_key) != new_val:
413
424
  widget._state[state_key] = new_val
414
- setter = (self._STATE_KEY_TO_SETTER.get(state_key)
415
- or f"set_{state_key}")
416
- self._push(wid, setter, *args)
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)
417
429
  # If this widget wraps a child (e.g. MDISubWindow),
418
430
  # propagate geometry into the parent's children options
419
431
  # so reconstruction replays with the current pos/size.
@@ -804,13 +816,33 @@ class Session:
804
816
  cls = self._widget_classes.get(cls_name, Widget) if cls_name else Widget
805
817
  widget = cls._from_existing(self, wid, cls_name or "Widget")
806
818
  self._widget_map[wid] = widget
807
- # Auto-listen for state-syncing callbacks (move, resize)
808
- # so position/size changes are tracked for reconstruction.
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"
809
830
  for action in STATE_SYNC_CALLBACKS:
810
- key = f"{wid}:{action}"
811
- if key not in self._callbacks:
812
- self._listen(wid, action, lambda wid, *a: None)
813
- widget._auto_sync_actions.add(action)
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)
814
846
  return widget
815
847
  if isinstance(val, list):
816
848
  return [self._resolve_return(v) for v in val]
@@ -907,6 +939,13 @@ class Session:
907
939
  _STATE_KEY_TO_SETTER = {v: k for k, v in SPECIAL_SETTERS.items()}
908
940
  # e.g. {"size": "resize"}
909
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
+
910
949
  # State keys handled by fixed-value methods (show/hide)
911
950
  _FIXED_STATE_KEYS = {}
912
951
  for _mname, (_key, _val) in FIXED_SETTERS.items():
@@ -952,12 +991,23 @@ class Session:
952
991
  self._listen(new_widget._wid, act,
953
992
  lambda wid, *a: None)
954
993
  new_widget._auto_sync_actions.add(act)
955
- # 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())
956
1001
  for key, value in old_widget._state.items():
957
1002
  if key.startswith("_"):
958
1003
  continue
959
1004
  if key in sync_keys:
960
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
961
1011
  method_name = (self._STATE_KEY_TO_SETTER.get(key)
962
1012
  or f"set_{key}")
963
1013
  if isinstance(value, tuple):
@@ -965,6 +1015,13 @@ class Session:
965
1015
  else:
966
1016
  self._call(new_widget._wid, method_name, value)
967
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)
968
1025
 
969
1026
  def _ensure_reconstructed(self, widget):
970
1027
  """Ensure a widget has been created on the JS side.
@@ -1147,6 +1204,20 @@ class Session:
1147
1204
  if key.startswith("_"):
1148
1205
  continue
1149
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
+
1150
1221
  # Binary-payload state (e.g. set_binary_image) replays via
1151
1222
  # _send_binary so the bytes go in a raw frame, not embedded
1152
1223
  # as base64 in JSON.
@@ -1193,10 +1264,13 @@ class Session:
1193
1264
  # _listen calls treat them as first-time registrations and
1194
1265
  # actually send the "listen" message to the browser.
1195
1266
  wid = widget._wid
1267
+ passive = getattr(widget, "_passive_sync_actions", set())
1196
1268
  for action in list(saved_cbs.keys()):
1197
1269
  self._callbacks.pop(f"{wid}:{action}", None)
1198
1270
  for action in widget._auto_sync_actions:
1199
1271
  self._callbacks.pop(f"{wid}:{action}", None)
1272
+ for action in passive:
1273
+ self._callbacks.pop(f"{wid}:{action}", None)
1200
1274
 
1201
1275
  for action, entries in saved_cbs.items():
1202
1276
  for handler, extra_args, extra_kwargs, style in entries:
@@ -1211,6 +1285,12 @@ class Session:
1211
1285
  for action in widget._auto_sync_actions:
1212
1286
  if action not in widget._registered_callbacks:
1213
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)
1214
1294
 
1215
1295
  def _child_method_for(self, parent):
1216
1296
  """Determine which child method a container uses."""
@@ -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
- if req_opt and req_opt not in opt_names_set:
184
- continue
185
- if req_opt is None and action not in all_callbacks:
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
- session._listen(wid, action, lambda wid, *a: None)
188
- self._auto_sync_actions.add(action)
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}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgwidgets-python
3
- Version: 0.2.1
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