From 4afd215f336ee74125c28bdf2aebaddc67161826 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:32:41 +0200 Subject: [PATCH 1/2] feat(applications): collapsible list and method accordion Assisted-by: Claude Opus 5.5 --- datalab/config/options.py | 8 + datalab/gui/applications.py | 203 +++++++++++++----- datalab/locale/fr/LC_MESSAGES/datalab.po | 12 ++ .../plugins/applications_dialog_test.py | 74 +++++++ doc/features/common/overview.rst | 2 +- .../LC_MESSAGES/features/common/overview.po | 4 +- .../LC_MESSAGES/release_notes/release_1.04.po | 6 + doc/release_notes/release_1.04.md | 2 + 8 files changed, 259 insertions(+), 52 deletions(-) diff --git a/datalab/config/options.py b/datalab/config/options.py index 6ba9f8d5d..94e0695e6 100644 --- a/datalab/config/options.py +++ b/datalab/config/options.py @@ -190,6 +190,14 @@ def __init__(self) -> None: expected_type=list, description="IDs of the recently used applications, most recent first.", ) + self.applications_list_collapsed = TypedOptionField( + self, + "applications_list_collapsed", + category="main", + default=False, + expected_type=bool, + description="If True, hide the application list of the catalog.", + ) self.v020_plugins_warning_ignore = TypedOptionField( self, "v020_plugins_warning_ignore", diff --git a/datalab/gui/applications.py b/datalab/gui/applications.py index 0a501db50..4ef227595 100644 --- a/datalab/gui/applications.py +++ b/datalab/gui/applications.py @@ -396,14 +396,17 @@ def _command_button(icon_name: str, text: str) -> QW.QPushButton: return button -def _section_label(text: str) -> QW.QLabel: - """Create the title of a catalog page section.""" - label = QW.QLabel(text) - font = label.font() - font.setBold(True) - font.setPointSize(font.pointSize() + 1) - label.setFont(font) - return label +def _status_icon(color: str) -> QG.QIcon: + """Return a round status icon of the given color.""" + pixmap = QG.QPixmap(32, 32) + pixmap.fill(QC.Qt.transparent) + painter = QG.QPainter(pixmap) + painter.setRenderHint(QG.QPainter.Antialiasing) + painter.setPen(QC.Qt.NoPen) + painter.setBrush(QG.QColor(color)) + painter.drawEllipse(6, 6, 20, 20) + painter.end() + return QG.QIcon(pixmap) def _entry_layout( @@ -429,13 +432,15 @@ def _entry_layout( return layout -class RecipeCard(QW.QFrame): +class RecipeCard(QW.QWidget): """Present one recipe: expected inputs, readiness, and dedicated examples.""" #: Emitted with the recipe ID when running the recipe on the selection run_requested = QC.Signal(str) #: Emitted with the example ID and the recipe ID when trying an example try_example_requested = QC.Signal(str, str) + #: Emitted when the shown readiness changes + readiness_changed = QC.Signal() READINESS_COLORS = { RecipeReadinessStatus.READY: "#2e7d32", @@ -454,15 +459,13 @@ def __init__( super().__init__(parent) self.recipe = recipe self.readiness: RecipeReadiness | None = None - self.setFrameShape(QW.QFrame.StyledPanel) + self.status_color = self.READINESS_COLORS[RecipeReadinessStatus.NO_INPUT] + self.status_summary = "" layout = QW.QVBoxLayout(self) - title = QW.QLabel( - f"{html.escape(recipe.title)}  " - f"v{html.escape(recipe.version)}" - ) - title.setTextFormat(QC.Qt.RichText) - layout.addWidget(title) + version = QW.QLabel(_("Version: %s") % recipe.version) + apply_subdued_color(version) + layout.addWidget(version) if recipe.description: description = QW.QLabel(recipe.description) description.setWordWrap(True) @@ -505,6 +508,7 @@ def __init__( ) self.example_buttons[example.id] = button layout.addLayout(_entry_layout(example.title, example.description, button)) + layout.addStretch() def set_readiness( self, readiness: RecipeReadiness | None, error: str | None = None @@ -529,6 +533,9 @@ def set_readiness( f"  • {html.escape(reason)}" for reason in reasons ) self.readiness_label.setText(text) + self.status_color = color + self.status_summary = summary + self.readiness_changed.emit() class ApplicationPage(QW.QWidget): @@ -557,6 +564,7 @@ def __init__(self, plugin: PluginBase, parent: QW.QWidget | None = None): self.recipe_cards: dict[str, RecipeCard] = {} self.tool_buttons: dict[str, QW.QPushButton] = {} self.dataset_buttons: dict[str, QW.QPushButton] = {} + self.toolbox = QW.QToolBox() self.documentation_button = _command_button( "libre-gui-help.svg", _("Documentation") ) @@ -579,18 +587,13 @@ def __init__(self, plugin: PluginBase, parent: QW.QWidget | None = None): layout.addWidget(metadata) layout.addLayout(self._create_welcome_layout()) - content = QW.QWidget() - content_layout = QW.QVBoxLayout(content) - content_layout.setContentsMargins(0, 0, 0, 0) - self._add_methods(content_layout) - self._add_tools(content_layout) - self._add_datasets(content_layout) - content_layout.addStretch() - scroll_area = QW.QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setFrameShape(QW.QFrame.NoFrame) - scroll_area.setWidget(content) - layout.addWidget(scroll_area, 1) + self._add_methods(layout) + self._add_tools() + self._add_datasets() + layout.addWidget(self.toolbox, 1) + if not self.toolbox.count(): + self.toolbox.hide() + layout.addStretch() layout.addWidget(self.status_label) bottom_layout = QW.QHBoxLayout() @@ -665,8 +668,7 @@ def showEvent(self, event: QG.QShowEvent) -> None: # pylint: disable=invalid-na super().showEvent(event) def _add_methods(self, layout: QW.QVBoxLayout) -> None: - """Add one card per recipe, with the examples designed for it.""" - layout.addWidget(_section_label(_("Methods"))) + """Add one item per recipe, with the examples designed for it.""" recipes = self.plugin.get_recipes() examples = self.plugin.get_examples() for recipe in recipes: @@ -686,20 +688,39 @@ def _add_methods(self, layout: QW.QVBoxLayout) -> None: self.plugin, example_id, recipe_id ) ) + card.readiness_changed.connect( + lambda card=card: self._show_readiness_of(card) + ) self.recipe_cards[recipe.recipe_id] = card - layout.addWidget(card) + self.toolbox.addItem(card, _status_icon(card.status_color), recipe.title) if not recipes: label = QW.QLabel(_("No methods declared")) apply_subdued_color(label) layout.addWidget(label) - def _add_tools(self, layout: QW.QVBoxLayout) -> None: + def _show_readiness_of(self, card: RecipeCard) -> None: + """Reflect the readiness of a recipe on its toolbox item.""" + index = self.toolbox.indexOf(card) + self.toolbox.setItemIcon(index, _status_icon(card.status_color)) + self.toolbox.setItemToolTip(index, card.status_summary) + + def _add_entries_item( + self, title: str, icon_name: str, layouts: list[QW.QHBoxLayout] + ) -> None: + """Add a toolbox item listing entries, if any.""" + if not layouts: + return + page = QW.QWidget() + page_layout = QW.QVBoxLayout(page) + for entry_layout in layouts: + page_layout.addLayout(entry_layout) + page_layout.addStretch() + self.toolbox.addItem(page, get_icon(icon_name), title) + + def _add_tools(self) -> None: """Add the plugin-owned tools, if any.""" tools = self.plugin.get_tools() - if not tools: - return - layout.addSpacing(8) - layout.addWidget(_section_label(_("Tools"))) + layouts = [] for tool in tools: button = _command_button("libre-toolbox.svg", _("Open tool")) button.clicked.connect( @@ -708,21 +729,21 @@ def _add_tools(self, layout: QW.QVBoxLayout) -> None: ) ) self.tool_buttons[tool.id] = button - layout.addLayout( + layouts.append( _entry_layout( tool.title, tool.description, button, get_plugin_icon(tool.icon) ) ) + self._add_entries_item( + _("Tools (%d)") % len(tools), "libre-toolbox.svg", layouts + ) - def _add_datasets(self, layout: QW.QVBoxLayout) -> None: + def _add_datasets(self) -> None: """Add the examples that are not designed for a specific recipe.""" datasets = [ example for example in self.plugin.get_examples() if not example.recipe_ids ] - if not datasets: - return - layout.addSpacing(8) - layout.addWidget(_section_label(_("Datasets"))) + layouts = [] for example in datasets: button = _command_button("io/fileopen_h5.svg", _("Open dataset")) button.clicked.connect( @@ -731,7 +752,10 @@ def _add_datasets(self, layout: QW.QVBoxLayout) -> None: ) ) self.dataset_buttons[example.id] = button - layout.addLayout(_entry_layout(example.title, example.description, button)) + layouts.append(_entry_layout(example.title, example.description, button)) + self._add_entries_item( + _("Datasets (%d)") % len(datasets), "io/fileopen_h5.svg", layouts + ) def refresh_readiness(self) -> None: """Assess every recipe on the current selection.""" @@ -761,6 +785,11 @@ class ApplicationsDialog(QW.QDialog): #: Emitted when an application is shown, hidden, pinned or used SIG_WELCOME_PREFERENCES_CHANGED = QC.Signal() + #: Minimum window width when the application list is shown + MINIMUM_WIDTH = 860 + CATALOG_MINIMUM_WIDTH = 260 + CATALOG_DEFAULT_WIDTH = 280 + def __init__(self, parent: QW.QWidget | None = None): super().__init__(parent) win32_fix_title_bar_background(self) @@ -770,10 +799,14 @@ def __init__(self, parent: QW.QWidget | None = None): self.application_list = QW.QListWidget() self.application_stack = QW.QStackedWidget() self.application_pages: list[ApplicationPage] = [] + self.catalog_widget = QW.QWidget() + self.list_toggle_button = QW.QToolButton() + self.__splitter = QW.QSplitter(QC.Qt.Horizontal) + self.__catalog_width = self.CATALOG_DEFAULT_WIDTH self.setWindowTitle(_("Applications")) self.setWindowIcon(get_icon("libre-gui-plugin.svg")) - self.setMinimumSize(860, 600) + self.setMinimumSize(self.MINIMUM_WIDTH, 600) self.__readiness_timer = QC.QTimer(self) self.__readiness_timer.setSingleShot(True) self.__readiness_timer.setInterval(150) @@ -782,21 +815,42 @@ def __init__(self, parent: QW.QWidget | None = None): self.search_edit.setClearButtonEnabled(True) _configure_catalog_list(self.application_list) - catalog = QW.QWidget() - catalog.setMinimumWidth(260) + catalog = self.catalog_widget + catalog.setMinimumWidth(self.CATALOG_MINIMUM_WIDTH) catalog.setMaximumWidth(360) catalog_layout = QW.QVBoxLayout(catalog) catalog_layout.setContentsMargins(0, 0, 0, 0) catalog_layout.addWidget(self.search_edit) catalog_layout.addWidget(self.application_list) + toggle = self.list_toggle_button + toggle.setFixedWidth(16) + toggle.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Expanding) + toggle.setStyleSheet( + "QToolButton { border: none; border-left: 1px solid palette(midlight);" + " border-right: 1px solid palette(midlight);" + " background: palette(alternate-base); }" + "QToolButton:hover { background: palette(midlight); }" + ) + font = toggle.font() + font.setPointSize(font.pointSize() + 8) + toggle.setFont(font) + toggle.clicked.connect(self.toggle_application_list) + pages = QW.QWidget() + pages_layout = QW.QHBoxLayout(pages) + pages_layout.setContentsMargins(0, 0, 0, 0) + pages_layout.setSpacing(0) + pages_layout.addWidget(toggle) + pages_layout.addWidget(self.application_stack, 1) + layout = QW.QVBoxLayout(self) - splitter = QW.QSplitter(QC.Qt.Horizontal) + splitter = self.__splitter splitter.addWidget(catalog) - splitter.addWidget(self.application_stack) + splitter.addWidget(pages) + splitter.setCollapsible(0, False) splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 1) - splitter.setSizes([280, 580]) + splitter.setSizes([self.CATALOG_DEFAULT_WIDTH, 580]) layout.addWidget(splitter, 1) button_box = QW.QDialogButtonBox(QW.QDialogButtonBox.Close) @@ -808,8 +862,59 @@ def __init__(self, parent: QW.QWidget | None = None): ) self.application_list.currentRowChanged.connect(self.schedule_readiness_update) self.search_edit.textChanged.connect(self.filter_applications) + self.set_application_list_visible(not Conf.applications_list_collapsed.get()) self.refresh() + def is_application_list_visible(self) -> bool: + """Return whether the application list is shown.""" + return not self.catalog_widget.isHidden() + + def toggle_application_list(self) -> None: + """Hide the application list if it is shown, show it otherwise.""" + self.set_application_list_visible(not self.is_application_list_visible()) + + def set_application_list_visible(self, visible: bool) -> None: + """Show or hide the application list, and remember this choice. + + The application page keeps its width: the window shrinks when the list + is hidden and grows back when the list is shown again. + + Args: + visible: True to show the application list, False to hide it + """ + if visible != self.is_application_list_visible(): + handle_width = self.__splitter.handleWidth() + width = self.width() + resize = self.isVisible() and not ( + self.isMaximized() or self.isFullScreen() + ) + if visible: + self.catalog_widget.show() + self.setMinimumWidth(self.MINIMUM_WIDTH) + if resize: + self.resize( + width + self.__catalog_width + handle_width, self.height() + ) + pages_width = self.__splitter.widget(1).width() + self.__splitter.setSizes([self.__catalog_width, pages_width]) + else: + if self.isVisible(): + self.__catalog_width = self.catalog_widget.width() + self.catalog_widget.hide() + self.setMinimumWidth(self.MINIMUM_WIDTH - self.CATALOG_MINIMUM_WIDTH) + if resize: + self.resize( + width - self.__catalog_width - handle_width, self.height() + ) + Conf.applications_list_collapsed.set(not visible) + if visible: + text, tooltip = "\u2039", _("Hide the application list") + else: + text, tooltip = "\u203a", _("Show the application list") + self.list_toggle_button.setText(text) + self.list_toggle_button.setToolTip(tooltip) + self.list_toggle_button.setAccessibleName(tooltip) + def refresh(self) -> None: """Rebuild the catalog from the currently registered plugins.""" self.application_list.clear() diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index f3fd13327..58a313c7a 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4539,3 +4539,15 @@ msgstr "Entrées attendues :" msgid "Running '%s'" msgstr "Exécution de '%s'" + +msgid "Hide the application list" +msgstr "Masquer la liste des applications" + +msgid "Show the application list" +msgstr "Afficher la liste des applications" + +msgid "Tools (%d)" +msgstr "Outils (%d)" + +msgid "Datasets (%d)" +msgstr "Jeux de données (%d)" diff --git a/datalab/tests/features/plugins/applications_dialog_test.py b/datalab/tests/features/plugins/applications_dialog_test.py index 99051cf33..38a25f6b4 100644 --- a/datalab/tests/features/plugins/applications_dialog_test.py +++ b/datalab/tests/features/plugins/applications_dialog_test.py @@ -160,6 +160,14 @@ def test_applications_dialog_filters_and_renders_declared_contracts() -> None: page = dialog.application_pages[0] assert list(page.recipe_cards) == [RECIPE.recipe_id] card = page.recipe_cards[RECIPE.recipe_id] + # Methods, tools and datasets are the items of a single accordion + toolbox = page.toolbox + assert [toolbox.itemText(index) for index in range(toolbox.count())] == [ + RECIPE.title, + "Tools (1)", + "Datasets (1)", + ] + assert toolbox.currentWidget() is card inputs_text = card.inputs_label.text() for text in ( "Flat frames", @@ -306,6 +314,16 @@ def test_application_page_shows_readiness_of_each_method() -> None: text = card.readiness_label.text() assert "cannot be analyzed" in text assert "Flat frames: metadata 'exposure_s' missing on 2" in text + # The method item shows the readiness without being opened + toolbox = dialog.application_pages[0].toolbox + assert "cannot be analyzed" in toolbox.itemToolTip(0) + center = toolbox.itemIcon(0).pixmap(32, 32).toImage().pixelColor(16, 16) + assert ( + center.name() + == applications_module.RecipeCard.READINESS_COLORS[ + RecipeReadinessStatus.NOT_READY + ] + ) def failing_assessment(_recipe_id: str) -> RecipeReadiness: raise RuntimeError("suggestion exploded") @@ -331,6 +349,62 @@ def failing_assessment(_recipe_id: str) -> RecipeReadiness: QW.QApplication.processEvents() +def test_applications_dialog_collapses_the_application_list() -> None: + """Hiding the list shrinks the window, keeps the page width and persists""" + qt_app = QW.QApplication.instance() or QW.QApplication([]) + camera = _plugin( + "org.example.camera", + "Camera Characterization", + frozenset({PluginCapability.APPLICATION}), + ) + registry = PluginRegistry.get_plugins() + previous_plugins = list(registry) + registry[:] = [camera] + previous_collapsed = Conf.applications_list_collapsed.get() + dialogs: list[ApplicationsDialog] = [] + try: + Conf.applications_list_collapsed.set(False) + dialog = ApplicationsDialog() + dialogs.append(dialog) + dialog.resize(1000, 650) + dialog.show() + QW.QApplication.processEvents() + width = dialog.width() + list_width = dialog.catalog_widget.width() + page_width = dialog.application_stack.width() + toggle = dialog.list_toggle_button + assert toggle.toolTip() == "Hide the application list" + + toggle.click() + QW.QApplication.processEvents() + assert not dialog.is_application_list_visible() + assert Conf.applications_list_collapsed.get() + assert toggle.toolTip() == "Show the application list" + assert dialog.width() < width - list_width + assert dialog.application_stack.width() == page_width + + toggle.click() + QW.QApplication.processEvents() + assert dialog.is_application_list_visible() + assert not Conf.applications_list_collapsed.get() + assert dialog.width() == width + assert dialog.catalog_widget.width() == list_width + assert dialog.application_stack.width() == page_width + + # The choice is restored by the next window + dialog.set_application_list_visible(False) + dialogs.append(ApplicationsDialog()) + assert not dialogs[-1].is_application_list_visible() + assert qt_app is not None + finally: + registry[:] = previous_plugins + Conf.applications_list_collapsed.set(previous_collapsed) + for dialog in dialogs: + dialog.close() + dialog.deleteLater() + QW.QApplication.processEvents() + + def test_welcome_application_preferences() -> None: """Welcome page order: pinned, then recently used, then by name""" plugins = [ diff --git a/doc/features/common/overview.rst b/doc/features/common/overview.rst index a9c66474d..ddf2d3393 100644 --- a/doc/features/common/overview.rst +++ b/doc/features/common/overview.rst @@ -47,7 +47,7 @@ A **Welcome** page is shown next to the signal and image views when DataLab star When application plugins are installed, an **Applications** section at the top of the page shows their tiles: clicking the main tile of an application opens its page in the **Applications** catalog, or starts the main action provided by the plugin. An application may provide other tiles, for example to open an example directly: they are shown next to its main tile when the section has room for them, and otherwise move to the menu of the main tile. This menu, opened from the "…" button of the main tile or by right-clicking it, also lets you pin the application to the top of the section or hide it from the welcome page. Pinned applications come first, then recently used ones, then the others by name. The section is limited to two rows of tiles by default (this number may be changed in the :ref:`settings`): when more applications are installed, the last tile gives the number of applications left out and opens the catalog. The "Browse all applications..." button also opens the catalog, where a search field filters applications by name or description, and where the "Show on welcome page" and "Pin to the welcome page" check boxes of each application change the same preferences (see :ref:`about_plugins`). -On the page of an application, the catalog presents each method with the inputs it expects and a status telling whether the current selection can be analyzed. "Run on selection..." starts the method, and asks you to assign the selected objects to its inputs only when needed. The examples designed for a method are listed below it: "Try with this example" opens the example and runs the method with suitable parameters. +On the page of an application, the methods are listed one below the other, followed by the tools and datasets of the application, if any: clicking a title opens its section and closes the one previously open. The colored dot before each method tells whether the current selection can be analyzed, and its tooltip gives the reason. The open method presents the inputs it expects and this status in detail. "Run on selection..." starts the method, and asks you to assign the selected objects to its inputs only when needed. The examples designed for a method are listed below it: "Try with this example" opens the example and runs the method with suitable parameters. The vertical strip between the application list and the page hides the list, or shows it again: the window shrinks or grows accordingly, and DataLab remembers this choice. The Welcome page is a regular dockable panel: as soon as a signal or an image is created or opened, the corresponding view is brought to the front, and the Welcome page remains available as a tab. It may be closed like any other panel, and reopened at any time from the "Welcome page" entry of the "?" (Help) menu or from the "View" menu; it is also reopened automatically when the current panel becomes empty. The "Show welcome page when the current panel is empty" check box, at the bottom of the page, controls this automatic display, including at startup (this option is also available in the :ref:`settings`). diff --git a/doc/locale/fr/LC_MESSAGES/features/common/overview.po b/doc/locale/fr/LC_MESSAGES/features/common/overview.po index f9bde77e8..4db324674 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/overview.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/overview.po @@ -51,8 +51,8 @@ msgstr "interroger l':ref:`ai_assistant`, suivre la visite guidée, lancer la d msgid "When application plugins are installed, an **Applications** section at the top of the page shows their tiles: clicking the main tile of an application opens its page in the **Applications** catalog, or starts the main action provided by the plugin. An application may provide other tiles, for example to open an example directly: they are shown next to its main tile when the section has room for them, and otherwise move to the menu of the main tile. This menu, opened from the \"…\" button of the main tile or by right-clicking it, also lets you pin the application to the top of the section or hide it from the welcome page. Pinned applications come first, then recently used ones, then the others by name. The section is limited to two rows of tiles by default (this number may be changed in the :ref:`settings`): when more applications are installed, the last tile gives the number of applications left out and opens the catalog. The \"Browse all applications...\" button also opens the catalog, where a search field filters applications by name or description, and where the \"Show on welcome page\" and \"Pin to the welcome page\" check boxes of each application change the same preferences (see :ref:`about_plugins`)." msgstr "Lorsque des plugins d'application sont installés, une section **Applications** en haut de la page affiche leurs tuiles : un clic sur la tuile principale d'une application ouvre sa page dans le catalogue **Applications**, ou lance l'action principale fournie par le plugin. Une application peut proposer d'autres tuiles, par exemple pour ouvrir directement un exemple : elles sont affichées à côté de sa tuile principale lorsque la section a la place nécessaire, et passent sinon dans le menu de la tuile principale. Ce menu, ouvert depuis le bouton « … » de la tuile principale ou par un clic droit sur celle-ci, permet aussi d'épingler l'application en tête de la section ou de la masquer de la page d'accueil. Les applications épinglées viennent en premier, puis celles utilisées récemment, puis les autres par ordre alphabétique. La section est limitée par défaut à deux lignes de tuiles (ce nombre peut être modifié dans les :ref:`settings`) : lorsque davantage d'applications sont installées, la dernière tuile indique le nombre d'applications non affichées et ouvre le catalogue. Le bouton « Parcourir toutes les applications... » ouvre également le catalogue, où un champ de recherche filtre les applications par nom ou par description, et où les cases à cocher « Afficher sur la page d'accueil » et « Épingler sur la page d'accueil » de chaque application modifient ces mêmes préférences (voir :ref:`about_plugins`)." -msgid "On the page of an application, the catalog presents each method with the inputs it expects and a status telling whether the current selection can be analyzed. \"Run on selection...\" starts the method, and asks you to assign the selected objects to its inputs only when needed. The examples designed for a method are listed below it: \"Try with this example\" opens the example and runs the method with suitable parameters." -msgstr "Sur la page d'une application, le catalogue présente chaque méthode avec les entrées qu'elle attend et un statut indiquant si la sélection courante peut être analysée. « Exécuter sur la sélection... » lance la méthode et ne vous demande d'affecter les objets sélectionnés à ses entrées qu'en cas de besoin. Les exemples conçus pour une méthode sont listés en dessous : « Essayer avec cet exemple » ouvre l'exemple et exécute la méthode avec des paramètres adaptés." +msgid "On the page of an application, the methods are listed one below the other, followed by the tools and datasets of the application, if any: clicking a title opens its section and closes the one previously open. The colored dot before each method tells whether the current selection can be analyzed, and its tooltip gives the reason. The open method presents the inputs it expects and this status in detail. \"Run on selection...\" starts the method, and asks you to assign the selected objects to its inputs only when needed. The examples designed for a method are listed below it: \"Try with this example\" opens the example and runs the method with suitable parameters. The vertical strip between the application list and the page hides the list, or shows it again: the window shrinks or grows accordingly, and DataLab remembers this choice." +msgstr "Sur la page d'une application, les méthodes sont listées les unes sous les autres, suivies le cas échéant des outils et des jeux de données de l'application : cliquer sur un titre ouvre sa section et referme celle qui était ouverte. La pastille de couleur placée devant chaque méthode indique si la sélection courante peut être analysée, et son infobulle en donne la raison. La méthode ouverte présente les entrées qu'elle attend et ce statut en détail. « Exécuter sur la sélection... » lance la méthode et ne vous demande d'affecter les objets sélectionnés à ses entrées qu'en cas de besoin. Les exemples conçus pour une méthode sont listés en dessous : « Essayer avec cet exemple » ouvre l'exemple et exécute la méthode avec des paramètres adaptés. La bande verticale située entre la liste des applications et la page masque la liste, ou l'affiche à nouveau : la fenêtre se réduit ou s'agrandit en conséquence, et DataLab mémorise ce choix." msgid "The Welcome page is a regular dockable panel: as soon as a signal or an image is created or opened, the corresponding view is brought to the front, and the Welcome page remains available as a tab. It may be closed like any other panel, and reopened at any time from the \"Welcome page\" entry of the \"?\" (Help) menu or from the \"View\" menu; it is also reopened automatically when the current panel becomes empty. The \"Show welcome page when the current panel is empty\" check box, at the bottom of the page, controls this automatic display, including at startup (this option is also available in the :ref:`settings`)." msgstr "La page d'accueil est un panneau ancrable comme les autres : dès qu'un signal ou une image est créé ou ouvert, la vue correspondante passe au premier plan, et la page d'accueil reste disponible sous forme d'onglet. Elle peut être fermée comme n'importe quel autre panneau, et rouverte à tout moment depuis l'entrée « Page d'accueil » du menu « ? » (Aide) ou depuis le menu « Affichage » ; elle est aussi rouverte automatiquement lorsque le panneau courant devient vide. La case à cocher « Afficher la page d'accueil lorsque le panneau courant est vide », en bas de la page, détermine cet affichage automatique, y compris au démarrage (cette option est également disponible dans les :ref:`settings`)." 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 c4d77c466..e1b3c21a3 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 @@ -81,6 +81,12 @@ msgstr "Les développeurs de plugins décrivent les entrées des recettes de man msgid "The catalog is non-modal and remains open after starting a method or opening an example, so users may continue interacting with the DataLab workspace" msgstr "Le catalogue est non modal et reste ouvert après le lancement d'une méthode ou l'ouverture d'un exemple, de sorte que l'utilisateur peut continuer à interagir avec l'espace de travail DataLab" +msgid "The methods, tools and datasets of an application form an accordion: one section is open at a time, and a colored dot tells, without opening it, whether each method can run on the current selection" +msgstr "Les méthodes, outils et jeux de données d'une application forment un accordéon : une seule section est ouverte à la fois, et une pastille de couleur indique, sans l'ouvrir, si chaque méthode peut s'exécuter sur la sélection courante" + +msgid "The application list of the catalog may be hidden with the strip separating it from the application page: the window shrinks accordingly, and the choice is remembered" +msgstr "La liste des applications du catalogue peut être masquée à l'aide de la bande qui la sépare de la page de l'application : la fenêtre se réduit en conséquence, et ce choix est mémorisé" + msgid "Completed application methods select their last generated object, consistently with standard DataLab processing" msgstr "Les méthodes d'application terminées sélectionnent le dernier objet qu'elles ont généré, de manière cohérente avec les traitements standard de DataLab" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index 75b313961..eea33dd61 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -31,6 +31,8 @@ * Application plugins may also list tools, such as wizards or editors, in the catalog * Plugin developers describe recipe inputs declaratively (titles, minimum counts, metadata requirements) and may add binding suggestions and fast input checks; every recipe then runs through DataLab's generic launcher, without plugin-specific input dialogs * The catalog is non-modal and remains open after starting a method or opening an example, so users may continue interacting with the DataLab workspace +* The methods, tools and datasets of an application form an accordion: one section is open at a time, and a colored dot tells, without opening it, whether each method can run on the current selection +* The application list of the catalog may be hidden with the strip separating it from the application page: the window shrinks accordingly, and the choice is remembered * Completed application methods select their last generated object, consistently with standard DataLab processing * Application plugins add tiles to a new **Applications** section of the welcome page: by default, one tile per plugin opens its page in the catalog, and plugins may declare additional tiles, for example to open an example directly. Additional tiles are shown when there is room for them, and otherwise move to the menu of the main tile * The welcome page stays tidy when many applications are installed: tiles are limited to a configurable number of rows (two by default), with a last tile opening the catalog for the others. Applications may be pinned to the top or hidden, and recently used ones come first; the catalog offers a search field and the same welcome page options From 8b6af3d28d98a62ed5dec08bdd18674ff82dc897 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:32:52 +0200 Subject: [PATCH 2/2] feat(welcome): align the Desktop welcome page with DataLab-Web Assisted-by: Claude Opus 5.5 --- datalab/gui/welcome.py | 193 ++++++++++++++++------- datalab/locale/fr/LC_MESSAGES/datalab.po | 6 + 2 files changed, 145 insertions(+), 54 deletions(-) diff --git a/datalab/gui/welcome.py b/datalab/gui/welcome.py index c1c2ad53d..2b5e9e12f 100644 --- a/datalab/gui/welcome.py +++ b/datalab/gui/welcome.py @@ -31,7 +31,7 @@ from qtpy import QtWidgets as QW import datalab -from datalab.config import APP_DESC, Conf, _ +from datalab.config import Conf, _ from datalab.gui.actionhandler import ActionCategory from datalab.gui.applications import ( get_application_plugins, @@ -42,6 +42,7 @@ sort_welcome_applications, ) from datalab.utils.qthelpers import qt_handle_error_message, try_or_log_error +from datalab.widgets.expandabletext import apply_subdued_color if TYPE_CHECKING: from datalab.gui.main import DLMainWindow @@ -57,21 +58,29 @@ TILE_SPACING = 12 #: Size of an application tile icon TILE_ICON_SIZE = 48 +#: Size of an action entry icon +ENTRY_ICON_SIZE = 24 ENTRY_STYLESHEET = """ QFrame#welcome_entry { border: 1px solid transparent; - border-radius: 4px; + border-radius: 6px; } -QFrame#welcome_tile { +QFrame#welcome_tile, QFrame#welcome_card { border: 1px solid palette(mid); border-radius: 6px; } +QFrame#welcome_card { + background-color: palette(alternate-base); +} QFrame#welcome_entry:hover, QFrame#welcome_entry:focus, QFrame#welcome_tile:hover, QFrame#welcome_tile:focus { border-color: palette(highlight); background-color: palette(alternate-base); } +QFrame#welcome_card:hover, QFrame#welcome_card:focus { + border-color: palette(highlight); +} """ @@ -98,6 +107,8 @@ class WelcomeEntry(QW.QFrame): larger icon above its title menu: if True (tile only), add a button requesting a menu of actions, also available from the context menu + card: if True, show the entry as a framed card + caret: if True, show a caret telling that the entry opens a menu """ SIG_CLICKED = QC.Signal() @@ -112,9 +123,14 @@ def __init__( parent: QW.QWidget | None = None, tile: bool = False, menu: bool = False, + card: bool = False, + caret: bool = False, ) -> None: super().__init__(parent) - self.setObjectName("welcome_tile" if tile else "welcome_entry") + if tile: + self.setObjectName("welcome_tile") + else: + self.setObjectName("welcome_card" if card else "welcome_entry") self.setStyleSheet(ENTRY_STYLESHEET) self.setAttribute(QC.Qt.WA_Hover) self.setFocusPolicy(QC.Qt.StrongFocus) @@ -125,7 +141,7 @@ def __init__( if isinstance(icon, str): icon = get_icon(icon) - icon_size = TILE_ICON_SIZE if tile else 32 + icon_size = TILE_ICON_SIZE if tile else ENTRY_ICON_SIZE self.icon_label = QW.QLabel() self.icon_label.setPixmap(icon.pixmap(icon_size, icon_size)) self.title_label = QW.QLabel(title) @@ -165,11 +181,22 @@ def __init__( layout.addLayout(text_layout) layout.addStretch(1) else: + apply_subdued_color(description_label) layout = QW.QHBoxLayout(self) - layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(10) + if card: + layout.setContentsMargins(14, 12, 14, 12) + else: + layout.setContentsMargins(10, 8, 10, 8) + layout.setSpacing(12) layout.addWidget(self.icon_label, 0, QC.Qt.AlignTop) layout.addLayout(text_layout, 1) + if caret: + caret_label = QW.QLabel("\u25be") + caret_font = caret_label.font() + caret_font.setPointSizeF(caret_font.pointSizeF() * 1.4) + caret_label.setFont(caret_font) + apply_subdued_color(caret_label) + layout.addWidget(caret_label, 0, QC.Qt.AlignVCenter) def set_title(self, title: str) -> None: """Change the entry title @@ -369,6 +396,7 @@ def __init__( get_icon("libre-gui-plugin.svg"), _("Browse all applications...") ) self.start_title = self.__create_section_title("") + self.header_layout = QW.QBoxLayout(QW.QBoxLayout.LeftToRight) self.columns_layout = QW.QBoxLayout(QW.QBoxLayout.LeftToRight) self.startup_checkbox = QW.QCheckBox( _("Show welcome page when the current panel is empty") @@ -379,12 +407,14 @@ def __init__( @staticmethod def __create_section_title(text: str) -> QW.QLabel: - """Create a section title label""" + """Create a small uppercase section title label""" label = QW.QLabel(text) font = label.font() - font.setPointSizeF(font.pointSizeF() * 1.3) font.setBold(True) + font.setCapitalization(QG.QFont.AllUppercase) + font.setLetterSpacing(QG.QFont.PercentageSpacing, 104) label.setFont(font) + apply_subdued_color(label) return label def __add_entry( @@ -395,9 +425,13 @@ def __add_entry( title: str, description: str, callback: Callable[[], None], + **kwargs: bool, ) -> None: - """Create an entry, register it under ``key`` and add it to ``layout``""" - entry = WelcomeEntry(icon_name, title, description, self) + """Create an entry, register it under ``key`` and add it to ``layout`` + + Keyword arguments (``card``, ``caret``) are passed to the entry. + """ + entry = WelcomeEntry(icon_name, title, description, self, **kwargs) entry.SIG_CLICKED.connect(callback) self.entries[key] = entry layout.addWidget(entry) @@ -551,62 +585,106 @@ def __launch_tile(self, plugin: PluginBase, tile: WelcomeTile) -> None: self.mainwindow, exc, _("Launching '%s'") % tile.title ) - def __setup_ui(self) -> None: - """Setup welcome page widgets""" + def __setup_header(self) -> None: + """Setup the header: logo, slogan with version, and edition""" logo = QW.QLabel() logo.setPixmap(QG.QPixmap(get_image_file_path("DataLab-Banner-200.png"))) - description = QW.QLabel(APP_DESC) - description.setWordWrap(True) - version = QW.QLabel(_("Version %s") % datalab.__version__) + slogan = QW.QLabel( + _("Scientific signal and image processing \u2014 version %s") + % datalab.__version__ + ) + slogan.setWordWrap(True) + font = slogan.font() + font.setPointSizeF(font.pointSizeF() * 1.15) + slogan.setFont(font) + apply_subdued_color(slogan) + edition = QW.QLabel(_("Desktop edition of DataLab")) + font = edition.font() + font.setItalic(True) + font.setPointSizeF(font.pointSizeF() * 0.9) + edition.setFont(font) + apply_subdued_color(edition) + title_layout = QW.QVBoxLayout() + title_layout.setSpacing(2) + title_layout.addStretch(1) + title_layout.addWidget(slogan) + title_layout.addWidget(edition) + title_layout.addStretch(1) + self.header_layout.setSpacing(18) + self.header_layout.addWidget(logo, 0, QC.Qt.AlignLeft | QC.Qt.AlignVCenter) + self.header_layout.addLayout(title_layout, 1) + + def __setup_ui(self) -> None: + """Setup welcome page widgets""" + self.__setup_header() mw = self.mainwindow start_layout = QW.QVBoxLayout() + start_layout.setSpacing(6) start_layout.addWidget(self.start_title) - for args in ( + start_layout.addSpacing(4) + for args, caret in ( ( - "create", - "new_sig.svg", - _("Create..."), - _("Generate a 1D signal or 2D image from a template."), - self.__popup_create_menu, + ( + "create", + "new_sig.svg", + _("Create..."), + _("Generate a 1D signal or 2D image from a template."), + self.__popup_create_menu, + ), + True, ), ( - "open", - "fileopen_sig.svg", - _("Open file..."), - _("Load a signal or image from your computer."), - self.__open_files, + ( + "open", + "fileopen_sig.svg", + _("Open file..."), + _("Load a signal or image from your computer."), + self.__open_files, + ), + True, ), ( - "browse_h5", - "h5browser.svg", - _("Browse HDF5 file..."), - _( - "Inspect any HDF5 file and import selected datasets " - "as signals or images." + ( + "browse_h5", + "h5browser.svg", + _("Browse HDF5 file..."), + _( + "Inspect any HDF5 file and import selected datasets " + "as signals or images." + ), + mw.browseh5_action.trigger, ), - mw.browseh5_action.trigger, + False, ), ( - "open_h5", - "fileopen_h5.svg", - _("Open HDF5 workspace..."), - _("Resume a previously saved DataLab workspace."), - mw.openh5_action.trigger, + ( + "open_h5", + "fileopen_h5.svg", + _("Open HDF5 workspace..."), + _("Resume a previously saved DataLab workspace."), + mw.openh5_action.trigger, + ), + False, ), ( - "import_text", - "import_text.svg", - _("Import text data..."), - _("Bring in CSV / TSV / column data with the wizard."), - self.__import_text, + ( + "import_text", + "import_text.svg", + _("Import text data..."), + _("Bring in CSV / TSV / column data with the wizard."), + self.__import_text, + ), + True, ), ): - self.__add_entry(start_layout, *args) + self.__add_entry(start_layout, *args, caret=caret) start_layout.addStretch(1) learn_layout = QW.QVBoxLayout() + learn_layout.setSpacing(6) learn_layout.addWidget(self.__create_section_title(_("Walkthroughs"))) + learn_layout.addSpacing(4) for args in ( ( "ai_assistant", @@ -647,26 +725,32 @@ def __setup_ui(self) -> None: lambda: webbrowser.open(get_release_notes_url()), ), ): - self.__add_entry(learn_layout, *args) + self.__add_entry(learn_layout, *args, card=True) learn_layout.addStretch(1) - self.columns_layout.setSpacing(24) + self.columns_layout.setSpacing(28) self.columns_layout.addLayout(start_layout, 1) self.columns_layout.addLayout(learn_layout, 1) + separator = QW.QFrame() + separator.setObjectName("welcome_separator") + separator.setFixedHeight(1) + separator.setStyleSheet( + "QFrame#welcome_separator { background-color: palette(mid); }" + ) + content = QW.QWidget() content.setMaximumWidth(880) content_layout = QW.QVBoxLayout(content) content_layout.setContentsMargins(16, 24, 16, 16) content_layout.setSpacing(12) - content_layout.addWidget(logo) - content_layout.addWidget(description) - content_layout.addWidget(version) - content_layout.addSpacing(12) + content_layout.addLayout(self.header_layout) + content_layout.addSpacing(16) self.__setup_applications_section() content_layout.addWidget(self.applications_section) content_layout.addLayout(self.columns_layout) - content_layout.addSpacing(12) + content_layout.addSpacing(16) + content_layout.addWidget(separator) content_layout.addWidget(self.startup_checkbox) content_layout.addStretch(1) @@ -764,6 +848,7 @@ def resizeEvent(self, event: QG.QResizeEvent) -> None: # pylint: disable=invali direction = QW.QBoxLayout.TopToBottom else: direction = QW.QBoxLayout.LeftToRight - if self.columns_layout.direction() != direction: - self.columns_layout.setDirection(direction) + for layout in (self.header_layout, self.columns_layout): + if layout.direction() != direction: + layout.setDirection(direction) super().resizeEvent(event) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 58a313c7a..c224751ea 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4551,3 +4551,9 @@ msgstr "Outils (%d)" msgid "Datasets (%d)" msgstr "Jeux de données (%d)" + +msgid "Scientific signal and image processing — version %s" +msgstr "Traitement scientifique du signal et de l'image — version %s" + +msgid "Desktop edition of DataLab" +msgstr "Édition Desktop de DataLab"