diff --git a/datalab/config/options.py b/datalab/config/options.py index ffe931059..0c4946d8c 100644 --- a/datalab/config/options.py +++ b/datalab/config/options.py @@ -314,6 +314,15 @@ def __init__(self) -> None: description="Ignore the message shown when inserting a title as an " "annotation label.", ) + self.show_delete_button_on_hover = TypedOptionField( + self, + "show_delete_button_on_hover", + category="view", + default=True, + expected_type=bool, + description="Show a delete button at the right of the signal, image or " + "group under the mouse cursor in the object lists.", + ) self.auto_refresh = TypedOptionField( self, "auto_refresh", diff --git a/datalab/gui/main.py b/datalab/gui/main.py index 2eb91b9d6..565ca05a2 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -2847,6 +2847,11 @@ def __edit_settings(self) -> None: # pylint: disable=too-many-branches,too-many self._update_console_show_mode() if option == "welcome_application_rows": self.welcomepanel.update_application_tiles() + if option == "show_delete_button_on_hover": + for panel in (self.signalpanel, self.imagepanel): + panel.objview.set_delete_button_enabled( + Conf.show_delete_button_on_hover.get() + ) if option == "plot_toolbar_position": for dock in self.docks.values(): widget = dock.widget() diff --git a/datalab/gui/objectview.py b/datalab/gui/objectview.py index cc7ff1fd6..8cabe5ab4 100644 --- a/datalab/gui/objectview.py +++ b/datalab/gui/objectview.py @@ -54,10 +54,11 @@ get_uuid, resolve_title_references, ) -from datalab.utils.qthelpers import block_signals +from datalab.utils.qthelpers import block_signals, event_pos, view_item_option from datalab.widgets.titledelegate import ( TITLE_REFERENCE_ROLE, ClickableTitleDelegate, + hover_background_color, ) if TYPE_CHECKING: @@ -67,6 +68,22 @@ from datalab.objectmodel import ObjectModel +#: Side of the delete button shown at the right of the hovered item (pixels) +DELETE_BUTTON_SIZE = 20 +#: Side of the icon of the delete button (pixels) +DELETE_BUTTON_ICON_SIZE = 14 +#: Space between the delete button and the right edge of the viewport (pixels) +DELETE_BUTTON_MARGIN = 6 +#: Space between the item title and the delete button (pixels) +DELETE_BUTTON_SPACING = 2 +#: Corner radius of the delete button background (pixels) +DELETE_BUTTON_RADIUS = 3 +#: Background colors of the delete button under the mouse cursor, and pressed +#: (the former is the "danger" color of DataLab-Web) +DELETE_BUTTON_HOVER_COLOR = "#c4302b" +DELETE_BUTTON_PRESSED_COLOR = "#9e2420" + + def metadata_to_html(metadata: dict[str, Any]) -> str: """Convert metadata to human-readable string. @@ -113,6 +130,10 @@ def __init__(self, parent: QW.QWidget, objmodel: ObjectModel) -> None: ) self.setItemDelegateForColumn(0, self.title_delegate) self.viewport().setMouseTracking(True) + # Hover events let the view flag the hovered item, whatever the Qt style + self.viewport().setAttribute(QC.Qt.WA_Hover, True) + #: Hovered item, tracked from the same events as the view's own hover state + self.__hover_index = QC.QPersistentModelIndex() #: Title reference indexes of the models (by model id), while a batch of #: items is updated (see :meth:`_title_reference_indexes`); None otherwise. self.__title_reference_indexes: dict[int, dict] | None = None @@ -344,7 +365,7 @@ def title_reference_at(self, pos: QC.QPoint) -> str | None: if not index.isValid() or index.column() != 0: return None rect = self.visualRect(index) - option = self.viewOptions() + option = view_item_option(self) option.rect = rect return self.title_delegate.anchor_at(index, rect, pos, option) @@ -352,7 +373,7 @@ def title_reference_at(self, pos: QC.QPoint) -> str | None: def mousePressEvent(self, event: QG.QMouseEvent) -> None: """Reimplement Qt method to handle short-ID hyperlink clicks.""" if event.button() == QC.Qt.LeftButton: - reference = self.title_reference_at(event.pos()) + reference = self.title_reference_at(event_pos(event)) if reference is not None: event.accept() self.handle_title_reference_click(reference) @@ -362,7 +383,7 @@ def mousePressEvent(self, event: QG.QMouseEvent) -> None: # pylint: disable=invalid-name def mouseMoveEvent(self, event: QG.QMouseEvent) -> None: """Reimplement Qt method to update the cursor over short-ID anchors.""" - reference = self.title_reference_at(event.pos()) + reference = self.title_reference_at(event_pos(event)) viewport = self.viewport() if reference is not None: viewport.setCursor(QC.Qt.PointingHandCursor) @@ -370,6 +391,41 @@ def mouseMoveEvent(self, event: QG.QMouseEvent) -> None: viewport.unsetCursor() super().mouseMoveEvent(event) + def hovered_index(self) -> QC.QModelIndex: + """Return the index of the item under the mouse cursor (invalid if none)""" + return QC.QModelIndex(self.__hover_index) + + # pylint: disable=invalid-name + def viewportEvent(self, event: QC.QEvent) -> bool: + """Reimplement Qt method to track the hovered item""" + etype = event.type() + if etype in (QC.QEvent.HoverEnter, QC.QEvent.HoverMove): + index = self.indexAt(event_pos(event)) + self.__hover_index = QC.QPersistentModelIndex(index) + elif etype == QC.QEvent.HoverLeave: + self.__hover_index = QC.QPersistentModelIndex() + return super().viewportEvent(event) + + # pylint: disable=invalid-name + def changeEvent(self, event: QC.QEvent) -> None: + """Reimplement Qt method to keep hover events after a style change (some + styles disable them when unpolishing item views)""" + if event.type() == QC.QEvent.StyleChange: + self.viewport().setAttribute(QC.Qt.WA_Hover, True) + super().changeEvent(event) + + # pylint: disable=invalid-name + def drawBranches( + self, painter: QG.QPainter, rect: QC.QRect, index: QC.QModelIndex + ) -> None: + """Reimplement Qt method to extend the highlight of the hovered item to + its branch area (the delegate highlights the rest of the item)""" + if index == self.hovered_index() and not self.selectionModel().isSelected( + index + ): + painter.fillRect(rect, hover_background_color(self.palette())) + super().drawBranches(painter, rect, index) + def populate_tree(self) -> None: """Populate tree with objects""" uuid = self.get_current_item_id() @@ -615,10 +671,16 @@ def get_selected_objects(self) -> list[SignalObj | ImageObj]: class ObjectView(SimpleObjectTree): - """Object handling panel list widget, object (sig/ima) lists""" + """Object handling panel list widget, object (sig/ima) lists + + A delete button is shown at the right of the hovered item (signal, image or + group), unless disabled (see :meth:`set_delete_button_enabled`): clicking it + emits :attr:`SIG_DELETE_REQUESTED` with the item UUID. + """ SIG_SELECTION_CHANGED = QC.Signal() SIG_IMPORT_FILES = QC.Signal(list) + SIG_DELETE_REQUESTED = QC.Signal(str) def __init__(self, parent: BaseDataPanel, objmodel: ObjectModel) -> None: super().__init__(parent, objmodel) @@ -630,6 +692,235 @@ def __init__(self, parent: BaseDataPanel, objmodel: ObjectModel) -> None: self.__dragged_objects: list[QW.QListWidgetItem] = [] self.__dragged_groups: list[QW.QListWidgetItem] = [] self.__dragged_expanded_states: dict[QW.QListWidgetItem, bool] = {} + self.__delete_button_enabled = Conf.show_delete_button_on_hover.get() + self.__delete_icon = get_icon("delete.svg") + #: True when the mouse cursor is over the delete button of the hovered item + self.__delete_button_hovered = False + #: Item whose delete button is pressed, None when no button is pressed + self.__delete_button_pressed: QC.QPersistentModelIndex | None = None + self.title_delegate.text_clip_provider = self.__title_clip_right + # The button sticks to the right edge of the viewport: scrolling + # horizontally must not move its pixels with the items + self.horizontalScrollBar().valueChanged.connect(self.__repaint_viewport) + + def set_delete_button_enabled(self, enabled: bool) -> None: + """Show or hide the delete button of the hovered item + + Args: + enabled: True to show the button + """ + self.__delete_button_enabled = enabled + self.__delete_button_hovered = False + self.__delete_button_pressed = None + self.viewport().update() + + def is_delete_button_enabled(self) -> bool: + """Return True if the delete button of the hovered item is shown""" + return self.__delete_button_enabled + + def delete_button_rect(self, index: QC.QModelIndex) -> QC.QRect | None: + """Return the rectangle of the delete button of an item + + The button is shown on the hovered item only, at the right edge of the + visible part of the viewport (the title column may be wider than the + viewport). + + Args: + index: model index of the item + + Returns: + Button rectangle in viewport coordinates, or None if the item shows + no delete button. + """ + if ( + not self.__delete_button_enabled + or not index.isValid() + or index.sibling(index.row(), 0) != self.hovered_index() + or self.state() + not in (QW.QAbstractItemView.NoState, QW.QAbstractItemView.AnimatingState) + ): + return None + item_rect = self.visualRect(index) + size = DELETE_BUTTON_SIZE + return QC.QRect( + self.viewport().width() - DELETE_BUTTON_MARGIN - size, + item_rect.top() + (item_rect.height() - size) // 2, + size, + size, + ) + + def get_delete_targets(self, uuid: str) -> list[str]: + """Return the UUIDs of the items deleted by the delete button of an item + + Args: + uuid: UUID of the item whose delete button is clicked + + Returns: + UUIDs of the selected objects or groups if the item is part of the + selection, else the item UUID only. + """ + selection = self.get_sel_object_uuids() + self.get_sel_group_uuids() + return selection if uuid in selection else [uuid] + + def delete_button_tooltip(self, index: QC.QModelIndex) -> str: + """Return the tooltip of the delete button of an item + + Args: + index: model index of the item + + Returns: + Tooltip text + """ + uuid = index.data(QC.Qt.UserRole) + targets = self.get_delete_targets(uuid) + if len(targets) > 1: + if isinstance(self.objmodel.get_object_or_group(targets[0]), ObjectGroup): + return _("Delete the %d selected groups") % len(targets) + return _("Delete the %d selected objects") % len(targets) + obj = self.objmodel.get_object_or_group(uuid) + if isinstance(obj, ObjectGroup): + return _("Delete this group") + if isinstance(obj, SignalObj): + return _("Delete this signal") + return _("Delete this image") + + def __delete_button_index_at(self, pos: QC.QPoint) -> QC.QModelIndex | None: + """Return the index of the item whose delete button is at viewport + position ``pos``, or None""" + index = self.indexAt(pos) + rect = self.delete_button_rect(index) + if rect is not None and rect.contains(pos): + return index + return None + + def __title_clip_right(self, index: QC.QModelIndex) -> int | None: + """Return the viewport X coordinate beyond which the title of an item must + not be drawn, to leave room for its delete button (None if no button)""" + rect = self.delete_button_rect(index) + return None if rect is None else rect.left() - DELETE_BUTTON_SPACING + + def __update_row(self, index: QC.QModelIndex) -> None: + """Repaint the row of an item, over the whole viewport width""" + if index.isValid(): + rect = self.visualRect(index) + width = self.viewport().width() + self.viewport().update(QC.QRect(0, rect.top(), width, rect.height())) + + def __repaint_viewport(self) -> None: + """Repaint the whole viewport""" + self.viewport().update() + + def __set_delete_button_hovered(self, hovered: bool) -> None: + """Set whether the mouse cursor is over the delete button""" + if hovered != self.__delete_button_hovered: + self.__delete_button_hovered = hovered + self.__update_row(self.hovered_index()) + + def __paint_delete_button(self, painter: QG.QPainter, rect: QC.QRect) -> None: + """Paint the delete button in ``rect``""" + painter.save() + if self.__delete_button_hovered: + pressed = self.__delete_button_pressed is not None + color = ( + DELETE_BUTTON_PRESSED_COLOR if pressed else DELETE_BUTTON_HOVER_COLOR + ) + painter.setRenderHint(QG.QPainter.Antialiasing) + painter.setPen(QC.Qt.NoPen) + painter.setBrush(QG.QColor(color)) + radius = DELETE_BUTTON_RADIUS + painter.drawRoundedRect(QC.QRectF(rect), radius, radius) + icon_rect = QC.QRect(0, 0, DELETE_BUTTON_ICON_SIZE, DELETE_BUTTON_ICON_SIZE) + icon_rect.moveCenter(rect.center()) + self.__delete_icon.paint(painter, icon_rect) + painter.restore() + + # pylint: disable=invalid-name + def drawRow( + self, + painter: QG.QPainter, + option: QW.QStyleOptionViewItem, + index: QC.QModelIndex, + ) -> None: + """Reimplement Qt method to draw the delete button of the hovered item""" + super().drawRow(painter, option, index) + rect = self.delete_button_rect(index) + if rect is not None: + self.__paint_delete_button(painter, rect) + + # pylint: disable=invalid-name + def viewportEvent(self, event: QC.QEvent) -> bool: + """Reimplement Qt method to show the tooltip of the delete button and + follow the mouse cursor over it""" + etype = event.type() + if etype == QC.QEvent.ToolTip: + index = self.__delete_button_index_at(event.pos()) + if index is not None: + QW.QToolTip.showText( + event.globalPos(), + self.delete_button_tooltip(index), + self.viewport(), + self.delete_button_rect(index), + ) + return True + result = super().viewportEvent(event) + if etype in (QC.QEvent.HoverEnter, QC.QEvent.HoverMove): + index = self.__delete_button_index_at(event_pos(event)) + self.__set_delete_button_hovered(index is not None) + elif etype == QC.QEvent.HoverLeave: + self.__set_delete_button_hovered(False) + return result + + # pylint: disable=invalid-name + def mousePressEvent(self, event: QG.QMouseEvent) -> None: + """Reimplement Qt method to press the delete button, without selecting + or dragging the item""" + if event.button() == QC.Qt.LeftButton: + index = self.__delete_button_index_at(event_pos(event)) + if index is not None: + self.__delete_button_pressed = QC.QPersistentModelIndex(index) + self.__update_row(index) + event.accept() + return + super().mousePressEvent(event) + + # pylint: disable=invalid-name + def mouseMoveEvent(self, event: QG.QMouseEvent) -> None: + """Reimplement Qt method to follow the mouse cursor over the delete + button""" + pos = event_pos(event) + if self.__delete_button_pressed is not None: + # The press started on a delete button: neither select nor drag items + rect = self.delete_button_rect(QC.QModelIndex(self.__delete_button_pressed)) + self.__set_delete_button_hovered(rect is not None and rect.contains(pos)) + event.accept() + return + super().mouseMoveEvent(event) + if self.__delete_button_index_at(pos) is not None: + self.viewport().setCursor(QC.Qt.PointingHandCursor) + + # pylint: disable=invalid-name + def mouseReleaseEvent(self, event: QG.QMouseEvent) -> None: + """Reimplement Qt method to click the delete button""" + if self.__delete_button_pressed is None or event.button() != QC.Qt.LeftButton: + super().mouseReleaseEvent(event) + return + pressed = QC.QModelIndex(self.__delete_button_pressed) + self.__delete_button_pressed = None + event.accept() + self.__update_row(pressed) + if pressed.isValid() and self.__delete_button_index_at(event_pos(event)) == ( + pressed + ): + self.SIG_DELETE_REQUESTED.emit(pressed.data(QC.Qt.UserRole)) + + # pylint: disable=invalid-name + def mouseDoubleClickEvent(self, event: QG.QMouseEvent) -> None: + """Reimplement Qt method to ignore double-clicks on the delete button + (which would open the item in a separate view)""" + if self.__delete_button_index_at(event_pos(event)) is not None: + event.accept() + return + super().mouseDoubleClickEvent(event) def resolve_title_reference( self, reference: str @@ -900,8 +1191,14 @@ def set_current_object(self, obj: SignalObj | ImageObj) -> None: """Set current object""" self.set_current_item_id(get_uuid(obj)) - def __select_uuids(self, uuids: list[str]) -> None: - """Select UUIDs atomically and publish the complete final state once.""" + def select_uuids(self, uuids: Sequence[str], notify: bool = True) -> None: + """Select objects or groups atomically, the last one becoming current + + Args: + uuids: UUIDs of the objects or groups to select + notify: if True, publish the complete final state once (see + :meth:`item_selection_changed`) + """ if not uuids: return signals_blocked = self.blockSignals(True) @@ -910,7 +1207,8 @@ def __select_uuids(self, uuids: list[str]) -> None: self.set_current_item_id(uuid, extend=idx > 0) finally: self.blockSignals(signals_blocked) - self.item_selection_changed() + if notify: + self.item_selection_changed() def item_selection_changed(self) -> None: """Refreshing the selection of objects and groups, emitting the @@ -954,7 +1252,7 @@ def select_objects( else: assert all(isinstance(obj, (SignalObj, ImageObj)) for obj in selection) uuids = [get_uuid(obj) for obj in selection] - self.__select_uuids(uuids) + self.select_uuids(uuids) def select_groups( self, groups: list[ObjectGroup | int | str] | None = None @@ -972,7 +1270,7 @@ def select_groups( elif all(isinstance(group, str) for group in groups): groups = self.objmodel.get_groups(groups) assert all(isinstance(group, ObjectGroup) for group in groups) - self.__select_uuids([get_uuid(group) for group in groups]) + self.select_uuids([get_uuid(group) for group in groups]) def __reorder_model(self) -> None: """Reorder model""" diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index e645c8223..794d3ab86 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -2588,6 +2588,7 @@ def setup_panel(self) -> None: lambda oid: self.open_separate_view([oid]) ) self.objview.SIG_CONTEXT_MENU.connect(self.__popup_contextmenu) + self.objview.SIG_DELETE_REQUESTED.connect(self.__delete_from_button) self.objprop.properties.SIG_APPLY_BUTTON_CLICKED.connect( self.properties_changed ) @@ -2959,6 +2960,19 @@ def remove_object(self, force: bool = False) -> None: ) if answer == QW.QMessageBox.No: return + self.__remove_selection(force) + + def __remove_selection( + self, force: bool, restored_selection: Sequence[str] = () + ) -> None: + """Remove the selected objects and groups + + Args: + force: ``force`` argument of :meth:`remove_object`, recorded in history + restored_selection: UUIDs of the remaining objects or groups to select + after removal (the last one becoming current). If empty, the selection + is left to the object view. + """ # IMPORTANT: save_state=True is required so that the selection of objects # being deleted is captured. On replay, the captured selection is restored # before remove_object runs, ensuring that the correct object is removed @@ -2970,6 +2984,7 @@ def remove_object(self, force: bool = False) -> None: save_state=True, force=force, ) + sel_groups = self.objview.get_sel_groups() sel_objects = self.objview.get_sel_objects(include_groups=True) for obj in sorted(sel_objects, key=get_short_id, reverse=True): dlg_list: list[QW.QDialog] = [] @@ -2985,9 +3000,72 @@ def remove_object(self, force: bool = False) -> None: self.objview.remove_item(get_uuid(group), refresh=False) self.objmodel.remove_group(group) self.objview.update_tree() + if restored_selection: + remaining = set(self.objmodel.get_object_ids()) + remaining.update(get_uuid(group) for group in self.objmodel.get_groups()) + self.objview.select_uuids( + [uuid for uuid in restored_selection if uuid in remaining], + notify=False, + ) self.selection_changed(update_items=True) self.SIG_OBJECT_REMOVED.emit() + def __confirm_deletion(self, uuids: Sequence[str]) -> bool: + """Ask the user to confirm the deletion of objects or groups + + Args: + uuids: UUIDs of the objects, or of the groups, to delete + + Returns: + True if the deletion is confirmed + """ + items = [self.objmodel.get_object_or_group(uuid) for uuid in uuids] + if len(items) > 1: + if isinstance(items[0], ObjectGroup): + message = _( + "Do you want to delete the %d selected groups and their objects?" + ) % len(items) + else: + message = _("Do you want to delete the %d selected objects?") % len( + items + ) + elif isinstance(items[0], ObjectGroup): + message = _('Do you want to delete group "%s" and its %d object(s)?') % ( + items[0].title, + len(items[0]), + ) + else: + message = _('Do you want to delete "%s"?') % items[0].title + answer = QW.QMessageBox.warning( + self, _("Delete"), message, QW.QMessageBox.Yes | QW.QMessageBox.No + ) + return answer == QW.QMessageBox.Yes + + def __delete_from_button(self, uuid: str) -> None: + """Delete objects or groups with the delete button of an item of the + object view, after confirmation + + If the item is part of the selection, the whole selection is deleted. + Otherwise, only the item is deleted, and the selection is preserved. + + Args: + uuid: UUID of the item whose delete button was clicked + """ + targets = self.objview.get_delete_targets(uuid) + if not execenv.unattended and not self.__confirm_deletion(targets): + return + selection = self.objview.get_sel_object_uuids() + selection += self.objview.get_sel_group_uuids() + restored_selection: list[str] = [] + if uuid not in selection: + current = self.objview.get_current_item_id() + restored_selection = [oid for oid in selection if oid != current] + if current in selection: + restored_selection.append(current) + # Selecting the item records it in history as the removed object + self.objview.select_uuids([uuid], notify=False) + self.__remove_selection(force=True, restored_selection=restored_selection) + def delete_all_objects(self) -> None: # pragma: no cover """Confirm before removing all objects""" if len(self) == 0: diff --git a/datalab/gui/settings.py b/datalab/gui/settings.py index 660a22c94..21f132dc7 100644 --- a/datalab/gui/settings.py +++ b/datalab/gui/settings.py @@ -381,6 +381,15 @@ class ViewSettings(gds.DataSet): ), default=False, ) + show_delete_button_on_hover = gds.BoolItem( + "", + _("Show delete button when hovering list items"), + help=_( + "Show a delete button at the right of the signal, image or group under " + "the mouse cursor in the signal and image lists" + ), + default=True, + ) _g0 = gds.EndGroup("") g1 = gds.BeginGroup(_("Signals")).set_prop("display", icon="signal.svg") diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 26abbedd7..067eb4dac 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1454,6 +1454,23 @@ msgstr "groupe" msgid "Source objects" msgstr "Objets source" +#, python-format +msgid "Delete the %d selected groups" +msgstr "Supprimer les %d groupes sélectionnés" + +#, python-format +msgid "Delete the %d selected objects" +msgstr "Supprimer les %d objets sélectionnés" + +msgid "Delete this group" +msgstr "Supprimer ce groupe" + +msgid "Delete this signal" +msgstr "Supprimer ce signal" + +msgid "Delete this image" +msgstr "Supprimer cette image" + msgid "Drag files here to open" msgstr "Déposer des fichiers ici pour les ouvrir" @@ -1704,6 +1721,22 @@ msgstr "Êtes-vous sûr de vouloir supprimer le(s) groupe(s) sélectionné(s) ?" msgid "Remove selected objects" msgstr "Supprimer les objets sélectionnés" +#, python-format +msgid "Do you want to delete the %d selected groups and their objects?" +msgstr "Souhaitez-vous supprimer les %d groupes sélectionnés et leurs objets ?" + +#, python-format +msgid "Do you want to delete the %d selected objects?" +msgstr "Souhaitez-vous supprimer les %d objets sélectionnés ?" + +#, python-format +msgid "Do you want to delete group \"%s\" and its %d object(s)?" +msgstr "Souhaitez-vous supprimer le groupe « %s » et ses objets (%d) ?" + +#, python-format +msgid "Do you want to delete \"%s\"?" +msgstr "Souhaitez-vous supprimer « %s » ?" + #, python-format msgid "Do you want to delete all objects (%s)?" msgstr "Souhaitez-vous supprimer tous les objets (%s) ?" @@ -3877,6 +3910,12 @@ msgstr "Ignorer le message d'insertion du titre" msgid "Ignore information message when inserting object title as annotation label" msgstr "Ignorer le message d'information lors de l'insertion du titre de l'objet en tant qu'étiquette d'annotation" +msgid "Show delete button when hovering list items" +msgstr "Afficher un bouton de suppression au survol des éléments des listes" + +msgid "Show a delete button at the right of the signal, image or group under the mouse cursor in the signal and image lists" +msgstr "Afficher un bouton de suppression à droite du signal, de l'image ou du groupe situé sous le curseur de la souris, dans les listes de signaux et d'images" + msgid "Signals" msgstr "Signaux" diff --git a/datalab/tests/backbone/titledelegate_unit_test.py b/datalab/tests/backbone/titledelegate_unit_test.py index 0be7e4242..82199fbdb 100644 --- a/datalab/tests/backbone/titledelegate_unit_test.py +++ b/datalab/tests/backbone/titledelegate_unit_test.py @@ -10,8 +10,13 @@ from qtpy import QtGui as QG from qtpy import QtWidgets as QW +from datalab.utils.qthelpers import view_item_option from datalab.widgets import titledelegate -from datalab.widgets.titledelegate import TITLE_REFERENCE_ROLE, ClickableTitleDelegate +from datalab.widgets.titledelegate import ( + TITLE_REFERENCE_ROLE, + ClickableTitleDelegate, + hover_background_color, +) def _contrast(first: QG.QColor, second: QG.QColor) -> float: @@ -34,11 +39,13 @@ def luminance(color: QG.QColor) -> float: @pytest.mark.parametrize("dark", [False, True]) @pytest.mark.parametrize("selected", [False, True]) @pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize("hovered", [False, True]) def test_reference_rendering( style_name: str, dark: bool, selected: bool, active: bool, + hovered: bool, monkeypatch: pytest.MonkeyPatch, ) -> None: """Keep actual backgrounds, text and clickable references consistent.""" @@ -72,6 +79,8 @@ def test_reference_rendering( option.state |= QW.QStyle.State_Selected if active: option.state |= QW.QStyle.State_Active | QW.QStyle.State_HasFocus + if hovered: + option.state |= QW.QStyle.State_MouseOver documents = [] make_document = titledelegate._make_text_document @@ -90,6 +99,15 @@ def record_document(*args, **kwargs): background = image.pixelColor(350, 30) if selected: assert background == QG.QColor("#204a70" if dark else "#cce8ff") + elif hovered and not active: + # Same discreet highlight whatever the style, distinct from the base + # and selection colors (styles may draw their focus indicator over + # the background of the item having the focus, e.g. Fusion) + assert background == hover_background_color(palette) + assert background not in ( + palette.color(QG.QPalette.Base), + QG.QColor("#204a70" if dark else "#cce8ff"), + ) assert len({link.name(), text.name(), meta.name()}) == 3 for color in (link, text, meta): assert _contrast(color, background) >= 4.5 @@ -147,3 +165,39 @@ def test_size_hint_height_tracks_painted_wrapping(with_item_uuid: bool) -> None: narrow_height = delegate.sizeHint(narrow_option, model.index(0, 0)).height() assert narrow_height > wide_height + + +def test_text_clip_provider_leaves_room_for_view_buttons() -> None: + """Do not draw rich titles beyond the X coordinate given by the view.""" + with qt_app_context(): + view = QW.QTreeView() + model = QG.QStandardItemModel() + item = QG.QStandardItem("Title referencing 12345678, " * 4) + item.setData("87654321", TITLE_REFERENCE_ROLE) + model.appendRow(item) + view.setModel(model) + delegate = ClickableTitleDelegate(view, lambda reference: reference) + option = view_item_option(view) + option.rect = QC.QRect(0, 0, 800, 40) + + def last_painted_column(clip_right: int | None) -> int: + """Return the rightmost painted column with the given clip""" + delegate.text_clip_provider = lambda _index: clip_right + image = QG.QImage(800, 40, QG.QImage.Format_ARGB32) + image.fill(QC.Qt.transparent) + painter = QG.QPainter(image) + delegate.paint(painter, option, model.index(0, 0)) + painter.end() + return max( + ( + x + for x in range(image.width()) + for y in range(image.height()) + if image.pixelColor(x, y).alpha() > 0 + ), + default=-1, + ) + + assert last_painted_column(None) > 300 + assert 0 < last_painted_column(300) < 300 + view.close() diff --git a/datalab/tests/features/common/objview_delete_button_unit_test.py b/datalab/tests/features/common/objview_delete_button_unit_test.py new file mode 100644 index 000000000..0d044fd24 --- /dev/null +++ b/datalab/tests/features/common/objview_delete_button_unit_test.py @@ -0,0 +1,303 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Delete button of the object view: geometry, tooltips, deletion and settings. + +The delete button is shown at the right of the hovered signal, image or group in +the object lists of the main window (as in DataLab-Web). +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +from unittest.mock import patch + +import numpy as np +from guidata.qthelpers import qt_app_context +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW +from qtpy.QtTest import QTest +from sigima.objects import SignalObj, create_image, create_signal + +from datalab.config import Conf, _ +from datalab.env import execenv +from datalab.gui.objectview import DELETE_BUTTON_MARGIN, ObjectView, SimpleObjectTree +from datalab.gui.panel.base import BaseDataPanel +from datalab.objectmodel import ObjectModel, get_uuid +from datalab.tests import datalab_test_app_context + + +def add_signals(panel: BaseDataPanel, *titles: str) -> list[SignalObj]: + """Add signals to the current group of a panel + + Args: + panel: signal panel + titles: signal titles + + Returns: + Added signals + """ + x = np.linspace(0.0, 1.0, 50) + signals = [create_signal(title, x, x) for title in titles] + for signal in signals: + panel.add_object(signal) + return signals + + +def index_of(view: ObjectView, uuid: str) -> QC.QModelIndex: + """Return the model index of the item of an object or group""" + return view.indexFromItem(view.get_item_from_id(uuid)) + + +def send_hover(view: ObjectView, etype: QC.QEvent.Type, pos: QC.QPoint) -> None: + """Send a hover event to the viewport of an object view""" + event = QG.QHoverEvent(etype, QC.QPointF(pos), QC.QPointF(-1, -1)) + QW.QApplication.sendEvent(view.viewport(), event) + + +def hover_item(view: ObjectView, uuid: str) -> QC.QModelIndex: + """Move the mouse cursor over the title of an item, and return its index""" + index = index_of(view, uuid) + rect = view.visualRect(index) + send_hover( + view, QC.QEvent.HoverMove, QC.QPoint(rect.left() + 10, rect.center().y()) + ) + return index + + +def delete_button_center(view: ObjectView, uuid: str) -> QC.QPoint: + """Hover the delete button of an item and return its center""" + rect = view.delete_button_rect(hover_item(view, uuid)) + assert rect is not None + send_hover(view, QC.QEvent.HoverMove, rect.center()) + return rect.center() + + +def click_delete_button(view: ObjectView, uuid: str) -> None: + """Click the delete button of an item""" + pos = delete_button_center(view, uuid) + QTest.mouseClick(view.viewport(), QC.Qt.LeftButton, QC.Qt.NoModifier, pos) + + +def test_delete_button_geometry() -> None: + """The button is shown on the hovered item only, at the viewport right edge.""" + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + view = panel.objview + sig1, sig2 = add_signals(panel, "Signal 1", "Signal 2") + index1, index2 = index_of(view, get_uuid(sig1)), index_of(view, get_uuid(sig2)) + assert view.delete_button_rect(index1) is None + hover_item(view, get_uuid(sig1)) + rect = view.delete_button_rect(index1) + assert rect is not None + assert view.delete_button_rect(index2) is None + viewport = view.viewport() + assert viewport.rect().contains(rect) + assert rect.right() == viewport.width() - DELETE_BUTTON_MARGIN - 1 + item_rect = view.visualRect(index1) + assert abs(rect.center().y() - item_rect.center().y()) <= 1 + # Hovering another item moves the button + hover_item(view, get_uuid(sig2)) + assert view.delete_button_rect(index1) is None + assert view.delete_button_rect(index2) is not None + # Leaving the view hides the button + send_hover(view, QC.QEvent.HoverLeave, QC.QPoint(-1, -1)) + assert view.delete_button_rect(index2) is None + # The button sticks to the visible right edge of the viewport, even when + # long titles make the view scroll horizontally + sig2.title = "A very long signal title " * 20 + view.update_item(get_uuid(sig2)) + view.resize_title_column() + QW.QApplication.processEvents() # Update the scroll bar range + scrollbar = view.horizontalScrollBar() + assert scrollbar.maximum() > 0 + for value in (0, scrollbar.maximum()): + scrollbar.setValue(value) + QW.QApplication.processEvents() + rect = view.delete_button_rect(hover_item(view, get_uuid(sig2))) + assert rect is not None + assert rect.right() == viewport.width() - DELETE_BUTTON_MARGIN - 1 + + +def test_delete_button_tooltips() -> None: + """Tooltips tell what the button deletes.""" + with datalab_test_app_context(console=False) as win: + panel, view = win.signalpanel, win.signalpanel.objview + sig1, sig2 = add_signals(panel, "Signal 1", "Signal 2") + group = panel.objmodel.get_groups()[0] + view.select_objects([sig1]) + assert view.delete_button_tooltip(index_of(view, get_uuid(sig2))) == _( + "Delete this signal" + ) + assert view.delete_button_tooltip(index_of(view, get_uuid(group))) == _( + "Delete this group" + ) + view.select_objects([sig1, sig2]) + assert view.delete_button_tooltip(index_of(view, get_uuid(sig2))) == ( + _("Delete the %d selected objects") % 2 + ) + group2 = panel.add_group("Group 2") + view.select_groups([group, group2]) + assert view.delete_button_tooltip(index_of(view, get_uuid(group))) == ( + _("Delete the %d selected groups") % 2 + ) + ipanel = win.imagepanel + image = create_image("Image", np.zeros((10, 10))) + ipanel.add_object(image) + iview = ipanel.objview + assert iview.delete_button_tooltip(index_of(iview, get_uuid(image))) == _( + "Delete this image" + ) + + +def test_delete_unselected_object_preserves_selection() -> None: + """Deleting an unselected object keeps the selection, and is recorded in + history as the removal of this object only.""" + with datalab_test_app_context(console=False, history=True) as win: + history, panel = win.historypanel, win.signalpanel + view = panel.objview + sig1, sig2, sig3 = add_signals(panel, "Signal 1", "Signal 2", "Signal 3") + history.toggle_record_mode(True) + view.select_objects([sig1, sig3]) + click_delete_button(view, get_uuid(sig2)) + assert panel.objmodel.get_object_ids() == [get_uuid(sig1), get_uuid(sig3)] + assert set(view.get_sel_object_uuids()) == {get_uuid(sig1), get_uuid(sig3)} + assert view.get_current_item_id() == get_uuid(sig3) + action = history[len(history)] + assert action.method_name == "remove_object" + assert action.state.selection["signal"] == [get_uuid(sig2)] + + +def test_delete_selected_object_deletes_selection() -> None: + """Deleting a selected object deletes the whole selection.""" + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + view = panel.objview + sig1, sig2, sig3 = add_signals(panel, "Signal 1", "Signal 2", "Signal 3") + view.select_objects([sig1, sig2]) + click_delete_button(view, get_uuid(sig2)) + assert panel.objmodel.get_object_ids() == [get_uuid(sig3)] + + +def test_delete_group() -> None: + """Deleting a group deletes its objects, and keeps the selection of other + objects.""" + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + view = panel.objview + add_signals(panel, "Signal 1", "Signal 2") + group1 = panel.objmodel.get_groups()[0] + group2 = panel.add_group("Group 2", select=True) + (sig3,) = add_signals(panel, "Signal 3") + view.select_objects([sig3]) + click_delete_button(view, get_uuid(group1)) + assert panel.objmodel.get_groups() == [group2] + assert panel.objmodel.get_object_ids() == [get_uuid(sig3)] + assert view.get_sel_object_uuids() == [get_uuid(sig3)] + # Deleting the group of the selected objects deletes them too + (sig4,) = add_signals(panel, "Signal 4") + view.select_objects([sig4]) + click_delete_button(view, get_uuid(group2)) + assert len(panel.objmodel) == 0 + assert not panel.objmodel.get_groups() + + +def test_delete_confirmation() -> None: + """Deletion is always confirmed, and nothing is deleted if refused.""" + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + view = panel.objview + sig1, sig2 = add_signals(panel, "Signal 1", "Signal 2") + view.select_objects([sig1]) + with execenv.context(unattended=False): + with patch.object( + QW.QMessageBox, "warning", return_value=QW.QMessageBox.No + ) as warning: + click_delete_button(view, get_uuid(sig2)) + assert warning.call_count == 1 + assert '"Signal 2"' in warning.call_args.args[2] + assert len(panel.objmodel) == 2 + assert view.get_sel_object_uuids() == [get_uuid(sig1)] + with patch.object( + QW.QMessageBox, "warning", return_value=QW.QMessageBox.Yes + ): + click_delete_button(view, get_uuid(sig2)) + assert panel.objmodel.get_object_ids() == [get_uuid(sig1)] + + +def test_delete_button_does_not_select_drag_or_open() -> None: + """Pressing the button neither selects nor drags the item, releasing it + elsewhere cancels the click, and double-clicking it opens no view.""" + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + view = panel.objview + sig1, sig2 = add_signals(panel, "Signal 1", "Signal 2") + view.select_objects([sig1]) + double_clicked: list[str] = [] + view.SIG_ITEM_DOUBLECLICKED.connect(double_clicked.append) + viewport = view.viewport() + pos = delete_button_center(view, get_uuid(sig2)) + QTest.mousePress(viewport, QC.Qt.LeftButton, QC.Qt.NoModifier, pos) + assert view.get_sel_object_uuids() == [get_uuid(sig1)] + assert view.state() == QW.QAbstractItemView.NoState + outside = QC.QPoint(10, pos.y()) + move = QG.QMouseEvent( + QC.QEvent.MouseMove, + QC.QPointF(outside), + QC.Qt.NoButton, + QC.Qt.LeftButton, + QC.Qt.NoModifier, + ) + QW.QApplication.sendEvent(viewport, move) + QTest.mouseRelease(viewport, QC.Qt.LeftButton, QC.Qt.NoModifier, outside) + assert len(panel.objmodel) == 2 + assert view.get_sel_object_uuids() == [get_uuid(sig1)] + pos = delete_button_center(view, get_uuid(sig2)) + event = QG.QMouseEvent( + QC.QEvent.MouseButtonDblClick, + QC.QPointF(pos), + QC.Qt.LeftButton, + QC.Qt.LeftButton, + QC.Qt.NoModifier, + ) + QW.QApplication.sendEvent(viewport, event) + assert not double_clicked + assert len(panel.objmodel) == 2 + + +def test_delete_button_setting() -> None: + """The option hides the button: clicking its place selects the item.""" + with Conf.show_delete_button_on_hover.context(False): + with datalab_test_app_context(console=False) as win: + view = win.signalpanel.objview + assert not view.is_delete_button_enabled() + sig1, sig2 = add_signals(win.signalpanel, "Signal 1", "Signal 2") + view.select_objects([sig1]) + index = hover_item(view, get_uuid(sig2)) + assert view.delete_button_rect(index) is None + view.set_delete_button_enabled(True) + pos = view.delete_button_rect(index).center() + view.set_delete_button_enabled(False) + QTest.mouseClick(view.viewport(), QC.Qt.LeftButton, QC.Qt.NoModifier, pos) + assert len(win.signalpanel.objmodel) == 2 + assert view.get_sel_object_uuids() == [get_uuid(sig2)] + + +def test_hover_events_survive_style_changes() -> None: + """Hovered items stay highlighted after switching the Qt style (some styles + disable hover events of item views when unpolishing them).""" + with qt_app_context(): + app = QW.QApplication.instance() + initial_style = app.style().objectName() + tree = SimpleObjectTree(None, ObjectModel("gs")) + tree.show() + try: + for style_name in QW.QStyleFactory.keys() + [initial_style]: + app.setStyle(QW.QStyleFactory.create(style_name)) + QW.QApplication.processEvents() + assert tree.viewport().testAttribute(QC.Qt.WA_Hover) + finally: + app.setStyle(QW.QStyleFactory.create(initial_style)) + tree.close() diff --git a/datalab/utils/qthelpers.py b/datalab/utils/qthelpers.py index 51927ba86..b642ecb99 100644 --- a/datalab/utils/qthelpers.py +++ b/datalab/utils/qthelpers.py @@ -46,6 +46,7 @@ "create_menu_button", "create_progress_bar", "datalab_app_context", + "event_pos", "grab_save_window", "is_running_tests", "open_local_path", @@ -57,9 +58,41 @@ "save_restore_stds", "show_in_folder", "try_or_log_error", + "view_item_option", ] +def event_pos(event: QG.QMouseEvent | QG.QHoverEvent) -> QC.QPoint: + """Return the position of a mouse or hover event, in the coordinates of the + widget receiving it. + + Args: + event: Mouse or hover event + + Returns: + Event position + """ + if hasattr(event, "position"): # Qt 6 (``pos`` is deprecated) + return event.position().toPoint() + return event.pos() + + +def view_item_option(view: QW.QAbstractItemView) -> QW.QStyleOptionViewItem: + """Return the style option an item view initializes to draw its items. + + Args: + view: Item view + + Returns: + Style option + """ + if hasattr(view, "initViewItemOption"): # Qt 6 (``viewOptions`` was removed) + option = QW.QStyleOptionViewItem() + view.initViewItemOption(option) + return option + return view.viewOptions() + + def open_local_path(path: str) -> bool: """Open a local path with the desktop handler.""" return QG.QDesktopServices.openUrl(QC.QUrl.fromLocalFile(path)) diff --git a/datalab/widgets/titledelegate.py b/datalab/widgets/titledelegate.py index 7fd6a79f4..ecf14ee03 100644 --- a/datalab/widgets/titledelegate.py +++ b/datalab/widgets/titledelegate.py @@ -7,9 +7,12 @@ The :mod:`datalab.widgets.titledelegate` module provides a :class:`QStyledItemDelegate` that renders object tree titles as rich text and turns embedded object references (e.g. ``1a2b3c4d``, ``g1a2b3c4d``) into -clickable hyperlinks. +clickable hyperlinks. Hovered items are highlighted with the same discreet +background color whatever the Qt style, in light and dark modes. .. autoclass:: ClickableTitleDelegate + +.. autofunction:: hover_background_color """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... @@ -37,6 +40,37 @@ TITLE_REFERENCE_ROLE = QC.Qt.UserRole + 1 TITLE_META_FONT_SIZE = "90%" TITLE_ROW_VERTICAL_PADDING = 2 +#: Background colors of hovered (unselected) items, in light and dark modes +HOVER_BACKGROUND_LIGHT = "#e5f3ff" +HOVER_BACKGROUND_DARK = "#2d3138" + + +def hover_background_color(palette: QG.QPalette) -> QG.QColor: + """Return the background color of hovered (unselected) items. + + The color is discreet but distinct from the base, alternate base and + selection colors, in light and dark modes. It does not depend on the Qt + style, because some styles (e.g. Fusion, used in dark mode) do not + highlight hovered items. + + Args: + palette: palette of the view. + + Returns: + Background color. + """ + dark = palette.color(QG.QPalette.Base).lightness() < 128 + return QG.QColor(HOVER_BACKGROUND_DARK if dark else HOVER_BACKGROUND_LIGHT) + + +def _apply_hover_background(opt: QW.QStyleOptionViewItem) -> None: + """Replace the native highlight of a hovered, unselected item by the + background color returned by :func:`hover_background_color`.""" + if opt.state & QW.QStyle.State_MouseOver and not ( + opt.state & QW.QStyle.State_Selected + ): + opt.state &= ~QW.QStyle.State_MouseOver + opt.backgroundBrush = QG.QBrush(hover_background_color(opt.palette)) def _build_html( @@ -118,6 +152,10 @@ class ClickableTitleDelegate(QW.QStyledItemDelegate): Hit-testing is performed by :meth:`anchor_at`, which is meant to be called from the host view's ``mousePressEvent`` / ``mouseMoveEvent``. + + Hovered, unselected items are highlighted with :func:`hover_background_color` + (the host view must set the ``Qt.WA_Hover`` attribute of its viewport, so that + hovered items are flagged by the view whatever the Qt style). """ def __init__( @@ -127,6 +165,10 @@ def __init__( ) -> None: super().__init__(parent) self.reference_resolver = reference_resolver + #: Optional callback returning, for a model index, the viewport X + #: coordinate beyond which its rich title must not be drawn (e.g. to + #: leave room for a button drawn by the view over the item), or None. + self.text_clip_provider: Callable[[QC.QModelIndex], int | None] | None = None # pylint: disable=invalid-name def paint( @@ -141,7 +183,9 @@ def paint( if not isinstance(text, str) or not ( find_title_references(text) or isinstance(item_reference, str) ): - super().paint(painter, option, index) + opt = QW.QStyleOptionViewItem(option) + _apply_hover_background(opt) + super().paint(painter, opt, index) return opt = QW.QStyleOptionViewItem(option) self.initStyleOption(opt, index) @@ -169,6 +213,7 @@ def paint( ) text_color = palette.color(group, QG.QPalette.Text) meta_color = QG.QColor("#aeb8c4" if dark else "#59636e") + _apply_hover_background(opt) # Let the style draw the background, focus rect and decoration # (icon), but not the text. opt.text = "" @@ -192,13 +237,18 @@ def paint( meta_color, ) doc.setTextWidth(text_rect.width()) + clip_width = text_rect.width() + if self.text_clip_provider is not None: + clip_right = self.text_clip_provider(index) + if clip_right is not None: + clip_width = max(0, min(clip_width, clip_right - text_rect.left())) painter.save() painter.translate(text_rect.topLeft()) ctx = QG.QAbstractTextDocumentLayout.PaintContext() # Unformatted fragments do not inherit the document's body CSS. ctx.palette = QG.QPalette(palette) ctx.palette.setColor(QG.QPalette.Text, text_color) - clip = QC.QRectF(0, 0, text_rect.width(), text_rect.height()) + clip = QC.QRectF(0, 0, clip_width, text_rect.height()) ctx.clip = clip painter.setClipRect(clip) doc.documentLayout().draw(painter, ctx) diff --git a/doc/features/common/overview.rst b/doc/features/common/overview.rst index 51449fb84..5287ddb48 100644 --- a/doc/features/common/overview.rst +++ b/doc/features/common/overview.rst @@ -27,6 +27,19 @@ self-explanatory. The main window is divided into two main areas: DataLab main window, at startup. +In the list of data sets, the signal, image or group under the mouse cursor is +highlighted, and a |delete| delete button appears at its right. Clicking this +button deletes the item, or the whole selection if the item is part of it, after +confirmation. Deleting an item which is not selected keeps the current selection +unchanged. Like the "Remove" action of the "Edit" menu, the deletion is recorded +in the :ref:`historypanel`. The button may be hidden in the :ref:`settings` +("Visualization" tab, "Common" sub-tab). + +.. |delete| image:: ../../../datalab/data/icons/edit/delete.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + Additional panels are available as dockable widgets, and may be shown or hidden from the "View" menu: the :ref:`welcome_page`, the :ref:`historypanel`, the Macro panel (see :ref:`about_macros`) and the :ref:`ai_assistant`. diff --git a/doc/features/common/settings.rst b/doc/features/common/settings.rst index 81b8960a6..ad0bcd09d 100644 --- a/doc/features/common/settings.rst +++ b/doc/features/common/settings.rst @@ -231,6 +231,13 @@ The Common sub-tab contains settings that apply to all visualizations: **Ignore title insertion message** Suppress the information message when inserting an object title as an annotation label. +**Show delete button when hovering list items** + When enabled (default), a delete button appears at the right of the signal, + image or group under the mouse cursor in the signal and image lists (see + :ref:`overview`). Disable this option to hide the button: items can still be + deleted with the "Remove" action of the "Edit" menu, or with the + :kbd:`Delete` key. + Signals ^^^^^^^ diff --git a/doc/locale/fr/LC_MESSAGES/features/common/overview.po b/doc/locale/fr/LC_MESSAGES/features/common/overview.po index 319097d2d..ea1946769 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/overview.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/overview.po @@ -33,6 +33,12 @@ msgstr "La zone de droite affiche la visualisation du jeu de données actuelleme msgid "DataLab main window, at startup." msgstr "Fenêtre principale de DataLab, au démarrage." +msgid "In the list of data sets, the signal, image or group under the mouse cursor is highlighted, and a |delete| delete button appears at its right. Clicking this button deletes the item, or the whole selection if the item is part of it, after confirmation. Deleting an item which is not selected keeps the current selection unchanged. Like the \"Remove\" action of the \"Edit\" menu, the deletion is recorded in the :ref:`historypanel`. The button may be hidden in the :ref:`settings` (\"Visualization\" tab, \"Common\" sub-tab)." +msgstr "Dans la liste des jeux de données, le signal, l'image ou le groupe situé sous le curseur de la souris est mis en évidence, et un bouton de suppression |delete| apparaît à sa droite. Un clic sur ce bouton supprime l'élément, ou toute la sélection si l'élément en fait partie, après confirmation. Supprimer un élément non sélectionné laisse la sélection courante inchangée. Comme l'action « Supprimer » du menu « Édition », la suppression est enregistrée dans le :ref:`historypanel`. Le bouton peut être masqué dans les :ref:`settings` (onglet « Visualisation », sous-onglet « Commun »)." + +msgid "delete" +msgstr "delete" + msgid "Additional panels are available as dockable widgets, and may be shown or hidden from the \"View\" menu: the :ref:`welcome_page`, the :ref:`historypanel`, the Macro panel (see :ref:`about_macros`) and the :ref:`ai_assistant`." msgstr "D'autres panneaux sont disponibles sous forme de widgets ancrables, et peuvent être affichés ou masqués depuis le menu « Affichage » : la :ref:`welcome_page`, le :ref:`historypanel`, le panneau de macros (voir :ref:`about_macros`) et l':ref:`ai_assistant`." diff --git a/doc/locale/fr/LC_MESSAGES/features/common/settings.po b/doc/locale/fr/LC_MESSAGES/features/common/settings.po index 6d038e3f1..dd59608f8 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/settings.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/settings.po @@ -301,6 +301,12 @@ msgstr "**Ignorer le message d'insertion de titre**" msgid "Suppress the information message when inserting an object title as an annotation label." msgstr "Supprimer le message d'information lors de l'insertion d'un titre d'objet comme étiquette d'annotation." +msgid "**Show delete button when hovering list items**" +msgstr "**Afficher un bouton de suppression au survol des éléments des listes**" + +msgid "When enabled (default), a delete button appears at the right of the signal, image or group under the mouse cursor in the signal and image lists (see :ref:`overview`). Disable this option to hide the button: items can still be deleted with the \"Remove\" action of the \"Edit\" menu, or with the :kbd:`Delete` key." +msgstr "Lorsque cette option est activée (par défaut), un bouton de suppression apparaît à droite du signal, de l'image ou du groupe situé sous le curseur de la souris, dans les listes de signaux et d'images (voir :ref:`overview`). Désactivez cette option pour masquer le bouton : les éléments peuvent toujours être supprimés avec l'action « Supprimer » du menu « Édition », ou avec la touche :kbd:`Suppr`." + msgid "Signals" msgstr "Signaux" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index 8c67e1a26..4214b2d27 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -281,6 +281,27 @@ msgstr "Lorsqu'un objet source est supprimé, sa référence est affichée sous msgid "Titles of workspaces saved by previous versions keep their former `s001`-style references as plain text" msgstr "Les titres des espaces de travail enregistrés par les versions précédentes conservent leurs anciennes références de type `s001` sous forme de texte simple" +msgid "**Signal and image lists:**" +msgstr "**Listes de signaux et d'images :**" + +msgid "Signals, images and groups can now be deleted directly from the lists, as in DataLab-Web (implements [Issue #449](https://github.com/DataLab-Platform/DataLab/issues/449))." +msgstr "Les signaux, images et groupes peuvent désormais être supprimés directement depuis les listes, comme dans DataLab-Web (implémente [l'issue #449](https://github.com/DataLab-Platform/DataLab/issues/449))." + +msgid "The item under the mouse cursor is subtly highlighted, in light and dark modes alike (the hovered item was not highlighted at all in dark mode)" +msgstr "L'élément situé sous le curseur de la souris est discrètement mis en évidence, en mode clair comme en mode sombre (l'élément survolé n'était pas du tout mis en évidence en mode sombre)" + +msgid "A delete button appears at the right of the hovered signal, image or group: it turns red when the mouse cursor is over it, and its tooltip tells what it deletes" +msgstr "Un bouton de suppression apparaît à droite du signal, de l'image ou du groupe survolé : il devient rouge lorsque le curseur de la souris le survole, et son info-bulle indique ce qu'il supprime" + +msgid "Clicking it deletes the item, or the whole selection when the item is part of it, after confirmation. Deleting an item which is not selected keeps the current selection and the displayed objects unchanged" +msgstr "Un clic sur ce bouton supprime l'élément, ou toute la sélection lorsque l'élément en fait partie, après confirmation. Supprimer un élément non sélectionné laisse inchangés la sélection courante et les objets affichés" + +msgid "As with the **Remove** action, the deletion is recorded in the History panel" +msgstr "Comme avec l'action **Supprimer**, la suppression est enregistrée dans le panneau Historique" + +msgid "The button may be hidden with the new **Show delete button when hovering list items** setting (Visualization > Common)" +msgstr "Le bouton peut être masqué avec le nouveau paramètre **Afficher un bouton de suppression au survol des éléments des listes** (Visualisation > Commun)" + msgid "**Curve fitting formulas:**" msgstr "**Formules des modèles d'ajustement :**" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index af6ff08ea..219d72e2a 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -112,6 +112,16 @@ DataLab now uses stable UUID-based object references across object trees, result * When a source object is deleted, its reference falls back to its 8-character UUID instead of becoming an anonymous placeholder * Titles of workspaces saved by previous versions keep their former `s001`-style references as plain text +**Signal and image lists:** + +Signals, images and groups can now be deleted directly from the lists, as in DataLab-Web (implements [Issue #449](https://github.com/DataLab-Platform/DataLab/issues/449)). + +* The item under the mouse cursor is subtly highlighted, in light and dark modes alike (the hovered item was not highlighted at all in dark mode) +* A delete button appears at the right of the hovered signal, image or group: it turns red when the mouse cursor is over it, and its tooltip tells what it deletes +* Clicking it deletes the item, or the whole selection when the item is part of it, after confirmation. Deleting an item which is not selected keeps the current selection and the displayed objects unchanged +* As with the **Remove** action, the deletion is recorded in the History panel +* The button may be hidden with the new **Show delete button when hovering list items** setting (Visualization > Common) + **Curve fitting formulas:** * Interactive fitting dialogs now show the formula of the fitted model below the fit parameters, and each parameter label shows its symbol in the formula (e.g. "Scale factor (x₀)"): the parameters stored in the fitted signal metadata can thus be related to the model without reading the source code. The *Fitting* documentation also gives, for each model, the name under which each parameter is stored (fixes [Issue #400](https://github.com/DataLab-Platform/DataLab/issues/400))