mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-07-25 12:42:58 +02:00
fix(ide-explorer): enhance file selection behavior across scripts and macros
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -6,6 +7,7 @@ from qtpy.QtCore import QModelIndex, QPoint, QSortFilterProxyModel, Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QAction,
|
||||
QApplication,
|
||||
QFileSystemModel,
|
||||
QInputDialog,
|
||||
QMenu,
|
||||
@@ -43,8 +45,24 @@ class FileBrowserTreeWidget(QWidget):
|
||||
file_delete_requested = Signal(str)
|
||||
file_renamed = Signal(str, str)
|
||||
|
||||
def __init__(self, parent=None, directory: str | None = None, read_only: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
directory: str | None = None,
|
||||
read_only: bool = False,
|
||||
name_filters: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
A file browser tree widget that displays files and directories in a tree view.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
directory: The initial directory to display. If None, the browser will be empty.
|
||||
read_only: If True, the browser will be in read-only mode (no drag-and-drop, no renaming, no deletion).
|
||||
name_filters: A list of name filters (e.g., ["*.py", "*.txt"]) to filter displayed files. If None, all files are displayed.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.name_filters = name_filters or ["*.py"]
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -56,14 +74,14 @@ class FileBrowserTreeWidget(QWidget):
|
||||
self.tree.setSortingEnabled(True)
|
||||
self.tree.setAnimated(True)
|
||||
self.tree.setAlternatingRowColors(False)
|
||||
self.tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.tree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.tree.setDefaultDropAction(Qt.DropAction.MoveAction)
|
||||
self.tree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
|
||||
self.model = QFileSystemModel(parent=self)
|
||||
self.model.setNameFilters(["*.py"])
|
||||
self.model.setNameFilters(self.name_filters)
|
||||
self.model.setNameFilterDisables(False)
|
||||
self.proxy_model = _FileBrowserFilterProxyModel(parent=self)
|
||||
self.proxy_model.setRecursiveFilteringEnabled(True)
|
||||
@@ -77,6 +95,7 @@ class FileBrowserTreeWidget(QWidget):
|
||||
self._apply_styling()
|
||||
|
||||
self.directory: str | None = None
|
||||
self._selection_extending = False
|
||||
|
||||
self.tree.clicked.connect(self._on_item_clicked)
|
||||
self.tree.doubleClicked.connect(self._on_item_double_clicked)
|
||||
@@ -133,18 +152,34 @@ class FileBrowserTreeWidget(QWidget):
|
||||
|
||||
def _on_item_clicked(self, index: QModelIndex):
|
||||
"""Emit a selection signal for Python files."""
|
||||
self._selection_extending = self._is_multi_selection_modifier_pressed()
|
||||
source_index = self._map_to_source(index)
|
||||
if not source_index.isValid() or self.model.isDir(source_index):
|
||||
return
|
||||
|
||||
selection_model = self.tree.selectionModel()
|
||||
if selection_model is not None and not selection_model.isSelected(index):
|
||||
return
|
||||
|
||||
file_path = self.model.filePath(source_index)
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
if Path(file_path).suffix.lower() == ".py":
|
||||
if self._matches_name_filters(file_path):
|
||||
logger.info(f"File selected: {file_path}")
|
||||
self.file_selected.emit(file_path)
|
||||
|
||||
def is_selection_extending(self) -> bool:
|
||||
"""Return whether the current click is extending an existing selection."""
|
||||
return self._selection_extending
|
||||
|
||||
@staticmethod
|
||||
def _is_multi_selection_modifier_pressed() -> bool:
|
||||
modifiers = QApplication.keyboardModifiers()
|
||||
return bool(
|
||||
modifiers & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier)
|
||||
)
|
||||
|
||||
def _on_item_double_clicked(self, index: QModelIndex):
|
||||
"""Emit an open signal for Python files."""
|
||||
source_index = self._map_to_source(index)
|
||||
@@ -189,6 +224,13 @@ class FileBrowserTreeWidget(QWidget):
|
||||
return None
|
||||
return file_path
|
||||
|
||||
def clear_selection(self) -> None:
|
||||
"""Clear the highlighted selection in the tree view."""
|
||||
selection_model = self.tree.selectionModel()
|
||||
if selection_model is not None:
|
||||
selection_model.clear()
|
||||
self.tree.setCurrentIndex(QModelIndex())
|
||||
|
||||
def _show_context_menu(self, position: QPoint) -> None:
|
||||
"""Show a right-click context menu for editable items."""
|
||||
if self.model.isReadOnly():
|
||||
@@ -302,3 +344,11 @@ class FileBrowserTreeWidget(QWidget):
|
||||
if not index.isValid():
|
||||
return QModelIndex()
|
||||
return self.proxy_model.mapToSource(index)
|
||||
|
||||
def _matches_name_filters(self, file_path: str) -> bool:
|
||||
"""Return whether file_path matches the configured name filters."""
|
||||
if not self.name_filters:
|
||||
return True
|
||||
|
||||
file_name = Path(file_path).name.lower()
|
||||
return any(fnmatch(file_name, name_filter.lower()) for name_filter in self.name_filters)
|
||||
|
||||
@@ -29,6 +29,7 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._sections = [] # Use list to maintain order instead of set
|
||||
self._file_browsers: list[FileBrowserTreeWidget] = []
|
||||
self.main_explorer = Explorer(parent=self)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -76,6 +77,7 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
"""Clear all sections from the explorer."""
|
||||
for section in reversed(self._sections):
|
||||
self._remove_section(section)
|
||||
self._file_browsers.clear()
|
||||
|
||||
def add_script_section(self):
|
||||
section = CollapsibleSection(
|
||||
@@ -97,7 +99,7 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
parent=self, directory=local_script_dir, read_only=False
|
||||
)
|
||||
script_widget.file_open_requested.connect(self._emit_file_open_scripts_local)
|
||||
script_widget.file_selected.connect(self._emit_file_preview_scripts_local)
|
||||
self._register_file_browser(script_widget, "scripts/local")
|
||||
script_widget.file_delete_requested.connect(self._delete_local_script)
|
||||
script_widget.file_renamed.connect(self._rename_local_script_path)
|
||||
local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
|
||||
@@ -120,7 +122,7 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
shared_script_section.set_widget(shared_script_widget)
|
||||
script_explorer.add_section(shared_script_section)
|
||||
shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared)
|
||||
shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared)
|
||||
self._register_file_browser(shared_script_widget, "scripts/shared")
|
||||
|
||||
def add_macro_section(self):
|
||||
section = CollapsibleSection(
|
||||
@@ -148,7 +150,7 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
parent=self, directory=local_macro_dir, read_only=False
|
||||
)
|
||||
macro_widget.file_open_requested.connect(self._emit_file_open_macros_local)
|
||||
macro_widget.file_selected.connect(self._emit_file_preview_macros_local)
|
||||
self._register_file_browser(macro_widget, "macros/local")
|
||||
macro_widget.file_delete_requested.connect(self._delete_local_macro)
|
||||
macro_widget.file_renamed.connect(self._rename_local_macro_path)
|
||||
local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
|
||||
@@ -171,7 +173,26 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
shared_macro_section.set_widget(shared_macro_widget)
|
||||
macro_explorer.add_section(shared_macro_section)
|
||||
shared_macro_widget.file_open_requested.connect(self._emit_file_open_macros_shared)
|
||||
shared_macro_widget.file_selected.connect(self._emit_file_preview_macros_shared)
|
||||
self._register_file_browser(shared_macro_widget, "macros/shared")
|
||||
|
||||
def _register_file_browser(self, widget: FileBrowserTreeWidget, scope: str) -> None:
|
||||
"""Track file browsers so only one file remains highlighted in the IDE explorer."""
|
||||
self._file_browsers.append(widget)
|
||||
widget.file_selected.connect(
|
||||
lambda file_name, source=widget, source_scope=scope: self._on_file_selected(
|
||||
source, file_name, source_scope
|
||||
)
|
||||
)
|
||||
|
||||
def _on_file_selected(
|
||||
self, source_widget: FileBrowserTreeWidget, file_name: str, scope: str
|
||||
) -> None:
|
||||
"""Clear other file browser selections and emit the preview request."""
|
||||
if not source_widget.is_selection_extending():
|
||||
for widget in self._file_browsers:
|
||||
if widget is not source_widget:
|
||||
widget.clear_selection()
|
||||
self.file_preview_requested.emit(file_name, scope)
|
||||
|
||||
def _get_plugin_dir(self, dir_name: Literal["scripts", "macros"]) -> str | None:
|
||||
"""Get the path to the specified directory within the BEC plugin.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QModelIndex, QPoint, Qt
|
||||
from qtpy.QtCore import QItemSelectionModel, QModelIndex, QPoint, Qt
|
||||
from qtpy.QtWidgets import QAbstractItemView, QInputDialog, QMenu
|
||||
|
||||
from bec_widgets.widgets.containers.explorer.file_browser_tree_widget import FileBrowserTreeWidget
|
||||
@@ -48,7 +48,11 @@ def test_file_browser_tree_hides_filtered_entries(file_browser_tree, qtbot):
|
||||
def test_file_browser_tree_native_browser_configuration(file_browser_tree):
|
||||
"""Test that the tree is configured like a plain native file browser."""
|
||||
assert file_browser_tree.model.isReadOnly() is False
|
||||
assert file_browser_tree.model.nameFilters() == ["*.py"]
|
||||
assert file_browser_tree.tree.alternatingRowColors() is False
|
||||
assert (
|
||||
file_browser_tree.tree.selectionMode() == QAbstractItemView.SelectionMode.ExtendedSelection
|
||||
)
|
||||
assert file_browser_tree.tree.dragDropMode() == QAbstractItemView.DragDropMode.InternalMove
|
||||
assert file_browser_tree.tree.defaultDropAction() == Qt.DropAction.MoveAction
|
||||
assert (
|
||||
@@ -95,6 +99,10 @@ def test_file_browser_tree_on_item_clicked(file_browser_tree, qtbot):
|
||||
py_file_index = None
|
||||
qtbot.waitUntil(has_py_file)
|
||||
|
||||
file_browser_tree.tree.selectionModel().select(
|
||||
py_file_index,
|
||||
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
|
||||
)
|
||||
file_browser_tree._on_item_clicked(py_file_index)
|
||||
qtbot.wait(100)
|
||||
|
||||
@@ -110,6 +118,50 @@ def test_file_browser_tree_on_item_clicked(file_browser_tree, qtbot):
|
||||
assert Path(file_open_requested_signals[0]).name == "test_file.py"
|
||||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
def test_file_browser_tree_accepts_custom_name_filters(qtbot, tmpdir):
|
||||
"""Test that the file browser can be configured for non-Python files."""
|
||||
(Path(tmpdir) / "config.yaml").touch()
|
||||
(Path(tmpdir) / "settings.yml").touch()
|
||||
(Path(tmpdir) / "script.py").touch()
|
||||
|
||||
widget = FileBrowserTreeWidget(directory=str(tmpdir), name_filters=["*.yaml", "*.yml"])
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
|
||||
selected = []
|
||||
widget.file_selected.connect(selected.append)
|
||||
|
||||
def visible_names():
|
||||
root_index = widget.tree.rootIndex()
|
||||
return [
|
||||
widget.proxy_model.data(widget.proxy_model.index(i, 0, root_index))
|
||||
for i in range(widget.proxy_model.rowCount(root_index))
|
||||
]
|
||||
|
||||
qtbot.waitUntil(lambda: "config.yaml" in visible_names(), timeout=5000)
|
||||
assert "settings.yml" in visible_names()
|
||||
assert "script.py" not in visible_names()
|
||||
|
||||
root_index = widget.tree.rootIndex()
|
||||
yaml_index = None
|
||||
for i in range(widget.proxy_model.rowCount(root_index)):
|
||||
index = widget.proxy_model.index(i, 0, root_index)
|
||||
if widget.proxy_model.data(index) == "config.yaml":
|
||||
yaml_index = index
|
||||
break
|
||||
|
||||
assert yaml_index is not None
|
||||
widget.tree.selectionModel().select(
|
||||
yaml_index,
|
||||
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
|
||||
)
|
||||
widget._on_item_clicked(yaml_index)
|
||||
|
||||
assert len(selected) == 1
|
||||
assert Path(selected[0]).name == "config.yaml"
|
||||
|
||||
|
||||
def test_file_browser_tree_delete_request_emits_selected_file(file_browser_tree, qtbot):
|
||||
"""Test that delete requests emit the currently selected file path."""
|
||||
delete_requests = []
|
||||
@@ -138,6 +190,30 @@ def test_file_browser_tree_delete_request_emits_selected_file(file_browser_tree,
|
||||
assert Path(delete_requests[0]).name == "test_file.py"
|
||||
|
||||
|
||||
def test_file_browser_tree_clear_selection(file_browser_tree, qtbot):
|
||||
"""Test clearing the current highlighted file selection."""
|
||||
|
||||
def get_test_file_index():
|
||||
root_index = file_browser_tree.tree.rootIndex()
|
||||
for i in range(file_browser_tree.proxy_model.rowCount(root_index)):
|
||||
index = file_browser_tree.proxy_model.index(i, 0, root_index)
|
||||
if file_browser_tree.proxy_model.data(index) == "test_file.py":
|
||||
return index
|
||||
return None
|
||||
|
||||
qtbot.waitUntil(lambda: get_test_file_index() is not None, timeout=5000)
|
||||
file_index = get_test_file_index()
|
||||
assert file_index is not None
|
||||
file_browser_tree.tree.setCurrentIndex(file_index)
|
||||
|
||||
assert file_browser_tree.tree.selectionModel().hasSelection()
|
||||
|
||||
file_browser_tree.clear_selection()
|
||||
|
||||
assert not file_browser_tree.tree.selectionModel().hasSelection()
|
||||
assert not file_browser_tree.tree.currentIndex().isValid()
|
||||
|
||||
|
||||
def test_file_browser_tree_context_menu_rename_action(file_browser_tree, qtbot, monkeypatch):
|
||||
"""Test that the context menu exposes a rename action for editable items."""
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QItemSelectionModel
|
||||
from qtpy.QtWidgets import QMessageBox
|
||||
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
@@ -174,6 +175,123 @@ def test_shared_sections_not_added_when_directory_empty(ide_explorer, tmpdir):
|
||||
assert shared_section is None
|
||||
|
||||
|
||||
def test_file_selection_highlight_is_global_across_scripts_and_macros(ide_explorer, qtbot, tmpdir):
|
||||
"""Selecting a script file clears the previous macro file highlight."""
|
||||
scripts_dir = tmpdir.mkdir("scripts")
|
||||
macros_dir = tmpdir.mkdir("macros")
|
||||
script_file = scripts_dir.join("script.py")
|
||||
macro_file = macros_dir.join("macro.py")
|
||||
script_file.write("print('script')")
|
||||
macro_file.write("def macro():\n pass\n")
|
||||
|
||||
scripts_browser = (
|
||||
ide_explorer.main_explorer.get_section("SCRIPTS")
|
||||
.content_widget.get_section("Local")
|
||||
.content_widget
|
||||
)
|
||||
macros_browser = (
|
||||
ide_explorer.main_explorer.get_section("MACROS")
|
||||
.content_widget.get_section("Local")
|
||||
.content_widget
|
||||
)
|
||||
scripts_browser.set_directory(str(scripts_dir))
|
||||
macros_browser.set_directory(str(macros_dir))
|
||||
|
||||
def get_index(browser, file_name):
|
||||
root_index = browser.tree.rootIndex()
|
||||
for i in range(browser.proxy_model.rowCount(root_index)):
|
||||
index = browser.proxy_model.index(i, 0, root_index)
|
||||
if browser.proxy_model.data(index) == file_name:
|
||||
return index
|
||||
return None
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: get_index(scripts_browser, "script.py") is not None
|
||||
and get_index(macros_browser, "macro.py") is not None,
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
macro_index = get_index(macros_browser, "macro.py")
|
||||
assert macro_index is not None
|
||||
macros_browser.tree.setCurrentIndex(macro_index)
|
||||
macros_browser.tree.selectionModel().select(
|
||||
macro_index,
|
||||
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
|
||||
)
|
||||
macros_browser.file_selected.emit(str(macro_file))
|
||||
|
||||
assert macros_browser.tree.selectionModel().hasSelection()
|
||||
|
||||
script_index = get_index(scripts_browser, "script.py")
|
||||
assert script_index is not None
|
||||
scripts_browser.tree.setCurrentIndex(script_index)
|
||||
scripts_browser.tree.selectionModel().select(
|
||||
script_index,
|
||||
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
|
||||
)
|
||||
scripts_browser.file_selected.emit(str(script_file))
|
||||
|
||||
assert scripts_browser.tree.selectionModel().hasSelection()
|
||||
assert not macros_browser.tree.selectionModel().hasSelection()
|
||||
|
||||
|
||||
def test_modifier_file_selection_keeps_existing_highlights(ide_explorer, qtbot, tmpdir):
|
||||
"""Modifier-click selection keeps earlier highlights across scripts and macros."""
|
||||
scripts_dir = tmpdir.mkdir("scripts")
|
||||
macros_dir = tmpdir.mkdir("macros")
|
||||
script_file = scripts_dir.join("script.py")
|
||||
macro_file = macros_dir.join("macro.py")
|
||||
script_file.write("print('script')")
|
||||
macro_file.write("def macro():\n pass\n")
|
||||
|
||||
scripts_browser = (
|
||||
ide_explorer.main_explorer.get_section("SCRIPTS")
|
||||
.content_widget.get_section("Local")
|
||||
.content_widget
|
||||
)
|
||||
macros_browser = (
|
||||
ide_explorer.main_explorer.get_section("MACROS")
|
||||
.content_widget.get_section("Local")
|
||||
.content_widget
|
||||
)
|
||||
scripts_browser.set_directory(str(scripts_dir))
|
||||
macros_browser.set_directory(str(macros_dir))
|
||||
|
||||
def get_index(browser, file_name):
|
||||
root_index = browser.tree.rootIndex()
|
||||
for i in range(browser.proxy_model.rowCount(root_index)):
|
||||
index = browser.proxy_model.index(i, 0, root_index)
|
||||
if browser.proxy_model.data(index) == file_name:
|
||||
return index
|
||||
return None
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: get_index(scripts_browser, "script.py") is not None
|
||||
and get_index(macros_browser, "macro.py") is not None,
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
macro_index = get_index(macros_browser, "macro.py")
|
||||
assert macro_index is not None
|
||||
macros_browser.tree.selectionModel().select(
|
||||
macro_index,
|
||||
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
|
||||
)
|
||||
macros_browser.file_selected.emit(str(macro_file))
|
||||
|
||||
script_index = get_index(scripts_browser, "script.py")
|
||||
assert script_index is not None
|
||||
scripts_browser.tree.selectionModel().select(
|
||||
script_index,
|
||||
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
|
||||
)
|
||||
scripts_browser._selection_extending = True
|
||||
scripts_browser.file_selected.emit(str(script_file))
|
||||
|
||||
assert scripts_browser.tree.selectionModel().hasSelection()
|
||||
assert macros_browser.tree.selectionModel().hasSelection()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"slot, signal, file_name,scope",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user