Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add copy/paste behaviour #61

Merged
merged 11 commits into from
Jan 27, 2023
124 changes: 118 additions & 6 deletions src/tailor/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from importlib import resources
from textwrap import dedent

import numpy as np
import packaging
import pyqtgraph as pg
from PySide6 import QtCore, QtGui, QtWidgets
Expand Down Expand Up @@ -75,6 +76,7 @@ def __init__(self):
self.ui.setWindowIcon(
QtGui.QIcon(str(resources.path("tailor.resources", "tailor.png")))
)
self.clipboard = QtWidgets.QApplication.clipboard()
# store reference to this code in data tab
self.ui.data.code = self

Expand Down Expand Up @@ -122,6 +124,8 @@ def __init__(self):
self.ui.actionRemove_column.triggered.connect(self.remove_column)
self.ui.actionRemove_row.triggered.connect(self.remove_row)
self.ui.actionClear_Cell_Contents.triggered.connect(self.clear_selected_cells)
self.ui.actionCopy.triggered.connect(self.copy_selected_cells)
self.ui.actionPaste.triggered.connect(self.paste_cells)

# set up the open recent menu
self.ui._recent_files_separator = self.ui.menuOpen_Recent.insertSeparator(
Expand All @@ -148,6 +152,8 @@ def __init__(self):
self.ui.actionClose.setShortcut(QtGui.QKeySequence.Close)
self.ui.actionSave.setShortcut(QtGui.QKeySequence.Save)
self.ui.actionSave_As.setShortcut(QtGui.QKeySequence.SaveAs)
self.ui.actionCopy.setShortcut(QtGui.QKeySequence.Copy)
self.ui.actionPaste.setShortcut(QtGui.QKeySequence.Paste)

# Set other shortcuts for menu items
self.ui.actionImport_CSV.setShortcut(QtGui.QKeySequence("Ctrl+I"))
Expand Down Expand Up @@ -317,15 +323,29 @@ def column_moved(self, logidx, oldidx, newidx):

Dragging a column to a new location triggers execution of this method.
Since the UI only reorders the column visually and does not change the
underlying data, we will store the ordering in the data model.
underlying data, things can get tricky when trying to determine which
variables are available to the left of calculated columns and which
columns include the bouding box of a selection for copy/paste. So, we
will immediately move back the column in the view and move the
underlying data instead. That way, visual and logical ordering are
always in sync.

Args:
logidx (int): the logical column index (index in the dataframe)
oldidx (int): the old visual index
newidx (int): the new visual index
oldidx (int): the old visual index newidx (int): the new visual
index
"""
self.data_model._column_order = self.get_column_ordering()
print(f"Column moved from {oldidx=} to {newidx=}")
header = self.ui.data_view.horizontalHeader()
header.blockSignals(True)
# move the column back, keep the header in logical order
header.moveSection(newidx, oldidx)
header.blockSignals(False)
# move the underlying data column instead
self.data_model.moveColumn(None, oldidx, None, newidx)
self.data_model.recalculate_all_columns()
# select the column that was just moved at the new location
self.ui.data_view.selectColumn(newidx)

def get_column_ordering(self):
"""Return the visual order of logical columns in the table view.
Expand All @@ -338,7 +358,9 @@ def get_column_ordering(self):
"""
header = self.ui.data_view.horizontalHeader()
n_columns = self.data_model.columnCount()
return [header.logicalIndex(i) for i in range(n_columns)]
ordering = [header.logicalIndex(i) for i in range(n_columns)]
print(f"{ordering=}")
return ordering

def eventFilter(self, watched, event):
"""Catch PySide6 events.
Expand Down Expand Up @@ -421,6 +443,95 @@ def get_index_below_selected_cell(self):
self.ui.data_view.MoveDown, QtCore.Qt.NoModifier
)

def copy_selected_cells(self):
"""Copy selected cells to clipboard."""

