mvvm-framework 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.
- mvvm_framework/__init__.py +21 -0
- mvvm_framework/core/__init__.py +18 -0
- mvvm_framework/core/binding.py +362 -0
- mvvm_framework/core/command.py +311 -0
- mvvm_framework/core/model.py +212 -0
- mvvm_framework/core/observable.py +358 -0
- mvvm_framework/core/viewmodel.py +217 -0
- mvvm_framework-0.1.0.dist-info/METADATA +220 -0
- mvvm_framework-0.1.0.dist-info/RECORD +10 -0
- mvvm_framework-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MVVM Framework for PySide6
|
|
3
|
+
A general-purpose MVVM framework based on PySide6 property system
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .core.observable import ObservableObject, ObservableList
|
|
7
|
+
from .core.model import Model
|
|
8
|
+
from .core.viewmodel import ViewModel
|
|
9
|
+
from .core.command import Command
|
|
10
|
+
from .core.binding import Binding
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
'ObservableObject',
|
|
14
|
+
'ObservableList',
|
|
15
|
+
'Model',
|
|
16
|
+
'ViewModel',
|
|
17
|
+
'Command',
|
|
18
|
+
'Binding',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
__version__ = '1.0.0'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core module for MVVM framework
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .observable import ObservableObject, ObservableList
|
|
6
|
+
from .model import Model
|
|
7
|
+
from .viewmodel import ViewModel
|
|
8
|
+
from .command import Command
|
|
9
|
+
from .binding import Binding
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
'ObservableObject',
|
|
13
|
+
'ObservableList',
|
|
14
|
+
'Model',
|
|
15
|
+
'ViewModel',
|
|
16
|
+
'Command',
|
|
17
|
+
'Binding',
|
|
18
|
+
]
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data binding utilities for MVVM framework.
|
|
3
|
+
Provides tools for binding UI elements to ViewModel properties.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Callable, Optional, Union
|
|
7
|
+
from PySide6.QtCore import QObject
|
|
8
|
+
from PySide6.QtWidgets import QWidget
|
|
9
|
+
from PySide6.QtGui import QAction
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Binding:
|
|
13
|
+
"""
|
|
14
|
+
Utility class for creating data bindings between ViewModels and Views.
|
|
15
|
+
|
|
16
|
+
Supports one-way and two-way bindings for various widget types.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
# One-way binding (ViewModel -> View)
|
|
20
|
+
Binding.bind_label(viewmodel, "name", label_widget)
|
|
21
|
+
|
|
22
|
+
# Two-way binding (ViewModel <-> View)
|
|
23
|
+
Binding.bind_text(viewmodel, "name", line_edit)
|
|
24
|
+
|
|
25
|
+
# Command binding
|
|
26
|
+
Binding.bind_command(viewmodel, "save_command", button)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def bind_property(
|
|
31
|
+
source: QObject,
|
|
32
|
+
source_property: str,
|
|
33
|
+
target: QObject,
|
|
34
|
+
target_property: str,
|
|
35
|
+
converter: Optional[Callable[[Any], Any]] = None,
|
|
36
|
+
reverse_converter: Optional[Callable[[Any], Any]] = None
|
|
37
|
+
) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Create a one-way binding from source property to target property.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
source: Source object (usually ViewModel)
|
|
43
|
+
source_property: Name of the source property
|
|
44
|
+
target: Target object (usually Widget)
|
|
45
|
+
target_property: Name of the target property
|
|
46
|
+
converter: Optional function to convert source value to target value
|
|
47
|
+
reverse_converter: Optional function for reverse conversion (two-way)
|
|
48
|
+
"""
|
|
49
|
+
def update_target(value: Any):
|
|
50
|
+
if converter:
|
|
51
|
+
value = converter(value)
|
|
52
|
+
setattr(target, target_property, value)
|
|
53
|
+
|
|
54
|
+
# Initial update
|
|
55
|
+
update_target(getattr(source, source_property))
|
|
56
|
+
|
|
57
|
+
# Connect to property changes
|
|
58
|
+
if hasattr(source, 'propertyChanged'):
|
|
59
|
+
source.propertyChanged.connect(lambda name: update_target(getattr(source, source_property)) if name == source_property else None)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def bind_text(
|
|
63
|
+
viewmodel: QObject,
|
|
64
|
+
property_name: str,
|
|
65
|
+
widget: QWidget,
|
|
66
|
+
two_way: bool = True
|
|
67
|
+
) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Bind a ViewModel property to a widget's text property.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
viewmodel: The ViewModel instance
|
|
73
|
+
property_name: Name of the property to bind
|
|
74
|
+
widget: Widget to bind to (QLineEdit, QLabel, etc.)
|
|
75
|
+
two_way: If True, create a two-way binding
|
|
76
|
+
"""
|
|
77
|
+
# ViewModel -> Widget
|
|
78
|
+
def update_widget(value: Any):
|
|
79
|
+
if hasattr(widget, 'setText'):
|
|
80
|
+
# Suppress widget signals during programmatic update to prevent re-entry
|
|
81
|
+
if hasattr(widget, 'blockSignals'):
|
|
82
|
+
was_blocked = widget.blockSignals(True)
|
|
83
|
+
try:
|
|
84
|
+
widget.setText(str(value) if value is not None else "")
|
|
85
|
+
finally:
|
|
86
|
+
widget.blockSignals(was_blocked)
|
|
87
|
+
else:
|
|
88
|
+
widget.setText(str(value) if value is not None else "")
|
|
89
|
+
|
|
90
|
+
# Initial update
|
|
91
|
+
update_widget(getattr(viewmodel, property_name))
|
|
92
|
+
|
|
93
|
+
# Connect to property changes
|
|
94
|
+
if hasattr(viewmodel, 'propertyChanged'):
|
|
95
|
+
viewmodel.propertyChanged.connect(
|
|
96
|
+
lambda name: update_widget(getattr(viewmodel, property_name)) if name == property_name else None
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Widget -> ViewModel (two-way binding)
|
|
100
|
+
if two_way:
|
|
101
|
+
# Prefer textEdited to avoid redundant updates from programmatic setText
|
|
102
|
+
if hasattr(widget, 'textEdited'):
|
|
103
|
+
widget.textEdited.connect(
|
|
104
|
+
lambda text: setattr(viewmodel, property_name, text) if hasattr(viewmodel, property_name) else None
|
|
105
|
+
)
|
|
106
|
+
elif hasattr(widget, 'textChanged'):
|
|
107
|
+
widget.textChanged.connect(
|
|
108
|
+
lambda text: setattr(viewmodel, property_name, text) if hasattr(viewmodel, property_name) else None
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def bind_label(
|
|
113
|
+
viewmodel: QObject,
|
|
114
|
+
property_name: str,
|
|
115
|
+
label: QWidget
|
|
116
|
+
) -> None:
|
|
117
|
+
"""
|
|
118
|
+
Bind a ViewModel property to a QLabel's text (one-way).
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
viewmodel: The ViewModel instance
|
|
122
|
+
property_name: Name of the property to bind
|
|
123
|
+
label: QLabel widget
|
|
124
|
+
"""
|
|
125
|
+
Binding.bind_text(viewmodel, property_name, label, two_way=False)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def bind_checked(
|
|
129
|
+
viewmodel: QObject,
|
|
130
|
+
property_name: str,
|
|
131
|
+
widget: QWidget
|
|
132
|
+
) -> None:
|
|
133
|
+
"""
|
|
134
|
+
Bind a ViewModel property to a widget's checked state.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
viewmodel: The ViewModel instance
|
|
138
|
+
property_name: Name of the property to bind
|
|
139
|
+
widget: Checkable widget (QCheckBox, QRadioButton, etc.)
|
|
140
|
+
"""
|
|
141
|
+
# ViewModel -> Widget
|
|
142
|
+
def update_widget(value: Any):
|
|
143
|
+
if hasattr(widget, 'setChecked'):
|
|
144
|
+
widget.setChecked(bool(value))
|
|
145
|
+
|
|
146
|
+
# Initial update
|
|
147
|
+
update_widget(getattr(viewmodel, property_name))
|
|
148
|
+
|
|
149
|
+
# Connect to property changes
|
|
150
|
+
if hasattr(viewmodel, 'propertyChanged'):
|
|
151
|
+
viewmodel.propertyChanged.connect(
|
|
152
|
+
lambda name: update_widget(getattr(viewmodel, property_name)) if name == property_name else None
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Widget -> ViewModel
|
|
156
|
+
if hasattr(widget, 'toggled'):
|
|
157
|
+
widget.toggled.connect(
|
|
158
|
+
lambda checked: setattr(viewmodel, property_name, checked)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def bind_value(
|
|
163
|
+
viewmodel: QObject,
|
|
164
|
+
property_name: str,
|
|
165
|
+
widget: QWidget,
|
|
166
|
+
converter: Optional[Callable[[Any], Any]] = None
|
|
167
|
+
) -> None:
|
|
168
|
+
"""
|
|
169
|
+
Bind a ViewModel property to a spinbox/slider value.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
viewmodel: The ViewModel instance
|
|
173
|
+
property_name: Name of the property to bind
|
|
174
|
+
widget: Value widget (QSpinBox, QSlider, etc.)
|
|
175
|
+
converter: Optional value converter
|
|
176
|
+
"""
|
|
177
|
+
# ViewModel -> Widget
|
|
178
|
+
def update_widget(value: Any):
|
|
179
|
+
if converter:
|
|
180
|
+
value = converter(value)
|
|
181
|
+
if hasattr(widget, 'setValue'):
|
|
182
|
+
widget.setValue(value)
|
|
183
|
+
|
|
184
|
+
# Initial update
|
|
185
|
+
update_widget(getattr(viewmodel, property_name))
|
|
186
|
+
|
|
187
|
+
# Connect to property changes
|
|
188
|
+
if hasattr(viewmodel, 'propertyChanged'):
|
|
189
|
+
viewmodel.propertyChanged.connect(
|
|
190
|
+
lambda name: update_widget(getattr(viewmodel, property_name)) if name == property_name else None
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Widget -> ViewModel
|
|
194
|
+
if hasattr(widget, 'valueChanged'):
|
|
195
|
+
widget.valueChanged.connect(
|
|
196
|
+
lambda value: setattr(viewmodel, property_name, value)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def bind_command(
|
|
201
|
+
viewmodel: QObject,
|
|
202
|
+
command_name: str,
|
|
203
|
+
widget: Union[QWidget, QAction]
|
|
204
|
+
) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Bind a ViewModel command to a widget's click/trigger action.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
viewmodel: The ViewModel instance
|
|
210
|
+
command_name: Name of the command property
|
|
211
|
+
widget: Widget to bind to (QPushButton, QAction, etc.)
|
|
212
|
+
"""
|
|
213
|
+
command = getattr(viewmodel, command_name, None)
|
|
214
|
+
|
|
215
|
+
if command is None:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# Enable/disable widget based on command's can_execute
|
|
219
|
+
def update_enabled():
|
|
220
|
+
enabled = command.can_execute()
|
|
221
|
+
if hasattr(widget, 'setEnabled'):
|
|
222
|
+
widget.setEnabled(enabled)
|
|
223
|
+
|
|
224
|
+
# Initial state
|
|
225
|
+
update_enabled()
|
|
226
|
+
|
|
227
|
+
# Connect to command's canExecuteChanged
|
|
228
|
+
if hasattr(command, 'canExecuteChanged'):
|
|
229
|
+
command.canExecuteChanged.connect(lambda _: update_enabled())
|
|
230
|
+
|
|
231
|
+
# Connect widget click to command execution
|
|
232
|
+
if hasattr(widget, 'clicked'):
|
|
233
|
+
widget.clicked.connect(lambda: command.execute())
|
|
234
|
+
elif hasattr(widget, 'triggered'):
|
|
235
|
+
widget.triggered.connect(lambda: command.execute())
|
|
236
|
+
elif hasattr(widget, 'pressed'):
|
|
237
|
+
widget.pressed.connect(lambda: command.execute())
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def bind_visibility(
|
|
241
|
+
viewmodel: QObject,
|
|
242
|
+
property_name: str,
|
|
243
|
+
widget: QWidget,
|
|
244
|
+
inverse: bool = False
|
|
245
|
+
) -> None:
|
|
246
|
+
"""
|
|
247
|
+
Bind a ViewModel boolean property to widget visibility.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
viewmodel: The ViewModel instance
|
|
251
|
+
property_name: Name of the boolean property
|
|
252
|
+
widget: Widget to bind to
|
|
253
|
+
inverse: If True, hide when True, show when False
|
|
254
|
+
"""
|
|
255
|
+
def update_visibility(value: Any):
|
|
256
|
+
visible = bool(value) != inverse
|
|
257
|
+
widget.setVisible(visible)
|
|
258
|
+
|
|
259
|
+
# Initial update
|
|
260
|
+
update_visibility(getattr(viewmodel, property_name))
|
|
261
|
+
|
|
262
|
+
# Connect to property changes
|
|
263
|
+
if hasattr(viewmodel, 'propertyChanged'):
|
|
264
|
+
viewmodel.propertyChanged.connect(
|
|
265
|
+
lambda name: update_visibility(getattr(viewmodel, property_name)) if name == property_name else None
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def bind_items(
|
|
270
|
+
viewmodel: QObject,
|
|
271
|
+
property_name: str,
|
|
272
|
+
widget: QWidget,
|
|
273
|
+
display_member: Optional[str] = None
|
|
274
|
+
) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Bind a ViewModel list property to a combobox/listwidget.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
viewmodel: The ViewModel instance
|
|
280
|
+
property_name: Name of the list property
|
|
281
|
+
widget: ComboBox or ListWidget
|
|
282
|
+
display_member: Property name to display for each item
|
|
283
|
+
"""
|
|
284
|
+
from .observable import ObservableList
|
|
285
|
+
|
|
286
|
+
def update_items():
|
|
287
|
+
items = getattr(viewmodel, property_name, [])
|
|
288
|
+
|
|
289
|
+
if hasattr(widget, 'clear'):
|
|
290
|
+
widget.clear()
|
|
291
|
+
|
|
292
|
+
for item in items:
|
|
293
|
+
if display_member and hasattr(item, display_member):
|
|
294
|
+
display_value = getattr(item, display_member)
|
|
295
|
+
else:
|
|
296
|
+
display_value = str(item)
|
|
297
|
+
|
|
298
|
+
if hasattr(widget, 'addItem'):
|
|
299
|
+
widget.addItem(display_value, item)
|
|
300
|
+
|
|
301
|
+
# Initial update
|
|
302
|
+
update_items()
|
|
303
|
+
|
|
304
|
+
# Connect to property changes
|
|
305
|
+
if hasattr(viewmodel, 'propertyChanged'):
|
|
306
|
+
viewmodel.propertyChanged.connect(
|
|
307
|
+
lambda name: update_items() if name == property_name else None
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Also listen to ObservableList changes
|
|
311
|
+
items = getattr(viewmodel, property_name, [])
|
|
312
|
+
if isinstance(items, ObservableList):
|
|
313
|
+
items.itemAdded.connect(lambda *args: update_items())
|
|
314
|
+
items.itemRemoved.connect(lambda *args: update_items())
|
|
315
|
+
items.itemChanged.connect(lambda *args: update_items())
|
|
316
|
+
items.listReset.connect(update_items)
|
|
317
|
+
items.listCleared.connect(update_items)
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def bind_validation_error(
|
|
321
|
+
viewmodel: QObject,
|
|
322
|
+
property_name: str,
|
|
323
|
+
widget: QWidget,
|
|
324
|
+
error_label: Optional[QWidget] = None
|
|
325
|
+
) -> None:
|
|
326
|
+
"""
|
|
327
|
+
Bind validation errors to widget styling and error label.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
viewmodel: The ViewModel instance
|
|
331
|
+
property_name: Name of the property
|
|
332
|
+
widget: Widget to style on error
|
|
333
|
+
error_label: Optional label to show error message
|
|
334
|
+
"""
|
|
335
|
+
def update_validation():
|
|
336
|
+
if hasattr(viewmodel, 'get_validation_error'):
|
|
337
|
+
error = viewmodel.get_validation_error(property_name)
|
|
338
|
+
|
|
339
|
+
if error:
|
|
340
|
+
widget.setProperty("error", True)
|
|
341
|
+
widget.style().unpolish(widget)
|
|
342
|
+
widget.style().polish(widget)
|
|
343
|
+
|
|
344
|
+
if error_label and hasattr(error_label, 'setText'):
|
|
345
|
+
error_label.setText(error)
|
|
346
|
+
error_label.show()
|
|
347
|
+
else:
|
|
348
|
+
widget.setProperty("error", False)
|
|
349
|
+
widget.style().unpolish(widget)
|
|
350
|
+
widget.style().polish(widget)
|
|
351
|
+
|
|
352
|
+
if error_label and hasattr(error_label, 'hide'):
|
|
353
|
+
error_label.hide()
|
|
354
|
+
|
|
355
|
+
# Initial update
|
|
356
|
+
update_validation()
|
|
357
|
+
|
|
358
|
+
# Connect to property changes
|
|
359
|
+
if hasattr(viewmodel, 'propertyChanged'):
|
|
360
|
+
viewmodel.propertyChanged.connect(
|
|
361
|
+
lambda name: update_validation() if name == property_name else None
|
|
362
|
+
)
|