diff --git a/magicgui/backends/_qtpy/widgets.py b/magicgui/backends/_qtpy/widgets.py index 56b66cd2a..3c1ffb0b1 100644 --- a/magicgui/backends/_qtpy/widgets.py +++ b/magicgui/backends/_qtpy/widgets.py @@ -7,7 +7,15 @@ import qtpy from qtpy import QtWidgets as QtW from qtpy.QtCore import QEvent, QObject, Qt, Signal -from qtpy.QtGui import QFont, QFontMetrics, QImage, QPixmap, QResizeEvent, QTextDocument +from qtpy.QtGui import ( + QFont, + QFontMetrics, + QImage, + QKeyEvent, + QPixmap, + QResizeEvent, + QTextDocument, +) from magicgui.types import FileDialogMode from magicgui.widgets import _protocols @@ -886,12 +894,95 @@ def _maybefloat(item): return num +class _QTableExtended(QtW.QTableWidget): + _read_only: bool = False + + def _copy_to_clipboard(self): + selranges = self.selectedRanges() + if not selranges: + return + if len(selranges) > 1: + import warnings + + warnings.warn( + "Multiple table selections detected: " + "only the first (upper left) selection will be copied" + ) + + # copy first selection range + sel = selranges[0] + lines = [] + for r in range(sel.topRow(), sel.bottomRow() + 1): + cells = [] + for c in range(sel.leftColumn(), sel.rightColumn() + 1): + item = self.item(r, c) + cells.append(item.text()) if hasattr(item, "text") else "" + lines.append("\t".join(cells)) + + if lines: + QtW.QApplication.clipboard().setText("\n".join(lines)) + + def _paste_from_clipboard(self): + if self._read_only: + return + + sel_idx = self.selectedIndexes() + if not sel_idx: + return + text = QtW.QApplication.clipboard().text() + if not text: + return + + # paste in the text + row0, col0 = sel_idx[0].row(), sel_idx[0].column() + data = [line.split("\t") for line in text.splitlines()] + if (row0 + len(data)) > self.rowCount(): + self.setRowCount(row0 + len(data)) + if data and (col0 + len(data[0])) > self.columnCount(): + self.setColumnCount(col0 + len(data[0])) + for r, line in enumerate(data): + for c, cell in enumerate(line): + try: + self.item(row0 + r, col0 + c).setText(str(cell)) + except AttributeError: + self.setItem(row0 + r, col0 + c, QtW.QTableWidgetItem(str(cell))) + + # select what was just pasted + selrange = QtW.QTableWidgetSelectionRange(row0, col0, row0 + r, col0 + c) + self.clearSelection() + self.setRangeSelected(selrange, True) + + def _delete_selection(self): + if self._read_only: + return + + for item in self.selectedItems(): + try: + item.setText("") + except AttributeError: + pass + + def keyPressEvent(self, e: QKeyEvent): + if e.modifiers() & Qt.ControlModifier and e.key() == Qt.Key_C: + return self._copy_to_clipboard() + if e.modifiers() & Qt.ControlModifier and e.key() == Qt.Key_V: + return self._paste_from_clipboard() + if e.modifiers() & Qt.ControlModifier and e.key() == Qt.Key_X: + self._copy_to_clipboard() + return self._delete_selection() + if e.key() in (Qt.Key_Delete, Qt.Key_Backspace): + return self._delete_selection() + return super().keyPressEvent(e) + + class Table(QBaseWidget, _protocols.TableWidgetProtocol): - _qwidget: QtW.QTableWidget + _qwidget: _QTableExtended _DATA_ROLE: int = 255 + _RO_FLAGS = Qt.ItemIsSelectable | Qt.ItemIsEnabled + _DEFAULT_FLAGS = Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled def __init__(self): - super().__init__(QtW.QTableWidget) + super().__init__(_QTableExtended) header = self._qwidget.horizontalHeader() # avoid strange AttributeError on CI if hasattr(header, "setSectionResizeMode"): @@ -899,6 +990,16 @@ def __init__(self): # self._qwidget.horizontalHeader().setSectionsMovable(True) # tricky!! self._qwidget.itemChanged.connect(self._update_item_data_with_text) + def _mgui_set_read_only(self, value: bool) -> None: + self._qwidget._read_only = bool(value) + flags = Table._RO_FLAGS if value else Table._DEFAULT_FLAGS + for row in range(self._qwidget.rowCount()): + for col in range(self._qwidget.columnCount()): + self._qwidget.item(row, col).setFlags(flags) + + def _mgui_get_read_only(self) -> bool: + return self._qwidget._read_only + def _update_item_data_with_text(self, item: QtW.QTableWidgetItem): self._qwidget.blockSignals(True) item.setData(self._DATA_ROLE, _maybefloat(item.text())) diff --git a/magicgui/widgets/_protocols.py b/magicgui/widgets/_protocols.py index 6ef13092e..94000091d 100644 --- a/magicgui/widgets/_protocols.py +++ b/magicgui/widgets/_protocols.py @@ -194,7 +194,22 @@ def _mgui_bind_change_callback(self, callback: Callable[[Any], Any]) -> None: @runtime_checkable -class TableWidgetProtocol(WidgetProtocol, Protocol): +class SupportsReadOnly(Protocol): + """Widget that can be read_only.""" + + @abstractmethod + def _mgui_set_read_only(self, value: bool) -> None: + """Set read_only.""" + raise NotImplementedError() + + @abstractmethod + def _mgui_get_read_only(self) -> bool: + """Get read_only status.""" + raise NotImplementedError() + + +@runtime_checkable +class TableWidgetProtocol(WidgetProtocol, SupportsReadOnly, Protocol): """ValueWidget subclass intended for 2D tabular data, with row & column headers.""" @abstractmethod @@ -374,21 +389,6 @@ def _mgui_get_text(self) -> str: raise NotImplementedError() -@runtime_checkable -class SupportsReadOnly(Protocol): - """Widget that can be read_only.""" - - @abstractmethod - def _mgui_set_read_only(self, value: bool) -> None: - """Set read_only.""" - raise NotImplementedError() - - @abstractmethod - def _mgui_get_read_only(self) -> bool: - """Get read_only status.""" - raise NotImplementedError() - - @runtime_checkable class ButtonWidgetProtocol(ValueWidgetProtocol, SupportsText, Protocol): """The "value" in a ButtonWidget is the current (checked) state.""" diff --git a/magicgui/widgets/_table.py b/magicgui/widgets/_table.py index 12523fe1a..c8fa640be 100644 --- a/magicgui/widgets/_table.py +++ b/magicgui/widgets/_table.py @@ -25,6 +25,7 @@ from magicgui.application import use_app from magicgui.events import EventEmitter from magicgui.widgets._bases import Widget +from magicgui.widgets._bases.mixins import _ReadOnlyMixin from magicgui.widgets._protocols import TableWidgetProtocol if TYPE_CHECKING: @@ -128,7 +129,7 @@ def __repr__(self) -> str: return f"table_items({n} {self._axis}s)" -class Table(Widget, MutableMapping[TblKey, list]): +class Table(Widget, _ReadOnlyMixin, MutableMapping[TblKey, list]): """A table widget representing columnar or 2D data with headers. Tables behave like plain `dicts`, where the keys are column headers and the diff --git a/tests/test_table.py b/tests/test_table.py index 7bfefa4d1..279b0a884 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -340,3 +340,51 @@ def test_check_new_headers(): with pytest.raises(ValueError) as e: table.row_headers = ("a", "b", "c", "d") assert "Length mismatch" in str(e) + + +# these are Qt-specific + + +def test_copy(qapp): + from qtpy.QtWidgets import QTableWidgetSelectionRange + + table = Table(value=_TABLE_DATA["data"]) + selrange = QTableWidgetSelectionRange(1, 1, 0, 0) + table.native.setRangeSelected(selrange, True) + table.native._copy_to_clipboard() + assert qapp.clipboard().text() == "1\t2\n4\t5" + + +def test_paste(qapp): + from qtpy.QtWidgets import QTableWidgetSelectionRange + + table = Table(value=_TABLE_DATA["data"]) + selrange = QTableWidgetSelectionRange(1, 1, 0, 0) + table.native.setRangeSelected(selrange, True) + qapp.clipboard().setText("0\t0\n1\t1") + + table.read_only = True + table.native._paste_from_clipboard() + assert table.data.to_list() == [[1, 2, 3], [4, 5, 6]] + + table.read_only = False + table.native._paste_from_clipboard() + assert table.data.to_list() == [[0, 0, 3], [1, 1, 6]] + + +def test_delete(qapp): + from qtpy.QtWidgets import QTableWidgetSelectionRange + + table = Table(value=_TABLE_DATA["data"]) + selrange = QTableWidgetSelectionRange(1, 1, 0, 0) + table.native.setRangeSelected(selrange, True) + + table.read_only = True + assert table.read_only + table.native._delete_selection() + assert table.data.to_list() == [[1, 2, 3], [4, 5, 6]] + + table.read_only = False + assert not table.read_only + table.native._delete_selection() + assert table.data.to_list() == [[None, None, 3], [None, None, 6]]