Skip to content

Commit

Permalink
Add copy, paste, cut, delete keyboard shortcuts to Table widget (#264)
Browse files Browse the repository at this point in the history
* add copy, paste, cut, delete

* fix copy

* fix imports

* add read only

* add tests

* add assert

* simplify
  • Loading branch information
tlambert03 committed Jul 29, 2021
1 parent 98d7ba7 commit 9cd1278
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 20 deletions.
107 changes: 104 additions & 3 deletions magicgui/backends/_qtpy/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -886,19 +894,112 @@ 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"):
header.setSectionResizeMode(QtW.QHeaderView.Stretch)
# 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()))
Expand Down
32 changes: 16 additions & 16 deletions magicgui/widgets/_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
3 changes: 2 additions & 1 deletion magicgui/widgets/_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions tests/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]

0 comments on commit 9cd1278

Please sign in to comment.