Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions datalab/config/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions datalab/gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
318 changes: 308 additions & 10 deletions datalab/gui/objectview.py

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions datalab/gui/panel/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand All @@ -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] = []
Expand All @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions datalab/gui/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
39 changes: 39 additions & 0 deletions datalab/locale/fr/LC_MESSAGES/datalab.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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) ?"
Expand Down Expand Up @@ -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"

Expand Down
56 changes: 55 additions & 1 deletion datalab/tests/backbone/titledelegate_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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()
Loading
Loading