diff --git a/brainframe_qt/ui/dialogs/task_configuration/task_configuration.py b/brainframe_qt/ui/dialogs/task_configuration/task_configuration.py index cae4e2b6a..08c99611e 100644 --- a/brainframe_qt/ui/dialogs/task_configuration/task_configuration.py +++ b/brainframe_qt/ui/dialogs/task_configuration/task_configuration.py @@ -4,7 +4,7 @@ from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QInputDialog from PyQt5.uic import loadUi -from brainframe.api import bf_codecs +from brainframe.api import bf_codecs, bf_errors from brainframe_qt.api_utils import api from brainframe_qt.ui.dialogs import AlarmCreationDialog @@ -58,6 +58,7 @@ def _init_signals(self) -> None: self.zone_list.initiate_zone_edit.connect(self._edit_zone_by_id) self.zone_list.zone_delete.connect(self.delete_zone) self.zone_list.alarm_delete.connect(self.delete_alarm) + self.zone_list.zone_name_change.connect(self.change_zone_name) self.dialog_button_box.accepted.connect(self.accept) self.dialog_button_box.rejected.connect(self.reject) @@ -171,6 +172,48 @@ def cancel_zone_edit(self) -> None: if None in self.zone_list.zones: self.zone_list.remove_zone(None) + def change_zone_name(self, zone_id: int, zone_name: str) -> None: + def update_zone_name() -> Zone: + zone = api.get_zone(zone_id) + zone.name = zone_name + + updated_zone = Zone.from_api_zone(api.set_zone(zone)) + return updated_zone + + def on_success(updated_zone: Zone) -> None: + self.zone_list.update_zone(updated_zone) + + def on_error(error: Exception) -> None: + dialog: Optional[BrainFrameMessage] = None + title = self.tr("Unable to rename Zone") + + if isinstance(error, bf_errors.ZoneNotFoundError): + message = self.tr( + f"Attempted to rename Zone {zone_id} to {zone_name} but the Zone " + f"no longer exists." + ) + try: + self.zone_list.remove_zone(zone_id) + except KeyError: + # Zone must already be gone + pass + + dialog = BrainFrameMessage.warning( + parent=self, + title=title, + warning=message, + ) + + if dialog is not None: + dialog.exec() + + QTAsyncWorker( + self, + update_zone_name, + on_success=on_success, + on_error=on_error, + ).start() + def delete_alarm(self, alarm_id: int) -> None: api.delete_zone_alarm(alarm_id) self.zone_list.remove_alarm(alarm_id) diff --git a/brainframe_qt/ui/dialogs/task_configuration/task_configuration.ui b/brainframe_qt/ui/dialogs/task_configuration/task_configuration.ui index ef7efbebe..c620e7726 100644 --- a/brainframe_qt/ui/dialogs/task_configuration/task_configuration.ui +++ b/brainframe_qt/ui/dialogs/task_configuration/task_configuration.ui @@ -150,6 +150,27 @@ + + + + + 0 + 0 + + + + + 35 + + + + Double click a zone to rename it + + + Qt::AlignCenter + + + diff --git a/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list.py b/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list.py index bf0235867..19640b929 100644 --- a/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list.py +++ b/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list.py @@ -1,4 +1,4 @@ -from typing import Dict, Callable, List +from typing import Callable, Dict, List, Optional from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtWidgets import QWidget, QVBoxLayout @@ -14,6 +14,8 @@ class ZoneList(QWidget): zone_delete = pyqtSignal(int) alarm_delete = pyqtSignal(int) + zone_name_change = pyqtSignal(int, str) + layout: Callable[..., QVBoxLayout] def __init__(self, parent=None): @@ -39,7 +41,7 @@ def _init_style(self) -> None: self.layout().setContentsMargins(0, 0, 0, 0) self.layout().setSpacing(0) - def add_zone(self, zone: Zone) -> ZoneListZoneItem: + def add_zone(self, zone: Zone, index: Optional[int] = None) -> ZoneListZoneItem: """Creates and returns the new ZoneListItem using the Zone""" zone_item = ZoneListZoneItem(zone, parent=self) @@ -47,11 +49,14 @@ def add_zone(self, zone: Zone) -> ZoneListZoneItem: zone_item.zone_edit.connect(self.initiate_zone_edit) zone_item.zone_delete.connect(self.zone_delete) + zone_item.zone_name_change.connect(self.zone_name_change) - self.layout().addWidget(zone_item) + if index is not None: + self.layout().insertWidget(index, zone_item) + else: + self.layout().addWidget(zone_item) - for alarm in zone.alarms: - self.add_alarm(zone, alarm) + self._add_alarm_widgets_for_zone(zone) return zone_item @@ -61,6 +66,13 @@ def confirm_zone(self, zone: Zone) -> None: self.remove_zone(None) self.add_zone(zone) + def update_zone(self, zone: Zone) -> None: + old_zone_widget = self.zones[zone.id] + old_index = self.layout().indexOf(old_zone_widget) + + self.remove_zone(zone.id) + self.add_zone(zone, old_index) + def add_alarm(self, zone: Zone, alarm: bf_codecs.ZoneAlarm): alarm_widget = ZoneListAlarmItem(alarm, parent=self) @@ -74,6 +86,7 @@ def remove_alarm(self, alarm_id: int) -> None: alarm_widget = self.alarms.pop(alarm_id) self.layout().removeWidget(alarm_widget) + alarm_widget.deleteLater() def remove_zone(self, zone_id: int) -> None: zone_widget: ZoneListZoneItem = self.zones.pop(zone_id) @@ -81,11 +94,12 @@ def remove_zone(self, zone_id: int) -> None: alarm_widgets = self._find_alarm_widgets_for_zone(zone_widget) # Remove zone and all child alarms - self.layout().removeWidget(zone_widget) for alarm_widget in alarm_widgets: # Uses private attribute for now, but this is temporary until zone widgets # contain alarm widgets inside of them self.remove_alarm(alarm_widget._alarm.id) + self.layout().removeWidget(zone_widget) + zone_widget.deleteLater() def _add_alarm_widget(self, alarm_widget: ZoneListAlarmItem, zone_id: int) -> None: """Add an alarm widget to the correct zone""" @@ -103,6 +117,10 @@ def _add_alarm_widget(self, alarm_widget: ZoneListAlarmItem, zone_id: int) -> No self.layout().insertWidget(insert_index, alarm_widget) + def _add_alarm_widgets_for_zone(self, zone: Zone) -> None: + for alarm in zone.alarms: + self.add_alarm(zone, alarm) + def _find_alarm_widgets_for_zone( self, zone_widget: ZoneListZoneItem ) -> List[ZoneListAlarmItem]: diff --git a/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list_item.py b/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list_item.py index 045db46e7..4c3a8a753 100644 --- a/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list_item.py +++ b/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list_item.py @@ -1,4 +1,7 @@ +from typing import Tuple + from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtGui import QValidator from PyQt5.QtWidgets import QWidget from brainframe.api import bf_codecs @@ -11,6 +14,7 @@ class ZoneListZoneItem(ZoneListItemUI): zone_delete = pyqtSignal(int) zone_edit = pyqtSignal(int) + zone_name_change = pyqtSignal(int, str) def __init__(self, zone: Zone, *, parent: QObject): super().__init__(parent=parent) @@ -20,24 +24,38 @@ def __init__(self, zone: Zone, *, parent: QObject): self.entry_name = zone.name self.entry_type = self._get_entry_type(zone) + self._init_validators() self._init_signals() - self._configure_buttons() + + self._handle_full_frame_zone() def _init_signals(self) -> None: self.trash_button.clicked.connect(self._on_trash_button_click) self.edit_button.clicked.connect(self._on_edit_button_click) + self.name_label.text_changed.connect(self._on_zone_name_change) + + def _init_validators(self) -> None: + validator = self._ZoneNameValidator() + + self.name_label.validator = validator - def _configure_buttons(self) -> None: + def _handle_full_frame_zone(self) -> None: + """Disable some functionality if the Zone is the full-frame zone""" if self._zone.name == bf_codecs.Zone.FULL_FRAME_ZONE_NAME: self.trash_button.setDisabled(True) self.edit_button.setDisabled(True) + self.name_label.editable = False + def _on_edit_button_click(self, _clicked: bool) -> None: self.zone_edit.emit(self._zone.id) def _on_trash_button_click(self, _clicked: bool) -> None: self.zone_delete.emit(self._zone.id) + def _on_zone_name_change(self, zone_name: str) -> None: + self.zone_name_change.emit(self._zone.id, zone_name) + @staticmethod def _get_entry_type(zone: Zone) -> ZoneListType: if isinstance(zone, Line): @@ -47,10 +65,20 @@ def _get_entry_type(zone: Zone) -> ZoneListType: else: return ZoneListType.UNKNOWN + class _ZoneNameValidator(QValidator): + def validate(self, input_: str, pos: int) -> Tuple[QValidator.State, str, int]: + if input_ == bf_codecs.Zone.FULL_FRAME_ZONE_NAME: + state = QValidator.Intermediate + else: + state = QValidator.Acceptable + + return state, input_, pos + class ZoneListAlarmItem(ZoneListItemUI): """Temporary until ZoneListZoneItem holds Alarm widgets""" alarm_delete = pyqtSignal(int) + alarm_edit = pyqtSignal(int) def __init__(self, alarm: bf_codecs.ZoneAlarm, *, parent: QObject): super().__init__(parent=parent) @@ -64,8 +92,11 @@ def __init__(self, alarm: bf_codecs.ZoneAlarm, *, parent: QObject): self._init_signals() + self._disable_editing() + def _init_signals(self) -> None: self.trash_button.clicked.connect(self._on_trash_button_click) + self.edit_button.clicked.connect(self._on_edit_button_click) def _init_padding_widget(self) -> QWidget: """Temporary solution to indent alarm widgets a bit. @@ -81,3 +112,15 @@ def _init_padding_widget(self) -> QWidget: def _on_trash_button_click(self, _clicked: bool) -> None: self.alarm_delete.emit(self._alarm.id) + + def _on_edit_button_click(self, _clicked: bool) -> None: + self.alarm_edit.emit(self._alarm.id) + + def _disable_editing(self) -> None: + """Editing of alarms is not currently supported""" + self.edit_button.setDisabled(True) + self.edit_button.setToolTip(self.tr( + "Editing of alarms is not currently not supported" + )) + + self.name_label.editable = False diff --git a/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list_item_ui.py b/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list_item_ui.py index 638e50c50..fc2b339c2 100644 --- a/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list_item_ui.py +++ b/brainframe_qt/ui/dialogs/task_configuration/widgets/zone_list_item_ui.py @@ -2,10 +2,11 @@ from PyQt5.QtCore import QObject, Qt from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QPushButton, QWidget, QHBoxLayout, QLabel +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLabel, QLineEdit from brainframe_qt.ui.resources.ui_elements.buttons import IconButton from brainframe_qt.ui.resources.ui_elements.widgets import AspectRatioSVGWidget +from brainframe_qt.ui.resources.ui_elements.widgets.text_edit import EditableLabel class ZoneListType(Enum): @@ -48,8 +49,11 @@ def _init_entry_icon(self) -> AspectRatioSVGWidget: return icon - def _init_name_label(self) -> QLabel: + def _init_name_label(self) -> EditableLabel: label = QLabel(self._entry_name, parent=self) + line_edit = QLineEdit(parent=self) + + label = EditableLabel(label, line_edit, parent=self) return label @@ -93,7 +97,7 @@ def entry_name(self) -> str: @entry_name.setter def entry_name(self, entry_name: str) -> None: self._entry_name = entry_name - self.name_label.setText(entry_name) + self.name_label.text = entry_name @property def entry_type(self) -> ZoneListType: diff --git a/brainframe_qt/ui/resources/i18n/brainframe_zh.ts b/brainframe_qt/ui/resources/i18n/brainframe_zh.ts index 06ff3a2cf..5f1e10c25 100644 --- a/brainframe_qt/ui/resources/i18n/brainframe_zh.ts +++ b/brainframe_qt/ui/resources/i18n/brainframe_zh.ts @@ -1284,22 +1284,22 @@ Please recheck the entered server address. TaskConfiguration - + Add points until done, then press "Confirm" button 添加点直到完成,然后按“确认”按钮 - + Item Name Already Exists 名称已存在 - + Item {} already exists in Stream 项目{}已存在于视频流中 - + Please use another name. 请使用另一个名称。 @@ -1334,35 +1334,50 @@ Please recheck the entered server address. 已有任务 - + Confirm 确认 - + Cancel 取消 - + New Line 新检测线段 - + Name for new line: 新检测线段名称: - + New Region 新检测区域 - + Name for new region: 新检测区域名称: + + + Unable to rename Zone + 无法重命名区域 + + + + Attempted to rename Zone {zone_id} to {zone_name} but the Zone no longer exists. + 尝试将区域 {zone_id} 重命名为 {zone_name},但该区域不再存在。 + + + + Double click a zone to rename it + 双击区域以重命名 + TextLicenseEditor @@ -1420,6 +1435,14 @@ Please recheck the entered server address. 这将删除所有此视频流相关的区域、检测结果、警报、报警信息等,并且无法撤消。这个流程会在后台发生<br><br>可能需要几分钟。 + + ZoneListAlarmItem + + + Editing of alarms is not currently not supported + 目前不支持编辑预警 + + ZoneStatusLabelItem diff --git a/brainframe_qt/ui/resources/images/icons/edit_pencil.svg b/brainframe_qt/ui/resources/images/icons/edit_pencil.svg index 276f59f81..743759708 100644 --- a/brainframe_qt/ui/resources/images/icons/edit_pencil.svg +++ b/brainframe_qt/ui/resources/images/icons/edit_pencil.svg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df71cec30d2dd80997cf80d90873e688e7942ccb53b2a785ff57436ae34a4713 -size 2917 +oid sha256:07034304fb15e15e31cc69ca8b6574d1dc3f0fd056b5bacae840eae56186fb19 +size 3819 diff --git a/brainframe_qt/ui/resources/images/icons/edit_pencil_inkscape.svg b/brainframe_qt/ui/resources/images/icons/edit_pencil_inkscape.svg index 289d0fa3b..aa2a3a138 100644 --- a/brainframe_qt/ui/resources/images/icons/edit_pencil_inkscape.svg +++ b/brainframe_qt/ui/resources/images/icons/edit_pencil_inkscape.svg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:993af1943c4bac5bb7e9d8486b31b957da8bbb0174ff0bd427a9d76ab51dcd0c -size 4221 +oid sha256:7bb7f6c27ebdb4352963287197656d0d3b1067319608e0f071ba06cbe812f3e0 +size 4663 diff --git a/brainframe_qt/ui/resources/ui_elements/widgets/text_edit/__init__.py b/brainframe_qt/ui/resources/ui_elements/widgets/text_edit/__init__.py index b55cab74a..b24e11fce 100644 --- a/brainframe_qt/ui/resources/ui_elements/widgets/text_edit/__init__.py +++ b/brainframe_qt/ui/resources/ui_elements/widgets/text_edit/__init__.py @@ -1,2 +1,3 @@ from .drag_and_drop_text_editor import DragAndDropTextEditor +from .editable_label import EditableLabel from .placeholder_text_edit import PlaceholderTextEdit diff --git a/brainframe_qt/ui/resources/ui_elements/widgets/text_edit/editable_label.py b/brainframe_qt/ui/resources/ui_elements/widgets/text_edit/editable_label.py new file mode 100644 index 000000000..b5ee320bd --- /dev/null +++ b/brainframe_qt/ui/resources/ui_elements/widgets/text_edit/editable_label.py @@ -0,0 +1,100 @@ +from PyQt5.QtCore import QObject, Qt, pyqtSignal +from PyQt5.QtGui import QMouseEvent, QKeyEvent, QValidator +from PyQt5.QtWidgets import QStackedWidget, QLabel, QLineEdit + + +class EditableLabel(QStackedWidget): + text_changed = pyqtSignal(str) + + def __init__(self, label: QLabel, line_edit: QLineEdit, *, parent: QObject): + super().__init__(parent=parent) + + self._label = label + self._line_edit = line_edit + + self.editable = True + """If this is False, editing text is disabled""" + self._editing = False + + self._init_layout() + self._init_style() + + self._init_signals() + + def _init_layout(self) -> None: + self.addWidget(self._label) + self.addWidget(self._line_edit) + + def _init_style(self) -> None: + self.setAttribute(Qt.WA_StyledBackground, True) + + def _init_signals(self) -> None: + self._line_edit.editingFinished.connect(self.finish_edit) + + def keyPressEvent(self, event: QKeyEvent) -> None: + if self._editing and event.key() == Qt.Key_Escape: + self.cancel_edit() + + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: + if not self.editable: + return + + if not self._editing: + self.start_edit() + + @property + def text(self) -> str: + return self._label.text() + + @text.setter + def text(self, text: str) -> None: + self._label.setText(text) + + @property + def validator(self) -> QValidator: + return self._line_edit.validator() + + @validator.setter + def validator(self, validator: QValidator) -> None: + self._line_edit.setValidator(validator) + + def cancel_edit(self) -> None: + if not self._editing: + return + + # Block signals to prevent out-focus event from self._line_edit triggering + # self.finish_edit + self._line_edit.blockSignals(True) + self.setCurrentWidget(self._label) + self._line_edit.blockSignals(False) + + self._editing = False + + def finish_edit(self) -> None: + if not self.editable: + self.cancel_edit() + return + + if not self._editing: + return + + self.text = self._line_edit.text() + + self.setCurrentWidget(self._label) + + self._editing = False + + self.text_changed.emit(self.text) + + def start_edit(self) -> None: + if not self.editable or self._editing: + return + + self._line_edit.setText(self.text) + + self._line_edit.selectAll() + self._line_edit.setFocus() + + self.setCurrentWidget(self._line_edit) + + self._editing = True