# get bounding rectangle coordinates and sizes
selection = self.selection.selection().toList()
left = min(r.left() for r in selection)
width = max(r.right() for r in selection) - left + 1
top = min(r.top() for r in selection)
height = max(r.bottom() for r in selection) - top + 1

# fill data from selected indexes, not selected -> NaN
data = np.full((height, width), np.nan)
for index in self.selection.selectedIndexes():
if (value := index.data()) == "":
value = np.nan
data[index.row() - top, index.column() - left] = value

# create tab separated values from data, NaN -> empty string, e.g.
# 1 2 3
# 2 4
# 5 5 6
text = "\n".join(
[
"\t".join([str(v) if not np.isnan(v) else "" for v in row])
for row in data
]
)

# write TSV text to clipboard
self.clipboard.setText(text)

def paste_cells(self):
"""Paste cells from clipboard."""

# get data from clipboard
text = self.clipboard.text()

# create array from tab separated values, "" -> NaN
try:
data = np.array(
[
[float(v) if v != "" else np.nan for v in row.split("\t")]
for row in text.split("\n")
]
)
except ValueError as exc:
self.ui.statusbar.showMessage(
f"Error pasting from clipboard: {exc}", timeout=MSG_TIMEOUT
)
return

# get current coordinates and data size
current_index = self.ui.data_view.currentIndex()
start_row, start_column = current_index.row(), current_index.column()
height, width = data.shape

# extend rows and columns if necessary
last_table_column = self.data_model.columnCount() - 1
if (last_data_column := start_column + width - 1) > last_table_column:
for _ in range(last_data_column - last_table_column):
self.add_column()
last_table_row = self.data_model.rowCount() - 1
if (last_data_row := start_row + height - 1) > last_table_row:
for _ in range(last_data_row - last_table_row):
self.add_row()

# write clipboard data to data model
it = np.nditer(data, flags=["multi_index"])
for value in it:
row, column = it.multi_index
self.data_model.setData(
self.data_model.createIndex(row + start_row, column + start_column),
value,
skip_update=True,
)

# signal that values have changed
self.data_model.dataChanged.emit(
self.data_model.createIndex(start_row, start_column),
self.data_model.createIndex(
start_row + height - 1, start_column + width - 1
),
)
# recalculate computed values once
self.data_model.recalculate_all_columns()
# reset current index and focus
self.ui.data_view.setCurrentIndex(current_index)
self.ui.data_view.setFocus()

def selection_changed(self, selected, deselected):
"""Handle selectionChanged events in the data view.

Expand All @@ -430,7 +541,8 @@ def selection_changed(self, selected, deselected):
These values are used to update the column information in the user
interface.

Args: selected: QItemSelection containing the newly selected events.
Args:
selected: QItemSelection containing the newly selected events.
deselected: QItemSelection containing previously selected, and now
deselected, items.
"""
Expand Down
22 changes: 22 additions & 0 deletions src/tailor/data_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,28 @@ def is_empty(self):
# check for *all* nans in a row or column
return self._data.dropna(how="all").empty

def moveColumn(
self, sourceParent, sourceColumn, destinationParent, destinationChild
):
"""Move column.

Move a column from sourceColumn to destinationChild. Alas, the Qt naming
scheme remains a bit of a mystery.

Args:
sourceParent: ignored.
sourceColumn (int): the source column number.
destinationParent: ignored.
destinationChild (int): the destination column number.

Returns:
bool: True if the column was moved.
"""
cols = list(self._data.columns)
cols.insert(destinationChild, cols.pop(sourceColumn))
self._data = self._data[cols]
return True

