diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b5e2e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*~ +__pycache__ diff --git a/README.md b/README.md index 1fcad09..0fcacc7 100644 --- a/README.md +++ b/README.md @@ -11,90 +11,8 @@ Currently this tool does not support `anyOf` or `oneOf` directives. The reason f Additionally, the `$ref` keyword is not supported. This will be fixed, but is waiting on some proposed upstream changes in `jsonschema` -## Example -```python3 -import sys -from json import dumps - -from PyQt5 import QtWidgets - -from qt_jsonschema_form import WidgetBuilder - -if __name__ == "__main__": - app = QtWidgets.QApplication(sys.argv) - - builder = WidgetBuilder() - - schema = { - "type": "object", - "title": "Number fields and widgets", - "properties": { - "schema_path": { - "title": "Schema path", - "type": "string" - }, - "integerRangeSteps": { - "title": "Integer range (by 10)", - "type": "integer", - "minimum": 55, - "maximum": 100, - "multipleOf": 10 - }, - "event": { - "type": "string", - "format": "date" - }, - "sky_colour": { - "type": "string" - }, - "names": { - "type": "array", - "items": [ - { - "type": "string", - "pattern": "[a-zA-Z\-'\s]+", - "enum": [ - "Jack", "Jill" - ] - }, - { - "type": "string", - "pattern": "[a-zA-Z\-'\s]+", - "enum": [ - "Alice", "Bob" - ] - }, - ], - "additionalItems": { - "type": "number" - }, - } - } - } +## Detailed explanation +For more details about each options, see [](USAGE.md) - ui_schema = { - "schema_path": { - "ui:widget": "filepath" - }, - "sky_colour": { - "ui:widget": "colour" - } - - } - form = builder.create_form(schema, ui_schema) - form.widget.state = { - "schema_path": "some_file.py", - "integerRangeSteps": 60, - "sky_colour": "#8f5902", - "names": [ - "Jack", - "Bob" - ] - } - form.show() - form.widget.on_changed.connect(lambda d: print(dumps(d, indent=4))) - - app.exec_() - - -``` +## Example +See (the test file)[test.py] diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..0feaf0d --- /dev/null +++ b/USAGE.md @@ -0,0 +1,99 @@ +# Main usage +As shown in the example, the main use of this library is as follows: + +```python3 +from qt_jsonschema_form import WidgetBuilder +builder = WidgetBuilder() +form = builder.create_form(schema, ui_schema) +form.show() +form.widget.state = default_value +``` +You can then apply a method `fn(json_value)` to the JSON value each time a change +is done by doing: +```python3 +form.widget.on_changed.connect(fn) +``` +and you can access to the current json value using +```python3 +form.widget.state +```. + +# Variants +JSON's type is extremely vague. For example decimal (floating point) +number and integral numbers have the same type. In order to decide +which widget the user see, this library uses "variant". A variant is a +name stating which kind of widget should be used to display a value to +the user and let them change it. A variant can only be assigned to an +element of an object, it is determined by the property name and the +parameter ui_schema. TODO: why? + +Each type has the variant "enum". If this variant is used for a +schema, this schema must have an "enum" property. Each element of this +property will be displayed in a `QComboBox`. We now list the other +variants. + +## Boolean +The only other variant of "boolean" is "checkbox", which is +implemented using a `QCheckBox` + +## Object +The only other variant of "object" is "object", which is implemented +using a `QGroupBox`. That is: its content is displayed in the same +window, with elements grouped together. + +## Number + +Number has two variants: +* "spin", which uses a `QDoubleSpinBox` +* "text", which uses a `QLineEdit` + +## Integer + +It admits the same variants as Number, plus: +* "range", which uses a `QSlider` + +## String + +Strings has the following variant: +* "textarea", which uses a `QTextEdit` +* "text", which uses a `QLineEdit` +* "password", which uses a `TextSchemaWidget` +* "filepath", which adds a button which use the open file name in the computer +* "dirpath", which adds a button which use the open directory name in the computer +* "colour", which uses a `QColorButton` + +# Defaults +When the UI is created, default values are inserted. Those values may +be changed by the user for a specific json value. Those values are of +the correct types; it's not guaranteed however that they satisfy all +schema constraints. (Actually, since there can be conjunction, +disjunction, negation of schema, and even if-then-else schema, finding +such value may be actually impossible). + +If a schema contains a "default" value, this value is used. + +If a schema is an enum, its first value is used as default value. + +If the type of the schema is an object, then its default value is an object +containing the values in "properties", and its associated default +values are computed as explained in this section. + +If the type of the schema is a list (array whose "items" is a schema) +then the default value is the empty list. + +If the type of the schema is a tuple (array whose "items" is an array +of schema) then the default value is a tuple, whose value contains as +many element as the items. Each element's default value is computed as +explained in this section. + +The default value of Boolean is True. + +The default value of a string is a string containing only spaces, as +short as possible, accordin to the minimal size constraint. + +The default value of a number is: +* as close as possible to the average between the maximum and the + minimum if they are set. +* as close as possible to the maximum or to the minimum if only one is + set +* 0 otherwise. diff --git a/qt_jsonschema_form/__init__.py b/qt_jsonschema_form/__init__.py index a2c3980..b9284ea 100644 --- a/qt_jsonschema_form/__init__.py +++ b/qt_jsonschema_form/__init__.py @@ -1 +1 @@ -from .form import WidgetBuilder \ No newline at end of file +from .form import WidgetBuilder diff --git a/qt_jsonschema_form/defaults.py b/qt_jsonschema_form/defaults.py index ebe5cd2..8e92bde 100644 --- a/qt_jsonschema_form/defaults.py +++ b/qt_jsonschema_form/defaults.py @@ -1,3 +1,6 @@ +from .numeric_defaults import numeric_defaults + + def enum_defaults(schema): try: return schema["enum"][0] @@ -9,14 +12,63 @@ def object_defaults(schema): return {k: compute_defaults(s) for k, s in schema["properties"].items()} -def array_defaults(schema): - items_schema = schema['items'] - if isinstance(items_schema, dict): +def list_defaults(schema): + # todo: respect unicity constraint. + # todo: deal with intersection of schema, in case there is contains and items + # e.g. add elements at end of tuple + if "contains" in schema: + return list_defaults_contains(schema) + else: + return list_defaults_no_contains(schema) + + +def list_defaults_contains(schema): + minContains = schema.get("minContains", 1) + if minContains <= 0: return [] + default = compute_defaults(schema["contains"]) + return [default] * minContains + +def list_defaults_no_contains(schema): + minItems = schema.get("minItems", 0) + if minItems <= 0: + return [] + default = compute_defaults(schema["items"]) + return [default] * minItems + + +def tuple_defaults(schema): return [compute_defaults(s) for s in schema["items"]] +def array_defaults(schema): + if isinstance(schema['items'], dict): + return list_defaults(schema) + else: + return tuple_defaults(schema) + + +def boolean_defaults(schema): + return True + + +def string_defaults(schema): + # todo: deal with pattern + minLength = schema.get("minLength", 0) + return " " * minLength + + +defaults = { + "array": array_defaults, + "object": object_defaults, + "numeric": numeric_defaults, + "integer": numeric_defaults, + "boolean": boolean_defaults, + "string": string_defaults, +} + + def compute_defaults(schema): if "default" in schema: return schema["default"] @@ -25,12 +77,20 @@ def compute_defaults(schema): if "enum" in schema: return enum_defaults(schema) - schema_type = schema["type"] + # Const + if "const" in schema: + return schema["const"] + + if "type" not in schema: + # any value is valid. + return {} - if schema_type == "object": - return object_defaults(schema) + schema_types = schema["type"] + if not isinstance(schema_types, list): + schema_types = [schema_types] - elif schema_type == "array": - return array_defaults(schema) + for schema_type in schema_types: + if schema_type in defaults: + return defaults[schema_type](schema) return None diff --git a/qt_jsonschema_form/form.py b/qt_jsonschema_form/form.py index 004a219..ee8cb4d 100644 --- a/qt_jsonschema_form/form.py +++ b/qt_jsonschema_form/form.py @@ -1,12 +1,16 @@ from copy import deepcopy from jsonschema.validators import validator_for -\ + from . import widgets from .defaults import compute_defaults - +from typing import Dict, Any def get_widget_state(schema, state=None): + """A JSON object. Either the state given in input if any, otherwise + the default value satisfying the current type. + + """ if state is None: return compute_defaults(schema) return state @@ -22,7 +26,7 @@ class WidgetBuilder: "object": {"object": widgets.ObjectSchemaWidget, "enum": widgets.EnumSchemaWidget}, "number": {"spin": widgets.SpinDoubleSchemaWidget, "text": widgets.TextSchemaWidget, "enum": widgets.EnumSchemaWidget}, "string": {"textarea": widgets.TextAreaSchemaWidget, "text": widgets.TextSchemaWidget, "password": widgets.PasswordWidget, - "filepath": widgets.FilepathSchemaWidget, "colour": widgets.ColorSchemaWidget, "enum": widgets.EnumSchemaWidget}, + "filepath": widgets.FilepathSchemaWidget, "dirpath": widgets.DirectorypathSchemaWidget, "colour": widgets.ColorSchemaWidget, "enum": widgets.EnumSchemaWidget}, "integer": {"spin": widgets.SpinSchemaWidget, "text": widgets.TextSchemaWidget, "range": widgets.IntegerRangeSchemaWidget, "enum": widgets.EnumSchemaWidget}, "array": {"array": widgets.ArraySchemaWidget, "enum": widgets.EnumSchemaWidget} @@ -42,10 +46,12 @@ class WidgetBuilder: } def __init__(self, validator_cls=None): + """validator_cls -- A validator, as in jsonschema library. Schemas are + supposed to be valid for it.""" self.widget_map = deepcopy(self.default_widget_map) self.validator_cls = validator_cls - def create_form(self, schema: dict, ui_schema: dict, state=None) -> widgets.SchemaWidgetMixin: + def create_form(self, schema: dict, ui_schema: dict = {}, state=None, parent=None) -> widgets.SchemaWidgetMixin: validator_cls = self.validator_cls if validator_cls is None: validator_cls = validator_for(schema) @@ -53,9 +59,11 @@ def create_form(self, schema: dict, ui_schema: dict, state=None) -> widgets.Sche validator_cls.check_schema(schema) validator = validator_cls(schema) schema_widget = self.create_widget(schema, ui_schema, state) - form = widgets.FormWidget(schema_widget) + form = widgets.FormWidget(schema_widget, parent) def validate(data): + """Show the error widget iff there are errors, and the error messages + in it.""" form.clear_errors() errors = [*validator.iter_errors(data)] @@ -71,7 +79,17 @@ def validate(data): def create_widget(self, schema: dict, ui_schema: dict, state=None) -> widgets.SchemaWidgetMixin: schema_type = get_schema_type(schema) + widget_variant = self.get_widget_variant(schema_type, schema, ui_schema) + widget_cls = self.widget_map[schema_type][widget_variant] + + widget = widget_cls(schema, ui_schema, self) + default_state = get_widget_state(schema, state) + if default_state is not None: + widget.state = default_state + return widget + + def get_widget_variant(self, schema_type: str, schema: Dict[str, Any], ui_schema: Dict[str, Any]) -> str: try: default_variant = self.widget_variant_modifiers[schema_type](schema) except KeyError: @@ -80,12 +98,4 @@ def create_widget(self, schema: dict, ui_schema: dict, state=None) -> widgets.Sc if "enum" in schema: default_variant = "enum" - widget_variant = ui_schema.get('ui:widget', default_variant) - widget_cls = self.widget_map[schema_type][widget_variant] - - widget = widget_cls(schema, ui_schema, self) - - default_state = get_widget_state(schema, state) - if default_state is not None: - widget.state = default_state - return widget + return ui_schema.get('ui:widget', default_variant) diff --git a/qt_jsonschema_form/numeric_defaults.py b/qt_jsonschema_form/numeric_defaults.py new file mode 100644 index 0000000..63ca3f8 --- /dev/null +++ b/qt_jsonschema_form/numeric_defaults.py @@ -0,0 +1,105 @@ +import math +from copy import deepcopy + +import jsonschema + + +"""function to compute a number default value. If min and max are set, +a number near their average is chosen. Otherwise, a number as close as +possible from the only bound. Otherwise 0. """ + + +def numeric_defaults(schema): + value = _numeric_defaults(schema) + try: + jsonschema.validate(value, schema) + return value + except jsonschema.ValidationError: + return None + + +def _numeric_defaults(schema): + schema = deepcopy(schema) + # Setting min and max according to exclusive min/max + if "exclusiveMinimum" in schema: + if "minimum" in schema: + schema["minimum"] = max( + schema["minimum"], schema["exclusiveMinimum"]) + else: + schema["minimum"] = schema["exclusiveMinimum"] + if "exclusiveMaximum" in schema: + if "maximum" in schema: + schema["maximum"] = max( + schema["maximum"], schema["exclusiveMaximum"]) + else: + schema["maximum"] = schema["exclusiveMaximum"] + + if "multipleOf" in schema: + return _numeric_defaults_multiple_of(schema) + else: + return _numeric_defaults_not_multiple_of(schema) + + +def _numeric_defaults_not_multiple_of(schema): + if "minimum" in schema and "maximum" in schema: + middle = (schema["minimum"] + schema["maximum"]) / 2 + if schema["type"] == "integer": + middle_ = math.floor(middle) + try: + jsonschema.validate(middle_, schema) + return middle_ + except jsonschema.ValidationError: + return math.ceil(middle) + else: + return middle + elif "minimum" in schema: + # no maximum + m = schema["minimum"] + if schema["type"] == "integer": + m = math.ceil(m) + if schema["exclusiveMinimum"] == m: + m += 1 + return m + elif "maximum" in schema: + # no minimum + M = schema["maximum"] + if schema["type"] == "integer": + M = math.ceil(M) + if schema["exclusiveMaximum"] == M: + M -= 1 + return M + else: + # neither min nor max + return 0 + + +def _numeric_defaults_multiple_of(schema): + multipleOf = schema["multipleOf"] + if schema["type"] == "integer" and multipleOf != math.floor(multipleOf): + # todo: find an integral multiple of a float distinct from 0 + return 0 + if "minimum" in schema and "maximum" in schema: + middle = (schema["minimum"] + schema["maximum"]) / 2 + middle_ = math.floor(middle//multipleOf) * multipleOf + try: + jsonschema.validate(middle_, schema) + return middle_ + except jsonschema.ValidationError: + return middle_ + multipleOf + elif "minimum" in schema: + # no maximum + m = schema["minimum"] + m = (math.ceil(m / multipleOf)) * multipleOf + if schema["exclusiveMinimum"] == m: + m += multipleOf + return m + elif "maximum" in schema: + # no minimum + M = schema["maximum"] + M = (math.floor(M / multipleOf)) * multipleOf + if schema["exclusiveMinimum"] == M: + M -= multipleOf + return M + else: + # neither min nor max + return 0 diff --git a/qt_jsonschema_form/widgets.py b/qt_jsonschema_form/widgets.py index 3de33cd..f5e64db 100644 --- a/qt_jsonschema_form/widgets.py +++ b/qt_jsonschema_form/widgets.py @@ -1,11 +1,10 @@ from functools import partial -from typing import List -from typing import Tuple, Optional, Dict +from typing import Dict, List, Optional, Tuple -from PyQt5 import QtWidgets, QtCore, QtGui +from PyQt5 import QtCore, QtGui, QtWidgets from .signal import Signal -from .utils import iter_layout_widgets, state_property, is_concrete_schema +from .utils import is_concrete_schema, iter_layout_widgets, state_property class SchemaWidgetMixin: @@ -46,7 +45,8 @@ def clear_error(self): def _set_valid_state(self, error: Exception = None): palette = self.palette() colour = QtGui.QColor() - colour.setNamedColor(self.VALID_COLOUR if error is None else self.INVALID_COLOUR) + colour.setNamedColor( + self.VALID_COLOUR if error is None else self.INVALID_COLOUR) palette.setColor(self.backgroundRole(), colour) self.setPalette(palette) @@ -54,13 +54,21 @@ def _set_valid_state(self, error: Exception = None): class TextSchemaWidget(SchemaWidgetMixin, QtWidgets.QLineEdit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.maxLength = self.schema.get("maxLength") def configure(self): self.textChanged.connect(self.on_changed.emit) @state_property def state(self) -> str: - return str(self.text()) + state = str(self.text()) + if self.maxLength is not None and len(state) > self.maxLength: + state = state[:self.maxLength] + self.setText(state) + # Stripping the text to limit to the admitted length + return state @state.setter def state(self, state: str): @@ -76,10 +84,18 @@ def configure(self): class TextAreaSchemaWidget(SchemaWidgetMixin, QtWidgets.QTextEdit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.maxLength = self.schema.get("maxLength") @state_property def state(self) -> str: - return str(self.toPlainText()) + state = str(self.toPlainText()) + if self.maxLength is not None and len(state) > self.maxLength: + state = state[:self.maxLength] + self.setPlainText(state) + # Stripping the text to limit to the admitted length + return state @state.setter def state(self, state: str): @@ -97,6 +113,8 @@ def state(self) -> bool: @state.setter def state(self, checked: bool): + if not isinstance(checked, bool): + print(f"«{checked}» should be a bool and is a {type(checked)}") self.setChecked(checked) def configure(self): @@ -115,6 +133,24 @@ def state(self, state: float): def configure(self): self.valueChanged.connect(self.on_changed.emit) + if "maximum" in self.schema: + if "exclusiveMaximum" in self.schema: + self.setMaximum( + min(self.schema["maximum"], self.schema["exclusiveMaximum"])) + else: + self.setMaximum(self.schema["maximum"]) + elif "exclusiveMaximum" in self.schema: + self.setMaximum(self.schema["exclusiveMaximum"]) + if "minimum" in self.schema: + if "exclusiveMinimum" in self.schema: + self.setMinimum( + min(self.schema["minimum"], self.schema["exclusiveMinimum"])) + else: + self.setMinimum(self.schema["minimum"]) + elif "exclusiveMinimum" in self.schema: + self.setMinimum(self.schema["exclusiveMinimum"]) + if "multipleOf" in self.schema: + self.setSingleStep(self.schema["multipleOf"]) class SpinSchemaWidget(SchemaWidgetMixin, QtWidgets.QSpinBox): @@ -129,12 +165,30 @@ def state(self, state: int): def configure(self): self.valueChanged.connect(self.on_changed.emit) + if "maximum" in self.schema: + if "exclusiveMaximum" in self.schema: + self.setMaximum( + min(self.schema["maximum"], self.schema["exclusiveMaximum"]-1)) + else: + self.setMaximum(self.schema["maximum"]) + elif "exclusiveMaximum" in self.schema: + self.setMaximum(self.schema["exclusiveMaximum"]-1) + if "minimum" in self.schema: + if "exclusiveMinimum" in self.schema: + self.setMinimum( + min(self.schema["minimum"], self.schema["exclusiveMinimum"]+1)) + else: + self.setMinimum(self.schema["minimum"]) + elif "exclusiveMinimum" in self.schema: + self.setMinimum(self.schema["exclusiveMinimum"]+1) + if "multipleOf" in self.schema: + self.setSingleStep(self.schema["multipleOf"]) class IntegerRangeSchemaWidget(SchemaWidgetMixin, QtWidgets.QSlider): - def __init__(self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder'): - super().__init__(schema, ui_schema, widget_builder, orientation=QtCore.Qt.Horizontal) + def __init__(self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder', *args, **kwargs): + super().__init__(schema, ui_schema, widget_builder, orientation=QtCore.Qt.Horizontal, *args, **kwargs) @state_property def state(self) -> int: @@ -226,8 +280,8 @@ def state(self, data: str): class FilepathSchemaWidget(SchemaWidgetMixin, QtWidgets.QWidget): - def __init__(self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder'): - super().__init__(schema, ui_schema, widget_builder) + def __init__(self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder', *args, **kwargs): + super().__init__(schema, ui_schema, widget_builder, *args, **kwargs) layout = QtWidgets.QHBoxLayout() self.setLayout(layout) @@ -253,13 +307,19 @@ def state(self, state: str): self.path_widget.setText(state) +class DirectorypathSchemaWidget(FilepathSchemaWidget): + def _on_clicked(self, flag): + path = QtWidgets.QFileDialog.getExistingDirectory() + self.path_widget.setText(path) + + class ArrayControlsWidget(QtWidgets.QWidget): on_delete = QtCore.pyqtSignal() on_move_up = QtCore.pyqtSignal() on_move_down = QtCore.pyqtSignal() - def __init__(self): - super().__init__() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) style = self.style() @@ -268,11 +328,13 @@ def __init__(self): self.up_button.clicked.connect(lambda _: self.on_move_up.emit()) self.delete_button = QtWidgets.QPushButton() - self.delete_button.setIcon(style.standardIcon(QtWidgets.QStyle.SP_DialogCancelButton)) + self.delete_button.setIcon(style.standardIcon( + QtWidgets.QStyle.SP_DialogCancelButton)) self.delete_button.clicked.connect(lambda _: self.on_delete.emit()) self.down_button = QtWidgets.QPushButton() - self.down_button.setIcon(style.standardIcon(QtWidgets.QStyle.SP_ArrowDown)) + self.down_button.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_ArrowDown)) self.down_button.clicked.connect(lambda _: self.on_move_down.emit()) group_layout = QtWidgets.QHBoxLayout() @@ -286,8 +348,8 @@ def __init__(self): class ArrayRowWidget(QtWidgets.QWidget): - def __init__(self, widget: QtWidgets.QWidget, controls: ArrayControlsWidget): - super().__init__() + def __init__(self, widget: QtWidgets.QWidget, controls: ArrayControlsWidget, *args, **kwargs): + super().__init__(*args, **kwargs) layout = QtWidgets.QHBoxLayout() layout.addWidget(widget) @@ -327,7 +389,8 @@ def configure(self): style = self.style() self.add_button = QtWidgets.QPushButton() - self.add_button.setIcon(style.standardIcon(QtWidgets.QStyle.SP_FileIcon)) + self.add_button.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_FileIcon)) self.add_button.clicked.connect(lambda _: self.add_item()) self.array_layout = QtWidgets.QVBoxLayout() @@ -350,7 +413,8 @@ def _on_updated(self, state): if previous_row: can_exchange_previous = previous_row.widget.schema == row.widget.schema row.controls.up_button.setEnabled(can_exchange_previous) - previous_row.controls.down_button.setEnabled(can_exchange_previous) + previous_row.controls.down_button.setEnabled( + can_exchange_previous) else: row.controls.up_button.setEnabled(False) row.controls.delete_button.setEnabled(not self.is_fixed_schema(i)) @@ -410,7 +474,8 @@ def _add_item(self, item_state=None): # Create widget item_ui_schema = self.ui_schema.get("items", {}) - widget = self.widget_builder.create_widget(item_schema, item_ui_schema, item_state) + widget = self.widget_builder.create_widget( + item_schema, item_ui_schema, item_state) controls = ArrayControlsWidget() # Create row @@ -436,10 +501,11 @@ def widget_on_changed(self, row: ArrayRowWidget, value): class ObjectSchemaWidget(SchemaWidgetMixin, QtWidgets.QGroupBox): - def __init__(self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder'): - super().__init__(schema, ui_schema, widget_builder) + def __init__(self, schema: dict, ui_schema: dict, widget_builder: 'WidgetBuilder', *args, **kwargs): + super().__init__(schema, ui_schema, widget_builder, *args, **kwargs) - self.widgets = self.populate_from_schema(schema, ui_schema, widget_builder) + self.widgets = self.populate_from_schema( + schema, ui_schema, widget_builder) @state_property def state(self) -> dict: @@ -476,9 +542,13 @@ def populate_from_schema(self, schema: dict, ui_schema: dict, widget_builder: 'W for name, sub_schema in schema['properties'].items(): sub_ui_schema = ui_schema.get(name, {}) - widget = widget_builder.create_widget(sub_schema, sub_ui_schema) # TODO onchanged + widget = widget_builder.create_widget( + sub_schema, sub_ui_schema) # TODO onchanged widget.on_changed.connect(partial(self.widget_on_changed, name)) label = sub_schema.get("title", name) + label = QtWidgets.QLabel(label) + if "description" in sub_schema: + label.setToolTip(sub_schema["description"]) layout.addRow(label, widget) widgets[name] = widget @@ -495,6 +565,7 @@ def state(self): def state(self, value): index = self.findData(value) if index == -1: + print(f"Value {value} not found in the combo box.") raise ValueError(value) self.setCurrentIndex(index) @@ -504,18 +575,19 @@ def configure(self): self.addItem(str(opt)) self.setItemData(i, opt) - self.currentIndexChanged.connect(lambda _: self.on_changed.emit(self.state)) + self.currentIndexChanged.connect( + lambda _: self.on_changed.emit(self.state)) def _index_changed(self, index: int): self.on_changed.emit(self.state) -class FormWidget(QtWidgets.QWidget): +class FormWidget(QtWidgets.QDialog): - def __init__(self, widget: SchemaWidgetMixin): - super().__init__() - layout = QtWidgets.QVBoxLayout() - self.setLayout(layout) + def __init__(self, widget: SchemaWidgetMixin, parent=None, *args, **kwargs): + super().__init__(*args, parent=parent, **kwargs) + self.layout = QtWidgets.QVBoxLayout() + self.setLayout(self.layout) self.error_widget = QtWidgets.QGroupBox() self.error_widget.setTitle("Errors") @@ -523,8 +595,8 @@ def __init__(self, widget: SchemaWidgetMixin): self.error_widget.setLayout(self.error_layout) self.error_widget.hide() - layout.addWidget(self.error_widget) - layout.addWidget(widget) + self.layout.addWidget(self.error_widget) + self.layout.addWidget(widget) self.widget = widget @@ -539,7 +611,8 @@ def display_errors(self, errors: List[Exception]): item.widget().deleteLater() for err in errors: - widget = QtWidgets.QLabel(f".{'.'.join(err.path)} {err.message}") + widget = QtWidgets.QLabel( + f".{'.'.join(err.path)} {err.message}") layout.addWidget(widget) def clear_errors(self): diff --git a/test.py b/test.py index d3c6f21..3bd948c 100644 --- a/test.py +++ b/test.py @@ -18,6 +18,10 @@ "title": "Schema path", "type": "string" }, + "text": { + "type": "string", + "maxLength": 20 + }, "integerRangeSteps": { "title": "Integer range (by 10)", "type": "integer", @@ -28,6 +32,15 @@ "sky_colour": { "type": "string" }, + "boolean": { + "type": "boolean", + + }, + "enum": { + "type": "boolean", + "enum": [True, False] + + }, "names": { "type": "array", "items": [ @@ -59,6 +72,9 @@ }, "sky_colour": { "ui:widget": "colour" + }, + "enum": { + "ui:widget": "enum", } } @@ -76,4 +92,3 @@ form.widget.on_changed.connect(lambda d: print(dumps(d, indent=4))) app.exec_() -