├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs └── decision-records │ ├── 001-initial-structure.md │ ├── 002-bindings.md │ ├── 003-paint-layout.md │ ├── 004-model-mapper.md │ ├── 005-sliders.md │ └── 006-model-data-mapper-api-simplification.md ├── examples ├── bindings.py ├── demo.py ├── element_editor_example.py ├── paint_layout.py ├── paint_utils.py └── slider_examples.py ├── met_qt ├── __init__.py ├── _internal │ ├── __init__.py │ ├── binding │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── expression.py │ │ ├── group.py │ │ ├── simple.py │ │ └── structs.py │ ├── qtcompat.py │ └── widgets │ │ ├── __init__.py │ │ └── abstract_slider.py ├── components │ └── __init__.py ├── constants.py ├── core │ ├── __init__.py │ ├── binding.py │ ├── meta.py │ └── model_data_mapper.py ├── gui │ ├── __init__.py │ ├── paint_layout.py │ └── paint_utils.py └── widgets │ ├── __init__.py │ ├── float_slider.py │ └── range_slider.py ├── pyproject.toml ├── pytest.ini ├── run_all_tests.bat ├── setup.py └── tests ├── conftest.py ├── integration ├── test_core_binding.py ├── test_core_model_data_mapper.py ├── test_float_slider.py ├── test_gui_paint_layout.py ├── test_gui_paint_utils.py ├── test_range_slider.py └── test_widgets_float_and_range_slider.py └── unit └── test_qtcompat.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtual environments 2 | venv*/ 3 | python37/ 4 | 5 | # Python cache files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | *.so 10 | 11 | # Distribution / packaging 12 | dist/ 13 | build/ 14 | *.egg-info/ 15 | *.egg 16 | 17 | # Test cache 18 | .pytest_cache/ 19 | .coverage 20 | htmlcov/ 21 | 22 | # Package cache directory 23 | .package_cache/ 24 | 25 | # IDE files 26 | .vscode/ 27 | .idea/ 28 | *.swp 29 | *.swo 30 | *~ 31 | 32 | # Environment files 33 | .env 34 | .venv 35 | env/ 36 | ENV/ 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Alex Telford, minimalefforttech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include pyproject.toml 4 | include pytest.ini 5 | recursive-include docs *.md 6 | recursive-include python *.py 7 | recursive-include tests *.py 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # met_qt 2 | Python based Qt Utilities and components 3 | 4 | ## Overview 5 | 6 | Met Qt provides utilities and components to simplify Qt application development in Python. It supports both PySide2 (Qt5) and PySide6 (Qt6). 7 | 8 | ## Features 9 | 10 | ## Status 11 | 12 | This project is under active development and prior to v1.0 may include breaking changes. 13 | See the decision records in `docs/decision-records/` for design details and rationale. 14 | If you incorporate any of these tools in your pipeline ensure that you set an appropriate minor version limit. 15 | Patch versions may be assumed to be api compatible. 16 | 17 | Currently this has only been tested in windows, but should work on other systems. 18 | 19 | ## License 20 | 21 | MIT License - See LICENSE file for details. 22 | 23 | 24 | ## Installation 25 | 26 | ```bash 27 | pip install git+https://github.com/minimalefforttech/met_qt.git 28 | ``` 29 | 30 | ## Development 31 | 32 | ### Running Tests 33 | 34 | The project includes tests for both PySide2 and PySide6. Use the provided test script: 35 | 36 | ```bash 37 | # Run all tests 38 | .\run_all_tests.bat 39 | 40 | # Run specific version 41 | .\run_all_tests.bat --pyside2 # For PySide2 only 42 | .\run_all_tests.bat --pyside6 # For PySide6 only 43 | 44 | # Force clean environment 45 | .\run_all_tests.bat --clean 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/decision-records/001-initial-structure.md: -------------------------------------------------------------------------------- 1 | # 1. Initial Structure 2 | 3 | ## Status 4 | Completed 5 | 6 | ## Stakeholders 7 | Alex Telford 8 | 9 | ## Context 10 | We need to establish a clear and maintainable structure for the met_qt package that supports: 11 | - Qt 5/6 compatibility without external dependencies 12 | - Clear separation of UI and non-UI components 13 | - Maintainable API compatibility during minor updates 14 | - Comprehensive testing across different Qt versions 15 | 16 | ## Decision 17 | The following structure has been implemented: 18 | 19 | ### Package Organization 20 | ``` 21 | met_qt/ 22 | ├── _internal/ # Internal implementation details 23 | │ ├── qtcompat.py # Qt 5/6 compatibility layer 24 | │ └── ... 25 | ├── core/ # Non-UI components 26 | │ ├── structs.py # Core data structures 27 | │ └── ... # Connection bindings etc. 28 | └── components/ # UI-specific components 29 | ├── structs.py # UI-related data structures 30 | └── ... # UI widgets 31 | ``` 32 | 33 | ### Key Components 34 | 35 | 1. Internal Layer (`_internal/`) 36 | - Houses implementation details that may change in minor updates 37 | - Contains qtcompat.py to abstract Qt 5/6 differences 38 | - Helps maintain API stability for public interfaces 39 | 40 | 2. Core Module (`core/`) 41 | - Contains non-UI specific functionality 42 | - Houses connection bindings and related utilities 43 | - Includes core data structures in structs.py 44 | 45 | 3. Components Module (`components/`) 46 | - Contains UI-specific widgets and components 47 | - Includes UI-related data structures in structs.py 48 | 49 | ### Testing Structure 50 | - Tests are configured to run against both PySide2 and PySide6 51 | - Utilizes QtTest framework for UI component testing 52 | - Custom pytest markers for Qt-specific tests: 53 | - `@pytest.mark.qt`: General Qt tests 54 | - `@pytest.mark.pyside2`: PySide2-specific tests 55 | - `@pytest.mark.pyside6`: PySide6-specific tests 56 | 57 | ## Consequences 58 | 59 | ### Advantages 60 | 1. Clear separation of concerns between UI and non-UI components 61 | 2. Internal compatibility layer reduces external dependencies 62 | 3. _internal module allows for implementation changes without breaking API 63 | 4. Structured testing ensures compatibility across Qt versions 64 | 65 | ### Challenges 66 | 1. Maintaining compatibility layer between Qt versions 67 | 2. Need to carefully manage what goes into _internal vs public API 68 | 69 | ## Notes 70 | - The package uses pyproject.toml as the primary build configuration 71 | - Development setup includes both PySide2 and PySide6 virtual environments 72 | - pythonpath is set to ./python/ for proper package resolution 73 | -------------------------------------------------------------------------------- /docs/decision-records/002-bindings.md: -------------------------------------------------------------------------------- 1 | # ADR 002: Property Bindings System 2 | 3 | ## Stakeholders 4 | Alex Telford 5 | 6 | ## Status 7 | Completed 8 | 9 | ## Context and Motivation 10 | 11 | I require a way to simplify property bindings across components, particularly for bidirectional bindings or expression based bindings. 12 | 13 | ## Decision 14 | 15 | We will implement a core bindings manager (`Bindings` class) in `met_qt.core.binding`. This system supports: 16 | 17 | - **One-way bindings**: Synchronize a source property to one or more targets, optionally with a converter. 18 | - **Two-way (group) bindings**: Keep multiple properties in sync bidirectionally. 19 | - **Expression bindings**: Bind a target property to an expression involving multiple sources. 20 | 21 | Bindings are managed centrally, with automatic observation of property changes via Qt signals, event filters, and dynamic property change events. 22 | 23 | ## Implementation Details 24 | 25 | - The `Bindings` class manages all active bindings and observed objects. 26 | - One-way bindings are created with `bind(source, property)`, returning a `SimpleBinding` that can be chained with `.to(target, property, converter)`. 27 | - Two-way bindings use `bind_group()`, returning a `GroupBinding` to which properties can be added. 28 | - Expression bindings use `bind_expression(target, property, expression_str)`, supporting variable binding from multiple sources, this is done with a context manager. 29 | - Property changes are detected using Qt's notify signals, custom signals, or event filters for dynamic properties and special events. 30 | - Special handling is implemented for Qt widgets that require direct method calls (e.g., QSpinBox's value() method) instead of property() access. 31 | - Callbacks can be registered for property changes, supporting custom reactions beyond value synchronization. 32 | - The system cleans up bindings and observers when objects are destroyed. 33 | 34 | ## Example Usage 35 | 36 | ``` 37 | from met_qt.core.binding import Bindings 38 | # ... Qt widget setup ... 39 | bindings = Bindings(self) 40 | 41 | # One-way binding: spinbox -> label 42 | bindings.bind(spinbox, "value").to(value_label, "text", lambda x: f"Value: {int(x)}") 43 | 44 | # Two-way binding between line edits 45 | group = bindings.bind_group() 46 | group.add(edit1, "text") 47 | group.add(edit2, "text") 48 | 49 | # Expression binding: combine first and last names 50 | with bindings.bind_expression(full_name, "text", "{first} {last}") as expr: 51 | expr.bind("first", first_name, "text") 52 | expr.bind("last", last_name, "text") 53 | ``` 54 | 55 | ## Consequences 56 | 57 | - The bindings system reduces boilerplate and improves maintainability for property synchronization in Qt UIs. 58 | - It supports advanced scenarios (expressions, dynamic properties, event-based updates) while remaining easy to use for common cases. 59 | - The binding layer introduces a degree of overhead that is unsuitable for scaled applications in python. 60 | 61 | --- 62 | Date: 2025-04-24 63 | Status: Implemented 64 | -------------------------------------------------------------------------------- /docs/decision-records/003-paint-layout.md: -------------------------------------------------------------------------------- 1 | # ADR 003: BoxPaintLayout for Paint-Based Layouts 2 | 3 | ## Stakeholders 4 | Alex Telford 5 | 6 | ## Status 7 | Completed 8 | 9 | ## Context and Motivation 10 | 11 | In some scenarios, we need to lay out items inside a paint event where using QWidget-based objects is not appropriate or possible. A common example is within a QStyledItemDelegate, where the painting is handled directly and widget instantiation is not feasible. While QGraphicsScene provides a flexible scene graph for painting and interaction, it is too heavyweight for these use cases. 12 | 13 | ## Decision 14 | 15 | We introduce `BoxPaintLayout`, a lightweight layout manager that can be used within paint events. This class mimics the API of standard Qt layouts but is designed for use in custom painting contexts. It supports: 16 | 17 | - Arranging and sizing paintable items (boxes, text, paths) without requiring QWidget instances. 18 | - Minimal interactive features: hover and click support for painted items. 19 | - Integration with standard Qt layouts for hybrid widget/painted UIs. 20 | 21 | `BoxPaintLayout` is implemented in `met_qt.gui.paint_layout` and is used in places where widget-based layouts are not suitable but a full scene graph is unnecessary. 22 | 23 | ## Consequences 24 | 25 | - Enables complex, interactive painted layouts in delegates and custom widgets without the overhead of QGraphicsScene or QWidget hierarchies. 26 | - Maintains a familiar layout API for developers already using Qt layouts. 27 | - Keeps the codebase lightweight and maintainable for paint-based UIs. 28 | - Only minimal interactivity (hover, click) is supported; advanced event handling or animations should use QGraphicsScene or custom solutions. 29 | 30 | --- 31 | Date: 2025-04-24 32 | Status: Implemented 33 | -------------------------------------------------------------------------------- /docs/decision-records/004-model-mapper.md: -------------------------------------------------------------------------------- 1 | # 004-model-mapper 2 | 3 | ## Stakeholders 4 | Alex Telford 5 | 6 | ## Status 7 | Completed 8 | 9 | ## Context 10 | 11 | In order to facilitate mapping data from a model to widgets we use s QDataWidgetMapper, however this does not work for user data. 12 | 13 | ## Decision 14 | 15 | Introduce a `ModelDataMapper` class to provide flexible, two-way data binding between a model usre data and arbitrary widget properties. This mapper supports custom setter and extractor functions, arbitrary roles, and automatic or manual commit of changes. It manages signal connections for two-way synchronization and is designed to work with any QObject-based widget or object. 16 | 17 | ## Consequences 18 | 19 | - Simplifies the implementation of editable forms and property editors. 20 | - Reduces boilerplate code for synchronizing model and widget data. 21 | - Enables custom mapping logic for complex data types or widget behaviors. 22 | - Requires careful management of signal connections and memory (handled via weak references). 23 | - Additional overhead for maintaining mappers 24 | - Additional overhead for objects that are removed out of turn 25 | 26 | ## Related ADRs 27 | - 002-bindings: General approach to data binding in the codebase. 28 | 29 | ## Example Usage 30 | 31 | ```python 32 | mapper = ModelDataMapper() 33 | mapper.set_model(model) 34 | mapper.add_mapping( 35 | spinbox, "value", role=QtCore.Qt.UserRole, 36 | fn_set=lambda w, d: w.setValue(d.get("quantity", 0)), 37 | fn_extract=lambda w, d: {**d, "quantity": w.value()}, 38 | signal=spinbox.valueChanged 39 | ) 40 | mapper.set_current_index(0) 41 | ``` 42 | 43 | This enables two-way synchronization between the model and the widget, with support for custom logic and roles. 44 | -------------------------------------------------------------------------------- /docs/decision-records/005-sliders.md: -------------------------------------------------------------------------------- 1 | # 5. Float and Range Sliders 2 | 3 | ## Status 4 | Completed 5 | 6 | ## Stakeholders 7 | Alex Telford 8 | 9 | ## Context 10 | We need float value sliders that have 11 | - Floating-point values with arbitrary precision 12 | - Hard and soft value ranges 13 | - Single value (FloatSlider) and range (RangeSlider) selection 14 | - Native Qt look and feel 15 | - Qt 5/6 compatibility 16 | 17 | ## Decision 18 | Two slider widget classes have been implemented: 19 | 20 | ### 1. FloatSlider 21 | A single-handle slider supporting floating-point values with: 22 | - Hard range: Absolute minimum and maximum values that cannot be exceeded 23 | - Soft range: Tha displayed range, this extends up to hard range if value is set outside tha range but within the hard range. 24 | - Interactive range: Dynamic intersection of soft and hard ranges 25 | - Configurable step sizes and decimal precision 26 | - Native Qt styling through QStylePainter 27 | 28 | ### 2. RangeSlider 29 | A dual-handle slider for selecting value ranges with: 30 | - Two handles for minimum and maximum value selection 31 | - Handles can pass each other (swapping min/max roles) 32 | - Soft range support matching FloatSlider 33 | - Custom painting with Qt native handle appearance because style painter draws line from 0 which is not valid here. 34 | 35 | ### Common Features 36 | Both sliders share: 37 | - Qt 5/6 compatibility through qtcompat layer 38 | - Mouse and keyboard interaction support 39 | - Value clamping and step size enforcement 40 | - Signal emission for value changes and slider events 41 | - Integration with met_qt binding system 42 | - Inheritance from AbstractSoftSlider for shared functionality 43 | 44 | ## Consequences 45 | 46 | ### Advantages 47 | 1. Flexible range control 48 | - Hard limits for absolute boundaries 49 | - Soft limits for non default but still valid ranges 50 | 51 | 2. Developer Benefits 52 | - Property API for basic usage 53 | - Consistent behavior across Qt versions 54 | 55 | ### Challenges 56 | Custom painting complexity in RangeSlider 57 | QStylePainter cannot draw the groove without putting a highlight blob at 0 58 | 59 | 60 | ## Notes 61 | - The widgets are demonstrated in slider_examples.py 62 | - Future improvements could include: 63 | - Support for non-linear values 64 | - Tick mark customization 65 | - Inner and outer range option 66 | -------------------------------------------------------------------------------- /docs/decision-records/006-model-data-mapper-api-simplification.md: -------------------------------------------------------------------------------- 1 | # 006-model-data-mapper-api-simplification 2 | 3 | ## Status 4 | Implemented 5 | 6 | ## Context 7 | 8 | The original `ModelDataMapper.add_mapping` API used `fn_set` and `fn_extract` to customize how data is transferred between the model and the widget property. This was flexible but the naming was not intuitive and did not clearly express the direction of data flow. Additionally, the signatures required both the widget and the model data, which was sometimes awkward for simple cases. 9 | 10 | ## Decision 11 | 12 | - Replace `fn_set` and `fn_extract` in `add_mapping` with two new callables: 13 | - `from_model(model_data) -> value`: Converts model data to the value to set on the widget property. Default: returns `model_data`. 14 | - `from_property(value, model_data) -> new_model_data`: Converts the property value (and current model data) to the new model data to store in the model. Default: returns `value`. 15 | - Update all usages and documentation to use the new API. 16 | - This change makes the direction of data flow explicit and simplifies the most common cases. 17 | 18 | ## Consequences 19 | 20 | - The API is more intuitive and easier to use for both simple and advanced mappings. 21 | - Existing code using `fn_set`/`fn_extract` must be updated to use `from_model`/`from_property`. 22 | - The new API is more consistent with other binding patterns in the codebase. 23 | 24 | ## Example (after) 25 | 26 | ```python 27 | mapper.add_mapping( 28 | spinbox, "value", role=QtCore.Qt.UserRole, 29 | from_model=lambda d: d.get("quantity", 0), 30 | from_property=lambda v, d: {**d, "quantity": v}, 31 | signal=spinbox.valueChanged 32 | ) 33 | ``` 34 | 35 | --- 36 | Date: 2025-04-26 37 | Status: Implemented 38 | -------------------------------------------------------------------------------- /examples/bindings.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from met_qt.core.binding import Bindings 3 | from PySide6 import QtWidgets 4 | 5 | class BindingExample(QtWidgets.QWidget): 6 | def __init__(self, parent=None): 7 | super(BindingExample, self).__init__(parent) 8 | self.setWindowTitle("Bindings Example") 9 | self.resize(400, 300) 10 | 11 | # Create widgets 12 | layout = QtWidgets.QVBoxLayout(self) 13 | 14 | # One-way binding example 15 | group1 = QtWidgets.QGroupBox("One-way Binding") 16 | layout1 = QtWidgets.QHBoxLayout(group1) 17 | spinbox = QtWidgets.QSpinBox() 18 | spinbox.setRange(0, 100) 19 | value_label = QtWidgets.QLabel("0") 20 | layout1.addWidget(QtWidgets.QLabel("Spin Box:")) 21 | layout1.addWidget(spinbox) 22 | layout1.addWidget(QtWidgets.QLabel("Label:")) 23 | layout1.addWidget(value_label) 24 | layout.addWidget(group1) 25 | 26 | # Two-way binding example 27 | group2 = QtWidgets.QGroupBox("Two-way Binding") 28 | layout2 = QtWidgets.QHBoxLayout(group2) 29 | edit1 = QtWidgets.QLineEdit() 30 | edit2 = QtWidgets.QLineEdit() 31 | layout2.addWidget(QtWidgets.QLabel("Edit 1:")) 32 | layout2.addWidget(edit1) 33 | layout2.addWidget(QtWidgets.QLabel("Edit 2:")) 34 | layout2.addWidget(edit2) 35 | layout.addWidget(group2) 36 | 37 | # Expression binding example 38 | group3 = QtWidgets.QGroupBox("Expression Binding") 39 | layout3 = QtWidgets.QHBoxLayout(group3) 40 | first_name = QtWidgets.QLineEdit() 41 | last_name = QtWidgets.QLineEdit() 42 | full_name = QtWidgets.QLineEdit() 43 | full_name.setReadOnly(True) 44 | layout3.addWidget(QtWidgets.QLabel("First:")) 45 | layout3.addWidget(first_name) 46 | layout3.addWidget(QtWidgets.QLabel("Last:")) 47 | layout3.addWidget(last_name) 48 | layout3.addWidget(QtWidgets.QLabel("Full:")) 49 | layout3.addWidget(full_name) 50 | layout.addWidget(group3) 51 | 52 | # Setup bindings 53 | bindings = Bindings(self) 54 | 55 | # One-way binding: spinbox -> label 56 | bindings.bind(spinbox, "value").to(value_label, "text", lambda x: f"Value: {int(x)}") 57 | 58 | # Two-way binding between line edits 59 | group = bindings.bind_group() 60 | group.add(edit1, "text") 61 | group.add(edit2, "text") 62 | 63 | # Expression binding: combine first and last names 64 | with bindings.bind_expression( 65 | full_name, "text", 66 | "{first} {last}") as expr: 67 | expr.bind("first", first_name, "text") 68 | expr.bind("last", last_name, "text") 69 | 70 | if __name__ == "__main__": 71 | app = QtWidgets.QApplication(sys.argv) 72 | widget = BindingExample() 73 | widget.show() 74 | sys.exit(app.exec()) 75 | -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | from PySide6 import QtWidgets, QtCore, QtGui 2 | from met_qt.gui import paint_utils 3 | from met_qt.core.binding import Bindings 4 | from met_qt.core.model_data_mapper import ModelDataMapper 5 | from met_qt.widgets.float_slider import FloatSlider 6 | from met_qt.widgets.range_slider import RangeSlider 7 | 8 | reference_date = QtCore.QDate(2025, 4, 7) 9 | 10 | PEOPLE = [ 11 | "Alice Tinker", 12 | "Bob Morgan", 13 | "Charlie Park", 14 | "David Chen", 15 | "Emma Williams", 16 | "Sophia Rodriguez", 17 | "Michael Johnson", 18 | "Olivia Smith", 19 | "Liam Brown", 20 | "Noah Davis", 21 | "Ava Wilson", 22 | "Isabella Martinez", 23 | ] 24 | 25 | SAMPLE_TICKET_DATA = [ 26 | { 27 | "id": "PIPE-101", 28 | "summary": "Fix pipeline issue", 29 | "assignee": "Alice Tinker", 30 | "comments": 2, 31 | "updated": reference_date.addDays(-3).toString("yyyy-MM-dd"), 32 | "reporter": "Emma Williams", 33 | "priority": 4.5 34 | }, 35 | { 36 | "id": "IT-202", 37 | "summary": "Resolve network problem", 38 | "assignee": "Bob Morgan", 39 | "comments": 0, 40 | "updated": reference_date.addDays(-8).toString("yyyy-MM-dd"), 41 | "reporter": "David Chen", 42 | "priority": 3.0 43 | }, 44 | { 45 | "id": "CREHELP-303", 46 | "summary": "Creature animation bug", 47 | "assignee": "Charlie Park", 48 | "comments": 14, 49 | "updated": reference_date.addDays(-1).toString("yyyy-MM-dd"), 50 | "reporter": "Sophia Rodriguez", 51 | "priority": 5.0 52 | }, 53 | { 54 | "id": "PIPE-104", 55 | "summary": "Optimize data flow", 56 | "assignee": "Alice Tinker", 57 | "comments": 0, 58 | "updated": reference_date.addDays(-12).toString("yyyy-MM-dd"), 59 | "reporter": "Michael Johnson", 60 | "priority": 2.5 61 | }, 62 | { 63 | "id": "IT-205", 64 | "summary": "Install new software", 65 | "assignee": "Bob Morgan", 66 | "comments": 0, 67 | "updated": reference_date.addDays(-6).toString("yyyy-MM-dd"), 68 | "reporter": "Olivia Smith", 69 | "priority": 1.5 70 | } 71 | ] 72 | 73 | class TicketItemDelegate(QtWidgets.QStyledItemDelegate): 74 | @staticmethod 75 | def priority_color(val): 76 | v = max(0.0, min(5.0, float(val))) / 5.0 77 | if v < 0.5: 78 | # Blue to orange transition 79 | r = int(30 + (255-30)*v*2) 80 | g = int(144 + (165-144)*v*2) 81 | b = int(255 - (255-0)*v*2) 82 | else: 83 | # Orange to red transition 84 | r = 255 85 | g = int(165 - (165-0)*(v-0.5)*2) 86 | b = 0 87 | return QtGui.QColor(r, g, b) 88 | 89 | def sizeHint(self, option, index): 90 | # Ideally you would get this from the actual data 91 | return QtCore.QSize(option.rect.width(), 60) 92 | 93 | def paint(self, painter, option, index): 94 | painter.save() 95 | ticket = index.data(QtCore.Qt.DisplayRole) 96 | data = index.data(QtCore.Qt.UserRole) 97 | if option.state & QtWidgets.QStyle.State_Selected: 98 | painter.fillRect(option.rect, option.palette.highlight()) 99 | rect = option.rect 100 | left_margin = 10 101 | spacing = 8 102 | circle_size = 30 103 | circle_rect = paint_utils.anchor((circle_size, circle_size), 104 | left=rect.left()+left_margin, 105 | top=rect.top() + (rect.height()-circle_size)//2) 106 | painter.setRenderHint(QtGui.QPainter.Antialiasing) 107 | name_hash = sum(ord(c) for c in data["reporter"]) 108 | hue = (name_hash % 360) / 360.0 109 | color = QtGui.QColor.fromHslF(hue, 0.7, 0.5) 110 | painter.setBrush(QtGui.QBrush(color)) 111 | painter.setPen(QtCore.Qt.NoPen) 112 | painter.drawEllipse(circle_rect) 113 | initials = "".join(name[0].upper() for name in data["reporter"].split() if name) 114 | painter.setPen(QtCore.Qt.white) 115 | paint_utils.draw_text(painter, circle_rect, QtCore.Qt.AlignCenter, initials) 116 | 117 | text_left = circle_rect.right() + spacing 118 | text_width = rect.width() - text_left - 10 119 | id_rect = paint_utils.anchor((text_width-100, 20), left=text_left, top=rect.top()+2) 120 | painter.setPen(option.palette.text().color()) 121 | id_rect = paint_utils.draw_text(painter, id_rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop, ticket) 122 | 123 | priority_size = id_rect.height() - 5 124 | priority_rect = paint_utils.anchor((priority_size, priority_size), 125 | left=id_rect.right()+spacing, 126 | vcenter=id_rect.center().y()) 127 | painter.setBrush(self.priority_color(data['priority'])) 128 | painter.setPen(QtCore.Qt.NoPen) 129 | paint_utils.draw_partially_rounded_rect( 130 | painter, priority_rect, 4, 8, 8, 4 131 | ) 132 | painter.setPen(QtCore.Qt.white) 133 | 134 | summary_rect = paint_utils.anchor((text_width-100, 20), left=text_left, top=id_rect.bottom()-2) 135 | paint_utils.draw_text(painter, summary_rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop, data["summary"]) 136 | 137 | assignee_rect = paint_utils.anchor((140, 20), right=rect.right()-10, top=rect.top()+2) 138 | paint_utils.draw_text(painter, assignee_rect, QtCore.Qt.AlignRight | QtCore.Qt.AlignTop, data["assignee"]) 139 | 140 | comments_text = f"{data['comments']} 🗩" 141 | comments_rect = paint_utils.anchor((140, 20), right=rect.right()-10, top=assignee_rect.bottom()-2) 142 | paint_utils.draw_text(painter, comments_rect, QtCore.Qt.AlignRight | QtCore.Qt.AlignTop, comments_text) 143 | 144 | updated_date = QtCore.QDate.fromString(data["updated"], "yyyy-MM-dd") 145 | current_date = QtCore.QDate.currentDate() 146 | days_diff = updated_date.daysTo(current_date) 147 | days_text = f"{days_diff} days ago" 148 | days_rect = paint_utils.anchor((140, 20), right=rect.right()-10, top=comments_rect.bottom()-2) 149 | paint_utils.draw_text(painter, days_rect, QtCore.Qt.AlignRight | QtCore.Qt.AlignTop, days_text) 150 | painter.restore() 151 | 152 | class TicketListWidget(QtWidgets.QWidget): 153 | def __init__(self, parent=None): 154 | super().__init__(parent) 155 | self.setWindowTitle("Ticket List View") 156 | 157 | self.setMinimumSize(700, 400) 158 | 159 | layout = QtWidgets.QVBoxLayout(self) 160 | splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) 161 | layout.addWidget(splitter) 162 | # ListView 163 | self.list_view = QtWidgets.QListView() 164 | self.list_view.setAlternatingRowColors(True) 165 | self.list_view.setItemDelegate(TicketItemDelegate(self)) 166 | splitter.addWidget(self.list_view) 167 | # Model 168 | self.model = QtGui.QStandardItemModel() 169 | for ticket in SAMPLE_TICKET_DATA: 170 | item = QtGui.QStandardItem(ticket["id"]) 171 | item.setData(ticket, QtCore.Qt.UserRole) 172 | self.model.appendRow(item) 173 | self.list_view.setModel(self.model) 174 | self.list_view.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) 175 | self.list_view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) 176 | # Form Layout 177 | form_widget = QtWidgets.QWidget() 178 | form_layout = QtWidgets.QFormLayout(form_widget) 179 | self.edit_summary = QtWidgets.QLineEdit() 180 | self.label_updated = QtWidgets.QLabel() 181 | self.combo_reporter = QtWidgets.QComboBox() 182 | self.combo_reporter.addItems(PEOPLE) 183 | self.combo_assignee = QtWidgets.QComboBox() 184 | self.combo_assignee.addItems(PEOPLE) 185 | self.float_priority = FloatSlider() 186 | self.float_priority.range = (0.0, 5.0) 187 | form_layout.addRow("Summary", self.edit_summary) 188 | form_layout.addRow("Updated", self.label_updated) 189 | form_layout.addRow("Reporter", self.combo_reporter) 190 | form_layout.addRow("Assignee", self.combo_assignee) 191 | form_layout.addRow("Priority", self.float_priority) 192 | splitter.addWidget(form_widget) 193 | splitter.setStretchFactor(1, 2) 194 | # Model Mapper 195 | self.mapper = ModelDataMapper(self) 196 | self.mapper.set_model(self.model) 197 | self.mapper.add_mapping(self.edit_summary, "text", role=QtCore.Qt.UserRole, 198 | from_model=lambda d: d.get("summary", ""), 199 | from_property=lambda v, d: {**d, "summary": v}, 200 | signal=self.edit_summary.textChanged) 201 | self.mapper.add_mapping(self.label_updated, "text", role=QtCore.Qt.UserRole, 202 | from_model=lambda d: self._format_updated(d.get("updated")), 203 | from_property=None) 204 | self.mapper.add_mapping(self.combo_reporter, "currentText", role=QtCore.Qt.UserRole, 205 | from_model=lambda d: d.get("reporter", PEOPLE[0]), 206 | from_property=lambda v, d: {**d, "reporter": v}) 207 | self.mapper.add_mapping(self.combo_assignee, "currentText", role=QtCore.Qt.UserRole, 208 | from_model=lambda d: d.get("assignee", PEOPLE[0]), 209 | from_property=lambda v, d: {**d, "assignee": v}) 210 | self.mapper.add_mapping(self.float_priority, "value", role=QtCore.Qt.UserRole, 211 | from_model=lambda d: d.get("priority", 1.0), 212 | from_property=lambda v, d: {**d, "priority": v}) 213 | self.list_view.selectionModel().currentChanged.connect(self._on_selection_changed) 214 | self.mapper.set_current_index(0) 215 | self.list_view.setCurrentIndex(self.model.index(0, 0)) 216 | 217 | # First range slider example - with soft range 218 | slider_group = QtWidgets.QGroupBox("Sliders") 219 | slider_layout = QtWidgets.QVBoxLayout(slider_group) 220 | 221 | float_layout = QtWidgets.QHBoxLayout() 222 | slider_layout.addLayout(float_layout) 223 | 224 | float_slider = FloatSlider() 225 | float_slider.range = (-10.0, 10.0) 226 | float_layout.addWidget(float_slider) 227 | float_layout.setStretchFactor(float_slider, 1) 228 | 229 | spin_value = QtWidgets.QDoubleSpinBox() 230 | spin_value.setRange(-10.0, 10.0) 231 | spin_value.setDecimals(2) 232 | float_layout.addWidget(spin_value) 233 | 234 | range_layout = QtWidgets.QHBoxLayout() 235 | slider_layout.addLayout(range_layout) 236 | 237 | spin_min = QtWidgets.QDoubleSpinBox() 238 | spin_min.setRange(0.0, 100.0) 239 | spin_min.setDecimals(2) 240 | range_layout.addWidget(spin_min) 241 | 242 | range_slider = RangeSlider() 243 | range_slider.range = (0.0, 100.0) 244 | range_slider.soft_range = (20.0, 80.0) 245 | range_layout.addWidget(range_slider) 246 | range_layout.setStretchFactor(range_slider, 1) 247 | 248 | spin_max = QtWidgets.QDoubleSpinBox() 249 | spin_max.setRange(0.0, 100.0) 250 | spin_max.setDecimals(2) 251 | range_layout.addWidget(spin_max) 252 | 253 | bindings = Bindings(self) 254 | with bindings.bind_group() as group: 255 | group.add(float_slider, "value") 256 | group.add(spin_value, "value") 257 | 258 | with bindings.bind_group() as group: 259 | group.add(range_slider, "min_value") 260 | group.add(spin_min, "value") 261 | 262 | with bindings.bind_group() as group: 263 | group.add(range_slider, "max_value") 264 | group.add(spin_max, "value") 265 | range_slider.min_value = 30 266 | range_slider.max_value = 70 267 | 268 | # Add all groups to main layout 269 | layout.addWidget(slider_group) 270 | layout.setStretchFactor(splitter, 1) 271 | 272 | def _on_selection_changed(self, current, previous): 273 | self.mapper.set_current_index(current.row()) 274 | 275 | def _format_updated(self, date_str): 276 | if not date_str: 277 | return "" 278 | updated_date = QtCore.QDate.fromString(date_str, "yyyy-MM-dd") 279 | current_date = QtCore.QDate.currentDate() 280 | days_diff = updated_date.daysTo(current_date) 281 | if days_diff == 0: 282 | return "Today" 283 | elif days_diff == 1: 284 | return "Yesterday" 285 | else: 286 | return f"{days_diff} days ago" 287 | 288 | if __name__ == "__main__": 289 | import sys 290 | app = QtWidgets.QApplication(sys.argv) 291 | widget = TicketListWidget() 292 | widget.show() 293 | sys.exit(app.exec()) -------------------------------------------------------------------------------- /examples/element_editor_example.py: -------------------------------------------------------------------------------- 1 | from met_qt._internal.qtcompat import QtCore, QtGui, QtWidgets 2 | from met_qt.core.model_data_mapper import ModelDataMapper 3 | 4 | FORMATS = ["usd", "fbx", "alembic", "obj", "ma", "mb"] 5 | HEADERS = ["Element Name", "Quantity", "Format"] 6 | BASE_ELEMENTS = [ 7 | ["chrHuman01", 3, "usd"], 8 | ["chrOrc01", 2, "fbx"], 9 | ["amlCheetah02", 1, "alembic"], 10 | ["prpSword01", 5, "usd"], 11 | ["envForest01", 1, "alembic"], 12 | ] 13 | 14 | def create_model(): 15 | DATA = [] 16 | for i in range(20): 17 | for elem in BASE_ELEMENTS: 18 | new_elem = elem.copy() 19 | new_elem[0] = f"{new_elem[0]}_{i+1:02d}" 20 | DATA.append(new_elem) 21 | model = QtGui.QStandardItemModel() 22 | model.setHorizontalHeaderLabels(HEADERS) 23 | for data_row in DATA: 24 | item = QtGui.QStandardItem(str(data_row[0])) 25 | item.setData({ 26 | "quantity": data_row[1], 27 | "format": data_row[2], 28 | }, QtCore.Qt.UserRole) 29 | model.appendRow(item) 30 | return model 31 | 32 | class ElementEditorWidget(QtWidgets.QWidget): 33 | def __init__(self, parent=None): 34 | super().__init__(parent) 35 | layout = QtWidgets.QHBoxLayout(self) 36 | # ListView 37 | self.list_view = QtWidgets.QListView() 38 | self.list_view.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) 39 | self.model = create_model() 40 | self.list_view.setModel(self.model) 41 | layout.addWidget(self.list_view, 1) 42 | # Form 43 | form_widget = QtWidgets.QWidget() 44 | form_layout = QtWidgets.QFormLayout(form_widget) 45 | self.label_name = QtWidgets.QLabel() 46 | self.spin_quantity = QtWidgets.QSpinBox() 47 | self.spin_quantity.setRange(0, 999) 48 | self.combo_format = QtWidgets.QComboBox() 49 | self.combo_format.addItems(FORMATS) 50 | form_layout.addRow("Element Name", self.label_name) 51 | form_layout.addRow("Quantity", self.spin_quantity) 52 | form_layout.addRow("Format", self.combo_format) 53 | layout.addWidget(form_widget, 2) 54 | # Mapper 55 | self.mapper = ModelDataMapper(self) 56 | self.mapper.set_model(self.model) 57 | self.mapper.add_mapping(self.label_name, "text", role=QtCore.Qt.DisplayRole) 58 | self.mapper.add_mapping( 59 | self.spin_quantity, "value", role=QtCore.Qt.UserRole, 60 | from_model=lambda d: d.get("quantity", 0), 61 | from_property=lambda v, d: {**d, "quantity": v}, 62 | signal=self.spin_quantity.valueChanged 63 | ) 64 | self.mapper.add_mapping( 65 | self.combo_format, "currentText", role=QtCore.Qt.UserRole, 66 | from_model=lambda d: d.get("format", FORMATS[0]), 67 | from_property=lambda v, d: {**d, "format": v}, 68 | signal=self.combo_format.currentTextChanged 69 | ) 70 | self.list_view.selectionModel().currentChanged.connect(self._on_selection_changed) 71 | self.mapper.set_current_index(0) 72 | self.list_view.setCurrentIndex(self.model.index(0, 0)) 73 | 74 | def _on_selection_changed(self, current, previous): 75 | self.mapper.set_current_index(current.row()) 76 | 77 | if __name__ == "__main__": 78 | import sys 79 | app = QtWidgets.QApplication(sys.argv) 80 | w = ElementEditorWidget() 81 | w.setWindowTitle("Element Editor Example") 82 | w.resize(600, 400) 83 | w.show() 84 | sys.exit(app.exec()) 85 | -------------------------------------------------------------------------------- /examples/paint_layout.py: -------------------------------------------------------------------------------- 1 | from met_qt.gui.paint_layout import BoxPaintLayout, BoxShape, BoxText, PaintStyle, ShapeType, BoxPaintItem, CornerFlag 2 | from PySide6 import QtWidgets, QtCore, QtGui 3 | 4 | 5 | class BoxLayoutWidget(QtWidgets.QWidget): 6 | """Widget that uses box layouts for painting and positioning nested items.""" 7 | def __init__(self, parent=None): 8 | super().__init__(parent) 9 | self.setMouseTracking(True) 10 | layout = BoxPaintLayout() 11 | layout.setContentsMargins(0, 0, 0, 0) 12 | layout.setSpacing(0) 13 | self.setLayout(layout) 14 | self.setMinimumSize(100, 100) 15 | 16 | def paintEvent(self, event): 17 | painter = QtGui.QPainter() 18 | try: 19 | if not painter.begin(self): 20 | return 21 | opt = QtWidgets.QStyleOption() 22 | opt.initFrom(self) 23 | self.style().drawPrimitive(QtWidgets.QStyle.PrimitiveElement.PE_Widget, opt, painter, self) 24 | painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) 25 | BoxPaintLayout.render(self.layout(), painter, self.mapFromGlobal(QtGui.QCursor.pos())) 26 | finally: 27 | painter.end() 28 | 29 | def mousePressEvent(self, event): 30 | if event.button() == QtCore.Qt.MouseButton.LeftButton: 31 | hit_items = BoxPaintLayout.hit_test(self.layout(), event.pos()) 32 | if hit_items: 33 | self.layout().clicked.emit() 34 | event.accept() 35 | return 36 | super().mousePressEvent(event) 37 | 38 | def mouseMoveEvent(self, event): 39 | self.update() 40 | super().mouseMoveEvent(event) 41 | 42 | def leaveEvent(self, event): 43 | self.update() 44 | super().leaveEvent(event) 45 | 46 | 47 | # --- Example Usage --- 48 | if __name__ == "__main__": 49 | print("Starting nested box layout example with standard Qt layouts") 50 | app = QtWidgets.QApplication(sys.argv) 51 | 52 | window = QtWidgets.QMainWindow() 53 | 54 | # Create the main widget 55 | box_widget = BoxLayoutWidget() 56 | palette = box_widget.palette() 57 | palette.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor("black")) 58 | box_widget.setPalette(palette) 59 | 60 | # Example fonts 61 | font_bold = QtGui.QFont() 62 | font_bold.setBold(True) 63 | font_italic = QtGui.QFont() 64 | font_italic.setItalic(True) 65 | font_large = QtGui.QFont() 66 | font_large.setPointSize(16) 67 | 68 | # 1. Add a top-level box (rectangle, centered bold text) 69 | top_shape = BoxShape( 70 | style=PaintStyle(brush_color=QtGui.QColor("dodgerblue")), 71 | hover_style=PaintStyle(brush_color=QtGui.QColor("lightyellow")), # Light yellow highlight on hover 72 | shape_type=ShapeType.Box 73 | ) 74 | top_text = BoxText( 75 | text="Main Container", 76 | alignment=QtCore.Qt.AlignmentFlag.AlignCenter, 77 | font=font_bold, 78 | style=PaintStyle() 79 | ) 80 | top_box = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 81 | top_box.setObjectName("top_box") 82 | top_box.setSizeHint((400, 500)) 83 | top_box.set_paint_items([BoxPaintItem(shape=top_shape, text=top_text)]) 84 | 85 | # 2. Nested box: rounded, left-aligned italic text, with hover style 86 | nested_shape = BoxShape( 87 | style=PaintStyle(brush_color=QtGui.QColor("orange")), 88 | hover_style=PaintStyle(brush_color=QtGui.QColor("#FFD580")), 89 | shape_type=ShapeType.Box, 90 | corner_radius=15 91 | ) 92 | nested_text = BoxText( 93 | text="Horizontal Box", 94 | alignment=QtCore.Qt.AlignmentFlag.AlignLeft, 95 | font=font_italic, 96 | ) 97 | nested_box1 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.LeftToRight) 98 | nested_box1.setObjectName("nested_box1") 99 | nested_box1.setSizeHint((200, 100)) 100 | nested_box1.set_paint_items([BoxPaintItem(shape=nested_shape, text=nested_text)]) 101 | 102 | # 3. Inner box: rounded, right-aligned large text, with disabled style 103 | inner_shape1 = BoxShape( 104 | style=PaintStyle(brush_color=QtGui.QColor("lightgreen")), 105 | hover_style=PaintStyle(brush_color=QtGui.QColor("#red")), 106 | disabled_style=PaintStyle(brush_color=QtGui.QColor("#A0A0A0")), 107 | shape_type=ShapeType.Box, 108 | corner_radius=10, 109 | rounded_corners=CornerFlag.TopLeft | CornerFlag.BottomLeft 110 | ) 111 | inner_text1 = BoxText( 112 | text="Green Box", 113 | alignment=QtCore.Qt.AlignmentFlag.AlignRight, 114 | font=font_large, 115 | ) 116 | inner_box1 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 117 | inner_box1.setObjectName("inner_box1") 118 | inner_box1.setSizeHint((90, 90)) 119 | inner_box1.set_paint_items([BoxPaintItem(shape=inner_shape1, text=inner_text1)]) 120 | 121 | # 4. Inner box: rounded, word-wrapped, centered text, invisible 122 | inner_shape2 = BoxShape( 123 | style=PaintStyle(brush_role=QtGui.QPalette.ColorRole.Highlight), 124 | shape_type=ShapeType.Box, 125 | corner_radius=10, 126 | rounded_corners=CornerFlag.TopRight | CornerFlag.BottomRight, 127 | visible=False 128 | ) 129 | inner_text2 = BoxText( 130 | text="Highlight\nBox", 131 | alignment=QtCore.Qt.AlignmentFlag.AlignCenter, 132 | wordWrap=True, 133 | style=PaintStyle(), 134 | visible=False 135 | ) 136 | inner_box2 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 137 | inner_box2.setObjectName("inner_box2") 138 | inner_box2.setSizeHint((90, 90)) 139 | inner_box2.set_paint_items([BoxPaintItem(shape=inner_shape2, text=inner_text2)]) 140 | 141 | # 5. Custom path (star), top-aligned text 142 | star_path = QtGui.QPainterPath() 143 | star_path.moveTo(50, 0) 144 | star_path.lineTo(60, 35) 145 | star_path.lineTo(100, 35) 146 | star_path.lineTo(70, 60) 147 | star_path.lineTo(80, 100) 148 | star_path.lineTo(50, 75) 149 | star_path.lineTo(20, 100) 150 | star_path.lineTo(30, 60) 151 | star_path.lineTo(0, 35) 152 | star_path.lineTo(40, 35) 153 | star_path.closeSubpath() 154 | star_shape = BoxShape( 155 | style=PaintStyle(brush_color=QtGui.QColor("lightpink")), 156 | shape_type=ShapeType.Path, 157 | painter_path=star_path 158 | ) 159 | star_text = BoxText( 160 | text="Star Shape", 161 | alignment=QtCore.Qt.AlignmentFlag.AlignTop, 162 | ) 163 | nested_box2 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 164 | nested_box2.setObjectName("nested_box2") 165 | nested_box2.setSizeHint((200, 100)) 166 | nested_box2.set_paint_items([BoxPaintItem(shape=star_shape, text=star_text)]) 167 | 168 | # 6. Grid layout with painted boxes (various alignments) 169 | standard_grid = QtWidgets.QGridLayout() 170 | standard_grid.setContentsMargins(10, 10, 10, 10) 171 | standard_grid.setSpacing(8) 172 | grid_box1 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 173 | grid_box1.setObjectName("grid_box1") 174 | grid_box1.setSizeHint((80, 60)) 175 | grid_box1.set_paint_items([ 176 | BoxPaintItem( 177 | shape=BoxShape(style=PaintStyle(brush_color=QtGui.QColor("lightcoral")), 178 | hover_style=PaintStyle(brush_color=QtGui.QColor("red")), # Light blue highlight 179 | shape_type=ShapeType.Box, corner_radius=5), 180 | text="Grid 1,1" 181 | ) 182 | ]) 183 | grid_box2 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 184 | grid_box2.setObjectName("grid_box2") 185 | grid_box2.setSizeHint((80, 60)) 186 | grid_box2.set_paint_items([ 187 | BoxPaintItem( 188 | shape=BoxShape(style=PaintStyle(brush_color=QtGui.QColor("palegreen")), shape_type=ShapeType.Box), 189 | text="Grid 1,2" 190 | ) 191 | ]) 192 | grid_box3 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 193 | grid_box3.setObjectName("grid_box3") 194 | grid_box3.setSizeHint((80, 60)) 195 | grid_box3.set_paint_items([ 196 | BoxPaintItem( 197 | shape=BoxShape(style=PaintStyle(brush_color=QtGui.QColor("cornflowerblue")), shape_type=ShapeType.Box, corner_radius=8, rounded_corners=CornerFlag.TopLeft | CornerFlag.BottomRight), 198 | text="Grid 2,1" 199 | ) 200 | ]) 201 | grid_box4 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 202 | grid_box4.setObjectName("grid_box4") 203 | grid_box4.setSizeHint((80, 60)) 204 | grid_box4.set_paint_items([ 205 | BoxPaintItem( 206 | shape=BoxShape(style=PaintStyle(brush_color=QtGui.QColor("khaki")), shape_type=ShapeType.Box), 207 | text="Grid 2,2" 208 | ) 209 | ]) 210 | standard_grid.addLayout(grid_box1, 0, 0) 211 | standard_grid.addLayout(grid_box2, 0, 1) 212 | standard_grid.addLayout(grid_box3, 1, 0) 213 | standard_grid.addLayout(grid_box4, 1, 1) 214 | 215 | # 7. Horizontal layout with painted boxes (diamond, different fonts) 216 | standard_hbox = QtWidgets.QHBoxLayout() 217 | standard_hbox.setContentsMargins(5, 5, 5, 5) 218 | standard_hbox.setSpacing(10) 219 | hbox_item1 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 220 | hbox_item1.setObjectName("hbox_item1") 221 | hbox_item1.setSizeHint((60, 40)) 222 | hbox_item1.set_paint_items([ 223 | BoxPaintItem( 224 | shape=BoxShape(style=PaintStyle(brush_color=QtGui.QColor("plum")), shape_type=ShapeType.Box, corner_radius=5, rounded_corners=CornerFlag.TopLeft | CornerFlag.TopRight), 225 | text=BoxText(text="HBox 1", alignment=QtCore.Qt.AlignmentFlag.AlignLeft, font=font_bold, style=PaintStyle()) 226 | ) 227 | ]) 228 | hbox_item2 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 229 | hbox_item2.setObjectName("hbox_item2") 230 | hbox_item2.setSizeHint((60, 40)) 231 | hbox_item2.set_paint_items([ 232 | BoxPaintItem( 233 | shape=BoxShape(style=PaintStyle(brush_color=QtGui.QColor("mediumaquamarine")), shape_type=ShapeType.Box), 234 | text=BoxText(text="HBox 2", alignment=QtCore.Qt.AlignmentFlag.AlignRight, font=font_italic, style=PaintStyle()) 235 | ) 236 | ]) 237 | diamond_path = QtGui.QPainterPath() 238 | diamond_path.moveTo(25, 0) 239 | diamond_path.lineTo(50, 20) 240 | diamond_path.lineTo(25, 40) 241 | diamond_path.lineTo(0, 20) 242 | diamond_path.closeSubpath() 243 | hbox_item3 = BoxPaintLayout(QtWidgets.QBoxLayout.Direction.TopToBottom) 244 | hbox_item3.setObjectName("hbox_item3") 245 | hbox_item3.setSizeHint((60, 40)) 246 | hbox_item3.set_paint_items([ 247 | BoxPaintItem( 248 | shape=BoxShape(style=PaintStyle(brush_color=QtGui.QColor("gold")), shape_type=ShapeType.Path, painter_path=diamond_path), 249 | text=BoxText(text="Diamond", alignment=QtCore.Qt.AlignmentFlag.AlignCenter, font=font_large, style=PaintStyle()) 250 | ) 251 | ]) 252 | standard_hbox.addLayout(hbox_item1) 253 | standard_hbox.addLayout(hbox_item2) 254 | standard_hbox.addLayout(hbox_item3) 255 | 256 | # Add all containers and layouts to the top box 257 | top_box.addLayout(nested_box1) 258 | top_box.addSpacing(10) 259 | top_box.addLayout(nested_box2) 260 | top_box.addSpacing(15) 261 | top_box.addLayout(standard_grid) 262 | top_box.addSpacing(15) 263 | top_box.addLayout(standard_hbox) 264 | 265 | # Add the top box to the widget 266 | box_widget.layout().addLayout(top_box) 267 | 268 | # Set up the main window 269 | window.setCentralWidget(box_widget) 270 | window.setWindowTitle("Painted Layouts Integration") 271 | window.setGeometry(100, 100, 500, 700) 272 | 273 | window.show() 274 | sys.exit(app.exec()) 275 | 276 | 277 | -------------------------------------------------------------------------------- /examples/paint_utils.py: -------------------------------------------------------------------------------- 1 | from PySide6 import QtWidgets, QtCore, QtGui 2 | from met_qt.gui import paint_utils 3 | 4 | 5 | 6 | class PainterExampleWidget(QtWidgets.QWidget): 7 | def paintEvent(self, event): 8 | painter = QtGui.QPainter(self) 9 | # # Apply a transform for testing 10 | # transform = QtGui.QTransform() 11 | # transform.translate(self.width() // 2, self.height() // 2) 12 | # transform.rotate(15) 13 | # transform.scale(1.2, 0.8) 14 | # transform.translate(-self.width() // 2, -self.height() // 2) 15 | # painter.setTransform(transform) 16 | 17 | event_rect = event.rect() 18 | spacing = 10 19 | # Top row: two left, two right, two center (center squish) 20 | # Left 21 | text_rect = paint_utils.draw_text(painter, paint_utils.anchor((120, 30), left=event_rect.left()+spacing, top=event_rect.top()+spacing), QtCore.Qt.AlignLeft, "Hello Anchor") 22 | rect_rect = paint_utils.anchor((80, 40), left=text_rect.right()+spacing, top=event_rect.top()+spacing) 23 | painter.drawRect(rect_rect) 24 | # Right 25 | option_btn_right = QtWidgets.QStyleOptionButton() 26 | option_btn_right.rect = paint_utils.anchor((80, 40), right=event_rect.right()-spacing, top=event_rect.top()+spacing) 27 | option_btn_right.text = "Right Btn" 28 | right_btn_rect = paint_utils.draw_control(painter, QtWidgets.QStyle.CE_PushButton, option_btn_right) 29 | option_btn = QtWidgets.QStyleOptionButton() 30 | option_btn.rect = paint_utils.anchor((80, 40), right=option_btn_right.rect.left()-spacing, top=event_rect.top()+spacing) 31 | option_btn.text = "Btn" 32 | control_rect = paint_utils.draw_control(painter, QtWidgets.QStyle.CE_PushButton, option_btn) 33 | # Center (squish between left and right) 34 | center_left = rect_rect.right() + spacing 35 | center_right = option_btn.rect.left() - spacing 36 | center_height = 40 37 | rounded_rect = paint_utils.draw_partially_rounded_rect( 38 | painter, 39 | paint_utils.anchor((0, center_height), 40 | left=center_left, right=(center_left+center_right)//2-spacing//2, 41 | top=event_rect.top()+spacing), 0, 0, 10, 0) 42 | ellipse_rect = paint_utils.anchor((0, center_height), left=(center_left+center_right)//2+spacing//2, right=center_right, top=event_rect.top()+spacing) 43 | painter.drawEllipse(ellipse_rect) 44 | # Bottom row, left-aligned 45 | pixmap = QtGui.QPixmap(40, 40) 46 | pixmap.fill(QtGui.QColor('blue')) 47 | pixmap_rect = paint_utils.anchor((40, 40), left=event_rect.left()+spacing, bottom=event_rect.bottom()-spacing-40) 48 | painter.drawPixmap(pixmap_rect, pixmap) 49 | image = QtGui.QImage(40, 40, QtGui.QImage.Format_ARGB32) 50 | image.fill(QtGui.QColor('green')) 51 | image_rect = paint_utils.anchor((40, 40), left=pixmap_rect.right()+spacing, bottom=event_rect.bottom()-spacing-40) 52 | painter.drawImage(image_rect, image) 53 | # Centered horizontally at the bottom 54 | center_x = event_rect.left() + (event_rect.width() - 100) // 2 55 | itemtext_rect = paint_utils.draw_item_text(painter, paint_utils.anchor((100, 30), left=center_x, bottom=event_rect.bottom()-spacing), QtCore.Qt.AlignCenter, self.palette(), True, "Centered Text") 56 | # Path between bottom left and bottom right 57 | path = QtGui.QPainterPath() 58 | path.moveTo(pixmap_rect.left(), pixmap_rect.bottom()-10) 59 | path.cubicTo(pixmap_rect.left()+40, pixmap_rect.bottom()-60, image_rect.right()+40, itemtext_rect.top()-20, event_rect.right()-spacing, itemtext_rect.bottom()-10) 60 | path_rect = paint_utils.draw_path(painter, path) 61 | 62 | if __name__ == "__main__": 63 | app = QtWidgets.QApplication(sys.argv) 64 | w = PainterExampleWidget() 65 | w.resize(650, 120) 66 | w.setWindowTitle("Painter Example") 67 | w.show() 68 | sys.exit(app.exec()) -------------------------------------------------------------------------------- /examples/slider_examples.py: -------------------------------------------------------------------------------- 1 | from met_qt._internal.qtcompat import QtWidgets, QtCore 2 | from met_qt.widgets.float_slider import FloatSlider 3 | from met_qt.widgets.range_slider import RangeSlider 4 | from met_qt.core.binding import Bindings 5 | import sys 6 | 7 | class SliderExamplesDemo(QtWidgets.QWidget): 8 | def __init__(self): 9 | super().__init__() 10 | self.setWindowTitle("Slider Examples") 11 | layout = QtWidgets.QVBoxLayout(self) 12 | 13 | # First float slider example - with soft range 14 | float_group1 = QtWidgets.QGroupBox("Float Slider with Soft Range") 15 | float_layout1 = QtWidgets.QHBoxLayout(float_group1) 16 | 17 | float_slider1 = FloatSlider() 18 | float_slider1.range = (0.0, 100.0) 19 | float_slider1.soft_range = (20.0, 80.0) 20 | float_layout1.addWidget(float_slider1) 21 | 22 | spin_value1 = QtWidgets.QDoubleSpinBox() 23 | spin_value1.setRange(0.0, 100.0) 24 | spin_value1.setDecimals(3) 25 | float_layout1.addWidget(QtWidgets.QLabel("Value:")) 26 | float_layout1.addWidget(spin_value1) 27 | 28 | bindings = Bindings(self) 29 | group = bindings.bind_group() 30 | group.add(float_slider1, "value") 31 | group.add(spin_value1, "value") 32 | float_slider1.value = 50 33 | 34 | # Second float slider example - basic 35 | float_group2 = QtWidgets.QGroupBox("Basic Float Slider") 36 | float_layout2 = QtWidgets.QHBoxLayout(float_group2) 37 | 38 | float_slider2 = FloatSlider() 39 | float_slider2.range = (-10.0, 10.0) 40 | float_layout2.addWidget(float_slider2) 41 | 42 | spin_value2 = QtWidgets.QDoubleSpinBox() 43 | spin_value2.setRange(-10.0, 10.0) 44 | spin_value2.setDecimals(2) 45 | float_layout2.addWidget(QtWidgets.QLabel("Value:")) 46 | float_layout2.addWidget(spin_value2) 47 | 48 | group = bindings.bind_group() 49 | group.add(float_slider2, "value") 50 | group.add(spin_value2, "value") 51 | float_slider2.value = 0 52 | 53 | # First range slider example - with soft range 54 | range_group1 = QtWidgets.QGroupBox("Range Slider with Soft Range") 55 | range_layout1 = QtWidgets.QHBoxLayout(range_group1) 56 | 57 | spin_min1 = QtWidgets.QDoubleSpinBox() 58 | spin_min1.setRange(0.0, 100.0) 59 | spin_min1.setDecimals(3) 60 | range_layout1.addWidget(QtWidgets.QLabel("Min:")) 61 | range_layout1.addWidget(spin_min1) 62 | 63 | range_slider1 = RangeSlider() 64 | range_slider1.range = (0.0, 100.0) 65 | range_slider1.soft_range = (20.0, 80.0) 66 | range_layout1.addWidget(range_slider1) 67 | 68 | spin_max1 = QtWidgets.QDoubleSpinBox() 69 | spin_max1.setRange(0.0, 100.0) 70 | spin_max1.setDecimals(3) 71 | range_layout1.addWidget(QtWidgets.QLabel("Max:")) 72 | range_layout1.addWidget(spin_max1) 73 | 74 | group = bindings.bind_group() 75 | group.add(range_slider1, "min_value") 76 | group.add(spin_min1, "value") 77 | group = bindings.bind_group() 78 | group.add(range_slider1, "max_value") 79 | group.add(spin_max1, "value") 80 | range_slider1.min_value = 30 81 | range_slider1.max_value = 70 82 | 83 | # Second range slider example - percentage 84 | range_group2 = QtWidgets.QGroupBox("Percentage Range Slider") 85 | range_layout2 = QtWidgets.QHBoxLayout(range_group2) 86 | 87 | spin_min2 = QtWidgets.QDoubleSpinBox() 88 | spin_min2.setRange(0.0, 100.0) 89 | spin_min2.setDecimals(1) 90 | spin_min2.setSuffix("%") 91 | range_layout2.addWidget(QtWidgets.QLabel("Min:")) 92 | range_layout2.addWidget(spin_min2) 93 | 94 | range_slider2 = RangeSlider() 95 | range_slider2.range = (0.0, 100.0) 96 | range_layout2.addWidget(range_slider2) 97 | 98 | spin_max2 = QtWidgets.QDoubleSpinBox() 99 | spin_max2.setRange(0.0, 100.0) 100 | spin_max2.setDecimals(1) 101 | spin_max2.setSuffix("%") 102 | range_layout2.addWidget(QtWidgets.QLabel("Max:")) 103 | range_layout2.addWidget(spin_max2) 104 | 105 | group = bindings.bind_group() 106 | group.add(range_slider2, "min_value") 107 | group.add(spin_min2, "value") 108 | group = bindings.bind_group() 109 | group.add(range_slider2, "max_value") 110 | group.add(spin_max2, "value") 111 | range_slider2.min_value = 25 112 | range_slider2.max_value = 75 113 | 114 | # Add all groups to main layout 115 | layout.addWidget(float_group1) 116 | layout.addWidget(float_group2) 117 | layout.addWidget(range_group1) 118 | layout.addWidget(range_group2) 119 | 120 | if __name__ == "__main__": 121 | app = QtWidgets.QApplication(sys.argv) 122 | demo = SliderExamplesDemo() 123 | demo.show() 124 | sys.exit(app.exec_()) 125 | -------------------------------------------------------------------------------- /met_qt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalefforttech/met_qt/e9d7ea543f61c9cadc515b8269afa2be5a216f68/met_qt/__init__.py -------------------------------------------------------------------------------- /met_qt/_internal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalefforttech/met_qt/e9d7ea543f61c9cadc515b8269afa2be5a216f68/met_qt/_internal/__init__.py -------------------------------------------------------------------------------- /met_qt/_internal/binding/__init__.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | from . import constants 3 | 4 | from .expression import ExpressionBinding 5 | from .group import GroupBinding 6 | from .simple import SimpleBinding 7 | from .structs import Converter, BoundProperty 8 | from .constants import EventInterest 9 | 10 | __all__ = [ 11 | 'constants', 12 | 'ExpressionBinding', 13 | 'GroupBinding', 14 | 'SimpleBinding', 15 | 'Converter', 16 | 'BoundProperty', 17 | 'EventInterest' 18 | ] 19 | -------------------------------------------------------------------------------- /met_qt/_internal/binding/constants.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | from enum import IntFlag, auto 3 | from typing import Dict 4 | from met_qt._internal.qtcompat import QtCore 5 | 6 | class EventInterest(IntFlag): 7 | """Flags indicating what types of events a binding is interested in.""" 8 | NONE = 0 9 | DYNAMIC_PROPERTY = auto() 10 | GEOMETRY = auto() 11 | STATE = auto() 12 | STYLE = auto() 13 | 14 | # Mapping of property names to their corresponding event interests 15 | PROPERTY_EVENT_MAPPING: Dict[str, EventInterest] = { 16 | "pos": EventInterest.GEOMETRY, 17 | "geometry": EventInterest.GEOMETRY, 18 | "size": EventInterest.GEOMETRY, 19 | "rect": EventInterest.GEOMETRY, 20 | "minimumSize": EventInterest.GEOMETRY, 21 | "maximumSize": EventInterest.GEOMETRY, 22 | "sizePolicy": EventInterest.GEOMETRY, 23 | "sizeIncrement": EventInterest.GEOMETRY, 24 | "baseSize": EventInterest.GEOMETRY, 25 | "palette": EventInterest.STYLE, 26 | "font": EventInterest.STYLE, 27 | "enabled": EventInterest.STATE, 28 | "visible": EventInterest.STATE, 29 | "focus": EventInterest.STATE, 30 | } 31 | 32 | # Mapping of Qt events to their corresponding event interests 33 | EVENT_TO_INTEREST: Dict[QtCore.QEvent.Type, EventInterest] = { 34 | QtCore.QEvent.DynamicPropertyChange: EventInterest.DYNAMIC_PROPERTY, 35 | QtCore.QEvent.Move: EventInterest.GEOMETRY, 36 | QtCore.QEvent.Resize: EventInterest.GEOMETRY, 37 | QtCore.QEvent.LayoutRequest: EventInterest.GEOMETRY, 38 | QtCore.QEvent.ShowToParent: EventInterest.STATE, 39 | QtCore.QEvent.HideToParent: EventInterest.STATE, 40 | QtCore.QEvent.EnabledChange: EventInterest.STATE, 41 | QtCore.QEvent.FontChange: EventInterest.STYLE, 42 | QtCore.QEvent.StyleChange: EventInterest.STYLE, 43 | QtCore.QEvent.PaletteChange: EventInterest.STYLE, 44 | } 45 | -------------------------------------------------------------------------------- /met_qt/_internal/binding/expression.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | import re 3 | from weakref import proxy 4 | from typing import Any, Dict, Callable 5 | from met_qt._internal.qtcompat import QtCore 6 | 7 | class ExpressionBinding: 8 | """ 9 | Manages bindings from an expression with multiple variables to a target property. 10 | Allows for both format-string and eval-based expressions, with optional converters and math helpers. 11 | """ 12 | 13 | def __init__( 14 | self, 15 | bindings_manager: QtCore.QObject, 16 | target: QtCore.QObject, 17 | target_property: str, 18 | expression_str: str, 19 | converter: Callable[[Any], Any] = None 20 | ) -> None: 21 | """ 22 | Initialize an ExpressionBinding. 23 | Args: 24 | bindings_manager: The parent bindings manager. 25 | target: The target QObject. 26 | target_property: The property name on the target to update. 27 | expression_str: The expression string (format or eval). 28 | converter: Optional converter for the result. 29 | """ 30 | self._bindings_manager = proxy(bindings_manager) 31 | self._target = target 32 | self._target_property = target_property 33 | self._expression_str = expression_str 34 | self._converter = converter 35 | self._variables = {} 36 | self._bindings = {} 37 | self._locals = {} 38 | self._use_eval = self._determine_if_use_eval(expression_str) 39 | self._updating = False 40 | self._building = False 41 | self._register_default_math_functions() 42 | 43 | def bind( 44 | self, 45 | var_name: str, 46 | obj: QtCore.QObject, 47 | property_name: str, 48 | converter: Callable[[Any], Any] = None 49 | ) -> 'ExpressionBinding': 50 | """ 51 | Bind a variable in the expression to a property of an object. 52 | Args: 53 | var_name: The variable name in the expression. 54 | obj: The QObject to bind from. 55 | property_name: The property name on the object. 56 | converter: Optional converter for this variable. 57 | Returns: 58 | self 59 | """ 60 | pattern = r'\b' + re.escape(var_name) + r'\b' 61 | if not re.search(pattern, self._expression_str): 62 | raise ValueError(f"Variable '{var_name}' not found in expression: {self._expression_str}") 63 | self._bindings[var_name] = (obj, property_name, converter) 64 | value = obj.property(property_name) 65 | value = self._convert_value(value, converter) 66 | self._variables[var_name] = value 67 | self._bindings_manager._connect_to_property_changes( 68 | obj, property_name, lambda: self._handle_property_change(var_name, obj, property_name)) 69 | if not self._building: 70 | self._update_target() 71 | return self 72 | 73 | def _convert_value(self, value: Any, converter: Callable[[Any], Any] = None) -> Any: 74 | """ 75 | Convert the value using the provided converter or, if in eval mode, attempt to convert to float/int as needed. 76 | Args: 77 | value: The value to convert. 78 | converter: Optional converter function. 79 | Returns: 80 | The converted value. 81 | """ 82 | if converter is not None: 83 | return converter(value) 84 | elif self._use_eval: 85 | if value in (None, ""): 86 | return 0 87 | elif isinstance(value, str): 88 | if '.' in value: 89 | return float(value) 90 | else: 91 | return int(value) 92 | return value 93 | 94 | def local(self, name: str, value: Any) -> 'ExpressionBinding': 95 | """ 96 | Register a local value or function to be used in the expression. 97 | Args: 98 | name: The local variable/function name. 99 | value: The value or function. 100 | Returns: 101 | self 102 | """ 103 | self._locals[name] = value 104 | if not self._building: 105 | self._update_target() 106 | return self 107 | 108 | def _register_default_math_functions(self) -> None: 109 | """ 110 | Register default math helper functions for expressions (lerp, clamp, saturate). 111 | """ 112 | self._locals.update({ 113 | "lerp": lambda a, b, t: a + (b - a) * t, 114 | "clamp": lambda value, min_val, max_val: max(min(value, max_val), min_val), 115 | "saturate": lambda value: max(0, min(value, 1)) 116 | }) 117 | 118 | def _handle_property_change(self, var_name: str, obj: QtCore.QObject, property_name: str) -> None: 119 | """ 120 | Handle property change events from bound variables. 121 | Args: 122 | var_name: The variable name in the expression. 123 | obj: The QObject whose property changed. 124 | property_name: The property name that changed. 125 | """ 126 | if self._updating: 127 | return 128 | try: 129 | self._updating = True 130 | value = obj.property(property_name) 131 | _, _, converter = self._bindings[var_name] 132 | value = self._convert_value(value, converter) 133 | self._variables[var_name] = value 134 | self._update_target() 135 | finally: 136 | self._updating = False 137 | 138 | def _update_target(self) -> None: 139 | """ 140 | Update the target property with the evaluated expression result. 141 | """ 142 | try: 143 | result = self._evaluate_expression() 144 | if self._converter is not None: 145 | result = self._converter(result) 146 | elif self._use_eval: 147 | # If result is string and can be converted, do so 148 | if isinstance(result, str): 149 | if result == "": 150 | result = 0 151 | elif '.' in result: 152 | result = float(result) 153 | else: 154 | result = int(result) 155 | self._target.setProperty(self._target_property, result) 156 | except Exception as e: 157 | print(f"Expression failed: {self._target.metaObject().className()}.{self._target_property} = {self._expression_str} ({e})") 158 | 159 | def _evaluate_expression(self) -> Any: 160 | """ 161 | Evaluate the expression using the current variable values. 162 | Returns: 163 | The result of the expression (type depends on expression and converter). 164 | """ 165 | eval_env = self._create_eval_environment() 166 | if self._use_eval: 167 | return eval(self._expression_str, globals(), eval_env) 168 | else: 169 | result = self._expression_str 170 | pattern = r"{([^{}]*)}" 171 | matches = list(re.finditer(pattern, self._expression_str)) 172 | for match in reversed(matches): 173 | expr = match.group(1) 174 | start, end = match.span() 175 | parts = expr.split(":") 176 | if len(parts) > 1: 177 | var_name = parts[0].strip() 178 | format_spec = parts[1].strip() 179 | if var_name in eval_env: 180 | value = eval_env[var_name] 181 | formatted = format(value, format_spec) 182 | result = result[:start] + formatted + result[end:] 183 | else: 184 | result = result[:start] + f"" + result[end:] 185 | else: 186 | try: 187 | value = eval(expr, globals(), eval_env) 188 | result = result[:start] + str(value) + result[end:] 189 | except Exception as e: 190 | result = result[:start] + f"" + result[end:] 191 | return result 192 | 193 | def _create_eval_environment(self) -> Dict[str, Any]: 194 | """ 195 | Create the environment dictionary for expression evaluation. 196 | Returns: 197 | A dictionary of variables and local helpers for eval/format. 198 | """ 199 | env = {} 200 | env.update(self._variables) 201 | env.update(self._locals) 202 | 203 | if "lerp" not in env: 204 | env["lerp"] = lambda a, b, t: a + (b - a) * t 205 | if "clamp" not in env: 206 | env["clamp"] = lambda value, min_val, max_val: max(min(value, max_val), min_val) 207 | if "saturate" not in env: 208 | env["saturate"] = lambda value: max(0, min(value, 1)) 209 | 210 | return env 211 | 212 | def _determine_if_use_eval(self, expression_str: str) -> bool: 213 | """ 214 | Determine if the expression should be evaluated with eval (math/logic) or as a format string. 215 | Args: 216 | expression_str: The expression string. 217 | Returns: 218 | True if eval should be used, False for format string. 219 | """ 220 | # Heuristic: if the expression is a single variable or contains math operators, use eval 221 | if any(op in expression_str for op in ['+', '-', '*', '/', '(', ')']): 222 | return True 223 | # If it's just a variable name 224 | if re.fullmatch(r"\w+", expression_str.strip()): 225 | return True 226 | return False 227 | 228 | def __enter__(self) -> 'ExpressionBinding': 229 | """ 230 | Enter context for building the expression binding (batch variable binds). 231 | Returns: 232 | self 233 | """ 234 | self._building = True 235 | return self 236 | 237 | def __exit__(self, exc_type, exc_val, exc_tb) -> bool: 238 | """ 239 | Exit context for building the expression binding. 240 | Args: 241 | exc_type: Exception type. 242 | exc_val: Exception value. 243 | exc_tb: Exception traceback. 244 | Returns: 245 | False (do not suppress exceptions) 246 | """ 247 | self._building = False 248 | return False 249 | -------------------------------------------------------------------------------- /met_qt/_internal/binding/group.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | from __future__ import annotations 3 | from typing import Any, Callable, List 4 | from met_qt._internal.qtcompat import QtCore 5 | from .structs import Converter, BoundProperty 6 | 7 | class GroupBinding: 8 | """Manages bidirectional bindings between multiple properties""" 9 | def __init__(self, bindings_manager, initial_value=None): 10 | self._bindings_manager = bindings_manager 11 | self._properties: List[BoundProperty] = [] 12 | self._normalized_value = initial_value 13 | self._updating = False 14 | 15 | def add(self, obj: QtCore.QObject, property_name: str, 16 | to_normalized: Callable[[Any], Any] = None, 17 | from_normalized: Callable[[Any], Any] = None, 18 | signal=None) -> GroupBinding: 19 | """Add a property to the binding group with optional converter functions""" 20 | converter = Converter() 21 | if to_normalized: 22 | converter.to_normalized = to_normalized 23 | if from_normalized: 24 | converter.from_normalized = from_normalized 25 | 26 | bound_prop = BoundProperty(obj, property_name, converter) 27 | self._properties.append(bound_prop) 28 | 29 | self._bindings_manager._connect_to_property_changes( 30 | obj, property_name, lambda: self._on_property_changed(bound_prop), 31 | signal=signal) 32 | 33 | if self._normalized_value is not None: 34 | self._update_property(bound_prop) 35 | elif self._properties and len(self._properties) == 1: 36 | self._normalized_value = bound_prop.converter.to_normalized( 37 | obj.property(property_name) 38 | ) 39 | 40 | return self 41 | 42 | def _on_property_changed(self, source_prop: BoundProperty): 43 | """Handle property change events from any bound property""" 44 | if self._updating: 45 | return 46 | 47 | try: 48 | self._updating = True 49 | new_value = source_prop.converter.to_normalized( 50 | source_prop.obj.property(source_prop.property_name) 51 | ) 52 | self._normalized_value = new_value 53 | 54 | for prop in self._properties: 55 | if prop is not source_prop: 56 | self._update_property(prop) 57 | finally: 58 | self._updating = False 59 | 60 | def _update_property(self, prop: BoundProperty): 61 | """Update a bound property with the current normalized value""" 62 | if self._normalized_value is None: 63 | return 64 | 65 | converted_value = prop.converter.from_normalized(self._normalized_value) 66 | prop.obj.setProperty(prop.property_name, converted_value) 67 | 68 | def update_value(self, value): 69 | """Manually update the normalized value and all properties""" 70 | if self._updating: 71 | return 72 | 73 | try: 74 | self._updating = True 75 | self._normalized_value = value 76 | 77 | for prop in self._properties: 78 | self._update_property(prop) 79 | finally: 80 | self._updating = False 81 | 82 | def __enter__(self): 83 | return self 84 | 85 | def __exit__(self, exc_type, exc_val, exc_tb): 86 | return False -------------------------------------------------------------------------------- /met_qt/_internal/binding/simple.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | import uuid 3 | from typing import Any, Callable, List 4 | from dataclasses import dataclass 5 | from met_qt._internal.qtcompat import QtCore 6 | 7 | class SimpleBinding: 8 | """Manages a one-way binding from a source property to one or more target properties""" 9 | def __init__(self, source: QtCore.QObject, source_property: str): 10 | self._uuid = uuid.uuid4() 11 | self._source = source 12 | self._source_property = source_property 13 | self._targets = [] 14 | 15 | def to(self, target: QtCore.QObject, target_property: str, converter=None): 16 | """Add a target property to this binding""" 17 | self._targets.append((target, target_property, converter)) 18 | return self 19 | 20 | def update_targets(self): 21 | """Update all target properties with the current source value""" 22 | if not self._source: 23 | return 24 | 25 | # Special handling for QSpinBox value 26 | if self._source_property == "value" and hasattr(self._source, "value"): 27 | source_value = self._source.value() 28 | else: 29 | source_value = self._source.property(self._source_property) 30 | 31 | for target, target_property, converter in self._targets: 32 | if not target: 33 | continue 34 | 35 | try: 36 | value = converter(source_value) if converter else source_value 37 | current_value = target.property(target_property) 38 | except Exception as e: 39 | print(f"SimpleBinding: Error converting value: {e}") 40 | return 41 | 42 | if current_value == value: 43 | continue 44 | 45 | if isinstance(current_value, float) and isinstance(value, float): 46 | if abs(current_value - value) <= 1e-9 * max(abs(current_value), abs(value)): 47 | continue 48 | 49 | target.setProperty(target_property, value) 50 | 51 | def __enter__(self): 52 | return self 53 | 54 | def __exit__(self, exc_type, exc_val, exc_tb): 55 | return False 56 | -------------------------------------------------------------------------------- /met_qt/_internal/binding/structs.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | from typing import Any, Callable, List 3 | from dataclasses import dataclass 4 | from met_qt._internal.qtcompat import QtCore 5 | 6 | @dataclass 7 | class Converter: 8 | """Represents bidirectional value converters for a property""" 9 | to_normalized: Callable[[Any], Any] = lambda x: x 10 | from_normalized: Callable[[Any], Any] = lambda x: x 11 | 12 | @dataclass 13 | class BoundProperty: 14 | """Represents a property that participates in a bidirectional binding group""" 15 | obj: QtCore.QObject 16 | property_name: str 17 | converter: Converter = None 18 | 19 | def __post_init__(self): 20 | if self.converter is None: 21 | self.converter = Converter() -------------------------------------------------------------------------------- /met_qt/_internal/qtcompat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Qt Compatibility Layer for PySide2/PySide6. 3 | 4 | This module provides a unified API for working with either PySide2 or PySide6. 5 | It attempts to import PySide6 first, and falls back to PySide2 if PySide6 is not available. 6 | This is strictly for internal use within the met_qt project. 7 | """ 8 | 9 | import importlib 10 | import sys 11 | import warnings 12 | from typing import Dict, Any, Tuple, Optional, Union, List, Callable 13 | 14 | # Track which Qt binding we're using 15 | QT_BINDING = '' 16 | QT_VERSION = '' 17 | 18 | # Try to import PySide6 first, fall back to PySide2 19 | try: 20 | # Import all the requested Qt modules 21 | from PySide6 import ( 22 | QtConcurrent, QtCore, QtGui, QtNetwork, QtQml, QtQuick, QtSvg, 23 | QtTest, QtWidgets, QtXml 24 | ) 25 | 26 | # Try to import optional modules that might not be available in all installations 27 | try: 28 | from PySide6 import QtDBus 29 | except ImportError: 30 | QtDBus = None 31 | 32 | try: 33 | from PySide6 import QtQuick3D 34 | except ImportError: 35 | QtQuick3D = None 36 | 37 | try: 38 | from PySide6 import QtQuickControls2 39 | except ImportError: 40 | QtQuickControls2 = None 41 | 42 | try: 43 | from PySide6 import QtQuickTest 44 | except ImportError: 45 | QtQuickTest = None 46 | 47 | try: 48 | from PySide6 import QtQuickWidgets 49 | except ImportError: 50 | QtQuickWidgets = None 51 | 52 | try: 53 | from PySide6 import QtStateMachine 54 | except ImportError: 55 | QtStateMachine = None 56 | 57 | try: 58 | from PySide6 import QtSvgWidgets 59 | except ImportError: 60 | QtSvgWidgets = None 61 | 62 | try: 63 | from PySide6 import QtPrintSupport 64 | except ImportError: 65 | QtPrintSupport = None 66 | 67 | # Import Shiboken 68 | try: 69 | import shiboken6 as Shiboken 70 | except ImportError: 71 | Shiboken = None 72 | 73 | # Core elements 74 | from PySide6.QtCore import Qt, Signal, Slot, Property 75 | QT_BINDING = 'PySide6' 76 | QT_VERSION = QtCore.__version__ 77 | 78 | # In Qt 6, QAction moved from QtWidgets to QtGui 79 | from PySide6.QtGui import QAction 80 | 81 | except ImportError: 82 | try: 83 | # Import all the requested Qt modules for PySide2 84 | from PySide2 import ( 85 | QtConcurrent, QtCore, QtGui, QtNetwork, QtQml, QtQuick, QtSvg, 86 | QtTest, QtWidgets, QtXml 87 | ) 88 | 89 | # Try to import optional modules that might not be available in all installations 90 | try: 91 | from PySide2 import QtDBus 92 | except ImportError: 93 | QtDBus = None 94 | 95 | # PySide2 doesn't have QtQuick3D in most installations 96 | QtQuick3D = None 97 | 98 | try: 99 | from PySide2 import QtQuickControls2 100 | except ImportError: 101 | QtQuickControls2 = None 102 | 103 | try: 104 | from PySide2 import QtQuickTest 105 | except ImportError: 106 | QtQuickTest = None 107 | 108 | try: 109 | from PySide2 import QtQuickWidgets 110 | except ImportError: 111 | QtQuickWidgets = None 112 | 113 | # In PySide2, it might be called QtStateMachine or QtState 114 | try: 115 | from PySide2 import QtStateMachine 116 | except ImportError: 117 | try: 118 | from PySide2 import QtState as QtStateMachine 119 | except ImportError: 120 | QtStateMachine = None 121 | 122 | # In PySide2, SVG widgets are part of QtSvg 123 | QtSvgWidgets = QtSvg 124 | 125 | try: 126 | from PySide2 import QtPrintSupport 127 | except ImportError: 128 | QtPrintSupport = None 129 | 130 | # Import Shiboken 131 | try: 132 | import shiboken2 as Shiboken 133 | except ImportError: 134 | Shiboken = None 135 | 136 | # Core elements 137 | from PySide2.QtCore import Qt, Signal, Slot, Property 138 | QT_BINDING = 'PySide2' 139 | QT_VERSION = QtCore.__version__ 140 | 141 | # In PySide2, some constants have different locations 142 | # Map Qt.AlignmentFlag to Qt.Alignment for compatibility 143 | if not hasattr(Qt, 'AlignmentFlag'): 144 | Qt.AlignmentFlag = Qt.Alignment 145 | 146 | if not hasattr(Qt, 'WindowType'): 147 | Qt.WindowType = Qt.WindowFlags 148 | 149 | # In Qt 5, QAction is in QtWidgets, not QtGui 150 | from PySide2.QtWidgets import QAction 151 | 152 | except ImportError: 153 | raise ImportError("Neither PySide6 nor PySide2 could be imported. " 154 | "Please install one of these packages.") 155 | 156 | # Simplified imports for common modules 157 | def import_qt_module(module_name: str) -> Any: 158 | """ 159 | Import a Qt module dynamically based on the current binding. 160 | 161 | Args: 162 | module_name: Name of the module without the Qt prefix (e.g., "WebEngineWidgets") 163 | 164 | Returns: 165 | The imported module 166 | """ 167 | full_module_name = f"{QT_BINDING}.Qt{module_name}" 168 | try: 169 | return importlib.import_module(full_module_name) 170 | except ImportError as e: 171 | warnings.warn(f"Could not import {full_module_name}: {e}") 172 | return None 173 | 174 | # Provide consistent API for QApplication 175 | def create_application(args: List[str] = None) -> QtWidgets.QApplication: 176 | """ 177 | Create a QApplication with the appropriate settings for the current Qt binding. 178 | 179 | Args: 180 | args: Command line arguments to pass to the application 181 | 182 | Returns: 183 | A QApplication instance 184 | """ 185 | if args is None: 186 | args = sys.argv 187 | 188 | app = QtWidgets.QApplication(args) 189 | return app 190 | 191 | # Export commonly used classes with consistent API 192 | class Color(QtGui.QColor): 193 | """Wrapper around QColor to ensure consistent API""" 194 | pass 195 | 196 | class Font(QtGui.QFont): 197 | """Wrapper around QFont to ensure consistent API""" 198 | pass 199 | 200 | # Mapping for classes that changed names or locations between versions 201 | if QT_BINDING == 'PySide6': 202 | # PySide6-specific mappings 203 | from PySide6.QtCore import QSize, QPoint, QPointF, QRect, QRectF 204 | 205 | # Qt 6 has different packaging for OpenGL 206 | try: 207 | from PySide6.QtOpenGLWidgets import QOpenGLWidget 208 | except ImportError: 209 | QOpenGLWidget = None 210 | 211 | # Qt 6 moved some classes to different modules 212 | try: 213 | from PySide6.QtWebEngineWidgets import QWebEngineView 214 | except ImportError: 215 | QWebEngineView = None 216 | 217 | # In Qt 6, QPrintDialog and related classes moved to a new module 218 | try: 219 | from PySide6.QtPrintSupport import QPrinter, QPrintDialog, QPrintPreviewDialog 220 | except ImportError: 221 | QPrinter = QPrintDialog = QPrintPreviewDialog = None 222 | 223 | # In Qt 6, SVG widgets are in a separate module 224 | try: 225 | from PySide6.QtSvgWidgets import QSvgWidget, QGraphicsSvgItem 226 | except ImportError: 227 | QSvgWidget = QGraphicsSvgItem = None 228 | 229 | # In Qt 6, QUndoCommand and related classes moved to QtGui 230 | from PySide6.QtGui import QUndoCommand, QUndoStack, QUndoGroup 231 | 232 | elif QT_BINDING == 'PySide2': 233 | # PySide2-specific mappings 234 | from PySide2.QtCore import QSize, QPoint, QPointF, QRect, QRectF 235 | 236 | # Qt 5 uses QGLWidget for OpenGL 237 | try: 238 | from PySide2.QtOpenGL import QGLWidget as QOpenGLWidget 239 | except ImportError: 240 | QOpenGLWidget = None 241 | 242 | try: 243 | from PySide2.QtWebEngineWidgets import QWebEngineView 244 | except ImportError: 245 | QWebEngineView = None 246 | 247 | # In Qt 5, printing classes are in QtPrintSupport 248 | try: 249 | from PySide2.QtPrintSupport import QPrinter, QPrintDialog, QPrintPreviewDialog 250 | except ImportError: 251 | QPrinter = QPrintDialog = QPrintPreviewDialog = None 252 | 253 | # In Qt 5, SVG widgets are part of QtSvg 254 | try: 255 | from PySide2.QtSvg import QSvgWidget, QGraphicsSvgItem 256 | except ImportError: 257 | QSvgWidget = QGraphicsSvgItem = None 258 | 259 | # In Qt 5, QUndoCommand and related classes are in QtWidgets 260 | from PySide2.QtWidgets import QUndoCommand, QUndoStack, QUndoGroup 261 | 262 | # Container compatibility 263 | # In Qt 6, QVector is now an alias for QList 264 | # Define compatible list/vector classes 265 | if QT_BINDING == 'PySide6': 266 | # In PySide6, QList and QVector are just Python lists 267 | # (QList was removed as a separate class) 268 | QList = list 269 | QVector = list 270 | else: 271 | # In PySide2, keep the original Qt classes 272 | QList = getattr(QtCore, 'QList', list) 273 | QVector = getattr(QtCore, 'QVector', list) 274 | 275 | # Additional compatibility mappings for specific modules 276 | if QT_BINDING == 'PySide6': 277 | # PySide6-specific changes 278 | # QtCore changes 279 | QStringListModel = QtCore.QStringListModel 280 | 281 | # Qt 6 changed QTextCodec to QStringConverter 282 | try: 283 | QStringConverter = QtCore.QStringConverter 284 | except AttributeError: 285 | QStringConverter = None 286 | 287 | # Qt 6 introduced new input event classes 288 | QSinglePointEvent = QtGui.QSinglePointEvent if hasattr(QtGui, 'QSinglePointEvent') else None 289 | QPointingDevice = QtGui.QPointingDevice if hasattr(QtGui, 'QPointingDevice') else None 290 | 291 | # Use dict as QHash/QMultiHash in PySide6 292 | QHash = dict 293 | QMultiHash = dict 294 | 295 | # Qt 6 changed QDesktopWidget to QScreen-based API 296 | QScreen = QtGui.QScreen 297 | 298 | # Qt 6 compatibility for QQuickWindow 299 | try: 300 | QQuickGraphicsConfiguration = QtQuick.QQuickGraphicsConfiguration 301 | QQuickRenderTarget = QtQuick.QQuickRenderTarget 302 | except (AttributeError, ImportError): 303 | QQuickGraphicsConfiguration = None 304 | QQuickRenderTarget = None 305 | 306 | # Qt 6 QML changes 307 | QJSEngine = QtQml.QJSEngine 308 | QQmlEngine = QtQml.QQmlEngine 309 | try: 310 | QQmlListProperty = QtQml.QQmlListProperty # Now uses qsizetype in Qt 6 311 | except AttributeError: 312 | QQmlListProperty = None 313 | 314 | # Qt 6 QML changes - QML property registration 315 | if hasattr(QtCore, 'QML_ELEMENT'): 316 | QML_ELEMENT = QtCore.QML_ELEMENT 317 | QML_SINGLETON = QtCore.QML_SINGLETON 318 | QML_ANONYMOUS = QtCore.QML_ANONYMOUS 319 | QML_INTERFACE = QtCore.QML_INTERFACE 320 | else: 321 | QML_ELEMENT = lambda: None 322 | QML_SINGLETON = lambda: None 323 | QML_ANONYMOUS = lambda: None 324 | QML_INTERFACE = lambda name=None: lambda: None 325 | 326 | elif QT_BINDING == 'PySide2': 327 | # PySide2-specific changes 328 | QStringListModel = QtCore.QStringListModel 329 | 330 | # Qt 5 uses QTextCodec instead of QStringConverter 331 | QStringConverter = None 332 | 333 | # Qt 5 doesn't have these input event classes 334 | QSinglePointEvent = None 335 | QPointingDevice = None 336 | 337 | # Use dict as QHash/QMultiHash equivalent in PySide2 338 | QHash = dict 339 | QMultiHash = dict 340 | 341 | # In Qt 5, QColorSpace is not available in most installations 342 | if not hasattr(QtGui, 'QColorSpace'): 343 | QtGui.QColorSpace = type('QColorSpace', (), {}) 344 | 345 | # QDesktopWidget in Qt 5 346 | try: 347 | QDesktopWidget = QtWidgets.QDesktopWidget 348 | except AttributeError: 349 | QDesktopWidget = None 350 | 351 | # Qt5 QScreen 352 | QScreen = QtGui.QScreen 353 | 354 | # QML in Qt5 355 | QJSEngine = QtQml.QJSEngine 356 | QQmlEngine = QtQml.QQmlEngine 357 | try: 358 | # QQmlListProperty is in QtCore for PySide2 359 | QQmlListProperty = QtCore.QQmlListProperty 360 | except AttributeError: 361 | try: 362 | # Fallback to QtQml for PySide6 363 | QQmlListProperty = QtQml.QQmlListProperty 364 | except AttributeError: 365 | QQmlListProperty = None 366 | 367 | # Dummy QML property registration for compatibility 368 | QML_ELEMENT = lambda: None 369 | QML_SINGLETON = lambda: None 370 | QML_ANONYMOUS = lambda: None 371 | QML_INTERFACE = lambda name=None: lambda: None 372 | 373 | # Handle changes in how SQL query results are returned 374 | def get_query_bound_values(query): 375 | """ 376 | In Qt 6, QSqlQuery.boundValues() returns a dict instead of a list. 377 | This function provides a consistent interface. 378 | """ 379 | if not hasattr(query, 'boundValues'): 380 | return {} 381 | 382 | result = query.boundValues() 383 | if QT_BINDING == 'PySide6': 384 | # In Qt 6, this already returns a dict 385 | return result 386 | else: 387 | # In Qt 5, convert the result to a dict 388 | if isinstance(result, list): 389 | return {i: val for i, val in enumerate(result)} 390 | return result 391 | 392 | # Handle touch event changes between Qt 5 and Qt 6 393 | def get_touch_points(touch_event): 394 | """ 395 | Extract touch points from a touch event in a compatible way. 396 | In Qt 6, the API changed significantly for touch events. 397 | """ 398 | if QT_BINDING == 'PySide6': 399 | return [point for point in touch_event.points()] 400 | else: 401 | return [point for point in touch_event.touchPoints()] 402 | -------------------------------------------------------------------------------- /met_qt/_internal/widgets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalefforttech/met_qt/e9d7ea543f61c9cadc515b8269afa2be5a216f68/met_qt/_internal/widgets/__init__.py -------------------------------------------------------------------------------- /met_qt/_internal/widgets/abstract_slider.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | from met_qt._internal.qtcompat import QtWidgets, QtCore, QtGui 3 | from typing import Optional 4 | from met_qt.core.meta import QProperty 5 | import sys 6 | 7 | 8 | class AbstractSoftSlider(QtWidgets.QWidget): 9 | """ 10 | Base class for custom sliders with a soft range 11 | """ 12 | range_changed = QtCore.Signal(float, float) 13 | soft_range_changed = QtCore.Signal(float, float) 14 | 15 | orientation, orientation_changed = QProperty("orientation", QtCore.Qt.Orientation, default=QtCore.Qt.Horizontal, signal=True) 16 | tracking, tracking_changed = QProperty("tracking", bool, default=True, signal=True) 17 | single_step = QProperty("single_step", float, default=0.01) 18 | page_step = QProperty("page_step", float, default=0.1) 19 | 20 | def __init__(self, orientation: QtCore.Qt.Orientation = QtCore.Qt.Horizontal, parent: Optional[QtWidgets.QWidget] = None): 21 | """Initialize the FloatSlider widget.""" 22 | super().__init__(parent) 23 | # Store ranges as tuples 24 | self._range = (0.0, 1.0) # type: tuple[float, float] 25 | self._soft_range = None # type: Optional[tuple[float, float]] 26 | self.orientation = orientation 27 | self.setFocusPolicy(QtCore.Qt.StrongFocus) 28 | self.setMinimumSize(40, 20) 29 | self._decimals = 4 # Number of decimal places for float values 30 | 31 | 32 | @QtCore.Property('QVariant', notify=range_changed) 33 | def range(self) -> 'tuple[float, float]': 34 | """Return the hard (min, max) range as a tuple.""" 35 | return self._range 36 | 37 | @range.setter 38 | def range(self, rng: 'tuple[Optional[float], Optional[float]]'): 39 | # Replace None with float limits 40 | min_ = float(rng[0]) if rng[0] is not None else -sys.float_info.max 41 | max_ = float(rng[1]) if rng[1] is not None else sys.float_info.max 42 | 43 | if min_ > max_: 44 | min_, max_ = max_, min_ 45 | old_range = self._range 46 | self._range = (min_, max_) 47 | # Clamp soft range 48 | if self._soft_range is not None: 49 | smin, smax = self._soft_range 50 | smin = max(min_, min(smin, max_)) 51 | smax = min(max_, max(smax, min_)) 52 | if smin > smax: 53 | smin, smax = smax, smin 54 | self._soft_range = (smin, smax) 55 | if old_range != self._range: 56 | self.range_changed.emit(*self._range) 57 | self.update() 58 | 59 | @QtCore.Property('QVariant', notify=soft_range_changed) 60 | def soft_range(self) -> 'Optional[tuple[float, float]]': 61 | """Return the soft (min, max) range as a tuple, or None if unset.""" 62 | return self._soft_range 63 | 64 | @soft_range.setter 65 | def soft_range(self, rng: 'Optional[tuple[float, float]]'): 66 | if rng is None: 67 | self._soft_range = None 68 | self.soft_range_changed.emit(*self._range) 69 | self.update() 70 | return 71 | min_, max_ = float(rng[0]), float(rng[1]) 72 | rmin, rmax = self._range 73 | min_ = max(rmin, min_) 74 | max_ = min(rmax, max_) 75 | if min_ > max_: 76 | min_, max_ = max_, min_ 77 | old_soft = self._soft_range 78 | self._soft_range = (min_, max_) 79 | if old_soft != self._soft_range: 80 | self.soft_range_changed.emit(*self._soft_range) 81 | self.update() 82 | 83 | def _bound(self, value: float) -> float: 84 | """Clamp value to the hard range.""" 85 | value = min(max(value, self._range[0]), self._range[1]) 86 | # round to step size 87 | if self.single_step > 0: 88 | value = round(value / self.single_step) * self.single_step 89 | return value 90 | 91 | def sizeHint(self) -> QtCore.QSize: 92 | """Return the recommended size for the widget.""" 93 | if self._orientation == QtCore.Qt.Horizontal: 94 | return QtCore.QSize(160, 24) 95 | else: 96 | return QtCore.QSize(24, 160) 97 | 98 | def minimumSizeHint(self) -> QtCore.QSize: 99 | """Return the minimum recommended size for the widget.""" 100 | return self.sizeHint() 101 | 102 | def _visual_range(self) -> 'tuple[float, float]': 103 | soft_min = self._soft_range[0] if self._soft_range is not None else self._range[0] 104 | soft_max = self._soft_range[1] if self._soft_range is not None else self._range[1] 105 | return ( 106 | max(self._range[0], soft_min), 107 | min(self._range[1], soft_max) 108 | ) 109 | 110 | def _value_to_pos(self, value: float, groove_rect: QtCore.QRect) -> int: 111 | """Map a value in [min, max] to a pixel position along the groove.""" 112 | vmin, vmax = self._visual_range() 113 | if self._orientation == QtCore.Qt.Horizontal: 114 | x0, x1 = groove_rect.left(), groove_rect.right() 115 | return int(x0 + (x1 - x0) * (value - vmin) / (vmax - vmin) if vmax > vmin else x0) 116 | else: 117 | y0, y1 = groove_rect.bottom(), groove_rect.top() 118 | return int(y0 + (y1 - y0) * (value - vmin) / (vmax - vmin) if vmax > vmin else y0) 119 | 120 | def _pos_to_value(self, pos: int, groove_rect: QtCore.QRect) -> float: 121 | """Map a pixel position along the groove to a value in [min, max].""" 122 | vmin, vmax = self._visual_range() 123 | pos = float(pos) 124 | if self._orientation == QtCore.Qt.Horizontal: 125 | x0, x1 = float(groove_rect.left()), float(groove_rect.right()) 126 | if x1 == x0: 127 | return vmin 128 | ratio = (pos - x0) / (x1 - x0) 129 | else: 130 | y0, y1 = float(groove_rect.bottom()), float(groove_rect.top()) 131 | if y1 == y0: 132 | return vmin 133 | ratio = (pos - y0) / (y1 - y0) 134 | return vmin + (vmax - vmin) * ratio 135 | 136 | def _groove_rect(self) -> QtCore.QRect: 137 | rect = self.rect() 138 | groove_thickness = 6 139 | handle_radius = 8 140 | if self._orientation == QtCore.Qt.Horizontal: 141 | return QtCore.QRect( 142 | rect.left() + handle_radius, rect.center().y() - groove_thickness // 2, 143 | rect.width() - 2 * handle_radius, groove_thickness) 144 | else: 145 | return QtCore.QRect( 146 | rect.center().x() - groove_thickness // 2, rect.top() + handle_radius, 147 | groove_thickness, rect.height() - 2 * handle_radius) 148 | -------------------------------------------------------------------------------- /met_qt/components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalefforttech/met_qt/e9d7ea543f61c9cadc515b8269afa2be5a216f68/met_qt/components/__init__.py -------------------------------------------------------------------------------- /met_qt/constants.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | 3 | # Qt properties that are affected by events rather than signals. 4 | EVENT_PROPERTIES = ( 5 | "pos", "geometry", "size", "rect", "minimumSize", "maximumSize", 6 | "sizePolicy", "sizeIncrement", "baseSize", "palette" 7 | ) -------------------------------------------------------------------------------- /met_qt/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalefforttech/met_qt/e9d7ea543f61c9cadc515b8269afa2be5a216f68/met_qt/core/__init__.py -------------------------------------------------------------------------------- /met_qt/core/binding.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | from typing import Dict, Any, Tuple, List, Callable, Set, Optional 3 | import uuid 4 | import re 5 | 6 | from met_qt import constants 7 | from met_qt._internal.qtcompat import QtCore 8 | 9 | from met_qt._internal import binding as _binding 10 | from .meta import get_metamethod as _get_metamethod 11 | 12 | 13 | class Bindings(QtCore.QObject): 14 | """Core manager class for property bindings""" 15 | def __init__(self, parent=None): 16 | super().__init__(parent) 17 | self._bindings: Dict[uuid.UUID, _binding.Binding] = {} 18 | self._observed_objects = set() 19 | self._signal_to_binding: Dict[Tuple[QtCore.QObject, int], uuid.UUID] = {} 20 | self._object_event_interest: Dict[QtCore.QObject, _binding.constants.EventInterest] = {} 21 | self._object_dynamic_properties: Dict[QtCore.QObject, Set[str]] = {} 22 | self._property_callbacks: Dict[Tuple[QtCore.QObject, str], List[Callable]] = {} 23 | 24 | def bind(self, source: QtCore.QObject, source_property: str, signal=None) -> _binding.SimpleBinding: 25 | """Create a one-way binding from a source property""" 26 | binding = _binding.SimpleBinding(source, source_property) 27 | self._bindings[binding._uuid] = binding 28 | 29 | signal_idx = self._setup_property_observation(source, source_property, signal) 30 | 31 | # Force initial update 32 | binding.update_targets() 33 | 34 | if signal_idx != -1: 35 | self._signal_to_binding[(source, signal_idx)] = binding._uuid 36 | 37 | return binding 38 | 39 | def bind_group(self, initial_value=None) -> _binding.GroupBinding: 40 | """Create a new binding group for bidirectional binding""" 41 | return _binding.GroupBinding(self, initial_value) 42 | 43 | def bind_expression(self, target: QtCore.QObject, target_property: str, 44 | expression_str: str, converter: Callable = None) -> _binding.ExpressionBinding: 45 | """Create a binding from an expression to a target property""" 46 | binding = _binding.ExpressionBinding(self, target, target_property, expression_str, converter) 47 | return binding 48 | 49 | @QtCore.Slot() 50 | def _source_destroyed(self): 51 | """Handle destruction of bound objects""" 52 | object = self.sender() 53 | if not object: 54 | return 55 | self._remove_bindings_for_object(object) 56 | 57 | @QtCore.Slot() 58 | def _property_changed(self, *args): 59 | """Handle property change notifications""" 60 | sender_obj = self.sender() 61 | signal_idx = self.senderSignalIndex() 62 | 63 | if not sender_obj: 64 | return 65 | 66 | key = (sender_obj, signal_idx) 67 | try: 68 | if key in self._signal_to_binding: 69 | binding_id = self._signal_to_binding[key] 70 | self._update_binding(binding_id) 71 | self._trigger_property_callbacks(sender_obj) 72 | except Exception as e: 73 | # Protect against transient connection issues or object deletion 74 | pass 75 | 76 | def _trigger_property_callbacks(self, obj: QtCore.QObject): 77 | """Trigger callbacks for property changes""" 78 | meta_obj = obj.metaObject() 79 | signal_idx = self.senderSignalIndex() 80 | 81 | for i in range(meta_obj.propertyCount()): 82 | prop = meta_obj.property(i) 83 | if prop.hasNotifySignal() and prop.notifySignalIndex() == signal_idx: 84 | prop_name = prop.name() 85 | key = (obj, prop_name) 86 | if key in self._property_callbacks: 87 | for callback in self._property_callbacks[key]: 88 | callback() 89 | break 90 | 91 | def _setup_property_observation(self, obj: QtCore.QObject, property_name: str, 92 | signal: Optional[QtCore.Signal] = None) -> int: 93 | """Setup property observation for an object and return the signal index if connected""" 94 | signal_idx = -1 95 | 96 | if obj not in self._observed_objects: 97 | obj.installEventFilter(self) 98 | obj.destroyed.connect(self._source_destroyed) 99 | self._observed_objects.add(obj) 100 | self._object_event_interest[obj] = _binding.constants.EventInterest.NONE 101 | self._object_dynamic_properties[obj] = set() 102 | 103 | meta_obj = obj.metaObject() 104 | meta_property = None 105 | property_index = meta_obj.indexOfProperty(property_name) 106 | 107 | if property_index >= 0: 108 | meta_property = meta_obj.property(property_index) 109 | 110 | if signal: 111 | signal.connect(self._property_changed) 112 | meta_method = _get_metamethod(obj, signal) 113 | signal_idx = meta_obj.indexOfSignal(meta_method.methodSignature().data().decode()) 114 | elif meta_property and meta_property.hasNotifySignal(): 115 | notify_signal = meta_property.notifySignal() 116 | notifier = getattr(obj, str(notify_signal.name(), 'utf-8')) 117 | notifier.connect(self._property_changed) 118 | signal_idx = meta_obj.indexOfSignal(notify_signal.methodSignature().data().decode()) 119 | elif property_name in constants.EVENT_PROPERTIES: 120 | event_interest = _binding.constants.PROPERTY_EVENT_MAPPING.get(property_name, _binding.constants.EventInterest.NONE) 121 | self._object_event_interest[obj] |= event_interest 122 | else: 123 | self._object_event_interest[obj] |= _binding.constants.EventInterest.DYNAMIC_PROPERTY 124 | self._object_dynamic_properties[obj].add(property_name) 125 | 126 | return signal_idx 127 | 128 | def _connect_to_property_changes(self, obj: QtCore.QObject, property_name: str, 129 | callback: Callable[[], None], 130 | signal: Optional[QtCore.Signal] = None): 131 | """Connect to property change notifications for an object""" 132 | key = (obj, property_name) 133 | if key not in self._property_callbacks: 134 | self._property_callbacks[key] = [] 135 | 136 | self._property_callbacks[key].append(callback) 137 | self._setup_property_observation(obj, property_name, signal=signal) 138 | 139 | def _update_binding(self, binding_id: uuid.UUID): 140 | """Update a binding's targets with the current source value""" 141 | if binding_id in self._bindings: 142 | self._bindings[binding_id].update_targets() 143 | 144 | def _remove_bindings_for_object(self, obj: QtCore.QObject): 145 | """Remove all bindings associated with an object""" 146 | self._observed_objects.discard(obj) 147 | self._object_event_interest.pop(obj, None) 148 | self._object_dynamic_properties.pop(obj, None) 149 | 150 | to_remove = [] 151 | for binding_id, binding in self._bindings.items(): 152 | if binding._source == obj: 153 | to_remove.append(binding_id) 154 | else: 155 | binding._targets = [(target, prop, conv) for target, prop, conv in binding._targets 156 | if target != obj] 157 | 158 | for binding_id in to_remove: 159 | self._bindings.pop(binding_id, None) 160 | 161 | keys_to_remove = [] 162 | for key in self._signal_to_binding: 163 | sender, _ = key 164 | if sender == obj: 165 | keys_to_remove.append(key) 166 | 167 | for key in keys_to_remove: 168 | self._signal_to_binding.pop(key, None) 169 | 170 | keys_to_remove = [] 171 | for key in self._property_callbacks: 172 | callback_obj, _ = key 173 | if callback_obj == obj: 174 | keys_to_remove.append(key) 175 | 176 | for key in keys_to_remove: 177 | self._property_callbacks.pop(key, None) 178 | 179 | def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: 180 | """Filter events for bound objects""" 181 | if obj not in self._object_event_interest: 182 | return False 183 | 184 | interest = self._object_event_interest.get(obj, _binding.constants.EventInterest.NONE) 185 | if interest == _binding.constants.EventInterest.NONE: 186 | return False 187 | 188 | event_type = event.type() 189 | event_interest = _binding.constants.EVENT_TO_INTEREST.get(event_type, _binding.constants.EventInterest.NONE) 190 | 191 | if event_interest == _binding.constants.EventInterest.NONE: 192 | return False 193 | 194 | if not (interest & event_interest): 195 | return False 196 | 197 | if event_type == QtCore.QEvent.DynamicPropertyChange: 198 | property_name = event.propertyName().data().decode() 199 | if property_name in self._object_dynamic_properties.get(obj, set()): 200 | for binding_id, binding in self._bindings.items(): 201 | if binding._source == obj and binding._source_property == property_name: 202 | self._update_binding(binding_id) 203 | 204 | else: 205 | for binding_id, binding in self._bindings.items(): 206 | if binding._source == obj: 207 | property_interest = _binding.constants.PROPERTY_EVENT_MAPPING.get(binding._source_property, _binding.constants.EventInterest.NONE) 208 | if property_interest & event_interest: 209 | self._update_binding(binding_id) 210 | 211 | if event_type == QtCore.QEvent.DynamicPropertyChange: 212 | property_name = event.propertyName().data().decode() 213 | key = (obj, property_name) 214 | if key in self._property_callbacks: 215 | for callback in self._property_callbacks[key]: 216 | callback() 217 | elif event_interest != _binding.constants.EventInterest.NONE: 218 | for prop_name, prop_interest in _binding.constants.PROPERTY_EVENT_MAPPING.items(): 219 | if prop_interest & event_interest: 220 | key = (obj, prop_name) 221 | if key in self._property_callbacks: 222 | for callback in self._property_callbacks[key]: 223 | callback() 224 | 225 | return super().eventFilter(obj, event) 226 | -------------------------------------------------------------------------------- /met_qt/core/meta.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | import re 3 | from typing import Optional 4 | from met_qt._internal.qtcompat import QtCore 5 | 6 | def get_metamethod(obj: QtCore.QObject, signal_or_slot) -> Optional[QtCore.QMetaMethod]: 7 | """Get the QMetaMethod of a signal in an object's meta-object 8 | eg: 9 | obj = QtCore.QObject() 10 | signal_method = get_metamethod(obj, obj.destroyed) 11 | """ 12 | match = re.search(r'SignalInstance (\w+)\(', repr(signal_or_slot)) 13 | if match: 14 | # Signals aren't exposed to python, so we resolve it from the displayed signature. 15 | name = match.group(1) 16 | method_type = QtCore.QMetaMethod.MethodType.Signal 17 | elif hasattr(signal_or_slot, '__name__'): 18 | name = signal_or_slot.__name__ 19 | method_type = QtCore.QMetaMethod.MethodType.Slot 20 | else: 21 | return None # Likely not a signal 22 | 23 | meta_obj = obj.metaObject() 24 | if name.startswith('2'): # Internally Qt prefixes slots and signals with 1 and 2. 25 | name = name[1:] 26 | 27 | # Get all methods with matching name first to avoid signature issues in PySide2 28 | matching_methods = [] 29 | for i in range(meta_obj.methodCount()): 30 | method = meta_obj.method(i) 31 | if method.methodType() == method_type and method.name().data().decode() == name: 32 | matching_methods.append(method) 33 | 34 | # If we found exactly one match, return it 35 | if len(matching_methods) == 1: 36 | return matching_methods[0] 37 | 38 | # If we have multiple matches, try to find the best one by signature 39 | if matching_methods: 40 | # In PySide2, some methods might not have signatures, return the first one in that case 41 | return matching_methods[0] 42 | 43 | return None 44 | 45 | def QProperty(name:str, type_, default=None, *, signal=False, 46 | converter=None, default_factory=None, 47 | variable_name=None, signal_name=None): 48 | """ 49 | This function generates a QtCore.Property with customizable behavior, optionally with a change signal. 50 | Parameters: 51 | name (str): The name of the property, this must be the same as the variable you assign it to. 52 | type_: The type of the property 53 | default: The default value for the property (used if default_factory is None) 54 | signal (bool): If True, creates a signal that will be emitted when the property changes 55 | converter (callable): A function to convert the input value before storing (defaults to identity function) 56 | default_factory (callable): A function that returns the default value (takes precedence over default) 57 | variable_name (str): The name of the backing variable (defaults to "_" + name) 58 | signal_name (str): The name of the change signal (defaults to name + "Changed") 59 | Returns: 60 | If signal is True: 61 | tuple: (QtCore.Property, QtCore.Signal) - the property and its change signal 62 | If signal is False: 63 | QtCore.Property: the property 64 | Example: 65 | class MyClass(QObject): 66 | # Create a property with a change signal 67 | text, textChanged = QProperty("text", str, "", signal=True) 68 | # Create a property without a signal 69 | count = QProperty("count", int, 0) 70 | """ 71 | variable_name = variable_name or f"_{name}" 72 | signal_name = signal_name or f"{name}Changed" 73 | converter = converter or (lambda x:x) 74 | def fget(self): 75 | if not hasattr(self, variable_name): 76 | setattr(self, variable_name, default_factory() if default_factory else default) 77 | return getattr(self, variable_name) 78 | 79 | def fset(self, value): 80 | value = converter(value) 81 | current = fget(self) 82 | if current == value: 83 | return 84 | setattr(self, variable_name, value) 85 | if signal: 86 | getattr(self, signal_name).emit(value) 87 | 88 | def freset(self): 89 | setattr(self, variable_name, default_factory() if default_factory else default) 90 | 91 | if signal: 92 | notifier = QtCore.Signal(type_, name=signal_name) 93 | prop = QtCore.Property(type_, fget, fset, freset, notify=notifier) 94 | return prop, notifier 95 | else: 96 | prop = QtCore.Property(type_, fget, fset, freset) 97 | return prop 98 | -------------------------------------------------------------------------------- /met_qt/core/model_data_mapper.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | 3 | from weakref import ref as weakref 4 | from met_qt._internal.qtcompat import Shiboken, QtCore 5 | from .meta import QProperty 6 | 7 | class ModelDataMapper(QtCore.QObject): 8 | """ 9 | Maps data between a QAbstractItemModel and UI widgets or objects, supporting arbitrary roles and two-way mapping. 10 | 11 | This class allows you to bind properties of widgets (or other QObject-based objects) to data in a model, with support for custom setter and extractor functions, and optional auto-commit of changes. It manages signal connections for two-way data synchronization and can be used to implement editable forms or views that reflect and update model data. 12 | 13 | Attributes: 14 | auto_commit (QProperty): If True, changes are immediately committed to the model; otherwise, they are batched until commit() is called. 15 | """ 16 | auto_commit = QProperty("auto_commit", bool, default=True, signal=False) 17 | 18 | def __init__(self, parent=None): 19 | super().__init__(parent) 20 | self._model = None 21 | self._mappings = {} 22 | self._current_index = 0 23 | self._in_update = False 24 | self._pending_commits = set() 25 | 26 | def commit(self): 27 | """ 28 | Push all pending changes from mapped widgets/objects to the model. 29 | 30 | If auto_commit is False, this method must be called to write changes back to the model. 31 | """ 32 | if not self._model: 33 | return 34 | for mapping in list(self._pending_commits): 35 | target = mapping['target_ref']() 36 | if target is None or (Shiboken and not Shiboken.isValid(target)): 37 | continue 38 | idx = self._get_index(mapping['section']) 39 | role = mapping['role'] 40 | if role == QtCore.Qt.DisplayRole: 41 | role = QtCore.Qt.EditRole 42 | old_data = self._model.data(idx, role) 43 | value = getattr(target, mapping['property_name'])() if callable(getattr(target, mapping['property_name'], None)) else target.property(mapping['property_name']) 44 | new_data = mapping['from_property'](value, old_data) 45 | if new_data != old_data: 46 | self._model.setData(idx, new_data, role) 47 | self._pending_commits.clear() 48 | 49 | def add_mapping(self, target, property_name, section=0, role=QtCore.Qt.DisplayRole, from_model=None, from_property=None, signal=None): 50 | """ 51 | Add a mapping between a widget/object property and a model data role. 52 | 53 | Args: 54 | target (QObject): The widget or object to map. 55 | property_name (str): The property name on the target to bind. 56 | section (int): The model column/section to map (default: 0). 57 | role (int): The Qt item data role to use (default: DisplayRole). 58 | from_model (callable): Function to convert model data to property value (optional). 59 | from_property (callable): Function to convert property value and model data to new model data (optional). 60 | signal (QtCore.Signal): Signal to connect for change notification (optional). 61 | """ 62 | # Default from_model: just return model_data 63 | if from_model is None: 64 | def from_model(model_data): 65 | return model_data 66 | # Default from_property: just return value 67 | if from_property is None: 68 | def from_property(value, model_data): 69 | return value 70 | target_ref = weakref(target) 71 | notify_signal = self._find_notify_signal(target, property_name) 72 | if notify_signal is None and signal is not None: 73 | notify_signal = signal 74 | mapping = { 75 | 'target_ref': target_ref, 76 | 'property_name': property_name, 77 | 'section': section, 78 | 'role': role, 79 | 'from_model': from_model, 80 | 'from_property': from_property, 81 | 'signal': notify_signal 82 | } 83 | # Use the weakref object itself as the key (unique per obj instance) 84 | self._mappings[target_ref] = mapping 85 | if notify_signal is not None: 86 | notify_signal.connect(self._apply_to_model) 87 | 88 | def set_model(self, model): 89 | """ 90 | Set the model to be mapped and refresh all mappings. 91 | 92 | Args: 93 | model (QAbstractItemModel): The model to use. 94 | """ 95 | self._model = model 96 | self._current_index = None 97 | self.refresh() 98 | 99 | def set_current_index(self, index): 100 | """ 101 | Set the current row/section index for mapping. 102 | 103 | Args: 104 | index (int or QModelIndex): The row index or QModelIndex to use. 105 | """ 106 | if self._pending_commits: 107 | self.commit() 108 | if isinstance(index, QtCore.QModelIndex): 109 | self._current_index = QtCore.QPersistentModelIndex(index) 110 | else: 111 | # Accept row int for convenience (first column) 112 | if self._model is not None: 113 | idx = self._model.index(index, 0) 114 | self._current_index = QtCore.QPersistentModelIndex(idx) 115 | else: 116 | self._current_index = None 117 | self.refresh() 118 | 119 | def _get_index(self, section): 120 | """ 121 | Get the QModelIndex for the current row and given section/column. 122 | 123 | Args: 124 | section (int): The column/section index. 125 | Returns: 126 | QModelIndex: The corresponding model index. 127 | """ 128 | if self._current_index is None or not self._current_index.isValid(): 129 | return QtCore.QModelIndex() 130 | return self._current_index.sibling(self._current_index.row(), section) 131 | 132 | def _apply_to_model(self): 133 | """ 134 | Slot to apply changes from a mapped widget/object to the model when its value changes. 135 | """ 136 | if self._in_update: 137 | return 138 | sender = self.sender() 139 | if sender is None: 140 | return 141 | mapping = None 142 | for ref, m in self._mappings.items(): 143 | target = ref() 144 | if target is sender: 145 | mapping = m 146 | break 147 | if not mapping: 148 | return 149 | target = mapping['target_ref']() 150 | if target is None or (Shiboken and not Shiboken.isValid(target)): 151 | return 152 | if not self._model: 153 | return 154 | idx = self._get_index(mapping['section']) 155 | old_data = self._model.data(idx, mapping['role']) 156 | value = getattr(target, mapping['property_name'])() if callable(getattr(target, mapping['property_name'], None)) else target.property(mapping['property_name']) 157 | new_data = mapping['from_property'](value, old_data) 158 | if new_data != old_data: 159 | if self.auto_commit: 160 | self._in_update = True 161 | self._model.setData(idx, new_data, mapping['role']) 162 | self._in_update = False 163 | else: 164 | self._pending_commits.add(mapping) 165 | 166 | def refresh(self): 167 | """ 168 | Update all mapped widgets/objects from the current model data. 169 | """ 170 | if not self._model: 171 | return 172 | for mapping in self._mappings.values(): 173 | target = mapping['target_ref']() 174 | if target is None or (Shiboken and not Shiboken.isValid(target)): 175 | continue 176 | idx = self._get_index(mapping['section']) 177 | data = self._model.data(idx, mapping['role']) 178 | value = mapping['from_model'](data) 179 | self._in_update = True 180 | if callable(getattr(target, 'set' + mapping['property_name'][0].upper() + mapping['property_name'][1:], None)): 181 | # Prefer setX method if it exists 182 | getattr(target, 'set' + mapping['property_name'][0].upper() + mapping['property_name'][1:])(value) 183 | else: 184 | target.setProperty(mapping['property_name'], value) 185 | self._in_update = False 186 | 187 | def _find_notify_signal(self, obj, property_name): 188 | """ 189 | Find the notify signal for a given property on a QObject, if available. 190 | 191 | Args: 192 | obj (QObject): The object to inspect. 193 | property_name (str): The property name. 194 | Returns: 195 | QtCore.Signal or None: The notify signal, or None if not found. 196 | """ 197 | meta = obj.metaObject() 198 | prop_idx = meta.indexOfProperty(property_name) 199 | if prop_idx < 0: 200 | return None 201 | prop = meta.property(prop_idx) 202 | if prop.hasNotifySignal(): 203 | return getattr(obj, prop.notifySignal().name().data().decode(), None) 204 | return None 205 | 206 | def clear(self): 207 | """ 208 | Remove all mappings and disconnect any connected signals. 209 | """ 210 | for mapping in self._mappings.values(): 211 | signal = mapping.get('signal') 212 | if signal is not None: 213 | try: 214 | signal.disconnect(self._apply_to_model) 215 | except Exception: 216 | pass 217 | self._mappings.clear() 218 | -------------------------------------------------------------------------------- /met_qt/gui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalefforttech/met_qt/e9d7ea543f61c9cadc515b8269afa2be5a216f68/met_qt/gui/__init__.py -------------------------------------------------------------------------------- /met_qt/gui/paint_layout.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | """ Simplified wrapper for working with layouts inside a paintevent. 3 | For most cases you can simply use anchor() from paint_utils, this is for 4 | advanced cases where you require hover support or complex layout management in 5 | a situation where you cannot use normal widgets. 6 | """ 7 | from typing import Optional, Union, List, Set 8 | from dataclasses import dataclass, field, asdict, replace 9 | from met_qt._internal.qtcompat import QtWidgets, QtCore, QtGui 10 | from enum import Enum, Flag, auto, IntFlag 11 | 12 | class ShapeType(Enum): 13 | Box = 0 14 | Path = 1 15 | 16 | class CornerFlag(IntFlag): 17 | NoCorners = 0 18 | TopLeft = auto() 19 | TopRight = auto() 20 | BottomLeft = auto() 21 | BottomRight = auto() 22 | AllCorners = TopLeft | TopRight | BottomLeft | BottomRight 23 | 24 | class PaintOptions(IntFlag): 25 | NoOptions = 0 26 | Enabled = auto() 27 | Hovered = auto() 28 | 29 | class BoxPaintLayoutFlag(IntFlag): 30 | Enabled = auto() 31 | Visible = auto() 32 | TransparentForHover = auto() 33 | 34 | @dataclass 35 | class PaintStyle: 36 | brush_color: QtGui.QColor = None # QColor or None 37 | brush_role: QtGui.QPalette.ColorRole = None 38 | pen_color: QtGui.QColor = None 39 | pen_role: QtGui.QPalette.ColorRole = None 40 | pen_width: float = 1.0 41 | 42 | @dataclass 43 | class PaintItem: 44 | id: Optional[str] = None 45 | visible: bool = True 46 | style: Optional[PaintStyle] = None 47 | disabled_style: Optional[PaintStyle] = None 48 | hover_style: Optional[PaintStyle] = None 49 | 50 | @dataclass 51 | class BoxShape(PaintItem): 52 | shape_type: ShapeType = ShapeType.Box 53 | painter_path: QtGui.QPainterPath = None 54 | corner_radius: int = 0 # Default to 0 for normal box 55 | rounded_corners: CornerFlag = CornerFlag.AllCorners 56 | content_margin: int = 4 57 | 58 | def __post_init__(self): 59 | if self.style is None: 60 | self.style = PaintStyle() 61 | 62 | @dataclass 63 | class BoxText(PaintItem): 64 | text: str = "" 65 | wordWrap: bool = False 66 | alignment: QtCore.Qt.AlignmentFlag = QtCore.Qt.AlignmentFlag.AlignCenter 67 | colorRole: QtGui.QPalette.ColorRole = QtGui.QPalette.ColorRole.WindowText 68 | font: QtGui.QFont = field(default_factory=QtGui.QFont) 69 | elideMode: QtCore.Qt.TextElideMode = QtCore.Qt.TextElideMode.ElideNone 70 | 71 | def __post_init__(self): 72 | if self.style is None: 73 | self.style = PaintStyle() 74 | 75 | def sizeHint(self): 76 | metrics = QtGui.QFontMetrics(self.font) 77 | lines = self.text.splitlines() or [''] 78 | width = max(metrics.horizontalAdvance(line) for line in lines) 79 | height = metrics.height() * len(lines) 80 | return QtCore.QSize(width, height) 81 | 82 | class BoxPaintItem: 83 | def __init__(self, shape: Union[None, BoxShape] = None, text: Union[str, BoxText] = None): 84 | self.shape = shape 85 | if isinstance(text, str): 86 | text = BoxText(text=text) 87 | self.text = text 88 | 89 | def paint(self, painter, rect, widget=None, options=PaintOptions.Enabled): 90 | if self.shape and self.shape.visible: 91 | self._draw_box(painter, self.shape, rect, widget, options) 92 | if self.text and self.text.visible and self.text.text: 93 | self._draw_text(painter, self.text, rect, widget, options) 94 | 95 | def hit_test(self, rect, pos): 96 | hit = False 97 | if not self.shape or not self.shape.visible: 98 | hit = False 99 | elif self.shape.shape_type == ShapeType.Box: 100 | hit = rect.contains(pos) 101 | else: 102 | path = self._resolve_path(self.shape, rect) 103 | hit = not path.isEmpty() and path.contains(QtCore.QPointF(pos)) 104 | return hit 105 | 106 | def _resolve_style(self, base: Optional[PaintStyle], disabled_style: Optional[PaintStyle], hover_style: Optional[PaintStyle], options:PaintOptions) -> PaintStyle: 107 | base = base or PaintStyle() 108 | if not (options & PaintOptions.Enabled) and disabled_style: 109 | return replace(base, **{k:v for k, v in asdict(disabled_style).items() if v is not None}) 110 | if (options & PaintOptions.Hovered) and hover_style: 111 | return replace(base, **{k:v for k, v in asdict(hover_style).items() if v is not None}) 112 | return base 113 | 114 | def _draw_box(self, painter, shape: BoxShape, rect, widget=None, options:PaintOptions=PaintOptions.Enabled): 115 | painter.save() 116 | style = self._resolve_style(shape.style, shape.disabled_style, shape.hover_style, options) 117 | color = style.brush_color 118 | if color is None and style.brush_role is not None and widget: 119 | color = widget.palette().color(style.brush_role) 120 | if color is not None: 121 | painter.setBrush(QtGui.QBrush(color)) 122 | else: 123 | painter.setBrush(QtCore.Qt.NoBrush) 124 | pen_color = style.pen_color 125 | if pen_color is None and widget and style.pen_role: 126 | pen_color = widget.palette().color(style.pen_role) 127 | if not pen_color: 128 | painter.setPen(QtCore.Qt.NoPen) 129 | else: 130 | painter.setPen(QtGui.QPen(pen_color, style.pen_width)) 131 | if shape.shape_type == ShapeType.Box: 132 | if shape.corner_radius and shape.corner_radius > 0: 133 | if shape.rounded_corners == CornerFlag.AllCorners: 134 | painter.drawRoundedRect(rect, shape.corner_radius, shape.corner_radius) 135 | else: 136 | path = self._resolve_path(shape, rect) 137 | if not path.isEmpty(): 138 | painter.drawPath(path) 139 | else: 140 | painter.drawRect(rect) 141 | elif shape.shape_type == ShapeType.Path and shape.painter_path: 142 | path = self._resolve_path(shape, rect) 143 | if not path.isEmpty(): 144 | painter.drawPath(path) 145 | painter.restore() 146 | 147 | def _draw_text(self, painter, text: BoxText, rect, widget=None, options:PaintOptions=PaintOptions.Enabled): 148 | painter.save() 149 | style = self._resolve_style(text.style, text.disabled_style, text.hover_style, options) 150 | painter.setFont(text.font) 151 | color = style.brush_color 152 | palette = widget.palette() if widget else QtWidgets.QApplication.palette() 153 | if color is None: 154 | color = palette.color(style.pen_role or QtGui.QPalette.ColorRole.WindowText) 155 | style_widget = widget.style() if widget else QtWidgets.QApplication.style() 156 | style_widget.drawItemText(painter, rect, int(text.alignment), color, True, text.text) 157 | painter.restore() 158 | 159 | def _resolve_path(self, shape, rect): 160 | # Returns a QPainterPath for the shape in the given rect 161 | path = QtGui.QPainterPath() 162 | if shape.shape_type == ShapeType.Box: 163 | if shape.corner_radius and shape.corner_radius > 0: 164 | if shape.rounded_corners == CornerFlag.AllCorners: 165 | path.addRoundedRect(QtCore.QRectF(rect), shape.corner_radius, shape.corner_radius) 166 | else: 167 | if shape.rounded_corners & CornerFlag.TopLeft: 168 | path.moveTo(rect.left() + shape.corner_radius, rect.top()) 169 | else: 170 | path.moveTo(rect.left(), rect.top()) 171 | if shape.rounded_corners & CornerFlag.TopRight: 172 | path.lineTo(rect.right() - shape.corner_radius, rect.top()) 173 | path.arcTo( 174 | rect.right() - shape.corner_radius * 2, rect.top(), 175 | shape.corner_radius * 2, shape.corner_radius * 2, 176 | 90, -90 177 | ) 178 | else: 179 | path.lineTo(rect.right(), rect.top()) 180 | if shape.rounded_corners & CornerFlag.BottomRight: 181 | path.lineTo(rect.right(), rect.bottom() - shape.corner_radius) 182 | path.arcTo( 183 | rect.right() - shape.corner_radius * 2, rect.bottom() - shape.corner_radius * 2, 184 | shape.corner_radius * 2, shape.corner_radius * 2, 185 | 0, -90 186 | ) 187 | else: 188 | path.lineTo(rect.right(), rect.bottom()) 189 | if shape.rounded_corners & CornerFlag.BottomLeft: 190 | path.lineTo(rect.left() + shape.corner_radius, rect.bottom()) 191 | path.arcTo( 192 | rect.left(), rect.bottom() - shape.corner_radius * 2, 193 | shape.corner_radius * 2, shape.corner_radius * 2, 194 | 270, -90 195 | ) 196 | else: 197 | path.lineTo(rect.left(), rect.bottom()) 198 | if shape.rounded_corners & CornerFlag.TopLeft: 199 | path.lineTo(rect.left(), rect.top() + shape.corner_radius) 200 | path.arcTo( 201 | rect.left(), rect.top(), 202 | shape.corner_radius * 2, shape.corner_radius * 2, 203 | 180, -90 204 | ) 205 | else: 206 | path.lineTo(rect.left(), rect.top()) 207 | path.closeSubpath() 208 | else: 209 | path.addRect(QtCore.QRectF(rect)) 210 | elif shape.shape_type == ShapeType.Path and shape.painter_path: 211 | source_rect = shape.painter_path.boundingRect() 212 | path = QtGui.QPainterPath(shape.painter_path) 213 | if not source_rect.isEmpty(): 214 | transform = QtGui.QTransform() 215 | scale_x = rect.width() / source_rect.width() 216 | scale_y = rect.height() / source_rect.height() 217 | transform.translate(rect.x() - source_rect.x() * scale_x, rect.y() - source_rect.y() * scale_y) 218 | transform.scale(scale_x, scale_y) 219 | path = transform.map(path) 220 | return path 221 | 222 | 223 | class BoxPaintLayout(QtWidgets.QBoxLayout): 224 | """ 225 | Layout for painting colored boxes that can also contain other layout items. 226 | Supports custom painting, hit-testing, and explicit size hints. 227 | """ 228 | 229 | def __init__(self, direction: QtWidgets.QBoxLayout.Direction = QtWidgets.QBoxLayout.Direction.TopToBottom): 230 | """ 231 | Initialize the BoxPaintLayout. 232 | 233 | Args: 234 | direction: Layout direction (default: TopToBottom). 235 | """ 236 | super().__init__(direction) 237 | self._explicit_size_hint: Optional[QtCore.QSize] = None 238 | self._explicit_min_size: Optional[QtCore.QSize] = None 239 | self._explicit_max_size: Optional[QtCore.QSize] = None 240 | self._paint_items: List[BoxPaintItem] = [] 241 | self._flags: BoxPaintLayoutFlag = BoxPaintLayoutFlag.Enabled | BoxPaintLayoutFlag.Visible 242 | 243 | @QtCore.Property(int) 244 | def flags(self) -> int: 245 | """ 246 | Get the current layout flags as an integer. 247 | """ 248 | return int(self._flags) 249 | 250 | @flags.setter 251 | def flags(self, value: int): 252 | """ 253 | Set the layout flags. 254 | Args: 255 | value (int): The new flags value. 256 | """ 257 | self._flags = BoxPaintLayoutFlag(value) 258 | self.update() 259 | self.invalidate() 260 | 261 | def setSizeHint(self, size: Union[QtCore.QSize, tuple, list]): 262 | """ 263 | Set explicit preferred size hint. 264 | Args: 265 | size: The preferred size. 266 | """ 267 | if isinstance(size, (tuple, list)) and len(size) == 2: 268 | self._explicit_size_hint = QtCore.QSize(size[0], size[1]) 269 | else: 270 | self._explicit_size_hint = size 271 | 272 | def setMinimumSize(self, size: Union[QtCore.QSize, tuple, list]): 273 | """ 274 | Set explicit minimum size. 275 | Args: 276 | size: The minimum size. 277 | """ 278 | if isinstance(size, (tuple, list)) and len(size) == 2: 279 | self._explicit_min_size = QtCore.QSize(size[0], size[1]) 280 | else: 281 | self._explicit_min_size = size 282 | 283 | def setMaximumSize(self, size: Union[QtCore.QSize, tuple, list]): 284 | """ 285 | Set explicit maximum size. 286 | Args: 287 | size: The maximum size. 288 | """ 289 | if isinstance(size, (tuple, list)) and len(size) == 2: 290 | self._explicit_max_size = QtCore.QSize(size[0], size[1]) 291 | else: 292 | self._explicit_max_size = size 293 | 294 | def sizeHint(self) -> QtCore.QSize: 295 | """ 296 | Return the preferred size (explicit or calculated). 297 | The size is determined by the union of all paint items' size hints and margins. 298 | Returns (0, 0) if there are no paint items. 299 | """ 300 | if self._explicit_size_hint: 301 | return self._explicit_size_hint 302 | if not self._paint_items: 303 | return QtCore.QSize(0, 0) 304 | max_width = 0 305 | max_height = 0 306 | for item in self._paint_items: 307 | if item.text and hasattr(item.text, 'sizeHint'): 308 | text_size = item.text.sizeHint() 309 | else: 310 | text_size = QtCore.QSize(0, 0) 311 | margin = item.shape.content_margin if item.shape and hasattr(item.shape, 'content_margin') else 0 312 | max_width = max(max_width, text_size.width() + 2 * margin) 313 | max_height = max(max_height, text_size.height() + 2 * margin) 314 | base_hint = super().sizeHint() 315 | return QtCore.QSize( 316 | max(base_hint.width(), max_width), 317 | max(base_hint.height(), max_height) 318 | ) 319 | 320 | def minimumSize(self) -> QtCore.QSize: 321 | """ 322 | Return the minimum size (explicit or calculated). 323 | """ 324 | if self._explicit_min_size: 325 | return self._explicit_min_size 326 | base_min = super().minimumSize() 327 | return base_min 328 | 329 | def maximumSize(self) -> QtCore.QSize: 330 | """ 331 | Return the maximum size (explicit or layout's). 332 | """ 333 | if self._explicit_max_size: 334 | return self._explicit_max_size 335 | return super().maximumSize() 336 | 337 | def set_paint_items(self, items: List[BoxPaintItem]): 338 | """ 339 | Set the list of BoxPaintItem objects to be painted. 340 | Args: 341 | items: The paint items. 342 | """ 343 | self._paint_items = list(items) 344 | self.update() 345 | self.invalidate() 346 | 347 | def get_paint_items(self) -> List[BoxPaintItem]: 348 | """ 349 | Get the list of BoxPaintItem objects. 350 | """ 351 | return list(self._paint_items) 352 | 353 | def paint(self, painter: QtGui.QPainter, options: PaintOptions, mouse_pos: Optional[QtCore.QPoint] = None): 354 | """ 355 | Paint all BoxPaintItem objects in the layout. 356 | Args: 357 | painter: The painter to use. 358 | options: Paint options (enabled, hovered, etc). 359 | mouse_pos: Mouse position for hover detection. 360 | """ 361 | layout_geom = self.geometry() 362 | for item in self._paint_items: 363 | item_flags = PaintOptions.NoOptions 364 | enabled = options & PaintOptions.Enabled and self.flags & BoxPaintLayoutFlag.Enabled 365 | if enabled: 366 | item_flags |= PaintOptions.Enabled 367 | if options & PaintOptions.Hovered and mouse_pos is not None: 368 | if item.hit_test(layout_geom, mouse_pos): 369 | item_flags |= PaintOptions.Hovered 370 | item.paint(painter, 371 | layout_geom, 372 | widget=painter.device() if isinstance(painter.device(), QtWidgets.QWidget) else None, 373 | options=item_flags) 374 | 375 | @classmethod 376 | def _recurse_paint(cls, layout: QtWidgets.QLayout, painter: QtGui.QPainter, mouse_pos: Optional[QtCore.QPoint], hovered_layouts: Set['BoxPaintLayout']): 377 | """ 378 | Recursively paint BoxPaintLayout items. 379 | Args: 380 | layout (QLayout): The layout to paint. 381 | painter (QPainter): The painter to use. 382 | mouse_pos (QPoint, optional): Mouse position for hover detection. 383 | hovered_layouts (set[BoxPaintLayout]): Layouts under the mouse. 384 | """ 385 | if not layout or not layout.geometry().isValid(): 386 | return 387 | if isinstance(layout, BoxPaintLayout): 388 | if not layout.flags & BoxPaintLayoutFlag.Visible: 389 | return 390 | options = PaintOptions.NoOptions 391 | if layout.flags & BoxPaintLayoutFlag.Enabled: 392 | if layout.widget() and layout.widget().isEnabled(): 393 | options |= PaintOptions.Enabled 394 | if layout in hovered_layouts: 395 | options |= PaintOptions.Hovered 396 | layout.paint(painter, options, mouse_pos) 397 | for i in range(layout.count()): 398 | item = layout.itemAt(i) 399 | if item and isinstance(item, QtWidgets.QLayoutItem): 400 | child_layout = item.layout() 401 | cls._recurse_paint(child_layout, painter, mouse_pos, hovered_layouts) 402 | 403 | @classmethod 404 | def render(cls, layout: QtWidgets.QLayout, painter: QtGui.QPainter, mouse_pos: Optional[QtCore.QPoint] = None): 405 | """ 406 | Render the layout and its children, handling hover and transparency. 407 | Args: 408 | layout (QLayout): The root layout to render. 409 | painter (QPainter): The painter to use. 410 | mouse_pos (QPoint, optional): Mouse position for hover detection. 411 | """ 412 | layout_geom = layout.geometry() 413 | rect = layout_geom 414 | if not rect.isValid() or rect.width() <= 0 or rect.height() <= 0: 415 | return 416 | layouts = cls.hit_test(layout, mouse_pos) 417 | hovered_layouts: Set[BoxPaintLayout] = set() 418 | for each in reversed(layouts): 419 | hovered_layouts.add(each) 420 | if each.flags & BoxPaintLayoutFlag.TransparentForHover: 421 | continue 422 | for item in each.get_paint_items(): 423 | if item.shape and item.shape.hover_style: 424 | break 425 | else: 426 | continue 427 | break 428 | cls._recurse_paint(layout, painter, mouse_pos, hovered_layouts) 429 | 430 | @staticmethod 431 | def hit_test(layout: QtWidgets.QLayout, pos: QtCore.QPoint, hit_items: Optional[List['BoxPaintLayout']] = None) -> List['BoxPaintLayout']: 432 | """ 433 | Recursively check which BoxPaintLayout items contain the point, using painter path if needed. 434 | Args: 435 | layout (QLayout): Layout to test (typically a BoxPaintLayout). 436 | pos (QPoint): Point in widget coordinates. 437 | hit_items (list, optional): List to append hits to. 438 | Returns: 439 | list[BoxPaintLayout]: List of BoxPaintLayout items under the point (outermost to innermost). 440 | """ 441 | if hit_items is None: 442 | hit_items = [] 443 | if not layout or not layout.geometry().isValid(): 444 | return hit_items 445 | if isinstance(layout, BoxPaintLayout): 446 | rect = layout.geometry() 447 | for item in layout._paint_items: 448 | if item.hit_test(rect, pos): 449 | hit_items.append(layout) 450 | break 451 | for i in range(layout.count()): 452 | item = layout.itemAt(i) 453 | if not item or not item.geometry().isValid(): 454 | continue 455 | rect = item.geometry() 456 | if rect.width() <= 0 or rect.height() <= 0: 457 | continue 458 | child_layout = item.layout() if isinstance(item, QtWidgets.QLayoutItem) else None 459 | if child_layout: 460 | BoxPaintLayout.hit_test(child_layout, pos, hit_items) 461 | return hit_items 462 | -------------------------------------------------------------------------------- /met_qt/gui/paint_utils.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | """ 3 | This file contains general utilities for painting, predominantly around anchors. 4 | The concept is that these methods can be used to draw and return the rect which was used. 5 | anchor() can be used to snap components together 6 | """ 7 | from typing import Union, Optional 8 | from met_qt._internal.qtcompat import QtWidgets, QtCore, QtGui 9 | 10 | 11 | def to_global(painter: QtGui.QPainter, point_or_rect: Union[QtCore.QRect, QtCore.QPoint]) -> Union[QtCore.QRect, QtCore.QPoint]: 12 | """ 13 | Map a QRect or QPoint from widget coordinates to global (transformed) coordinates using the painter's transform. 14 | Args: 15 | painter: QPainter instance. 16 | point_or_rect: QRect or QPoint to map. 17 | Returns: 18 | QRect or QPoint in global coordinates. 19 | """ 20 | t = painter.transform() 21 | if isinstance(point_or_rect, (QtCore.QRect, QtCore.QRectF)): 22 | return t.mapRect(point_or_rect) 23 | elif isinstance(point_or_rect, (QtCore.QPoint, QtCore.QPointF)): 24 | return t.map(point_or_rect) 25 | else: 26 | raise ValueError("to_global expects a QRect or QPoint") 27 | 28 | def from_global(painter: QtGui.QPainter, point_or_rect: Union[QtCore.QRect, QtCore.QPoint]) -> Union[QtCore.QRect, QtCore.QPoint]: 29 | """ 30 | Map a QRect or QPoint from global (transformed) coordinates to widget coordinates using the painter's transform. 31 | Args: 32 | painter: QPainter instance. 33 | point_or_rect: QRect or QPoint to map. 34 | Returns: 35 | QRect or QPoint in widget coordinates. 36 | """ 37 | inv, invertible = painter.transform().inverted() 38 | if not invertible: 39 | raise ValueError("Painter transform is not invertible") 40 | if isinstance(point_or_rect, QtCore.QRect): 41 | return inv.mapRect(point_or_rect) 42 | elif isinstance(point_or_rect, QtCore.QPoint): 43 | return inv.map(point_or_rect) 44 | else: 45 | raise ValueError("from_global expects a QRect or QPoint") 46 | 47 | def draw_text(painter: QtGui.QPainter, *args, **kwargs) -> QtCore.QRect: 48 | """ 49 | Draw text using the painter and return the bounding rect of the text as drawn. 50 | Accepts either a QRect and flags/text, or x/y/text, matching QPainter.drawText signatures. 51 | Returns: 52 | QRect: The bounding rect of the text as drawn. 53 | """ 54 | if len(args) > 0 and isinstance(args[0], QtCore.QRect): 55 | rect = args[0] 56 | text = args[2] if len(args) > 2 else kwargs.get('text', '') 57 | flags = args[1] if len(args) > 1 else kwargs.get('flags', 0) 58 | font_metrics = painter.fontMetrics() 59 | used_rect = font_metrics.boundingRect(rect, flags, text) 60 | elif len(args) >= 2 and all(isinstance(a, int) for a in args[:2]): 61 | font_metrics = painter.fontMetrics() 62 | text = args[2] if len(args) > 2 else '' 63 | used_rect = font_metrics.boundingRect(args[0], args[1], 10000, 10000, 0, text) 64 | rect = used_rect 65 | else: 66 | rect = QtCore.QRect(0, 0, 0, 0) 67 | used_rect = rect 68 | painter.drawText(*args, **kwargs) 69 | return used_rect 70 | 71 | def draw_partially_rounded_rect( 72 | painter: QtGui.QPainter, 73 | rect: QtCore.QRect, 74 | top_left: int, 75 | top_right: int, 76 | bottom_right: int, 77 | bottom_left: int 78 | ) -> QtCore.QRect: 79 | """ 80 | Draw a rectangle with selectively rounded corners using the painter and return the rect used. 81 | Args: 82 | painter: QPainter instance. 83 | rect: QRect to draw. 84 | top_left, top_right, bottom_right, bottom_left: Radii for each corner. 85 | Returns: 86 | QRect: The rect drawn. 87 | """ 88 | path = QtGui.QPainterPath() 89 | r = rect 90 | path.moveTo(r.left() + top_left, r.top()) 91 | path.lineTo(r.right() - top_right, r.top()) 92 | if top_right: 93 | path.quadTo(r.right(), r.top(), r.right(), r.top() + top_right) 94 | path.lineTo(r.right(), r.bottom() - bottom_right) 95 | if bottom_right: 96 | path.quadTo(r.right(), r.bottom(), r.right() - bottom_right, r.bottom()) 97 | path.lineTo(r.left() + bottom_left, r.bottom()) 98 | if bottom_left: 99 | path.quadTo(r.left(), r.bottom(), r.left(), r.bottom() - bottom_left) 100 | path.lineTo(r.left(), r.top() + top_left) 101 | if top_left: 102 | path.quadTo(r.left(), r.top(), r.left() + top_left, r.top()) 103 | painter.drawPath(path) 104 | return rect 105 | 106 | def draw_path(painter: QtGui.QPainter, path: QtGui.QPainterPath) -> QtCore.QRect: 107 | """ 108 | Draw a QPainterPath using the painter and return its bounding rect. 109 | Args: 110 | painter: QPainter instance. 111 | path: QPainterPath to draw. 112 | Returns: 113 | QRect: The bounding rect of the path. 114 | """ 115 | painter.drawPath(path) 116 | rect = path.boundingRect().toRect() 117 | return rect 118 | 119 | def draw_item_text( 120 | painter: QtGui.QPainter, 121 | rect: QtCore.QRect, 122 | flags: int, 123 | palette: QtGui.QPalette, 124 | enabled: bool, 125 | text: str, 126 | textRole: int = QtGui.QPalette.WindowText, 127 | font: Optional[QtGui.QFont] = None, 128 | style: Optional[QtWidgets.QStyle] = None 129 | ) -> QtCore.QRect: 130 | """ 131 | Draw item text using the style and painter, and return the actual text rect as computed by the style. 132 | Args: 133 | painter: QPainter instance. 134 | rect: QRect to draw in. 135 | flags: Alignment flags. 136 | palette: QPalette for text. 137 | enabled: Whether the item is enabled. 138 | text: The text to draw. 139 | textRole: QPalette role for the text. 140 | font: Optional QFont. 141 | style: Optional QStyle. 142 | Returns: 143 | QRect: The actual text rect as computed by the style. 144 | """ 145 | style = style or QtWidgets.QApplication.style() 146 | option = QtWidgets.QStyleOptionViewItem() 147 | option.rect = rect 148 | option.displayAlignment = flags 149 | option.palette = palette 150 | option.state = QtWidgets.QStyle.State_Enabled if enabled else QtWidgets.QStyle.State_None 151 | option.text = text 152 | if font is not None: 153 | option.font = font 154 | text_rect = style.subElementRect(QtWidgets.QStyle.SE_ItemViewItemText, option) 155 | style.drawItemText(painter, rect, flags, palette, enabled, text, textRole) 156 | return text_rect 157 | 158 | def draw_primitive( 159 | painter: QtGui.QPainter, 160 | element: int, 161 | option: QtWidgets.QStyleOption, 162 | widget: Optional[QtWidgets.QWidget] = None, 163 | style: Optional[QtWidgets.QStyle] = None 164 | ) -> QtCore.QRect: 165 | """ 166 | Draw a primitive element using the style and painter, and return the option's rect. 167 | Args: 168 | painter: QPainter instance. 169 | element: QStyle.PrimitiveElement. 170 | option: QStyleOption. 171 | widget: Optional QWidget. 172 | style: Optional QStyle. 173 | Returns: 174 | QRect: The rect from the option. 175 | """ 176 | style = style or QtWidgets.QApplication.style() 177 | style.drawPrimitive(element, option, painter, widget) 178 | rect = option.rect 179 | return rect 180 | 181 | def draw_control( 182 | painter: QtGui.QPainter, 183 | element: int, 184 | option: QtWidgets.QStyleOption, 185 | widget: Optional[QtWidgets.QWidget] = None, 186 | style: Optional[QtWidgets.QStyle] = None 187 | ) -> QtCore.QRect: 188 | """ 189 | Draw a control element using the style and painter, and return the option's rect. 190 | Args: 191 | painter: QPainter instance. 192 | element: QStyle.ControlElement. 193 | option: QStyleOption. 194 | widget: Optional QWidget. 195 | style: Optional QStyle. 196 | Returns: 197 | QRect: The rect from the option. 198 | """ 199 | style = style or QtWidgets.QApplication.style() 200 | style.drawControl(element, option, painter, widget) 201 | rect = option.rect 202 | return rect 203 | 204 | def draw_complex_control( 205 | painter: QtGui.QPainter, 206 | element: int, 207 | option: QtWidgets.QStyleOptionComplex, 208 | widget: Optional[QtWidgets.QWidget] = None, 209 | style: Optional[QtWidgets.QStyle] = None, 210 | subcontrol: Optional[int] = None 211 | ) -> QtCore.QRect: 212 | """ 213 | Draw a complex control element using the style and painter, and return the subcontrol rect if specified, otherwise the option's rect. 214 | Args: 215 | painter: QPainter instance. 216 | element: QStyle.ComplexControl. 217 | option: QStyleOptionComplex. 218 | widget: Optional QWidget. 219 | style: Optional QStyle. 220 | subcontrol: Optional QStyle.SubControl to get the rect for. 221 | Returns: 222 | QRect: The subcontrol rect if specified, else the rect from the option. 223 | """ 224 | style = style or QtWidgets.QApplication.style() 225 | style.drawComplexControl(element, option, painter, widget) 226 | if subcontrol is not None: 227 | return style.subControlRect(element, option, subcontrol, widget) 228 | rect = option.rect 229 | return rect 230 | 231 | def anchor( 232 | rect: Union[QtCore.QRect, QtCore.QSize, tuple], 233 | left: Optional[int] = None, 234 | right: Optional[int] = None, 235 | top: Optional[int] = None, 236 | bottom: Optional[int] = None, 237 | vcenter: Optional[int] = None, 238 | hcenter: Optional[int] = None 239 | ) -> QtCore.QRect: 240 | """ 241 | Anchor and optionally stretch a rect based on left, right, top, bottom, vcenter, hcenter. 242 | Args: 243 | rect: QRect, QSize, or (w, h) tuple. If size/tuple, origin is (0,0). 244 | left, right, top, bottom: Optional anchor positions. 245 | vcenter: Optional vertical center position. 246 | hcenter: Optional horizontal center position. 247 | Returns: 248 | QRect: The anchored and/or stretched rect. 249 | """ 250 | if isinstance(rect, QtCore.QRect): 251 | r = QtCore.QRect(rect) 252 | elif isinstance(rect, QtCore.QSize): 253 | r = QtCore.QRect(0, 0, rect.width(), rect.height()) 254 | elif isinstance(rect, tuple) and len(rect) == 2: 255 | r = QtCore.QRect(0, 0, rect[0], rect[1]) 256 | else: 257 | raise ValueError("rect must be QRect, QSize, or (w, h) tuple") 258 | if left is not None: 259 | r.moveLeft(left) 260 | if right is not None: 261 | if left is not None: 262 | r.setWidth(right - left) 263 | else: 264 | r.moveRight(right) 265 | if top is not None: 266 | r.moveTop(top) 267 | if bottom is not None: 268 | if top is not None: 269 | r.setHeight(bottom - top) 270 | else: 271 | r.moveBottom(bottom) 272 | if vcenter is not None: 273 | r.moveCenter(QtCore.QPoint(r.center().x(), vcenter)) 274 | if hcenter is not None: 275 | r.moveCenter(QtCore.QPoint(hcenter, r.center().y())) 276 | return r -------------------------------------------------------------------------------- /met_qt/widgets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalefforttech/met_qt/e9d7ea543f61c9cadc515b8269afa2be5a216f68/met_qt/widgets/__init__.py -------------------------------------------------------------------------------- /met_qt/widgets/float_slider.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | from met_qt._internal.qtcompat import QtWidgets, QtCore, QtGui 3 | from typing import Optional 4 | from met_qt._internal.widgets.abstract_slider import AbstractSoftSlider as _AbstractSoftSlider 5 | from met_qt.core.meta import QProperty 6 | import sys 7 | 8 | 9 | class FloatSlider(_AbstractSoftSlider): 10 | """ 11 | A slider widget supporting floating-point values, with hard (min/max), soft, and interactive ranges. 12 | - Hard range: absolute min/max (cannot be exceeded) 13 | - Soft range: user-adjustable subrange within hard range 14 | - Interactive range: intersection of soft and hard range (not stored) 15 | """ 16 | value_changed = QtCore.Signal(float) 17 | slider_moved = QtCore.Signal(float) 18 | slider_pressed = QtCore.Signal() 19 | slider_released = QtCore.Signal() 20 | 21 | def __init__(self, orientation: QtCore.Qt.Orientation = QtCore.Qt.Horizontal, parent: Optional[QtWidgets.QWidget] = None): 22 | super().__init__(orientation, parent) 23 | self._value: float = 0.0 24 | self._slider_down: bool = False 25 | self.range_changed.connect(self._on_range_changed) 26 | 27 | def _on_range_changed(self, min_, max_): 28 | # Clamp value to new range 29 | clamped = self._bound(self._value) 30 | if clamped != self._value: 31 | self.value = clamped 32 | 33 | @QtCore.Property(float, notify=value_changed) 34 | def value(self) -> float: 35 | """Return the current value.""" 36 | return self._value 37 | 38 | @value.setter 39 | def value(self, value: float): 40 | value = self._bound(float(value)) 41 | changed = False 42 | if self._soft_range is not None and value < self._soft_range[0]: 43 | self._soft_range = (value, self._soft_range[1]) 44 | changed = True 45 | if self._soft_range is not None and value > self._soft_range[1]: 46 | self._soft_range = (self._soft_range[0], value) 47 | changed = True 48 | if changed: 49 | self.soft_range_changed.emit(self._soft_range[0] if self._soft_range is not None else self._range[0], 50 | self._soft_range[1] if self._soft_range is not None else self._range[1]) 51 | if self._value != value: 52 | self._value = value 53 | self.value_changed.emit(self._value) 54 | self.update() 55 | 56 | def paintEvent(self, event: QtGui.QPaintEvent): 57 | """ 58 | Paint the slider using QStylePainter and QStyleOptionSlider for native look and feel. 59 | """ 60 | visual_range = self._visual_range() 61 | 62 | opt = QtWidgets.QStyleOptionSlider() 63 | opt.initFrom(self) 64 | opt.orientation = self._orientation 65 | # Allow for 4dp accuracy 66 | mult = 10**self._decimals 67 | opt.minimum = int(visual_range[0]*mult) 68 | opt.maximum = int(visual_range[1]*mult) 69 | opt.sliderPosition = int(self._value*mult) 70 | opt.sliderValue = int(self._value*mult) 71 | opt.singleStep = int(self.single_step*mult) 72 | opt.pageStep = int(self.page_step*mult) 73 | opt.upsideDown = False 74 | opt.state |= QtWidgets.QStyle.State_HasFocus if self.hasFocus() else QtWidgets.QStyle.State_None 75 | if self._slider_down: 76 | opt.state |= QtWidgets.QStyle.State_Sunken 77 | else: 78 | opt.state &= ~QtWidgets.QStyle.State_Sunken 79 | 80 | painter = QtWidgets.QStylePainter(self) 81 | painter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt) 82 | 83 | def mousePressEvent(self, event: QtGui.QMouseEvent): 84 | if event.button() == QtCore.Qt.LeftButton: 85 | groove_rect = self._groove_rect() 86 | pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() 87 | if self._orientation == QtCore.Qt.Horizontal: 88 | if pos.x() < groove_rect.left(): 89 | pos = QtCore.QPoint(groove_rect.left(), pos.y()) 90 | elif pos.x() > groove_rect.right(): 91 | pos = QtCore.QPoint(groove_rect.right(), pos.y()) 92 | else: 93 | if pos.y() < groove_rect.top(): 94 | pos = QtCore.QPoint(pos.x(), groove_rect.top()) 95 | elif pos.y() > groove_rect.bottom(): 96 | pos = QtCore.QPoint(pos.x(), groove_rect.bottom()) 97 | val = self._pos_to_value( 98 | pos.x() if self._orientation == QtCore.Qt.Horizontal else pos.y(), 99 | groove_rect) 100 | self._slider_down = True 101 | self.value = val 102 | self.slider_moved.emit(self._value) 103 | event.accept() 104 | else: 105 | super().mousePressEvent(event) 106 | 107 | def mouseMoveEvent(self, event: QtGui.QMouseEvent): 108 | if self._slider_down: 109 | groove_rect = self._groove_rect() 110 | pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() 111 | if self._orientation == QtCore.Qt.Horizontal: 112 | if pos.x() < groove_rect.left(): 113 | pos = QtCore.QPoint(groove_rect.left(), pos.y()) 114 | elif pos.x() > groove_rect.right(): 115 | pos = QtCore.QPoint(groove_rect.right(), pos.y()) 116 | else: 117 | if pos.y() < groove_rect.top(): 118 | pos = QtCore.QPoint(pos.x(), groove_rect.top()) 119 | elif pos.y() > groove_rect.bottom(): 120 | pos = QtCore.QPoint(pos.x(), groove_rect.bottom()) 121 | val = self._pos_to_value( 122 | pos.x() if self._orientation == QtCore.Qt.Horizontal else pos.y(), 123 | groove_rect) 124 | self.value = val 125 | self.slider_moved.emit(self._value) 126 | event.accept() 127 | else: 128 | super().mouseMoveEvent(event) 129 | 130 | def mouseReleaseEvent(self, event: QtGui.QMouseEvent): 131 | if self._slider_down and event.button() == QtCore.Qt.LeftButton: 132 | self._slider_down = False 133 | self.slider_released.emit() 134 | event.accept() 135 | else: 136 | super().mouseReleaseEvent(event) 137 | 138 | def keyPressEvent(self, event: QtGui.QKeyEvent): 139 | key = event.key() 140 | val = self._value 141 | if key in (QtCore.Qt.Key_Left, QtCore.Qt.Key_Down): 142 | val -= self.single_step 143 | elif key in (QtCore.Qt.Key_Right, QtCore.Qt.Key_Up): 144 | val += self.single_step 145 | elif key == QtCore.Qt.Key_PageUp: 146 | val += self.page_step 147 | elif key == QtCore.Qt.Key_PageDown: 148 | val -= self.page_step 149 | elif key == QtCore.Qt.Key_Home: 150 | val = self._visual_range()[0] 151 | elif key == QtCore.Qt.Key_End: 152 | val = self._visual_range()[1] 153 | else: 154 | super().keyPressEvent(event) 155 | return 156 | self.value = val 157 | self.slider_moved.emit(self._value) 158 | event.accept() 159 | -------------------------------------------------------------------------------- /met_qt/widgets/range_slider.py: -------------------------------------------------------------------------------- 1 | # copyright (c) 2025 Alex Telford, http://minimaleffort.tech 2 | from met_qt._internal.qtcompat import QtWidgets, QtCore, QtGui 3 | from typing import Optional 4 | from met_qt._internal.widgets.abstract_slider import AbstractSoftSlider as _AbstractSoftSlider 5 | 6 | class RangeSlider(_AbstractSoftSlider): 7 | """ 8 | A slider widget supporting floating-point range selection with two handles (min/max) 9 | Dragging a handle past the other switches which handle is being dragged. 10 | """ 11 | min_value_changed = QtCore.Signal(float) 12 | max_value_changed = QtCore.Signal(float) 13 | slider_moved = QtCore.Signal(float, float) 14 | slider_pressed = QtCore.Signal() 15 | slider_released = QtCore.Signal() 16 | 17 | def __init__(self, orientation: QtCore.Qt.Orientation = QtCore.Qt.Horizontal, parent: Optional[QtWidgets.QWidget] = None): 18 | super().__init__(orientation, parent) 19 | self._min_value: float = self._range[0] 20 | self._max_value: float = self._range[1] 21 | self._active_handle: Optional[str] = None # 'min' or 'max' 22 | self._slider_down: bool = False 23 | self.range_changed.connect(self._on_range_changed) 24 | 25 | def _on_range_changed(self, min_: float, max_: float): 26 | self._min_value = min_ 27 | self._max_value = max_ 28 | self.min_value_changed.emit(self._min_value) 29 | self.max_value_changed.emit(self._max_value) 30 | self.slider_moved.emit(self._min_value, self._max_value) 31 | self.update() 32 | 33 | @QtCore.Property(float, notify=min_value_changed) 34 | def min_value(self) -> float: 35 | return self._min_value 36 | 37 | @min_value.setter 38 | def min_value(self, value: float): 39 | value = float(value) 40 | value = min(max(value, self._range[0]), self._range[1]) 41 | if self.single_step > 0: 42 | value = round(value / self.single_step) * self.single_step 43 | # Handle swapping if past max 44 | if value > self._max_value: 45 | old_min = self._min_value 46 | self._min_value, self._max_value = self._max_value, value 47 | self._active_handle = 'max' if self._active_handle == 'min' else 'min' 48 | self.max_value_changed.emit(self._max_value) 49 | else: 50 | old_min = self._min_value 51 | self._min_value = value 52 | if old_min != self._min_value: 53 | self.min_value_changed.emit(self._min_value) 54 | self.slider_moved.emit(self._min_value, self._max_value) 55 | self.update() 56 | 57 | @QtCore.Property(float, notify=max_value_changed) 58 | def max_value(self) -> float: 59 | return self._max_value 60 | 61 | @max_value.setter 62 | def max_value(self, value: float): 63 | value = float(value) 64 | value = min(max(value, self._range[0]), self._range[1]) 65 | if self.single_step > 0: 66 | value = round(value / self.single_step) * self.single_step 67 | # Handle swapping if before min 68 | if value < self._min_value: 69 | old_max = self._max_value 70 | self._min_value, self._max_value = value, self._min_value 71 | self._active_handle = 'min' if self._active_handle == 'max' else 'max' 72 | self.min_value_changed.emit(self._min_value) 73 | else: 74 | old_max = self._max_value 75 | self._max_value = value 76 | if old_max != self._max_value: 77 | self.max_value_changed.emit(self._max_value) 78 | self.slider_moved.emit(self._min_value, self._max_value) 79 | self.update() 80 | 81 | def paintEvent(self, event: QtGui.QPaintEvent): 82 | visual_range = self._visual_range() 83 | mult = 10 ** self._decimals 84 | painter = QtGui.QPainter(self) 85 | painter.setRenderHint(QtGui.QPainter.Antialiasing, True) 86 | groove_rect = self._groove_rect() 87 | min_center = self._value_to_pos(self._min_value, groove_rect) 88 | max_center = self._value_to_pos(self._max_value, groove_rect) 89 | 90 | groove_color = self.palette().color(QtGui.QPalette.Button) 91 | groove_color2 = groove_color.lighter(120) 92 | groove_gradient = QtGui.QLinearGradient( 93 | groove_rect.center().x(), groove_rect.top(), 94 | groove_rect.center().x(), groove_rect.bottom() 95 | ) 96 | groove_gradient.setColorAt(0, groove_color) 97 | groove_gradient.setColorAt(1, groove_color2) 98 | painter.save() 99 | painter.setPen(QtCore.Qt.NoPen) 100 | painter.setBrush(groove_gradient) 101 | painter.drawRoundedRect(groove_rect, 3, 3) 102 | painter.restore() 103 | 104 | if self.isEnabled(): 105 | highlight_color = self.palette().color(QtGui.QPalette.Highlight) 106 | else: 107 | highlight_color = self.palette().color(QtGui.QPalette.Disabled, QtGui.QPalette.Highlight) 108 | painter.save() 109 | painter.setPen(QtCore.Qt.NoPen) 110 | painter.setBrush(QtGui.QBrush(highlight_color)) 111 | if self._orientation == QtCore.Qt.Horizontal: 112 | y = groove_rect.top() 113 | h = groove_rect.height() 114 | left = min(min_center, max_center) 115 | right = max(min_center, max_center) 116 | highlight_rect = QtCore.QRect(left, y, right - left, h) 117 | else: 118 | x = groove_rect.left() 119 | w = groove_rect.width() 120 | top = min(min_center, max_center) 121 | bottom = max(min_center, max_center) 122 | highlight_rect = QtCore.QRect(x, top, w, bottom - top) 123 | painter.drawRoundedRect(highlight_rect, 3, 3) 124 | painter.restore() 125 | 126 | style = self.style() 127 | opt = QtWidgets.QStyleOptionSlider() 128 | opt.initFrom(self) 129 | opt.orientation = self._orientation 130 | opt.minimum = int(visual_range[0] * mult) 131 | opt.maximum = int(visual_range[1] * mult) 132 | opt.subControls = QtWidgets.QStyle.SC_SliderHandle 133 | 134 | opt.sliderPosition = int(self._min_value * mult) 135 | opt.sliderValue = int(self._min_value * mult) 136 | opt.state = QtWidgets.QStyle.State_Enabled if self.isEnabled() else QtWidgets.QStyle.State_None 137 | if self._active_handle == 'min' and self._slider_down: 138 | opt.state |= QtWidgets.QStyle.State_Sunken 139 | style.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt, painter, self) 140 | 141 | opt.sliderPosition = int(self._max_value * mult) 142 | opt.sliderValue = int(self._max_value * mult) 143 | opt.state = QtWidgets.QStyle.State_Enabled if self.isEnabled() else QtWidgets.QStyle.State_None 144 | if self._active_handle == 'max' and self._slider_down: 145 | opt.state |= QtWidgets.QStyle.State_Sunken 146 | style.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt, painter, self) 147 | 148 | def _pick_handle(self, pos): 149 | # Decide which handle is closer to the mouse position 150 | groove_rect = self._groove_rect() 151 | if self._orientation == QtCore.Qt.Horizontal: 152 | min_pos = self._value_to_pos(self._min_value, groove_rect) 153 | max_pos = self._value_to_pos(self._max_value, groove_rect) 154 | if abs(pos.x() - min_pos) < abs(pos.x() - max_pos): 155 | return 'min' 156 | else: 157 | return 'max' 158 | else: 159 | min_pos = self._value_to_pos(self._min_value, groove_rect) 160 | max_pos = self._value_to_pos(self._max_value, groove_rect) 161 | if abs(pos.y() - min_pos) < abs(pos.y() - max_pos): 162 | return 'min' 163 | else: 164 | return 'max' 165 | 166 | def mousePressEvent(self, event: QtGui.QMouseEvent): 167 | if event.button() == QtCore.Qt.LeftButton: 168 | groove_rect = self._groove_rect() 169 | pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() 170 | self._active_handle = self._pick_handle(pos) 171 | self._slider_down = True 172 | val = self._pos_to_value(pos.x() if self._orientation == QtCore.Qt.Horizontal else pos.y(), groove_rect) 173 | if self._active_handle == 'min': 174 | self.min_value = val 175 | else: 176 | self.max_value = val 177 | self.slider_moved.emit(self._min_value, self._max_value) 178 | event.accept() 179 | else: 180 | super().mousePressEvent(event) 181 | 182 | def mouseMoveEvent(self, event: QtGui.QMouseEvent): 183 | if self._slider_down and self._active_handle: 184 | groove_rect = self._groove_rect() 185 | pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() 186 | val = self._pos_to_value(pos.x() if self._orientation == QtCore.Qt.Horizontal else pos.y(), groove_rect) 187 | if self._active_handle == 'min': 188 | self.min_value = val 189 | else: 190 | self.max_value = val 191 | self.slider_moved.emit(self._min_value, self._max_value) 192 | event.accept() 193 | else: 194 | super().mouseMoveEvent(event) 195 | 196 | def mouseReleaseEvent(self, event: QtGui.QMouseEvent): 197 | if self._slider_down and event.button() == QtCore.Qt.LeftButton: 198 | self._slider_down = False 199 | self._active_handle = None 200 | self.slider_released.emit() 201 | event.accept() 202 | else: 203 | super().mouseReleaseEvent(event) 204 | 205 | def keyPressEvent(self, event: QtGui.QKeyEvent): 206 | key = event.key() 207 | if self._active_handle == 'min': 208 | val = self._min_value 209 | else: 210 | val = self._max_value 211 | if key in (QtCore.Qt.Key_Left, QtCore.Qt.Key_Down): 212 | val -= self.single_step 213 | elif key in (QtCore.Qt.Key_Right, QtCore.Qt.Key_Up): 214 | val += self.single_step 215 | elif key == QtCore.Qt.Key_PageUp: 216 | val += self.page_step 217 | elif key == QtCore.Qt.Key_PageDown: 218 | val -= self.page_step 219 | elif key == QtCore.Qt.Key_Home: 220 | val = self._visual_range()[0] 221 | elif key == QtCore.Qt.Key_End: 222 | val = self._visual_range()[1] 223 | else: 224 | super().keyPressEvent(event) 225 | return 226 | if self._active_handle == 'min': 227 | self.min_value = val 228 | else: 229 | self.max_value = val 230 | self.slider_moved.emit(self._min_value, self._max_value) 231 | event.accept() 232 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "met_qt" 7 | version = "0.1.0" 8 | authors = [{ name = "Alex Telford" }] 9 | description = "Python Qt utilities and components" 10 | readme = "README.md" 11 | requires-python = ">=3.7" 12 | classifiers = [ 13 | "Programming Language :: Python :: 3", 14 | "Operating System :: OS Independent", 15 | ] 16 | 17 | [project.optional-dependencies] 18 | test = ["pytest>=7.0"] 19 | 20 | [tool.pytest.ini_options] 21 | testpaths = ["tests"] 22 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = python 3 | testpaths = tests 4 | python_files = test_*.py 5 | python_classes = Test* 6 | python_functions = test_* 7 | addopts = -v 8 | 9 | # Custom markers 10 | markers = 11 | qt: mark test as requiring Qt bindings 12 | pyside2: mark test as requiring PySide2 13 | pyside6: mark test as requiring PySide6 14 | -------------------------------------------------------------------------------- /run_all_tests.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal EnableDelayedExpansion 3 | 4 | set "RUN_PYSIDE2=0" 5 | set "RUN_PYSIDE6=0" 6 | set "CLEAN=0" 7 | 8 | REM Parse command line arguments 9 | if "%1"=="" ( 10 | set "RUN_PYSIDE2=1" 11 | set "RUN_PYSIDE6=1" 12 | ) else ( 13 | :parse_args 14 | if "%1"=="--help" ( 15 | echo Usage: %0 [options] 16 | echo Options: 17 | echo --pyside2 Run PySide2 tests only 18 | echo --pyside6 Run PySide6 tests only 19 | echo --clean Force rebuild of virtual environments 20 | echo --help Show this help message 21 | exit /b 0 22 | ) 23 | if "%1"=="--pyside2" set "RUN_PYSIDE2=1" 24 | if "%1"=="--pyside6" set "RUN_PYSIDE6=1" 25 | if "%1"=="--clean" set "CLEAN=1" 26 | shift 27 | if not "%1"=="" goto parse_args 28 | ) 29 | 30 | if %RUN_PYSIDE2%==0 if %RUN_PYSIDE6%==0 ( 31 | echo No test suite specified. Use --pyside2 and/or --pyside6 32 | exit /b 1 33 | ) 34 | 35 | REM Store current directory and create package cache 36 | set "INITIAL_DIR=%CD%" 37 | if not exist ".package_cache" mkdir .package_cache 38 | 39 | REM Download Python 3.7 if not already present and needed 40 | if %RUN_PYSIDE2%==1 ( 41 | set "PYTHON37_INSTALLER=.package_cache\python-3.7.9-amd64.exe" 42 | if not exist "python37" ( 43 | if not exist "%PYTHON37_INSTALLER%" ( 44 | echo Downloading Python 3.7... 45 | curl -o "%PYTHON37_INSTALLER%" https://www.python.org/ftp/python/3.7.9/python-3.7.9-amd64.exe 46 | ) 47 | echo Installing Python 3.7... 48 | "%PYTHON37_INSTALLER%" /quiet InstallAllUsers=0 PrependPath=0 Include_test=0 InstallLauncherAllUsers=0 TargetDir=%CD%\python37 49 | ) 50 | ) 51 | 52 | set "PYSIDE2_RESULT=0" 53 | set "PYSIDE6_RESULT=0" 54 | 55 | REM Run PySide2 tests if requested 56 | if %RUN_PYSIDE2%==1 ( 57 | echo. 58 | echo ===== Setting up PySide2 environment with Python 3.7 ===== 59 | if %CLEAN%==1 call :clean_venv venv-pyside2 60 | if not exist "venv-pyside2" ( 61 | %CD%\python37\python.exe -m venv venv-pyside2 62 | if errorlevel 1 ( 63 | echo Failed to create PySide2 virtual environment 64 | exit /b 1 65 | ) 66 | 67 | call venv-pyside2\Scripts\activate 68 | echo Installing dependencies for PySide2... 69 | python -m pip install --upgrade pip 70 | pip install --no-index --find-links=.package_cache pytest pytest-qt PySide2==5.15.2 || ( 71 | pip download --dest=.package_cache pytest pytest-qt PySide2==5.15.2 72 | pip install --no-index --find-links=.package_cache pytest pytest-qt PySide2==5.15.2 73 | ) 74 | pip install -e . 75 | if errorlevel 1 ( 76 | echo Failed to install PySide2 dependencies 77 | call deactivate 78 | exit /b 1 79 | ) 80 | call deactivate 81 | ) 82 | 83 | echo Running tests with PySide2... 84 | call venv-pyside2\Scripts\activate.bat 85 | pytest tests -vv 86 | set PYSIDE2_RESULT=!errorlevel! 87 | call venv-pyside2\Scripts\deactivate.bat 88 | cd "%INITIAL_DIR%" 89 | ) 90 | 91 | REM Run PySide6 tests if requested 92 | if %RUN_PYSIDE6%==1 ( 93 | echo. 94 | echo ===== Setting up PySide6 environment ===== 95 | if %CLEAN%==1 call :clean_venv venv-pyside6 96 | if not exist "venv-pyside6" ( 97 | python -m venv venv-pyside6 98 | if errorlevel 1 ( 99 | echo Failed to create PySide6 virtual environment 100 | exit /b 1 101 | ) 102 | 103 | call venv-pyside6\Scripts\activate 104 | echo Installing dependencies for PySide6... 105 | python -m pip install --upgrade pip 106 | pip install --no-index --find-links=.package_cache pytest pytest-qt PySide6 || ( 107 | pip download --dest=.package_cache pytest pytest-qt PySide6 108 | pip install --no-index --find-links=.package_cache pytest pytest-qt PySide6 109 | ) 110 | pip install -e . 111 | if errorlevel 1 ( 112 | echo Failed to install PySide6 dependencies 113 | call deactivate 114 | exit /b 1 115 | ) 116 | call deactivate 117 | ) 118 | 119 | echo Running tests with PySide6... 120 | call venv-pyside6\Scripts\activate.bat 121 | pytest tests -vv 122 | set PYSIDE6_RESULT=!errorlevel! 123 | call venv-pyside6\Scripts\deactivate.bat 124 | cd "%INITIAL_DIR%" 125 | ) 126 | 127 | echo. 128 | echo ===== Test Results ===== 129 | if %RUN_PYSIDE2%==1 ( 130 | if !PYSIDE2_RESULT! EQU 0 ( 131 | echo PySide2 tests: PASSED 132 | ) else ( 133 | echo PySide2 tests: FAILED 134 | ) 135 | ) 136 | 137 | if %RUN_PYSIDE6%==1 ( 138 | if !PYSIDE6_RESULT! EQU 0 ( 139 | echo PySide6 tests: PASSED 140 | ) else ( 141 | echo PySide6 tests: FAILED 142 | ) 143 | ) 144 | 145 | if !PYSIDE2_RESULT! NEQ 0 exit /b !PYSIDE2_RESULT! 146 | if !PYSIDE6_RESULT! NEQ 0 exit /b !PYSIDE6_RESULT! 147 | 148 | echo. 149 | echo All tests completed successfully! 150 | exit /b 0 151 | 152 | :clean_venv 153 | if exist "%~1" ( 154 | echo Removing existing virtual environment: %~1 155 | rmdir /s /q "%~1" 156 | ) 157 | goto :eof 158 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | packages=find_packages(), 5 | ) 6 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pytest configuration file for met_qt tests. 3 | 4 | This file contains configuration and common fixtures for pytest. 5 | """ 6 | 7 | import pytest 8 | 9 | # No need to modify sys.path; met_qt is now at the root 10 | 11 | # Skip tests that require Qt if no Qt bindings are available 12 | def pytest_configure(config): 13 | """Configure pytest.""" 14 | try: 15 | from met_qt._internal import qtcompat 16 | # Mark which binding is being used 17 | if qtcompat.QT_BINDING == 'PySide6': 18 | config.addinivalue_line("markers", "pyside6: mark test as requiring PySide6") 19 | elif qtcompat.QT_BINDING == 'PySide2': 20 | config.addinivalue_line("markers", "pyside2: mark test as requiring PySide2") 21 | except ImportError: 22 | # No Qt bindings available, add a skip marker 23 | config.addinivalue_line("markers", "qt: mark test as requiring Qt bindings") 24 | 25 | def pytest_collection_modifyitems(config, items): 26 | """Skip tests marked as requiring specific Qt versions if those bindings aren't available.""" 27 | try: 28 | from met_qt._internal import qtcompat 29 | qt_binding = qtcompat.QT_BINDING 30 | except ImportError: 31 | # If no Qt bindings are available, skip all tests marked with qt 32 | skip_qt = pytest.mark.skip(reason="No Qt bindings available") 33 | for item in items: 34 | if "qt" in item.keywords: 35 | item.add_marker(skip_qt) 36 | return 37 | 38 | # Skip PySide6-specific tests if PySide2 is being used 39 | if qt_binding == 'PySide2': 40 | skip_pyside6 = pytest.mark.skip(reason="Test requires PySide6, but PySide2 is being used") 41 | for item in items: 42 | if "pyside6" in item.keywords: 43 | item.add_marker(skip_pyside6) 44 | 45 | # Skip PySide2-specific tests if PySide6 is being used 46 | elif qt_binding == 'PySide6': 47 | skip_pyside2 = pytest.mark.skip(reason="Test requires PySide2, but PySide6 is being used") 48 | for item in items: 49 | if "pyside2" in item.keywords: 50 | item.add_marker(skip_pyside2) -------------------------------------------------------------------------------- /tests/integration/test_core_binding.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from met_qt._internal.qtcompat import QtWidgets 3 | from met_qt.core.binding import Bindings 4 | 5 | @pytest.fixture 6 | def bindings_widget(qtbot): 7 | widget = QtWidgets.QWidget() 8 | layout = QtWidgets.QVBoxLayout(widget) 9 | spinbox = QtWidgets.QSpinBox() 10 | spinbox.setRange(0, 100) 11 | value_label = QtWidgets.QLabel("0") 12 | layout.addWidget(spinbox) 13 | layout.addWidget(value_label) 14 | edit1 = QtWidgets.QLineEdit() 15 | edit2 = QtWidgets.QLineEdit() 16 | layout.addWidget(edit1) 17 | layout.addWidget(edit2) 18 | first_name = QtWidgets.QLineEdit() 19 | last_name = QtWidgets.QLineEdit() 20 | full_name = QtWidgets.QLineEdit() 21 | full_name.setReadOnly(True) 22 | layout.addWidget(first_name) 23 | layout.addWidget(last_name) 24 | layout.addWidget(full_name) 25 | widget.show() 26 | qtbot.addWidget(widget) 27 | return { 28 | 'widget': widget, 29 | 'spinbox': spinbox, 30 | 'value_label': value_label, 31 | 'edit1': edit1, 32 | 'edit2': edit2, 33 | 'first_name': first_name, 34 | 'last_name': last_name, 35 | 'full_name': full_name, 36 | 'bindings': Bindings(widget) 37 | } 38 | 39 | def test_one_way_binding(qtbot, bindings_widget): 40 | spinbox = bindings_widget['spinbox'] 41 | value_label = bindings_widget['value_label'] 42 | bindings = bindings_widget['bindings'] 43 | # Create a helper to track signal emissions 44 | signal_count = 0 45 | def on_value_changed(value): 46 | nonlocal signal_count 47 | signal_count += 1 48 | print(f"Value changed to: {value}") 49 | 50 | binding = bindings.bind(spinbox, "value") 51 | binding.to(value_label, "text", lambda x: str(x)) 52 | spinbox.valueChanged.connect(on_value_changed) 53 | spinbox.setValue(42) 54 | # Update binding after value change 55 | binding.update_targets() 56 | qtbot.wait(100) # Give time for signals to propagate 57 | print(f"Spinbox value: {spinbox.value()}") 58 | print(f"Label text: {value_label.text()}") 59 | assert value_label.text() == "42", "Label text was not updated" 60 | 61 | def test_two_way_binding(qtbot, bindings_widget): 62 | edit1 = bindings_widget['edit1'] 63 | edit2 = bindings_widget['edit2'] 64 | bindings = bindings_widget['bindings'] 65 | group = bindings.bind_group() 66 | group.add(edit1, "text") 67 | group.add(edit2, "text") 68 | edit1.setText("foo") 69 | qtbot.waitUntil(lambda: edit2.text() == "foo") 70 | edit2.setText("bar") 71 | qtbot.waitUntil(lambda: edit1.text() == "bar") 72 | 73 | def test_expression_binding(qtbot, bindings_widget): 74 | first_name = bindings_widget['first_name'] 75 | last_name = bindings_widget['last_name'] 76 | full_name = bindings_widget['full_name'] 77 | bindings = bindings_widget['bindings'] 78 | with bindings.bind_expression(full_name, "text", "{first} {last}") as expr: 79 | expr.bind("first", first_name, "text") 80 | expr.bind("last", last_name, "text") 81 | first_name.setText("Ada") 82 | last_name.setText("Lovelace") 83 | qtbot.waitUntil(lambda: full_name.text() == "Ada Lovelace") 84 | 85 | def test_math_expression_binding(qtbot, bindings_widget): 86 | # Add two QLineEdit widgets for numbers and a QLabel for the result 87 | widget = bindings_widget['widget'] 88 | num1 = QtWidgets.QLineEdit() 89 | num2 = QtWidgets.QLineEdit() 90 | result = QtWidgets.QSlider() 91 | widget.layout().addWidget(num1) 92 | widget.layout().addWidget(num2) 93 | widget.layout().addWidget(result) 94 | bindings = bindings_widget['bindings'] 95 | # Expression: sum of two floats 96 | with bindings.bind_expression(result, "value", "a+b") as expr: 97 | expr.bind("a", num1, "text") 98 | expr.bind("b", num2, "text") 99 | num1.setText("2") 100 | num2.setText("3") 101 | qtbot.waitUntil(lambda: result.value() == 5, timeout=20) 102 | num1.setText("10") 103 | num2.setText("5") 104 | qtbot.waitUntil(lambda: result.value() == 15, timeout=20) 105 | -------------------------------------------------------------------------------- /tests/integration/test_core_model_data_mapper.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from met_qt._internal.qtcompat import QtCore, QtGui, QtWidgets 3 | from met_qt.core.model_data_mapper import ModelDataMapper 4 | 5 | class DummyWidget(QtWidgets.QWidget): 6 | valueChanged = QtCore.Signal(int) 7 | def __init__(self): 8 | super().__init__() 9 | self._value = 0 10 | def setValue(self, v): 11 | self._value = v 12 | self.valueChanged.emit(v) 13 | def value(self): 14 | return self._value 15 | 16 | def create_simple_model(): 17 | model = QtGui.QStandardItemModel() 18 | item = QtGui.QStandardItem("Test") 19 | item.setData({"quantity": 42, "format": "usd"}, QtCore.Qt.UserRole) 20 | model.appendRow(item) 21 | return model 22 | 23 | def test_model_data_mapper_basic(qtbot): 24 | model = create_simple_model() 25 | widget = DummyWidget() 26 | mapper = ModelDataMapper() 27 | mapper.set_model(model) 28 | mapper.add_mapping( 29 | widget, "value", role=QtCore.Qt.UserRole, 30 | from_model=lambda d: d.get("quantity", 0), 31 | from_property=lambda v, d: {**d, "quantity": v}, 32 | signal=widget.valueChanged 33 | ) 34 | mapper.set_current_index(0) 35 | # Model to widget 36 | assert widget.value() == 42 37 | # Widget to model 38 | widget.setValue(99) 39 | assert model.item(0).data(QtCore.Qt.UserRole)["quantity"] == 99 40 | 41 | def test_model_data_mapper_refresh(qtbot): 42 | model = create_simple_model() 43 | widget = DummyWidget() 44 | mapper = ModelDataMapper() 45 | mapper.set_model(model) 46 | mapper.add_mapping( 47 | widget, "value", role=QtCore.Qt.UserRole, 48 | from_model=lambda d: d.get("quantity", 0), 49 | from_property=lambda v, d: {**d, "quantity": v}, 50 | signal=widget.valueChanged 51 | ) 52 | mapper.set_current_index(0) 53 | model.item(0).setData({"quantity": 123, "format": "usd"}, QtCore.Qt.UserRole) 54 | mapper.refresh() 55 | assert widget.value() == 123 56 | -------------------------------------------------------------------------------- /tests/integration/test_float_slider.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from met_qt.widgets.float_slider import FloatSlider 3 | from met_qt._internal.qtcompat import QtWidgets, QtCore 4 | 5 | @pytest.fixture 6 | def app(qtbot): 7 | return QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) 8 | 9 | def test_float_slider_basic(app, qtbot): 10 | slider = FloatSlider() 11 | slider.range = (0.0, 1.0) 12 | slider.single_step = 0.25 13 | 14 | # Test step size clamping 15 | slider.value = 0.3 # Should clamp to 0.25 16 | assert slider.value == 0.25 17 | slider.value = 0.7 # Should clamp to 0.75 18 | assert slider.value == 0.75 19 | 20 | # Test min/max clamping 21 | slider.value = -0.5 # Should clamp to min 22 | assert slider.value == 0.0 23 | slider.value = 1.5 # Should clamp to max 24 | assert slider.value == 1.0 25 | 26 | def test_float_slider_click(app, qtbot): 27 | slider = FloatSlider() 28 | slider.range = (0.0, 1.0) 29 | slider.single_step = 0.25 30 | 31 | # Show widget and click in center 32 | slider.show() 33 | qtbot.addWidget(slider) 34 | 35 | # Get center position 36 | center = slider.rect().center() 37 | qtbot.mouseClick(slider, QtCore.Qt.LeftButton, pos=center) 38 | 39 | # Value should be approximately 0.5 (may have small float precision differences) 40 | assert abs(slider.value - 0.5) < 0.001 41 | -------------------------------------------------------------------------------- /tests/integration/test_gui_paint_layout.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from met_qt.gui.paint_layout import BoxPaintLayout, BoxShape, BoxText, PaintStyle, ShapeType, BoxPaintItem, CornerFlag, BoxPaintLayoutFlag 4 | 5 | from met_qt._internal.qtcompat import QtWidgets, QtCore, QtGui 6 | 7 | @pytest.fixture 8 | def widget(qtbot): 9 | w = QtWidgets.QWidget() 10 | layout = BoxPaintLayout() 11 | w.setLayout(layout) 12 | qtbot.addWidget(w) 13 | return w, layout 14 | 15 | def test_box_paint_layout_add_and_get_items(widget): 16 | w, layout = widget 17 | shape = BoxShape(style=PaintStyle(brush_color=QtGui.QColor('red'))) 18 | text = BoxText(text="Hello") 19 | item = BoxPaintItem(shape=shape, text=text) 20 | layout.set_paint_items([item]) 21 | assert layout.get_paint_items() == [item] 22 | 23 | def test_box_paint_layout_size_hint(widget): 24 | w, layout = widget 25 | layout.setSizeHint((123, 45)) 26 | hint = layout.sizeHint() 27 | assert hint.width() == 123 28 | assert hint.height() == 45 29 | 30 | def test_box_paint_layout_flags(widget): 31 | w, layout = widget 32 | layout.flags = int(BoxPaintLayoutFlag.Enabled | BoxPaintLayoutFlag.Visible) 33 | assert layout.flags == int(BoxPaintLayoutFlag.Enabled | BoxPaintLayoutFlag.Visible) 34 | layout.flags = int(BoxPaintLayoutFlag.Visible) 35 | assert layout.flags == int(BoxPaintLayoutFlag.Visible) 36 | 37 | def test_box_paint_item_hit_test(widget): 38 | w, layout = widget 39 | shape = BoxShape(style=PaintStyle(brush_color=QtGui.QColor('red'))) 40 | text = BoxText(text="Hello") 41 | item = BoxPaintItem(shape=shape, text=text) 42 | layout.set_paint_items([item]) 43 | w.resize(100, 100) 44 | rect = QtCore.QRect(0, 0, 100, 100) 45 | pos_inside = QtCore.QPoint(10, 10) 46 | pos_outside = QtCore.QPoint(200, 200) 47 | assert item.hit_test(rect, pos_inside) 48 | assert not item.hit_test(rect, pos_outside) 49 | 50 | def test_box_paint_layout_paint_runs(widget, qtbot): 51 | w, layout = widget 52 | shape = BoxShape(style=PaintStyle(brush_color=QtGui.QColor('red'))) 53 | text = BoxText(text="Hello") 54 | item = BoxPaintItem(shape=shape, text=text) 55 | layout.set_paint_items([item]) 56 | w.resize(100, 100) 57 | pixmap = QtGui.QPixmap(100, 100) 58 | pixmap.fill(QtCore.Qt.GlobalColor.white) 59 | painter = QtGui.QPainter(pixmap) 60 | layout.paint(painter, 0) 61 | painter.end() 62 | 63 | def test_box_paint_layout_render_runs(widget, qtbot): 64 | w, layout = widget 65 | shape = BoxShape(style=PaintStyle(brush_color=QtGui.QColor('red'))) 66 | text = BoxText(text="Hello") 67 | item = BoxPaintItem(shape=shape, text=text) 68 | layout.set_paint_items([item]) 69 | w.resize(100, 100) 70 | pixmap = QtGui.QPixmap(100, 100) 71 | pixmap.fill(QtCore.Qt.GlobalColor.white) 72 | painter = QtGui.QPainter(pixmap) 73 | BoxPaintLayout.render(layout, painter) 74 | painter.end() 75 | -------------------------------------------------------------------------------- /tests/integration/test_gui_paint_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from met_qt.gui import paint_utils 3 | from met_qt._internal.qtcompat import QtWidgets, QtCore, QtGui 4 | 5 | @pytest.fixture 6 | def painter_and_pixmap(qtbot): 7 | widget = QtWidgets.QWidget() 8 | qtbot.addWidget(widget) 9 | pixmap = QtGui.QPixmap(100, 100) 10 | pixmap.fill(QtCore.Qt.white) 11 | painter = QtGui.QPainter(pixmap) 12 | yield painter, pixmap 13 | painter.end() 14 | 15 | def test_draw_text(painter_and_pixmap): 16 | painter, _ = painter_and_pixmap 17 | rect = paint_utils.draw_text(painter, QtCore.QRect(10, 10, 80, 20), 0, "Hello") 18 | assert rect.width() > 0 and rect.height() > 0 19 | 20 | def test_draw_partially_rounded_rect(painter_and_pixmap): 21 | painter, _ = painter_and_pixmap 22 | rect = paint_utils.draw_partially_rounded_rect(painter, QtCore.QRect(10, 10, 80, 80), 10, 10, 10, 10) 23 | assert rect.width() > 0 and rect.height() > 0 24 | 25 | def test_draw_path(painter_and_pixmap): 26 | painter, _ = painter_and_pixmap 27 | path = QtGui.QPainterPath() 28 | path.addRect(20, 20, 40, 40) 29 | rect = paint_utils.draw_path(painter, path) 30 | assert rect.width() > 0 and rect.height() > 0 31 | 32 | def test_anchor(qtbot): 33 | rect = paint_utils.anchor((50, 50), left=10, top=10) 34 | assert rect.width() > 0 and rect.height() > 0 35 | 36 | def test_to_global_and_from_global(painter_and_pixmap): 37 | painter, _ = painter_and_pixmap 38 | point = QtCore.QPoint(5, 5) 39 | global_point = paint_utils.to_global(painter, point) 40 | widget_point = paint_utils.from_global(painter, global_point) 41 | assert isinstance(global_point, QtCore.QPoint) 42 | assert isinstance(widget_point, QtCore.QPoint) 43 | 44 | def test_draw_item_text(painter_and_pixmap): 45 | painter, _ = painter_and_pixmap 46 | palette = QtGui.QPalette() 47 | rect = paint_utils.draw_item_text( 48 | painter, 49 | QtCore.QRect(10, 10, 80, 20), 50 | QtCore.Qt.AlignLeft, 51 | palette, 52 | True, 53 | "Test Text" 54 | ) 55 | assert rect.width() >= 0 and rect.height() >= 0 56 | 57 | def test_draw_primitive(painter_and_pixmap): 58 | painter, _ = painter_and_pixmap 59 | style = QtWidgets.QApplication.style() 60 | option = QtWidgets.QStyleOption() 61 | option.rect = QtCore.QRect(10, 10, 20, 20) 62 | rect = paint_utils.draw_primitive( 63 | painter, 64 | QtWidgets.QStyle.PE_Frame, 65 | option, 66 | None, 67 | style 68 | ) 69 | assert rect.width() > 0 and rect.height() > 0 70 | 71 | def test_draw_control(painter_and_pixmap): 72 | painter, _ = painter_and_pixmap 73 | style = QtWidgets.QApplication.style() 74 | option = QtWidgets.QStyleOption() 75 | option.rect = QtCore.QRect(10, 10, 20, 20) 76 | rect = paint_utils.draw_control( 77 | painter, 78 | QtWidgets.QStyle.CE_PushButton, 79 | option, 80 | None, 81 | style 82 | ) 83 | assert rect.width() > 0 and rect.height() > 0 84 | -------------------------------------------------------------------------------- /tests/integration/test_range_slider.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from met_qt.widgets.range_slider import RangeSlider 3 | from met_qt._internal.qtcompat import QtWidgets 4 | 5 | @pytest.fixture 6 | def app(qtbot): 7 | return QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) 8 | 9 | def test_range_slider_basic(app, qtbot): 10 | slider = RangeSlider() 11 | slider.range = (0.0, 10.0) 12 | slider.soft_range = (2.0, 8.0) 13 | slider.min_value = 3.0 14 | slider.max_value = 7.0 15 | assert slider.min_value == 3.0 16 | assert slider.max_value == 7.0 17 | slider.min_value = 11.0 # Should swap and clamp 18 | assert slider.min_value == 7.0 19 | assert slider.max_value == 10.0 20 | slider.max_value = -2.0 # Should swap and clamp 21 | assert slider.max_value == 7.0 22 | assert slider.min_value == 0.0 23 | -------------------------------------------------------------------------------- /tests/integration/test_widgets_float_and_range_slider.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalefforttech/met_qt/e9d7ea543f61c9cadc515b8269afa2be5a216f68/tests/integration/test_widgets_float_and_range_slider.py -------------------------------------------------------------------------------- /tests/unit/test_qtcompat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Qt compatibility layer. 3 | 4 | These tests verify that the compatibility functions in qtcompat.py work correctly 5 | across different Qt versions (PySide2/PySide6). 6 | """ 7 | 8 | import unittest 9 | import pytest 10 | from unittest import mock 11 | import sys 12 | 13 | # Import the module under test 14 | from met_qt._internal import qtcompat 15 | 16 | @pytest.mark.parametrize('qt_binding', [qtcompat.QT_BINDING]) 17 | class TestQtCompat: 18 | """Test the Qt compatibility layer functions.""" 19 | 20 | @pytest.fixture(autouse=True) 21 | def setup_qt_binding(self, qt_binding): 22 | """Setup the Qt binding for each test.""" 23 | self.original_binding = qtcompat.QT_BINDING 24 | with mock.patch.object(qtcompat, 'QT_BINDING', qt_binding): 25 | yield 26 | qtcompat.QT_BINDING = self.original_binding 27 | 28 | def test_qt_binding_identification(self): 29 | """Test that the Qt binding is correctly identified.""" 30 | assert qtcompat.QT_BINDING in ['PySide2', 'PySide6'] 31 | assert qtcompat.QT_VERSION is not None 32 | 33 | def test_import_qt_module(self): 34 | """Test the import_qt_module function.""" 35 | # Test importing a module that should exist 36 | widgets_module = qtcompat.import_qt_module("Widgets") 37 | assert widgets_module is not None 38 | 39 | # Test importing a module that likely doesn't exist 40 | with mock.patch('warnings.warn') as mock_warn: 41 | with mock.patch('importlib.import_module', side_effect=ImportError("Test import error")): 42 | nonexistent_module = qtcompat.import_qt_module("NonexistentModule") 43 | assert nonexistent_module is None 44 | mock_warn.assert_called_once() 45 | 46 | def test_create_application(self): 47 | """Test the create_application function.""" 48 | # Mock QApplication to avoid creating a real one during tests 49 | with mock.patch(f'{qtcompat.QT_BINDING}.QtWidgets.QApplication') as mock_qapp: 50 | # Test with default args 51 | qtcompat.create_application() 52 | mock_qapp.assert_called_once() 53 | args = mock_qapp.call_args[0][0] 54 | assert args == sys.argv 55 | 56 | mock_qapp.reset_mock() 57 | 58 | # Test with custom args 59 | custom_args = ['test', '--arg1', '--arg2'] 60 | qtcompat.create_application(custom_args) 61 | mock_qapp.assert_called_once() 62 | args = mock_qapp.call_args[0][0] 63 | assert args == custom_args 64 | 65 | def test_get_query_bound_values(self): 66 | """Test the get_query_bound_values function.""" 67 | # Create a mock query object without boundValues 68 | mock_query_no_bound = mock.Mock(spec=[]) 69 | result = qtcompat.get_query_bound_values(mock_query_no_bound) 70 | assert result == {} 71 | 72 | if qtcompat.QT_BINDING == 'PySide6': 73 | # Test PySide6-like query (returns dict) 74 | mock_query = mock.Mock() 75 | expected_dict = {'param1': 'value1', 'param2': 'value2'} 76 | mock_query.boundValues.return_value = expected_dict 77 | result = qtcompat.get_query_bound_values(mock_query) 78 | assert result == expected_dict 79 | 80 | else: 81 | # Test PySide2-like query (returns list) 82 | mock_query = mock.Mock() 83 | bound_list = ['value1', 'value2'] 84 | expected_dict = {0: 'value1', 1: 'value2'} 85 | mock_query.boundValues.return_value = bound_list 86 | result = qtcompat.get_query_bound_values(mock_query) 87 | assert result == expected_dict 88 | 89 | def test_get_touch_points(self): 90 | """Test the get_touch_points function.""" 91 | if qtcompat.QT_BINDING == 'PySide6': 92 | # Test PySide6 behavior (uses points method) 93 | mock_touch_event = mock.Mock() 94 | touch_points = [mock.Mock(), mock.Mock()] 95 | mock_touch_event.points.return_value = touch_points 96 | result = qtcompat.get_touch_points(mock_touch_event) 97 | mock_touch_event.points.assert_called_once() 98 | assert result == touch_points 99 | 100 | else: 101 | # Test PySide2 behavior (uses touchPoints method) 102 | mock_touch_event = mock.Mock() 103 | touch_points = [mock.Mock(), mock.Mock()] 104 | mock_touch_event.touchPoints.return_value = touch_points 105 | result = qtcompat.get_touch_points(mock_touch_event) 106 | mock_touch_event.touchPoints.assert_called_once() 107 | assert result == touch_points 108 | --------------------------------------------------------------------------------