Skip to content

Commit 85e51b8

Browse files
copy / paste (#60)
* Enable copy pasting as it is behaviour in Numbers/Excel * Check that rows are enough * Copy Pasting now also correctly cast into dtpye, and does not create empty condition id
1 parent b2e0f5f commit 85e51b8

7 files changed

Lines changed: 229 additions & 43 deletions

File tree

src/petab_gui/C.py

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,42 @@
11
"""Constants for the PEtab edit GUI."""
2+
import numpy as np
3+
24
COLUMNS = {
35
"measurement": {
4-
"observableId": {"type": "STRING", "optional": False},
5-
"preequilibrationConditionId": {"type": "STRING", "optional": True},
6-
"simulationConditionId": {"type": "STRING", "optional": False},
7-
"time": {"type": "NUMERIC", "optional": False},
8-
"measurement": {"type": "NUMERIC", "optional": False},
9-
"observableParameters": {"type": "STRING", "optional": True},
10-
"noiseParameters": {"type": "STRING", "optional": True},
11-
"datasetId": {"type": "STRING", "optional": True},
12-
"replicateId": {"type": "STRING", "optional": True},
6+
"observableId": {"type": np.object_, "optional": False},
7+
"preequilibrationConditionId": {"type": np.object_, "optional": True},
8+
"simulationConditionId": {"type": np.object_, "optional": False},
9+
"time": {"type": np.float64, "optional": False},
10+
"measurement": {"type": np.float64, "optional": False},
11+
"observableParameters": {"type": np.object_, "optional": True},
12+
"noiseParameters": {"type": np.object_, "optional": True},
13+
"datasetId": {"type": np.object_, "optional": True},
14+
"replicateId": {"type": np.object_, "optional": True},
1315
},
1416
"observable": {
15-
"observableId": {"type": "STRING", "optional": False},
16-
"observableName": {"type": "STRING", "optional": True},
17-
"observableFormula": {"type": "STRING", "optional": False},
18-
"observableTransformation": {"type": "STRING", "optional": True},
19-
"noiseFormula": {"type": "STRING", "optional": False},
20-
"noiseDistribution": {"type": "STRING", "optional": True},
17+
"observableId": {"type": np.object_, "optional": False},
18+
"observableName": {"type": np.object_, "optional": True},
19+
"observableFormula": {"type": np.object_, "optional": False},
20+
"observableTransformation": {"type": np.object_, "optional": True},
21+
"noiseFormula": {"type": np.object_, "optional": False},
22+
"noiseDistribution": {"type": np.object_, "optional": True},
2123
},
2224
"parameter": {
23-
"parameterId": {"type": "STRING", "optional": False},
24-
"parameterName": {"type": "STRING", "optional": True},
25-
"parameterScale": {"type": "STRING", "optional": False},
26-
"lowerBound": {"type": "NUMERIC", "optional": False},
27-
"upperBound": {"type": "NUMERIC", "optional": False},
28-
"nominalValue": {"type": "NUMERIC", "optional": False},
29-
"estimate": {"type": "STRING", "optional": False},
30-
"initializationPriorType": {"type": "STRING", "optional": True},
31-
"initializationPriorParameters": {"type": "STRING", "optional": True},
32-
"objectivePriorType": {"type": "STRING", "optional": True},
33-
"objectivePriorParameters": {"type": "STRING", "optional": True},
25+
"parameterId": {"type": np.object_, "optional": False},
26+
"parameterName": {"type": np.object_, "optional": True},
27+
"parameterScale": {"type": np.object_, "optional": False},
28+
"lowerBound": {"type": np.float64, "optional": False},
29+
"upperBound": {"type": np.float64, "optional": False},
30+
"nominalValue": {"type": np.float64, "optional": False},
31+
"estimate": {"type": np.object_, "optional": False},
32+
"initializationPriorType": {"type": np.object_, "optional": True},
33+
"initializationPriorParameters": {"type": np.object_, "optional": True},
34+
"objectivePriorType": {"type": np.object_, "optional": True},
35+
"objectivePriorParameters": {"type": np.object_, "optional": True},
3436
},
3537
"condition": {
36-
"conditionId": {"type": "STRING", "optional": False},
37-
"conditionName": {"type": "STRING", "optional": False},
38+
"conditionId": {"type": np.object_, "optional": False},
39+
"conditionName": {"type": np.object_, "optional": False},
3840
}
3941
}
4042

src/petab_gui/controllers/mother_controller.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def setup_connections(self):
121121
self.observable_controller.observable_2be_renamed.connect(
122122
partial(
123123
self.measurement_controller.rename_value,
124-
column_names = "observableId"
124+
column_names="observableId"
125125
)
126126
)
127127
# Rename Condition
@@ -236,6 +236,19 @@ def setup_actions(self):
236236
actions["find+replace"].setShortcut("Ctrl+R")
237237
actions["find+replace"].triggered.connect(
238238
self.open_find_replace_dialog)
239+
# Copy / Paste
240+
actions["copy"] = QAction(
241+
qta.icon("mdi6.content-copy"),
242+
"Copy", self.view
243+
)
244+
actions["copy"].setShortcut("Ctrl+C")
245+
actions["copy"].triggered.connect(self.copy_to_clipboard)
246+
actions["paste"] = QAction(
247+
qta.icon("mdi6.content-paste"),
248+
"Paste", self.view
249+
)
250+
actions["paste"].setShortcut("Ctrl+V")
251+
actions["paste"].triggered.connect(self.paste_from_clipboard)
239252
# add/delete row
240253
actions["add_row"] = QAction(
241254
qta.icon("mdi6.table-row-plus-after"),
@@ -687,3 +700,13 @@ def delete_column(self):
687700
controller = self.active_controller()
688701
if controller:
689702
controller.delete_column()
703+
704+
def copy_to_clipboard(self):
705+
controller = self.active_controller()
706+
if controller:
707+
controller.copy_to_clipboard()
708+
709+
def paste_from_clipboard(self):
710+
controller = self.active_controller()
711+
if controller:
712+
controller.paste_from_clipboard()

src/petab_gui/controllers/table_controllers.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Classes for the controllers of the tables in the GUI."""
22
from PySide6.QtWidgets import QInputDialog, QMessageBox, QFileDialog, \
33
QCompleter
4+
import numpy as np
45
import pandas as pd
56
import petab.v1 as petab
67
from PySide6.QtCore import Signal, QObject, QModelIndex, QPoint
@@ -45,6 +46,7 @@ def __init__(
4546
self.model.view = self.view.table_view
4647
self.proxy_model = PandasTableFilterProxy(model)
4748
self.logger = logger
49+
self.check_petab_lint_mode = True
4850
self.mother_controller = mother_controller
4951
self.view.table_view.setModel(self.model)
5052
self.setup_connections()
@@ -85,6 +87,8 @@ def setup_connections(self):
8587

8688
def validate_changed_cell(self, row, column):
8789
"""Validate the changed cell and whether its linting is correct."""
90+
if not self.check_petab_lint_mode:
91+
return
8892
row_data = self.model.get_df().iloc[row]
8993
index_name = self.model.get_df().index.name
9094
row_data = row_data.to_frame().T
@@ -137,6 +141,12 @@ def open_table(self, file_path=None, separator=None, mode="overwrite"):
137141
color="red"
138142
)
139143
return
144+
dtypes = {
145+
col: self.model._allowed_columns.get(
146+
col, {"type": np.object_}
147+
)["type"] for col in new_df.columns
148+
}
149+
new_df = new_df.astype(dtypes)
140150
if mode is None:
141151
mode = prompt_overwrite_or_append(self)
142152
# Overwrite or append the table with the new DataFrame
@@ -259,12 +269,36 @@ def set_index_on_new_row(self, index: QModelIndex):
259269
"""Set the index of the model when a new row is added."""
260270
self.view.table_view.setCurrentIndex(index)
261271

272+
def copy_to_clipboard(self):
273+
"""Copy the currently selected cells to the clipboard."""
274+
self.view.copy_to_clipboard()
275+
276+
def paste_from_clipboard(self):
277+
"""Paste the clipboard content to the currently selected cells."""
278+
self.check_petab_lint_mode = False
279+
self.view.paste_from_clipboard()
280+
self.check_petab_lint_mode = True
281+
try:
282+
self.check_petab_lint()
283+
except Exception as e:
284+
self.logger.log_message(
285+
f"PEtab linter failed after copying: {str(e)}",
286+
color="red"
287+
)
288+
def check_petab_lint(self, row_data):
289+
"""Check a single row of the model with petablint."""
290+
raise NotImplementedError(
291+
"This method must be implemented in child classes."
292+
)
293+
262294

263295
class MeasurementController(TableController):
264296
"""Controller of the Measurement table."""
265297

266-
def check_petab_lint(self, row_data):
267-
"""Check a single row of the model with petablint."""
298+
def check_petab_lint(self, row_data: pd.DataFrame = None):
299+
"""Check a number of rows of the model with petablint."""
300+
if row_data is None:
301+
row_data = self.model.get_df()
268302
# Can this be done more elegantly?
269303
observable_df = self.mother_controller.model.observable.get_df()
270304
return petab.check_measurement_df(
@@ -496,8 +530,10 @@ def setup_connections_specific(self):
496530
self.maybe_rename_condition
497531
)
498532

499-
def check_petab_lint(self, row_data):
500-
"""Check a single row of the model with petablint."""
533+
def check_petab_lint(self, row_data: pd.DataFrame = None):
534+
"""Check a number of rows of the model with petablint."""
535+
if row_data is None:
536+
row_data = self.model.get_df()
501537
observable_df = self.mother_controller.model.observable.get_df()
502538
sbml_model = self.mother_controller.model.sbml.get_current_sbml_model()
503539
return petab.check_condition_df(
@@ -532,7 +568,7 @@ def maybe_rename_condition(self, new_id, old_id):
532568

533569
def maybe_add_condition(self, condition_id, old_id=None):
534570
"""Add a condition to the condition table if it does not exist yet."""
535-
if condition_id in self.model.get_df().index:
571+
if condition_id in self.model.get_df().index or not condition_id:
536572
return
537573
# add a row
538574
self.model.insertRows(position=None, rows=1)
@@ -635,8 +671,10 @@ def setup_connections_specific(self):
635671
self.maybe_rename_observable
636672
)
637673

638-
def check_petab_lint(self, row_data):
639-
"""Check a single row of the model with petablint."""
674+
def check_petab_lint(self, row_data: pd.DataFrame = None):
675+
"""Check a number of rows of the model with petablint."""
676+
if row_data is None:
677+
row_data = self.model.get_df()
640678
return petab.check_observable_df(row_data)
641679

642680
def maybe_rename_observable(self, new_id, old_id):
@@ -668,7 +706,7 @@ def maybe_add_observable(self, observable_id, old_id=None):
668706
669707
Currently, `old_id` is not used.
670708
"""
671-
if observable_id in self.model.get_df().index:
709+
if observable_id in self.model.get_df().index or not observable_id:
672710
return
673711
# add a row
674712
self.model.insertRows(position=None, rows=1)
@@ -754,8 +792,10 @@ def setup_completers(self):
754792
self.completers["parameterId"]
755793
)
756794

757-
def check_petab_lint(self, row_data):
758-
"""Check a single row of the model with petablint."""
795+
def check_petab_lint(self, row_data: pd.DataFrame = None):
796+
"""Check a number of rows of the model with petablint."""
797+
if row_data is None:
798+
row_data = self.model.get_df()
759799
observable_df = self.mother_controller.model.observable.get_df()
760800
measurement_df = self.mother_controller.model.measurement.get_df()
761801
condition_df = self.mother_controller.model.condition.get_df()

src/petab_gui/models/pandas_table_model.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pandas as pd
2-
from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex, Signal, QSortFilterProxyModel
2+
from PySide6.QtCore import (Qt, QAbstractTableModel, QModelIndex, Signal,
3+
QSortFilterProxyModel, QMimeData)
34
from PySide6.QtGui import QColor
45

56
from ..C import COLUMNS
@@ -130,6 +131,8 @@ def insertColumn(self, column_name: str):
130131
def setData(self, index, value, role=Qt.EditRole):
131132
if not (index.isValid() and role == Qt.EditRole):
132133
return False
134+
if is_invalid(value) or value == "":
135+
value = None
133136
# check whether multiple rows but only one column is selected
134137
multi_row_change, selected = self.check_selection()
135138
if not multi_row_change:
@@ -157,6 +160,16 @@ def _set_data_single(self, index, value):
157160
# Handling non-index (regular data) columns
158161
column_name = self._data_frame.columns[column - col_setoff]
159162
old_value = self._data_frame.iloc[row, column - col_setoff]
163+
# cast to numeric if necessary
164+
if not self._data_frame[column_name].dtype == "object":
165+
try:
166+
value = float(value)
167+
except ValueError:
168+
self.new_log_message.emit(
169+
f"Column '{column_name}' expects a numeric value",
170+
"red"
171+
)
172+
return False
160173
if value == old_value:
161174
return False
162175

@@ -288,11 +301,67 @@ def reset_invalid_cells(self):
288301
self._invalid_cells = set()
289302
self.layoutChanged.emit()
290303

304+
def mimeData(self, rectangle, start_index):
305+
"""Return the data to be copied to the clipboard.
306+
307+
Parameters
308+
----------
309+
rectangle: np.ndarray
310+
The rectangle of selected cells. Creates a minimum rectangle
311+
around all selected cells and is True if the cell is selected.
312+
start_index: (int, int)
313+
The start index of the selection. Used to determine the location
314+
of the copied data.
315+
"""
316+
copied_data = ""
317+
for row in range(rectangle.shape[0]):
318+
for col in range(rectangle.shape[1]):
319+
if rectangle[row, col]:
320+
copied_data += self.data(
321+
self.index(start_index[0] + row, start_index[1] + col),
322+
Qt.DisplayRole
323+
)
324+
else:
325+
copied_data += "SKIP"
326+
if col < rectangle.shape[1] - 1:
327+
copied_data += "\t"
328+
copied_data += "\n"
329+
mime_data = QMimeData()
330+
mime_data.setText(copied_data.strip())
331+
return mime_data
332+
333+
def setDataFromText(self, text, start_row, start_column):
334+
"""Set the data from text."""
335+
# TODO: Does this need to be more flexible in the separator?
336+
lines = text.split("\n")
337+
self.maybe_add_rows(start_row, len(lines))
338+
for row_offset, line in enumerate(lines):
339+
values = line.split("\t")
340+
for col_offset, value in enumerate(values):
341+
if value == "SKIP":
342+
continue
343+
self.setData(
344+
self.index(
345+
start_row + row_offset, start_column + col_offset
346+
),
347+
value,
348+
Qt.EditRole
349+
)
350+
351+
def maybe_add_rows(self, start_row, n_rows):
352+
"""Add rows if needed."""
353+
if start_row + n_rows > self._data_frame.shape[0]:
354+
self.insertRows(
355+
self._data_frame.shape[0],
356+
start_row + n_rows - self._data_frame.shape[0]
357+
)
358+
self.layoutChanged.emit()
291359

292360

293361
class IndexedPandasTableModel(PandasTableModel):
294362
"""Table model for tables with named index."""
295363
condition_2be_renamed = Signal(str, str) # Signal to mother controller
364+
296365
def __init__(self, data_frame, allowed_columns, table_type, parent=None):
297366
super().__init__(
298367
data_frame=data_frame,
@@ -341,6 +410,7 @@ class MeasurementModel(PandasTableModel):
341410
"""Table model for the measurement data."""
342411
possibly_new_condition = Signal(str) # Signal for new condition
343412
possibly_new_observable = Signal(str) # Signal for new observable
413+
344414
def __init__(self, data_frame, parent=None):
345415
super().__init__(
346416
data_frame=data_frame,

src/petab_gui/utils.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -525,9 +525,8 @@ def connect_forwarded(self, slot):
525525
def create_empty_dataframe(column_dict: dict, table_type: str):
526526
columns = [col for col, props in column_dict.items() if not props["optional"]]
527527
dtypes = {
528-
col: 'float64' if props["type"] == "NUMERIC"
529-
else 'object'
530-
for col, props in column_dict.items() if not props["optional"]
528+
col: props["type"] for col, props in column_dict.items() if not
529+
props["optional"]
531530
}
532531
df = pd.DataFrame(columns=columns).astype(dtypes)
533532
# set potential index columns
@@ -586,6 +585,29 @@ def get_selected(table_view: QTableView, mode: str = ROW) -> list[int]:
586585
return None
587586

588587

588+
def get_selected_rectangles(table_view: QTableView) -> np.array:
589+
"""Returns the selected cells in a rectangular view.
590+
591+
The size of the rectangle is determined by Max_row - Min_row and
592+
Max_column - Min_column. The returned array is a boolean array with
593+
True values for selected cells.
594+
"""
595+
selected = get_selected(table_view, mode=INDEX)
596+
if not selected:
597+
return None
598+
rows = [index.row() for index in selected]
599+
cols = [index.column() for index in selected]
600+
min_row, max_row = min(rows), max(rows)
601+
min_col, max_col = min(cols), max(cols)
602+
rect_start = (min_row, min_col)
603+
selected_rect = np.zeros(
604+
(max_row - min_row + 1, max_col - min_col + 1), dtype=bool
605+
)
606+
for index in selected:
607+
selected_rect[index.row() - min_row, index.column() - min_col] = True
608+
return selected_rect, rect_start
609+
610+
589611
def process_file(filepath, logger):
590612
"""
591613
Utility function to process a file based on its type and content.

0 commit comments

Comments
 (0)