def insertColumn(self, column, parent=None, column_name=None):
"""Insert a single column.

Expand Down
18 changes: 18 additions & 0 deletions src/tailor/resources/tailor.ui
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,15 @@
</property>
<addaction name="actionAbout_Tailor"/>
</widget>
<widget class="QMenu" name="menuEdit">
<property name="title">
<string>Edit</string>
</property>
<addaction name="actionCopy"/>
<addaction name="actionPaste"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuEdit"/>
<addaction name="menuTable"/>
<addaction name="menuHelp"/>
</widget>
Expand Down Expand Up @@ -318,6 +326,16 @@
<string>Check for updates</string>
</property>
</action>
<action name="actionCopy">
<property name="text">
<string>Copy</string>
</property>
</action>
<action name="actionPaste">
<property name="text">
<string>Paste</string>
</property>
</action>
</widget>
<tabstops>
<tabstop>tabWidget</tabstop>
Expand Down
98 changes: 98 additions & 0 deletions src/tests/test_copy_paste.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import sys

sys.path.append("src/")


import IPython
import numpy as np
import pandas as pd
from PySide6 import QtCore, QtWidgets

from tailor.app import Application

SELECT = QtCore.QItemSelectionModel.SelectionFlag.Select
TOGGLE = QtCore.QItemSelectionModel.SelectionFlag.Toggle


def test_copy_paste(app):
x = np.arange(20)
app.data_model.beginResetModel()
app.data_model._data = pd.DataFrame.from_dict(
{"x": x, "y": x**2, "z": x / 2, "a": x * 2, "b": np.sin(x)}
)
app.data_model.endResetModel()

set_selection(app, 1, 1, 3, 8, SELECT)
set_selection(app, 2, 2, 2, 4, TOGGLE)
set_selection(app, 4, 9, 4, 9, SELECT)
app.copy_selected_cells()

text = """\
1.0 0.5 2.0
4.0 4.0
9.0 6.0
16.0 8.0
25.0 2.5 10.0
36.0 3.0 12.0
49.0 3.5 14.0
64.0 4.0 16.0
0.4121184852"""
assert app.clipboard.text() == text

app.ui.data_view.setCurrentIndex(app.data_model.createIndex(3, 0))
app.paste_cells()

data = """\
x y z a b
0 0.0 0.0 0.0 0.000000 0.000000
1 1.0 1.0 0.5 2.000000 0.841471
2 2.0 4.0 1.0 4.000000 0.909297
3 1.0 0.5 2.0 NaN 0.141120
4 4.0 NaN 4.0 NaN -0.756802
5 9.0 NaN 6.0 NaN -0.958924
6 16.0 NaN 8.0 NaN -0.279415
7 25.0 2.5 10.0 NaN 0.656987
8 36.0 3.0 12.0 NaN 0.989358
9 49.0 3.5 14.0 NaN 0.412118
10 64.0 4.0 16.0 NaN -0.544021
11 NaN NaN NaN 0.412118 -0.999990
12 12.0 144.0 6.0 24.000000 -0.536573
13 13.0 169.0 6.5 26.000000 0.420167
14 14.0 196.0 7.0 28.000000 0.990607
15 15.0 225.0 7.5 30.000000 0.650288
16 16.0 256.0 8.0 32.000000 -0.287903
17 17.0 289.0 8.5 34.000000 -0.961397
18 18.0 324.0 9.0 36.000000 -0.750987
19 19.0 361.0 9.5 38.000000 0.149877"""
assert str(app.data_model._data) == data


def set_selection(app, x1, y1, x2, y2, flag):
app.selection.select(
QtCore.QItemSelection(
app.data_model.createIndex(y1, x1),
app.data_model.createIndex(y2, x2),
),
flag,
)


def test_paste_adds_rows_and_columns(app):
app.clear_all()
C, R = 3, 10
data = "\n".join(["\t".join([str(i * j) for j in range(C)]) for i in range(R)])
app.clipboard.setText(data)
app.paste_cells()


if __name__ == "__main__":
qapp = QtWidgets.QApplication()
app = Application()

test_copy_paste(app)
test_paste_adds_rows_and_columns(app)

app.ui.show()
qapp.exec()

qapp.quit()
Loading