diff --git a/src/deadline/client/exceptions.py b/src/deadline/client/exceptions.py index 5950894c..5f5cda50 100644 --- a/src/deadline/client/exceptions.py +++ b/src/deadline/client/exceptions.py @@ -15,3 +15,7 @@ class CreateJobWaiterCanceled(Exception): class UserInitiatedCancel(Exception): """Error for when the user requests cancelation""" + + +class NonValidInputError(Exception): + """Error for when the user input is nonvalid""" diff --git a/src/deadline/client/ui/cli_job_submitter.py b/src/deadline/client/ui/cli_job_submitter.py index 0b33fa35..fb35c64c 100644 --- a/src/deadline/client/ui/cli_job_submitter.py +++ b/src/deadline/client/ui/cli_job_submitter.py @@ -5,7 +5,8 @@ import os from importlib import reload from logging import getLogger -from typing import Any, Dict +from typing import Any, Dict, Optional +import copy from PySide2.QtCore import Qt # pylint: disable=import-error from PySide2.QtWidgets import ( # pylint: disable=import-error; type: ignore @@ -50,6 +51,7 @@ def on_create_job_bundle_callback( settings: CliJobSettings, queue_parameters: list[dict[str, Any]], asset_references: AssetReferences, + host_requirements: Optional[Dict[str, Any]] = None, purpose: JobBundlePurpose = JobBundlePurpose.SUBMISSION, ) -> None: """ @@ -121,6 +123,12 @@ def on_create_job_bundle_callback( ] } + # If "HostRequirements" is provided, inject it into each of the "Step" + if host_requirements: + # for each step in the template, append the same host requirements. + for step in job_template["steps"]: + step["hostRequirements"] = copy.deepcopy(host_requirements) + with open( os.path.join(job_bundle_dir, f"template.{settings.file_format.lower()}"), "w", @@ -163,6 +171,7 @@ def on_create_job_bundle_callback( initial_shared_parameter_values={}, auto_detected_attachments=AssetReferences(), attachments=AssetReferences(), + show_host_requirements_tab=True, on_create_job_bundle_callback=on_create_job_bundle_callback, parent=parent, f=f, diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index b8f78244..679d6690 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -1,10 +1,10 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. from __future__ import annotations - +import copy import json import os from logging import getLogger -from typing import Any, Optional +from typing import Any, Optional, Dict from PySide2.QtCore import Qt # pylint: disable=import-error from PySide2.QtWidgets import ( # pylint: disable=import-error; type: ignore @@ -70,6 +70,7 @@ def on_create_job_bundle_callback( settings: JobBundleSettings, queue_parameters: list[JobParameter], asset_references: AssetReferences, + host_requirements: Optional[Dict[str, Any]] = None, purpose: JobBundlePurpose = JobBundlePurpose.SUBMISSION, ) -> None: """ @@ -92,6 +93,12 @@ def on_create_job_bundle_callback( ) template["name"] = settings.name + # If "HostRequirements" is provided, inject it into each of the "Step" + if host_requirements: + # for each step in the template, append the same host requirements. + for step in template["steps"]: + step["hostRequirements"] = copy.deepcopy(host_requirements) + with open( os.path.join(job_bundle_dir, f"template.{file_type.lower()}"), "w", encoding="utf8" ) as f: @@ -165,6 +172,7 @@ def on_create_job_bundle_callback( submitter_dialog = SubmitJobToDeadlineDialog( job_setup_widget_type=JobBundleSettingsWidget, initial_job_settings=initial_settings, + # show_host_requirements_tab=True, // Enable when we want to show the host requirement tab initial_shared_parameter_values=initial_shared_parameter_values, auto_detected_attachments=asset_references, attachments=AssetReferences(), diff --git a/src/deadline/client/ui/resources/info.svg b/src/deadline/client/ui/resources/info.svg new file mode 100644 index 00000000..31653e79 --- /dev/null +++ b/src/deadline/client/ui/resources/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/deadline/client/ui/widgets/host_requirements_tab.py b/src/deadline/client/ui/widgets/host_requirements_tab.py index 4682e757..beb84171 100644 --- a/src/deadline/client/ui/widgets/host_requirements_tab.py +++ b/src/deadline/client/ui/widgets/host_requirements_tab.py @@ -3,10 +3,10 @@ """ UI widgets for the Host Requirements tab. """ -from typing import Any, Dict, List, Optional - +from typing import Any, Dict, List, Optional, Union +from pathlib import Path from PySide2.QtCore import Qt # type: ignore -from PySide2.QtGui import QFont, QValidator, QIntValidator, QBrush, QPalette +from PySide2.QtGui import QFont, QValidator, QIntValidator, QBrush, QIcon, QRegExpValidator from PySide2.QtWidgets import ( # type: ignore QComboBox, QGroupBox, @@ -19,11 +19,57 @@ QVBoxLayout, QWidget, QPushButton, + QListWidget, + QListWidgetItem, + QFrame, + QLineEdit, + QListView, ) -LABLE_FIXED_WIDTH: int = 150 +from deadline.client.exceptions import NonValidInputError + +from logging import getLogger + +logger = getLogger(__name__) + +MAX_INT_VALUE = (2**31) - 1 +MIN_INT_VALUE = -(2**31) + 1 +LABEL_FIXED_WIDTH: int = 150 BUTTON_FIXED_WIDTH: int = 150 + +ATTRIBUTE = "Attribute" +AMOUNT = "Amount" PLACEHOLDER_TEXT = "-" +INFO_ICON_PATH = str(Path(__file__).parent.parent / "resources" / "info.svg") +CUSTOM_REQUIREMENT_TOOL_TIP = ( + "" + "

