pydux-store 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pydux/__init__.py +69 -0
- pydux/adapters/base.py +113 -0
- pydux/adapters/kivy.py +24 -0
- pydux/adapters/psx.py +97 -0
- pydux/adapters/qt.py +29 -0
- pydux/adapters/qyro.py +227 -0
- pydux/adapters/tkinter.py +20 -0
- pydux/core/selector.py +134 -0
- pydux/core/store.py +180 -0
- pydux/core/types.py +64 -0
- pydux/devtools/inspector.py +422 -0
- pydux/devtools/state_snapshot.py +54 -0
- pydux/devtools/static/index.html +50 -0
- pydux/devtools/static/inspector.css +267 -0
- pydux/devtools/static/inspector.js +256 -0
- pydux/devtools/tracker.py +149 -0
- pydux/toolkit/async_thunk.py +108 -0
- pydux/toolkit/configure_store.py +135 -0
- pydux/toolkit/hot_reload.py +74 -0
- pydux/toolkit/slice.py +138 -0
- pydux_store-0.1.0.dist-info/METADATA +138 -0
- pydux_store-0.1.0.dist-info/RECORD +24 -0
- pydux_store-0.1.0.dist-info/WHEEL +4 -0
- pydux_store-0.1.0.dist-info/licenses/LICENSE +21 -0
pydux/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyDux 3.0 — UI-Agnostic State Management for Python Desktop Applications.
|
|
3
|
+
Supports modern Redux Toolkit style (create_slice, configure_store, create_async_thunk)
|
|
4
|
+
with memoized selectors, zero redundant re-renders, and full Qyro/PySide/Tkinter/Kivy support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Redux Toolkit style modern API
|
|
8
|
+
from pydux.toolkit.slice import create_slice, Slice
|
|
9
|
+
from pydux.toolkit.configure_store import configure_store, combine_reducers
|
|
10
|
+
from pydux.toolkit.hot_reload import reconfigure_store
|
|
11
|
+
from pydux.toolkit.async_thunk import create_async_thunk, create_thunk_middleware
|
|
12
|
+
|
|
13
|
+
# Core Store & Selectors
|
|
14
|
+
from pydux.core.store import Store
|
|
15
|
+
from pydux.core.selector import create_selector, shallow_equal, deep_equal, is_identical
|
|
16
|
+
from pydux.core.types import Action, Reducer, Selector, Middleware, Dispatch
|
|
17
|
+
|
|
18
|
+
# DevTools & Time-Travel
|
|
19
|
+
from pydux.devtools.tracker import PyDuxDevTools, devtools_middleware
|
|
20
|
+
from pydux.devtools.inspector import PyDuxInspectorServer, start_inspector
|
|
21
|
+
|
|
22
|
+
# UI Adapters & Framework Bridges
|
|
23
|
+
from pydux.adapters.base import MainThreadBridge, ReactiveBinding, connect
|
|
24
|
+
from pydux.adapters.qyro import Reactive, connect_qyro
|
|
25
|
+
try: # Optional: importing Pydux core does not require PSX.
|
|
26
|
+
from pydux.adapters.psx import StoreProvider, use_dispatch, use_selector
|
|
27
|
+
except ModuleNotFoundError as exc: # pragma: no cover - depends on installation extras
|
|
28
|
+
if exc.name != "psx":
|
|
29
|
+
raise
|
|
30
|
+
|
|
31
|
+
__version__ = "3.0.0"
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Redux Toolkit API
|
|
34
|
+
"create_slice",
|
|
35
|
+
"configure_store",
|
|
36
|
+
"create_async_thunk",
|
|
37
|
+
"create_thunk_middleware",
|
|
38
|
+
"combine_reducers",
|
|
39
|
+
"reconfigure_store",
|
|
40
|
+
"Slice",
|
|
41
|
+
|
|
42
|
+
# Core Store & Selectors
|
|
43
|
+
"Store",
|
|
44
|
+
"create_selector",
|
|
45
|
+
"shallow_equal",
|
|
46
|
+
"deep_equal",
|
|
47
|
+
"is_identical",
|
|
48
|
+
"Action",
|
|
49
|
+
"Reducer",
|
|
50
|
+
"Selector",
|
|
51
|
+
"Middleware",
|
|
52
|
+
"Dispatch",
|
|
53
|
+
|
|
54
|
+
# DevTools
|
|
55
|
+
"PyDuxDevTools",
|
|
56
|
+
"devtools_middleware",
|
|
57
|
+
"PyDuxInspectorServer",
|
|
58
|
+
"start_inspector",
|
|
59
|
+
|
|
60
|
+
# Adapters
|
|
61
|
+
"MainThreadBridge",
|
|
62
|
+
"ReactiveBinding",
|
|
63
|
+
"connect",
|
|
64
|
+
"Reactive",
|
|
65
|
+
"connect_qyro",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
if "StoreProvider" in globals():
|
|
69
|
+
__all__ += ["StoreProvider", "use_dispatch", "use_selector"]
|
pydux/adapters/base.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UI-Agnostic Reactive Binding & Thread Dispatcher.
|
|
3
|
+
Decouples PyDux from any concrete GUI library while providing
|
|
4
|
+
rock-solid main thread synchronization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import (
|
|
8
|
+
Callable,
|
|
9
|
+
Any,
|
|
10
|
+
Optional,
|
|
11
|
+
Protocol,
|
|
12
|
+
TypeVar,
|
|
13
|
+
Dict,
|
|
14
|
+
)
|
|
15
|
+
from pydux.core.store import Store
|
|
16
|
+
from pydux.core.types import Selector, Unsubscribe, EqualityFn
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MainThreadBridge(Protocol):
|
|
22
|
+
"""
|
|
23
|
+
Protocol to safely schedule a callable onto the GUI's main thread.
|
|
24
|
+
Required by desktop frameworks (Qt, Tkinter, Kivy) to avoid threading crashes.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def schedule_on_main_thread(self, fn: Callable[[], None]) -> None:
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DirectThreadBridge:
|
|
32
|
+
"""Default fallback bridge for pure synchronous execution or tests."""
|
|
33
|
+
|
|
34
|
+
def schedule_on_main_thread(self, fn: Callable[[], None]) -> None:
|
|
35
|
+
fn()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ReactiveBinding:
|
|
39
|
+
"""
|
|
40
|
+
Connects a specific PyDux selector to any widget setter or callback.
|
|
41
|
+
Automatically handles main-thread dispatching.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
ReactiveBinding(
|
|
45
|
+
store=store,
|
|
46
|
+
selector=lambda s: s["user"]["name"],
|
|
47
|
+
target=lambda name: label.config(text=name),
|
|
48
|
+
bridge=TkinterBridge(root)
|
|
49
|
+
)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
store: Store[Any],
|
|
55
|
+
selector: Selector[Any, T],
|
|
56
|
+
target: Callable[[T], None],
|
|
57
|
+
equality_fn: Optional[EqualityFn] = None,
|
|
58
|
+
bridge: Optional[MainThreadBridge] = None,
|
|
59
|
+
):
|
|
60
|
+
self._bridge = bridge or DirectThreadBridge()
|
|
61
|
+
self._target = target
|
|
62
|
+
|
|
63
|
+
def safe_listener(value: T):
|
|
64
|
+
self._bridge.schedule_on_main_thread(lambda: self._target(value))
|
|
65
|
+
|
|
66
|
+
self._unsubscribe = store.select(
|
|
67
|
+
selector=selector,
|
|
68
|
+
listener=safe_listener,
|
|
69
|
+
equality_fn=equality_fn,
|
|
70
|
+
fire_immediately=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def dispose(self) -> None:
|
|
74
|
+
"""Tears down the subscription."""
|
|
75
|
+
if self._unsubscribe:
|
|
76
|
+
self._unsubscribe()
|
|
77
|
+
self._unsubscribe = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def connect(
|
|
81
|
+
store: Store[Any],
|
|
82
|
+
map_state_to_props: Optional[Callable[[Any], Dict[str, Any]]] = None,
|
|
83
|
+
bridge: Optional[MainThreadBridge] = None,
|
|
84
|
+
):
|
|
85
|
+
"""
|
|
86
|
+
Class decorator for framework-agnostic component state injection.
|
|
87
|
+
"""
|
|
88
|
+
def decorator(cls):
|
|
89
|
+
orig_init = cls.__init__
|
|
90
|
+
|
|
91
|
+
def new_init(self, *args, **kwargs):
|
|
92
|
+
orig_init(self, *args, **kwargs)
|
|
93
|
+
self._pydux_unsubscribers = []
|
|
94
|
+
active_bridge = bridge or getattr(self, "_pydux_bridge", DirectThreadBridge())
|
|
95
|
+
|
|
96
|
+
if map_state_to_props:
|
|
97
|
+
def on_state_slice(props: Dict[str, Any]):
|
|
98
|
+
for prop_name, prop_val in props.items():
|
|
99
|
+
setattr(self, prop_name, prop_val)
|
|
100
|
+
if hasattr(self, "on_props_changed") and callable(self.on_props_changed):
|
|
101
|
+
self.on_props_changed()
|
|
102
|
+
|
|
103
|
+
unsub = store.select(
|
|
104
|
+
selector=map_state_to_props,
|
|
105
|
+
listener=lambda p: active_bridge.schedule_on_main_thread(lambda: on_state_slice(p)),
|
|
106
|
+
fire_immediately=True,
|
|
107
|
+
)
|
|
108
|
+
self._pydux_unsubscribers.append(unsub)
|
|
109
|
+
|
|
110
|
+
cls.__init__ = new_init
|
|
111
|
+
return cls
|
|
112
|
+
|
|
113
|
+
return decorator
|
pydux/adapters/kivy.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kivy Adapter for PyDux 3.0.
|
|
3
|
+
Schedules state updates via Kivy's Clock.schedule_once.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Callable, Any
|
|
7
|
+
from pydux.adapters.base import MainThreadBridge
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KivyBridge:
|
|
11
|
+
"""Ensures Kivy widgets and canvas instructions update on the Kivy Clock loop."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
try:
|
|
15
|
+
from kivy.clock import Clock
|
|
16
|
+
self._clock = Clock
|
|
17
|
+
except ImportError:
|
|
18
|
+
self._clock = None
|
|
19
|
+
|
|
20
|
+
def schedule_on_main_thread(self, fn: Callable[[], None]) -> None:
|
|
21
|
+
if self._clock:
|
|
22
|
+
self._clock.schedule_once(lambda dt: fn(), 0)
|
|
23
|
+
else:
|
|
24
|
+
fn()
|
pydux/adapters/psx.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Optional Pydux state hooks for declarative PSX component trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar, cast
|
|
7
|
+
|
|
8
|
+
from psx import component
|
|
9
|
+
from psx.core.errors import PSXError
|
|
10
|
+
from psx.core.hooks import provide_context, use_context, use_effect, use_state
|
|
11
|
+
from psx.core.vnode import VNode
|
|
12
|
+
|
|
13
|
+
TState = TypeVar("TState")
|
|
14
|
+
TSelected = TypeVar("TSelected")
|
|
15
|
+
_STORE_CONTEXT = object()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def StoreProvider(store: object, child: VNode) -> VNode:
|
|
19
|
+
"""Provide exactly one existing Pydux store to a PSX subtree."""
|
|
20
|
+
_validate_store(store)
|
|
21
|
+
return _StoreProvider(child, store=store)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@component
|
|
25
|
+
def _StoreProvider(store: object, children: tuple[object, ...]) -> VNode:
|
|
26
|
+
provide_context(_STORE_CONTEXT, store)
|
|
27
|
+
use_effect(lambda: _lease_inspector(store), [store])
|
|
28
|
+
if len(children) != 1 or not isinstance(children[0], VNode):
|
|
29
|
+
raise TypeError("StoreProvider requires exactly one VNode child.")
|
|
30
|
+
return children[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def use_dispatch() -> Callable[[object], object]:
|
|
34
|
+
"""Return the dispatch method of the nearest explicit StoreProvider."""
|
|
35
|
+
store = _store_from_context()
|
|
36
|
+
return cast(Callable[[object], object], getattr(store, "dispatch"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def use_selector(
|
|
40
|
+
selector: Callable[[TState], TSelected],
|
|
41
|
+
equality_fn: Callable[[TSelected, TSelected], bool] | None = None,
|
|
42
|
+
) -> TSelected:
|
|
43
|
+
"""Subscribe the current PSX component to a selected Pydux slice."""
|
|
44
|
+
store = _store_from_context()
|
|
45
|
+
initial = selector(cast(TState, store.get_state()))
|
|
46
|
+
selected, set_selected = use_state(lambda: initial)
|
|
47
|
+
|
|
48
|
+
def subscribe() -> Callable[[], None]:
|
|
49
|
+
def on_change(value: TSelected) -> None:
|
|
50
|
+
set_selected(value)
|
|
51
|
+
|
|
52
|
+
return store.select(
|
|
53
|
+
selector=selector,
|
|
54
|
+
listener=on_change,
|
|
55
|
+
equality_fn=equality_fn,
|
|
56
|
+
fire_immediately=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
use_effect(subscribe, [store, selector, equality_fn])
|
|
60
|
+
return cast(TSelected, selected)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _store_from_context() -> Any:
|
|
64
|
+
try:
|
|
65
|
+
store = use_context(_STORE_CONTEXT)
|
|
66
|
+
except LookupError as exc:
|
|
67
|
+
raise PSXError("use_selector() and use_dispatch() require an ancestor StoreProvider.") from exc
|
|
68
|
+
_validate_store(store)
|
|
69
|
+
return store
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_store(store: object) -> None:
|
|
73
|
+
required = ("get_state", "dispatch", "select")
|
|
74
|
+
missing = [name for name in required if not callable(getattr(store, name, None))]
|
|
75
|
+
if missing:
|
|
76
|
+
raise TypeError(f"StoreProvider expected a Pydux-compatible store; missing {', '.join(missing)}.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _lease_inspector(store: object) -> Callable[[], None]:
|
|
80
|
+
"""Stop an auto-started inspector after its last provider unmounts."""
|
|
81
|
+
leases = int(getattr(store, "_pydux_psx_inspector_leases", 0)) + 1
|
|
82
|
+
setattr(store, "_pydux_psx_inspector_leases", leases)
|
|
83
|
+
|
|
84
|
+
def release() -> None:
|
|
85
|
+
remaining = max(0, int(getattr(store, "_pydux_psx_inspector_leases", 1)) - 1)
|
|
86
|
+
setattr(store, "_pydux_psx_inspector_leases", remaining)
|
|
87
|
+
if remaining:
|
|
88
|
+
return
|
|
89
|
+
inspector = getattr(store, "inspector", None)
|
|
90
|
+
stop = getattr(inspector, "stop", None)
|
|
91
|
+
if callable(stop):
|
|
92
|
+
stop()
|
|
93
|
+
|
|
94
|
+
return release
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ["StoreProvider", "use_dispatch", "use_selector"]
|
pydux/adapters/qt.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qt Adapter for PySide6, PySide2, PyQt5, and PyQt6.
|
|
3
|
+
Uses Qt MetaObject and QTimer.singleShot safely on the Qt event loop,
|
|
4
|
+
without locking PyDux core to Qt dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Callable, Any, Optional
|
|
8
|
+
from pydux.adapters.base import MainThreadBridge
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QtMainThreadBridge:
|
|
12
|
+
"""Dispatches calls onto the Qt GUI event loop safely."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
# Auto-detect Qt binding
|
|
16
|
+
self._qtimer = None
|
|
17
|
+
for mod_name in ("PySide6.QtCore", "PyQt6.QtCore", "PySide2.QtCore", "PyQt5.QtCore"):
|
|
18
|
+
try:
|
|
19
|
+
mod = __import__(mod_name, fromlist=["QTimer"])
|
|
20
|
+
self._qtimer = getattr(mod, "QTimer")
|
|
21
|
+
break
|
|
22
|
+
except ImportError:
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
def schedule_on_main_thread(self, fn: Callable[[], None]) -> None:
|
|
26
|
+
if self._qtimer:
|
|
27
|
+
self._qtimer.singleShot(0, fn)
|
|
28
|
+
else:
|
|
29
|
+
fn()
|
pydux/adapters/qyro.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qyro Framework Adapter for PyDux 3.0.
|
|
3
|
+
Works seamlessly with qyro.ui.component.Component and qyro.ApplicationContext.
|
|
4
|
+
Automatically handles subscription on mount and unsubscription on unmount!
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Callable, Any, Optional, Dict, List
|
|
10
|
+
from pydux.core.store import Store
|
|
11
|
+
from pydux.core.types import Selector, EqualityFn, Unsubscribe
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_kivy_widget(component: Any) -> bool:
|
|
15
|
+
"""Return whether a component is running inside an already-loaded Kivy app."""
|
|
16
|
+
# Do not import Kivy merely to identify another UI framework: its import
|
|
17
|
+
# initializes Kivy's logger and window providers inside Qt applications.
|
|
18
|
+
if not any(name == "kivy" or name.startswith("kivy.") for name in sys.modules):
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from kivy.uix.widget import Widget # type: ignore
|
|
23
|
+
|
|
24
|
+
return isinstance(component, Widget)
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_qt_single_shot() -> Optional[Callable[[int, Callable[[], None]], None]]:
|
|
30
|
+
"""Resolve QTimer.singleShot from the active Qt binding, if available."""
|
|
31
|
+
for module_name in ("PySide6.QtCore", "PyQt6.QtCore", "PySide2.QtCore", "PyQt5.QtCore"):
|
|
32
|
+
module = sys.modules.get(module_name)
|
|
33
|
+
|
|
34
|
+
if module is None:
|
|
35
|
+
try:
|
|
36
|
+
module = __import__(module_name, fromlist=["QTimer"])
|
|
37
|
+
except ImportError:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
qtimer = getattr(module, "QTimer", None)
|
|
41
|
+
if qtimer is not None and hasattr(qtimer, "singleShot"):
|
|
42
|
+
return qtimer.singleShot
|
|
43
|
+
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _schedule_on_ui_tick(component: Any, fn: Callable[[], None]) -> bool:
|
|
48
|
+
"""Schedule callback on the active UI framework tick (Qt, Tkinter, or Kivy)."""
|
|
49
|
+
# Kivy applications can be installed alongside a Qt binding. Detect a
|
|
50
|
+
# real Kivy widget before probing Qt so updates stay on Kivy's event loop.
|
|
51
|
+
if _is_kivy_widget(component):
|
|
52
|
+
try:
|
|
53
|
+
from kivy.clock import Clock # type: ignore
|
|
54
|
+
|
|
55
|
+
Clock.schedule_once(lambda _dt: fn(), 0)
|
|
56
|
+
return True
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# Tk widgets expose ``after``. Check this before probing/importing Qt:
|
|
61
|
+
# environments used for Qyro demos commonly include PySide6 as well, and
|
|
62
|
+
# a QTimer without a Qt event loop can crash at Tk's native teardown.
|
|
63
|
+
tk_after = getattr(component, "after", None)
|
|
64
|
+
if callable(tk_after):
|
|
65
|
+
tk_after(0, fn)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
single_shot = _get_qt_single_shot()
|
|
69
|
+
if single_shot is not None:
|
|
70
|
+
single_shot(0, fn)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _select_with_deferred_initial(
|
|
77
|
+
component: Any,
|
|
78
|
+
store: Store[Any],
|
|
79
|
+
selector: Selector[Any, Any],
|
|
80
|
+
on_change: Callable[[Any], None],
|
|
81
|
+
equality_fn: Optional[EqualityFn] = None,
|
|
82
|
+
) -> Unsubscribe:
|
|
83
|
+
"""Subscribe without immediate fire and dispatch initial value in the UI tick."""
|
|
84
|
+
delivered = {"called": False}
|
|
85
|
+
|
|
86
|
+
def tracked_on_change(value: Any) -> None:
|
|
87
|
+
delivered["called"] = True
|
|
88
|
+
on_change(value)
|
|
89
|
+
|
|
90
|
+
unsub = store.select(
|
|
91
|
+
selector=selector,
|
|
92
|
+
listener=tracked_on_change,
|
|
93
|
+
equality_fn=equality_fn,
|
|
94
|
+
fire_immediately=False,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def emit_initial_value() -> None:
|
|
98
|
+
if delivered["called"]:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
tracked_on_change(selector(store.get_state()))
|
|
103
|
+
except Exception:
|
|
104
|
+
logging.getLogger("pydux").exception(
|
|
105
|
+
"Error while dispatching initial Qyro selector value"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if _schedule_on_ui_tick(component, emit_initial_value):
|
|
109
|
+
return unsub
|
|
110
|
+
|
|
111
|
+
# Last-resort fallback when no known UI scheduler is available.
|
|
112
|
+
# This keeps non-framework test contexts functional.
|
|
113
|
+
emit_initial_value()
|
|
114
|
+
|
|
115
|
+
return unsub
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Reactive:
|
|
119
|
+
"""
|
|
120
|
+
Mixin for Qyro Component classes.
|
|
121
|
+
Integrates seamlessly into Qyro's lifecycle:
|
|
122
|
+
- component_will_mount
|
|
123
|
+
- render
|
|
124
|
+
- component_will_unmount
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, *args, **kwargs):
|
|
128
|
+
super().__init__(*args, **kwargs)
|
|
129
|
+
self._qyro_store_subscriptions: List[Unsubscribe] = []
|
|
130
|
+
|
|
131
|
+
def bind_selector(
|
|
132
|
+
self,
|
|
133
|
+
store: Store[Any],
|
|
134
|
+
selector: Selector[Any, Any],
|
|
135
|
+
on_change: Callable[[Any], None],
|
|
136
|
+
equality_fn: Optional[EqualityFn] = None,
|
|
137
|
+
) -> Unsubscribe:
|
|
138
|
+
"""
|
|
139
|
+
Subscribes to a store slice. The subscription is tracked and
|
|
140
|
+
will be automatically cleaned up when the Qyro component unmounts.
|
|
141
|
+
|
|
142
|
+
The first callback is deferred to the next Qt tick so components can
|
|
143
|
+
subscribe during component_will_mount without touching widgets that are
|
|
144
|
+
created later in render().
|
|
145
|
+
"""
|
|
146
|
+
if not hasattr(self, "_qyro_store_subscriptions"):
|
|
147
|
+
self._qyro_store_subscriptions = []
|
|
148
|
+
|
|
149
|
+
unsub = _select_with_deferred_initial(
|
|
150
|
+
component=self,
|
|
151
|
+
store=store,
|
|
152
|
+
selector=selector,
|
|
153
|
+
on_change=on_change,
|
|
154
|
+
equality_fn=equality_fn,
|
|
155
|
+
)
|
|
156
|
+
self._qyro_store_subscriptions.append(unsub)
|
|
157
|
+
|
|
158
|
+
return unsub
|
|
159
|
+
|
|
160
|
+
def component_will_unmount(self):
|
|
161
|
+
"""Lifecycle hook: clean up all PyDux subscriptions to prevent leaks."""
|
|
162
|
+
if not hasattr(self, "_qyro_store_subscriptions"):
|
|
163
|
+
self._qyro_store_subscriptions = []
|
|
164
|
+
|
|
165
|
+
for unsub in self._qyro_store_subscriptions:
|
|
166
|
+
unsub()
|
|
167
|
+
self._qyro_store_subscriptions.clear()
|
|
168
|
+
|
|
169
|
+
# Call super if parent component defines component_will_unmount
|
|
170
|
+
if hasattr(super(), "component_will_unmount"):
|
|
171
|
+
super().component_will_unmount()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def connect_qyro(store: Store[Any], selectors: Dict[str, Selector[Any, Any]]):
|
|
175
|
+
"""
|
|
176
|
+
Decorator for Qyro components that automatically maps selectors to component properties.
|
|
177
|
+
"""
|
|
178
|
+
def decorator(cls):
|
|
179
|
+
orig_mount = getattr(cls, "component_will_mount", None)
|
|
180
|
+
orig_unmount = getattr(cls, "component_will_unmount", None)
|
|
181
|
+
|
|
182
|
+
def new_component_will_mount(self):
|
|
183
|
+
if not hasattr(self, "_qyro_unsubs"):
|
|
184
|
+
self._qyro_unsubs = []
|
|
185
|
+
if not hasattr(self, "_qyro_connected_props"):
|
|
186
|
+
self._qyro_connected_props = {}
|
|
187
|
+
|
|
188
|
+
for attr_name, selector in selectors.items():
|
|
189
|
+
def make_listener(name: str):
|
|
190
|
+
def listener(val: Any) -> None:
|
|
191
|
+
setattr(self, name, val)
|
|
192
|
+
self._qyro_connected_props[name] = val
|
|
193
|
+
|
|
194
|
+
on_state_change = getattr(self, "on_state_change", None)
|
|
195
|
+
if callable(on_state_change):
|
|
196
|
+
on_state_change(dict(self._qyro_connected_props))
|
|
197
|
+
|
|
198
|
+
return listener
|
|
199
|
+
|
|
200
|
+
unsub = _select_with_deferred_initial(
|
|
201
|
+
component=self,
|
|
202
|
+
store=store,
|
|
203
|
+
selector=selector,
|
|
204
|
+
on_change=make_listener(attr_name),
|
|
205
|
+
)
|
|
206
|
+
self._qyro_unsubs.append(unsub)
|
|
207
|
+
|
|
208
|
+
if orig_mount:
|
|
209
|
+
orig_mount(self)
|
|
210
|
+
|
|
211
|
+
def new_component_will_unmount(self):
|
|
212
|
+
if hasattr(self, "_qyro_unsubs"):
|
|
213
|
+
for unsub in self._qyro_unsubs:
|
|
214
|
+
unsub()
|
|
215
|
+
self._qyro_unsubs.clear()
|
|
216
|
+
if orig_unmount:
|
|
217
|
+
orig_unmount(self)
|
|
218
|
+
|
|
219
|
+
cls.component_will_mount = new_component_will_mount
|
|
220
|
+
cls.component_will_unmount = new_component_will_unmount
|
|
221
|
+
return cls
|
|
222
|
+
|
|
223
|
+
return decorator
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# Backward-compatible alias for projects already using @pydux(...)
|
|
227
|
+
pydux = connect_qyro
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tkinter Adapter for PyDux 3.0.
|
|
3
|
+
Schedules state updates via Tkinter's event loop (widget.after / after_idle).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Callable, Any
|
|
7
|
+
from pydux.adapters.base import MainThreadBridge
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TkinterBridge:
|
|
11
|
+
"""Ensures Tkinter widgets are only mutated on the Tk main thread."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, root_or_widget: Any):
|
|
14
|
+
self._widget = root_or_widget
|
|
15
|
+
|
|
16
|
+
def schedule_on_main_thread(self, fn: Callable[[], None]) -> None:
|
|
17
|
+
try:
|
|
18
|
+
self._widget.after_idle(fn)
|
|
19
|
+
except Exception:
|
|
20
|
+
fn()
|