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.
@@ -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
+ )