" + "Custom worker requirements
" + "With this feature, you can define your own custom worker " + "capabilities. There are two kinds of worker capabilities you can add." + "

" + "" + "" +) + +CUSTOM_CAPABILITY_NAME_REGEX = "^(\\.[a-zA-Z][a-zA-Z0-9]{0,63})+$" + +ATTRIBUTE_CAPABILITY_VALUE_REGEX = "^[a-zA-Z_]([a-zA-Z0-9_\\-]{0,99})$" + +ATTRIBUTE_CAPABILITY_PREFIX = "attr.worker." +AMOUNT_CAPABILITY_PREFIX = "amount.worker." + + +class AddIcon(QIcon): + def __init__(self): + file_path = str(Path(__file__).parent.parent / "resources" / "add.svg") + super().__init__(file_path) class HostRequirementsWidget(QWidget): # pylint: disable=too-few-public-methods @@ -85,16 +131,17 @@ def get_requirements(self) -> Optional[Dict[str, Any]]: os_requirements = self.os_requirements_box.get_requirements() hardware_requirements = self.hardware_requirements_box.get_requirements() - # TODO: add custom requirements + custom_requirements = self.custom_requirements_box.get_requirements() - requirements = {} + requirements: Dict[str, Union[List[str], List[int]]] = {} if os_requirements: - # OS requirements are currently all amount type capabilities - requirements["attributes"] = os_requirements - + requirements.setdefault("attributes", []).extend(os_requirements) # type: ignore if hardware_requirements: - # hardware requirements are currently all amount - requirements["amounts"] = hardware_requirements + requirements.setdefault("amounts", []).extend(hardware_requirements) # type: ignore + if custom_requirements["amounts"]: + requirements.setdefault("amounts", []).extend(custom_requirements["amounts"]) # type: ignore + if custom_requirements["attributes"]: + requirements.setdefault("attributes", []).extend(custom_requirements["attributes"]) # type: ignore return requirements @@ -255,21 +302,46 @@ class CustomRequirementsWidget(QGroupBox): def __init__(self, parent=None): super().__init__("Custom host requirements", parent) self.layout = QVBoxLayout(self) + self.attribute_index_numbers = set() + self.amount_index_numbers = set() self._build_ui() def _build_ui(self): - # TODO: make the "More Info" text open a pop-up or tool tip - # self.info_icon = QIcon(QStyle.SP_MessageBoxInformation) - # self.info_label = QLabel("More info") - - # Add a row with two buttons + # Add a label that will display tool tip when hovered above + self.info = QLabel( + f" More info" + ) + info_font = self.info.font() + info_font.setPointSize(10) + self.info.setFont(info_font) + self.info.setToolTip(CUSTOM_REQUIREMENT_TOOL_TIP) + + self.info_row = QHBoxLayout() + self.info_row.setAlignment(Qt.AlignLeft) + self.info_row.addWidget(self.info) + + # Create a list widget for placing custom capability items + # - no frame & no background + # - disable directly selecting list items + # - no scroll bars + self.list_widget = QListWidget(self) + self.list_widget.setSpacing(2) + self.list_widget.setSelectionMode(QListView.NoSelection) + self.list_widget.viewport().setAutoFillBackground(False) + self.list_widget.setFrameStyle(QFrame.NoFrame) + self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.list_widget.setSizeAdjustPolicy(QListWidget.AdjustToContents) + self.resize_list_to_fit() + + # Add a row with Add Amount and Add Attribute buttons self.add_amount_button = QPushButton("Add amount") self.add_amount_button.setFixedWidth(BUTTON_FIXED_WIDTH) self.add_attr_button = QPushButton("Add attribute") self.add_attr_button.setFixedWidth(BUTTON_FIXED_WIDTH) - self.buttons_row = QHBoxLayout(self) + self.buttons_row = QHBoxLayout() self.buttons_row.setAlignment(Qt.AlignLeft) self.buttons_row.addWidget(self.add_amount_button) self.buttons_row.addWidget(self.add_attr_button) @@ -277,49 +349,409 @@ def _build_ui(self): self.add_amount_button.clicked.connect(self._add_new_custom_amount) self.add_attr_button.clicked.connect(self._add_new_custom_attr) + # Add everything together + self.layout.addLayout(self.info_row) + self.layout.addWidget(self.list_widget) self.layout.addLayout(self.buttons_row) def _add_new_custom_amount(self): - print("Feature not yet supported!") - # TODO: insert widget once UI design is finalized - # self.layout.insertWidget(0, CustomAmountWidget()) + self._add_new_item(AMOUNT) def _add_new_custom_attr(self): - print("Feature not yet supported!") - # TODO: insert widget once UI design is finalized - # self.layout.insertWidget(0, CustomAttributeWidget()) + self._add_new_item(ATTRIBUTE) + + def _add_new_item(self, type): + list_item = QListWidgetItem(self.list_widget) + + if type == ATTRIBUTE: + new_attribute_number = max(self.attribute_index_numbers, default=0) + 1 + item = CustomAttributeWidget(list_item, new_attribute_number, self) + self.attribute_index_numbers.add(new_attribute_number) + elif type == AMOUNT: + new_amount_number = max(self.amount_index_numbers, default=0) + 1 + item = CustomAmountWidget(list_item, new_amount_number, self) + self.amount_index_numbers.add(new_amount_number) + else: + raise NonValidInputError(f"Unexpected item type when adding new item: {type}") + + list_item.setSizeHint(item.sizeHint()) + + self.list_widget.addItem(list_item) + self.list_widget.setItemWidget(list_item, item) + self.resize_list_to_fit() + + def remove_widget_item(self, custom_capability_widget): + # remove the ListWidgetItem from list + if custom_capability_widget.capability_type == ATTRIBUTE: + self.attribute_index_numbers.remove(custom_capability_widget.item_number) + elif custom_capability_widget.capability_type == AMOUNT: + self.amount_index_numbers.remove(custom_capability_widget.item_number) + else: + raise NonValidInputError( + f"Unexpected item type when removing item: {custom_capability_widget.capability_type}" + ) - def get_requirements(self): + item = custom_capability_widget.list_item + self.list_widget.takeItem(self.list_widget.indexFromItem(item).row()) + + self.resize_list_to_fit() + + def resize_list_to_fit(self): + # Resize the list widget to based on the size of the contents + if self.list_widget.count() == 0: + self.list_widget.setFixedHeight(0) + else: + current_height = 0 + for i in range(self.list_widget.count()): + widget = self.list_widget.itemWidget(self.list_widget.item(i)) + if widget is not None: + current_height += widget.height() + 2 * self.list_widget.spacing() + + self.list_widget.setFixedHeight(current_height + 2 * self.list_widget.frameWidth()) + + def get_requirements(self) -> Dict[str, List]: """ - Returns a list of OpenJD parameter definition dicts + Returns two lists of OpenJD parameter definition dicts + for both amounts and attributes requirements. """ - print("Feature not yet supported!") + requirements: Dict[str, Any] = {"amounts": [], "attributes": []} + for i in range(self.list_widget.count()): + widget = self.list_widget.itemWidget(self.list_widget.item(i)) + widget_requirement = widget.get_requirement() + if widget_requirement: + if isinstance(widget, CustomAmountWidget): + requirements["amounts"].append(widget_requirement) + elif isinstance(widget, CustomAttributeWidget): + requirements["attributes"].append(widget_requirement) + else: + logger.warning( + f"Widget requirement is not a valid expected type: {type(widget)}" + ) + return requirements -class CustomAmountWidget(QWidget): + +class CustomCapabilityWidget(QGroupBox): """ - UI element to hold a single custom attribute. + UI element to hold a single custom requirement, either Attribute or Amount. """ - def __init__(self, parent=None): + def __init__( + self, + capability_type: str, + list_item: QListWidgetItem, + item_number: int, + parent: CustomRequirementsWidget, + ): super().__init__(parent) - self.layout = QHBoxLayout(self) - # remove default spaces around BoxLayout + self._parent: CustomRequirementsWidget = parent + self.capability_type = capability_type + self.list_item = list_item + self.item_number = item_number + + self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) - # TODO: build widget once UI design is finalized + self.title_label = QLabel(f"{capability_type} {item_number}") + self.title_label.setStyleSheet("font-weight: bold") + self.delete_button = QPushButton("Delete") + self.delete_button.clicked.connect(self._delete) -class CustomAttributeWidget(QWidget): + self.title_row = QHBoxLayout() + self.title_row.addWidget(self.title_label) + self.title_row.addStretch() + self.title_row.addWidget(self.delete_button) + + self.layout.addLayout(self.title_row) + + def _delete(self): + self._parent.remove_widget_item(self) + self.setParent(None) + self.deleteLater() + + +class CustomAmountWidget(CustomCapabilityWidget): """ UI element to hold a single custom attribute. """ - def __init__(self, parent=None): + def __init__(self, list_item: QListWidgetItem, item_number: int, parent=None): + super().__init__(AMOUNT, list_item, item_number, parent) + self._build_ui() + + def _build_ui(self): + # Name / Value + self.name_label = QLabel("Amount Name") + self.name_label.setFixedWidth(LABEL_FIXED_WIDTH) + self.name_line_edit = QLineEdit() + self.name_line_edit.setFixedWidth(LABEL_FIXED_WIDTH) + self.name_line_edit.setValidator(QRegExpValidator(ATTRIBUTE_CAPABILITY_VALUE_REGEX)) + assert (100 - len(AMOUNT_CAPABILITY_PREFIX)) > 0 + self.name_line_edit.setMaxLength(100 - len(AMOUNT_CAPABILITY_PREFIX)) + + # Create layout with min/max spinbox + self.min_label = QLabel("Min") + self.min_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.max_label = QLabel("Max") + self.max_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.min_spin_box = OptionalSpinBox(min=MIN_INT_VALUE, max=MAX_INT_VALUE, parent=self) + self.min_spin_box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.max_spin_box = OptionalSpinBox(min=MIN_INT_VALUE, max=MAX_INT_VALUE, parent=self) + self.max_spin_box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.min_max_row = QHBoxLayout() + self.min_max_row.addWidget(self.min_label) + self.min_max_row.addWidget(self.min_spin_box) + self.min_max_row.addWidget(self.max_label) + self.min_max_row.addWidget(self.max_spin_box) + + self.name_column = QVBoxLayout() + self.name_column.setContentsMargins(2, 0, 0, 0) + self.name_column.addWidget(self.name_label) + self.name_column.addWidget(self.name_line_edit) + + self.value_column = QVBoxLayout() + self.value_column.setContentsMargins(0, 0, 0, 0) + self.value_column.addStretch() + self.value_column.addLayout(self.min_max_row) + + self.columns = QHBoxLayout() + self.columns.setContentsMargins(0, 0, 0, 15) + + self.columns.addLayout(self.name_column) + self.columns.addLayout(self.value_column) + + # LineEdit / LineEdit / Optional [X] + self.layout.addLayout(self.columns) + + def get_requirement(self) -> Dict[str, Any]: + """ + Returns an OpenJD parameter definition dict with + a "value" key filled from the widget. + + An amount capability is prefixed with "amount.worker.". + """ + requirement: Dict[str, Any] = {} + if self.name_line_edit.text(): + requirement = {"name": AMOUNT_CAPABILITY_PREFIX + self.name_line_edit.text()} + + if self.min_spin_box.has_input() and self.max_spin_box.has_input(): + minimum = self.min_spin_box.value() + requirement["min"] = minimum + + maximum = self.max_spin_box.value() + requirement["max"] = maximum + + if minimum > maximum: + raise NonValidInputError( + "Please make sure that the custom amounts in the custom host requirement options have valid min/max ranges!" + ) + elif self.min_spin_box.has_input(): + minimum = self.min_spin_box.value() + requirement["min"] = minimum + elif self.max_spin_box.has_input(): + maximum = self.max_spin_box.value() + requirement["max"] = maximum + + else: + raise NonValidInputError( + "Please fill out all custom amount names in the custom host requirement options!" + ) + return requirement + + +class CustomAttributeWidget(CustomCapabilityWidget): + """ + UI element to hold a single custom attribute. + """ + + def __init__( + self, list_item: QListWidgetItem, item_number: int, parent=CustomRequirementsWidget + ): + super().__init__(ATTRIBUTE, list_item, item_number, parent) + self._build_ui() + + def _build_ui(self): + # Name / Value / All / Any + self.name_label = QLabel("Attribute Name") + self.name_label.setFixedWidth(LABEL_FIXED_WIDTH) + self.value_label = QLabel("Value(s)") + self.all_of_button = QRadioButton("All") + self.all_of_button.setChecked(True) + self.any_of_button = QRadioButton("Any") + self.name_line_edit = QLineEdit() + self.name_line_edit.setFixedWidth(LABEL_FIXED_WIDTH) + assert (100 - len(ATTRIBUTE_CAPABILITY_PREFIX)) > 0 + self.name_line_edit.setMaxLength(100 - len(ATTRIBUTE_CAPABILITY_PREFIX)) + self.name_line_edit.setValidator(QRegExpValidator(ATTRIBUTE_CAPABILITY_VALUE_REGEX)) + self.add_value_button = None + + self.top_row = QHBoxLayout() + self.top_row.addWidget(self.value_label) + + self.top_row.addStretch() + self.top_row.addWidget(self.all_of_button) + self.top_row.addWidget(self.any_of_button) + + # Create a list widget for placing custom attribute values + self.value_list_widget = QListWidget(self) + self.value_list_widget.setSelectionMode(QListView.NoSelection) + self.value_list_widget.viewport().setAutoFillBackground(False) + self.value_list_widget.setFrameStyle(QFrame.NoFrame) + self.value_list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.value_list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.value_list_widget.setSizeAdjustPolicy(QListWidget.AdjustToContents) + + self.name_column = QVBoxLayout() + self.name_column.setContentsMargins(2, 0, 0, 0) + self.name_column.setAlignment(Qt.AlignTop) + self.name_column.addWidget(self.name_label) + self.name_column.addWidget(self.name_line_edit) + + self.value_column = QVBoxLayout() + self.value_column.setContentsMargins(0, 0, 0, 0) + self.value_column.addLayout(self.top_row) + self.value_column.addWidget(self.value_list_widget) + + # Reduce the spacing between top row and value list + self.value_column.setSpacing(2) + + self.columns_widget = QWidget(self) + self.value_column_widget = QWidget(self.columns_widget) + self.value_column_widget.setLayout(self.value_column) + self.columns = QVBoxLayout() + self.columns.setContentsMargins(0, 15, 0, 0) + self.columns.addLayout(self.name_column) + self.columns.addWidget(self.value_column_widget) + + # LineEdit / LineEdit / Optional [X] + self.columns_widget.setLayout(self.columns) + self.layout.addWidget(self.columns_widget) + self._add_value() + + def _add_value(self): + value_list_item = QListWidgetItem(self.value_list_widget) + value = CustomAttributeValueWidget(value_list_item, self) + value_list_item.setSizeHint(value.sizeHint()) + self.value_list_widget.addItem(value_list_item) + self.value_list_widget.setItemWidget(value_list_item, value) + self._resize_value_list_to_fit(1) + self._move_add_button_to_last_item() + self._set_remove_button_for_first_item() + + def remove_value_item(self, value): + # remove the ListWidgetItem from value_list_item + self.value_list_widget.takeItem(self.value_list_widget.indexFromItem(value).row()) + self._resize_value_list_to_fit(-1) + self._move_add_button_to_last_item() + self._set_remove_button_for_first_item() + + def _resize_value_list_to_fit(self, item_count_change: int): + # Resize the list widget as well as parents to based on the size of the contents + self.value_column_widget.setFixedHeight( + self.value_column_widget.height() + + item_count_change + * self.value_list_widget.itemWidget(self.value_list_widget.item(0)).height() + ) + self.columns_widget.adjustSize() + self.adjustSize() + self.list_item.setSizeHint(self.sizeHint()) + self._parent.resize_list_to_fit() + + def _move_add_button_to_last_item(self): + # Add value button + if self.add_value_button is not None: + self.add_value_button.setParent(None) + + else: + self.add_value_button = QPushButton("Add") + self.add_value_button.setStyleSheet("border-width: 0px") + self.add_value_button.setToolTip( + "Add a new value to evaluate against for this attribute" + ) + self.add_value_button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + self.add_value_button.clicked.connect(self._add_value) + + last_item = self.value_list_widget.itemWidget( + self.value_list_widget.item(self.value_list_widget.count() - 1) + ) + last_item.layout.insertWidget(last_item.layout.count() - 2, self.add_value_button) + + def _set_remove_button_for_first_item(self): + if self.value_list_widget.count() == 1: + first_item = self.value_list_widget.itemWidget(self.value_list_widget.item(0)) + first_item.remove_button.setEnabled(False) + first_item.remove_button.unsetCursor() + + if self.value_list_widget.count() >= 2: + first_item = self.value_list_widget.itemWidget(self.value_list_widget.item(0)) + first_item.remove_button.setEnabled(True) + + def get_requirement(self) -> Dict[str, Any]: + """ + Return an OpenJD parameter definition dict with + a "value" key filled from the widget, and a list of values. + + An attribute capability is prefixed with "attr.worker". + """ + requirement: Dict[str, Any] = {} + requirements_are_valid = True + + if self.name_line_edit.text(): + option = "anyOf" if self.any_of_button.isChecked() else "allOf" + values = [] + for i in range(self.value_list_widget.count()): + value = self.value_list_widget.itemWidget(self.value_list_widget.item(i)) + if value.line_edit.text(): + values.append(value.line_edit.text()) + else: + requirements_are_valid = False + if values: + requirement = { + "name": ATTRIBUTE_CAPABILITY_PREFIX + self.name_line_edit.text(), + f"{option}": values, + } + else: + requirements_are_valid = False + + if not requirements_are_valid: + raise NonValidInputError( + "Please fill out all custom attribute names and values in the custom host requirements options!" + ) + return requirement + + +class CustomAttributeValueWidget(QWidget): + """ + UI element to hold a single custom attribute value. + """ + + def __init__(self, value_list_item: QListWidgetItem, parent: CustomAttributeWidget): super().__init__(parent) + self._parent: CustomAttributeWidget = parent + self.value_list_item = value_list_item + + self.line_edit = QLineEdit() + self.line_edit.setFixedWidth(LABEL_FIXED_WIDTH + 20) + self.line_edit.setMaxLength(100) + self.line_edit.setValidator(QRegExpValidator(ATTRIBUTE_CAPABILITY_VALUE_REGEX)) + + self.remove_button = QPushButton("Remove") + self.remove_button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.remove_button.clicked.connect(self._remove) + self.layout = QHBoxLayout(self) - # remove default spaces around BoxLayout - self.layout.setContentsMargins(0, 0, 0, 0) - # TODO: build widget once UI design is finalized + self.layout.setContentsMargins(2, 0, 0, 0) + self.layout.addWidget(self.line_edit) + self.layout.addWidget(self.remove_button) + self.layout.addStretch() + self.layout.setAlignment(Qt.AlignLeft) + + def _remove(self): + self._parent.remove_value_item(self.value_list_item) + self.setParent(None) + self.deleteLater() class OSRequirementRowWidget(QWidget): @@ -341,7 +773,7 @@ def __init__(self, label: str, items: List[str], parent=None): def _build_ui(self, label: str, items: List[str]): self.label = QLabel(label) - self.label.setFixedWidth(LABLE_FIXED_WIDTH) + self.label.setFixedWidth(LABEL_FIXED_WIDTH) self.combo_box = OptionalComboBox(items, parent=self) self.layout.addWidget(self.label) self.layout.addWidget(self.combo_box) @@ -365,7 +797,7 @@ def __init__(self, label: str, parent=None): def _build_ui(self, label: str): self.label = QLabel(label) - self.label.setFixedWidth(LABLE_FIXED_WIDTH) + self.label.setFixedWidth(LABEL_FIXED_WIDTH) # Create "Min" label, and set label to fixed width self.min_label = QLabel("Min") @@ -427,18 +859,14 @@ class OptionalSpinBox(QSpinBox): A custom QSpinBox that set min - 1 value as "-" to represent value not set. """ - NAN_VALUE = -(2**31) - MAX_INT_VALUE = (2**31) - 1 - MIN_INT_VALUE = -(2**31) + 1 - palette = QPalette() - def __init__(self, min: int = MIN_INT_VALUE, max: int = MAX_INT_VALUE, parent=None) -> None: super().__init__(parent) self.min = min self.max = max - # Set the range to include NaN as a valid value - self.setRange(self.NAN_VALUE, self.MAX_INT_VALUE) - self.setValue(self.NAN_VALUE) + self.no_input_value = min - 1 + # Set the range to include min-1 as a valid value + self.setRange(self.no_input_value, MAX_INT_VALUE) + self.setValue(self.no_input_value) def validate(self, input: str, pos: int) -> QValidator.State: """ @@ -455,24 +883,48 @@ def validate(self, input: str, pos: int) -> QValidator.State: def valueFromText(self, text: str) -> int: """ - Override valueFromText function to return NaN if input string is empty or placeholder. + Override valueFromText function to return no-input-value if input string is empty or placeholder. """ if text == "" or text == PLACEHOLDER_TEXT: - return self.NAN_VALUE + return self.no_input_value else: return super().valueFromText(text) def textFromValue(self, val: int) -> str: """ - Override textFromValue function to return placeholder text if value is NaN. + Override textFromValue function to return placeholder text if value is no-input-value. """ - if val == self.NAN_VALUE: + if val == self.no_input_value: return PLACEHOLDER_TEXT else: return super().textFromValue(val) + def wheelEvent(self, event): + """ + Override wheelEvent to disable scrolling from accidentally gaining focus and changing the numbers. + """ + event.ignore() + def has_input(self) -> bool: """ Custom function to indicate whether the SpinBox has received input. """ - return self.NAN_VALUE != self.value() + return self.no_input_value != self.value() + + def stepBy(self, steps: int) -> None: + current_value: int = self.value() + result_value = self.value() + steps + if ( + result_value == self.no_input_value + or result_value > self.maximum() + or result_value < self.minimum() + ): + # If result value is not a valid value, do not go to that value + return + + if ( + current_value == self.no_input_value and steps == 1 + ): # We should allow the user to increment from null value to a valid value + super().setValue(max(self.min, 0)) + else: + super().stepBy(steps)