Skip to content

Commit 41c756d

Browse files
Do and Undo (#84)
* Very fist Undo Command working * Completed column alteration * More stable undo redo of a singular column * adding and deleting rows is now reversible * Undo Redo provisourically complete, testing remains * More unified petab linting * Casting into correct datatypes now. Slight changes to error messages * Various fixes: 1. Copy pasting is one big undo/redo 2. Setting an invalid index has a new overwrite message and sets a default value 3. Ordering of functionality is changes such that a row is only checked when necessary 4. In parameter case, now accounts for potentially more parameters than were thrown into the model * Worked in suggestions
1 parent 69a456a commit 41c756d

File tree

9 files changed

+603
-269
lines changed

9 files changed

+603
-269
lines changed

src/petab_gui/C.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,8 @@
173173
"condition": DEFAULT_COND_CONFIG,
174174
"measurement": DEFAULT_MEAS_CONFIG
175175
}
176+
177+
COMMON_ERRORS = {
178+
r"Error parsing '': Syntax error at \d+:\d+: mismatched input '<EOF>' "
179+
r"expecting \{[^}]+\}" : "Invalid empty cell!"
180+
}

src/petab_gui/commands.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""Store commands for the do/undo functionality."""
2+
from PySide6.QtGui import QUndoCommand
3+
from PySide6.QtCore import QModelIndex, Qt
4+
import pandas as pd
5+
import numpy as np
6+
7+
8+
pd.set_option('future.no_silent_downcasting', True)
9+
10+
11+
class ModifyColumnCommand(QUndoCommand):
12+
"""Command to add a column to the table."""
13+
14+
def __init__(self, model, column_name, add_mode: bool = True):
15+
action = "Add" if add_mode else "Remove"
16+
super().__init__(
17+
f"{action} column {column_name} in table {model.table_type}"
18+
)
19+
self.model = model
20+
self.column_name = column_name
21+
self.add_mode = add_mode
22+
self.old_values = None
23+
self.position = None
24+
25+
if not add_mode and column_name in model._data_frame.columns:
26+
self.position = model._data_frame.columns.get_loc(column_name)
27+
self.old_values = model._data_frame[column_name].copy()
28+
29+
def redo(self):
30+
if self.add_mode:
31+
position = self.model._data_frame.shape[1]
32+
self.model.beginInsertColumns(QModelIndex(), position, position)
33+
self.model._data_frame[self.column_name] = ""
34+
self.model.endInsertColumns()
35+
else:
36+
self.position = self.model._data_frame.columns.get_loc(self.column_name)
37+
self.model.beginRemoveColumns(QModelIndex(), self.position, self.position)
38+
self.model._data_frame.drop(columns=self.column_name, inplace=True)
39+
self.model.endRemoveColumns()
40+
41+
def undo(self):
42+
if self.add_mode:
43+
position = self.model._data_frame.columns.get_loc(self.column_name)
44+
self.model.beginRemoveColumns(QModelIndex(), position, position)
45+
self.model._data_frame.drop(columns=self.column_name, inplace=True)
46+
self.model.endRemoveColumns()
47+
else:
48+
self.model.beginInsertColumns(QModelIndex(), self.position, self.position)
49+
self.model._data_frame.insert(self.position, self.column_name, self.old_values)
50+
self.model.endInsertColumns()
51+
52+
53+
class ModifyRowCommand(QUndoCommand):
54+
"""Command to add a row to the table."""
55+
56+
def __init__(
57+
self,
58+
model,
59+
row_indices: list[int] | int,
60+
add_mode: bool = True
61+
):
62+
action = "Add" if add_mode else "Remove"
63+
super().__init__(f"{action} row(s) in table {model.table_type}")
64+
self.model = model
65+
self.add_mode = add_mode
66+
self.old_rows = None
67+
self.old_ind_names = None
68+
69+
df = self.model._data_frame
70+
71+
if add_mode:
72+
# Adding: interpret input as count of new rows
73+
self.row_indices = self._generate_new_indices(row_indices)
74+
else:
75+
# Deleting: interpret input as specific index labels
76+
self.row_indices = row_indices if isinstance(row_indices, list) else [row_indices]
77+
self.old_rows = df.iloc[self.row_indices].copy()
78+
self.old_ind_names = [df.index[idx] for idx in self.row_indices]
79+
80+
def _generate_new_indices(self, count):
81+
"""Generate default row indices based on table type and index type."""
82+
df = self.model._data_frame
83+
base = 0
84+
existing = set(df.index.astype(str))
85+
86+
indices = []
87+
while len(indices) < count:
88+
idx = f"new_{self.model.table_type}_{base}"
89+
if idx not in existing:
90+
indices.append(idx)
91+
base += 1
92+
self.old_ind_names = indices
93+
return indices
94+
95+
def redo(self):
96+
df = self.model._data_frame
97+
98+
if self.add_mode:
99+
position = df.shape[0] - 1 # insert *before* the auto-row
100+
self.model.beginInsertRows(QModelIndex(), position, position + len(self.row_indices) - 1)
101+
for i, idx in enumerate(self.row_indices):
102+
df.loc[idx] = [""] * df.shape[1]
103+
self.model.endInsertRows()
104+
else:
105+
self.model.beginRemoveRows(QModelIndex(), min(self.row_indices), max(self.row_indices))
106+
df.drop(index=self.old_ind_names, inplace=True)
107+
self.model.endRemoveRows()
108+
109+
def undo(self):
110+
df = self.model._data_frame
111+
112+
if self.add_mode:
113+
positions = [df.index.get_loc(idx) for idx in self.row_indices]
114+
self.model.beginRemoveRows(QModelIndex(), min(positions), max(positions))
115+
df.drop(index=self.old_ind_names, inplace=True)
116+
self.model.endRemoveRows()
117+
else:
118+
self.model.beginInsertRows(QModelIndex(), min(self.row_indices), max(self.row_indices))
119+
restore_index_order = df.index
120+
for pos, index_name, row in zip(
121+
self.row_indices, self.old_ind_names, self.old_rows.values
122+
):
123+
restore_index_order = restore_index_order.insert(
124+
pos, index_name
125+
)
126+
df.loc[index_name] = row
127+
df.sort_index(
128+
inplace=True,
129+
key=lambda x: x.map(restore_index_order.get_loc)
130+
)
131+
self.model.endInsertRows()
132+
133+
134+
class ModifyDataFrameCommand(QUndoCommand):
135+
def __init__(self, model, changes: dict[tuple, tuple], description="Modify values"):
136+
super().__init__(description)
137+
self.model = model
138+
self.changes = changes # {(row_key, column_name): (old_val, new_val)}
139+
140+
def redo(self):
141+
self._apply_changes(use_new=True)
142+
143+
def undo(self):
144+
self._apply_changes(use_new=False)
145+
146+
def _apply_changes(self, use_new: bool):
147+
df = self.model._data_frame
148+
col_offset = 1 if self.model._has_named_index else 0
149+
original_dtypes = df.dtypes.copy()
150+
151+
# Apply changes
152+
update_vals = {
153+
(row, col): val[1 if use_new else 0]
154+
for (row, col), val in self.changes.items()
155+
}
156+
update_df = pd.Series(update_vals).unstack()
157+
for col in update_df.columns:
158+
if col in df.columns:
159+
df[col] = df[col].astype('object')
160+
update_df.replace({None: "Placeholder_temp"}, inplace=True)
161+
df.update(update_df)
162+
df.replace({"Placeholder_temp": ""}, inplace=True)
163+
for col, dtype in original_dtypes.items():
164+
if col not in update_df.columns:
165+
continue
166+
if np.issubdtype(dtype, np.number):
167+
df[col] = pd.to_numeric(df[col], errors="coerce")
168+
else:
169+
df[col] = df[col].astype(dtype)
170+
171+
rows = [df.index.get_loc(row_key) for (row_key, _) in
172+
self.changes.keys()]
173+
cols = [df.columns.get_loc(col) + col_offset for (_, col) in
174+
self.changes.keys()]
175+
176+
top_left = self.model.index(min(rows), min(cols))
177+
bottom_right = self.model.index(max(rows), max(cols))
178+
self.model.dataChanged.emit(top_left, bottom_right, [Qt.DisplayRole])
179+
180+
181+
class RenameIndexCommand(QUndoCommand):
182+
def __init__(self, model, old_index, new_index, model_index):
183+
super().__init__(f"Rename index {old_index}{new_index}")
184+
self.model = model
185+
self.model_index = model_index
186+
self.old_index = old_index
187+
self.new_index = new_index
188+
189+
def redo(self):
190+
self._apply(self.old_index, self.new_index)
191+
192+
def undo(self):
193+
self._apply(self.new_index, self.old_index)
194+
195+
def _apply(self, src, dst):
196+
df = self.model._data_frame
197+
df.rename(index={src: dst}, inplace=True)
198+
self.model.dataChanged.emit(
199+
self.model_index, self.model_index, [Qt.DisplayRole]
200+
)

src/petab_gui/controllers/mother_controller.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from PySide6.QtWidgets import QMessageBox, QFileDialog, QLineEdit, QWidget, \
44
QHBoxLayout, QToolButton, QTableView
5-
from PySide6.QtGui import QAction, QDesktopServices
5+
from PySide6.QtGui import QAction, QDesktopServices, QUndoStack, QKeySequence
66
import zipfile
77
import tempfile
88
import os
@@ -19,7 +19,7 @@
1919
ConditionController, ParameterController
2020
from .logger_controller import LoggerController
2121
from ..views import TaskBar
22-
from .utils import prompt_overwrite_or_append, RecentFilesManager
22+
from .utils import prompt_overwrite_or_append, RecentFilesManager, filtered_error
2323
from functools import partial
2424
from ..settings_manager import SettingsDialog, settings_manager
2525

@@ -42,6 +42,7 @@ def __init__(self, view, model: PEtabModel):
4242
model: PEtabModel
4343
The PEtab model.
4444
"""
45+
self.undo_stack = QUndoStack()
4546
self.task_bar = None
4647
self.view = view
4748
self.model = model
@@ -51,24 +52,28 @@ def __init__(self, view, model: PEtabModel):
5152
self.view.measurement_dock,
5253
self.model.measurement,
5354
self.logger,
55+
self.undo_stack,
5456
self
5557
)
5658
self.observable_controller = ObservableController(
5759
self.view.observable_dock,
5860
self.model.observable,
5961
self.logger,
62+
self.undo_stack,
6063
self
6164
)
6265
self.parameter_controller = ParameterController(
6366
self.view.parameter_dock,
6467
self.model.parameter,
6568
self.logger,
69+
self.undo_stack,
6670
self
6771
)
6872
self.condition_controller = ConditionController(
6973
self.view.condition_dock,
7074
self.model.condition,
7175
self.logger,
76+
self.undo_stack,
7277
self
7378
)
7479
self.sbml_controller = SbmlController(
@@ -189,21 +194,21 @@ def setup_actions(self):
189194
"&Close", self.view
190195
)}
191196
# Close
192-
actions["close"].setShortcut("Ctrl+Q")
197+
actions["close"].setShortcut(QKeySequence.Close)
193198
actions["close"].triggered.connect(self.view.close)
194199
# New File
195200
actions["new"] = QAction(
196201
qta.icon("mdi6.file-document"),
197202
"&New", self.view
198203
)
199-
actions["new"].setShortcut("Ctrl+N")
204+
actions["new"].setShortcut(QKeySequence.New)
200205
actions["new"].triggered.connect(self.new_file)
201206
# Open File
202207
actions["open"] = QAction(
203208
qta.icon("mdi6.folder-open"),
204209
"&Open", self.view
205210
)
206-
actions["open"].setShortcut("Ctrl+O")
211+
actions["open"].setShortcut(QKeySequence.Open)
207212
actions["open"].triggered.connect(
208213
partial(self.open_file, mode="overwrite")
209214
)
@@ -221,33 +226,33 @@ def setup_actions(self):
221226
qta.icon("mdi6.content-save-all"),
222227
"&Save", self.view
223228
)
224-
actions["save"].setShortcut("Ctrl+S")
229+
actions["save"].setShortcut(QKeySequence.Save)
225230
actions["save"].triggered.connect(self.save_model)
226231
# Find + Replace
227232
actions["find"] = QAction(
228233
qta.icon("mdi6.magnify"),
229234
"Find", self.view
230235
)
231-
actions["find"].setShortcut("Ctrl+F")
236+
actions["find"].setShortcut(QKeySequence.Find)
232237
actions["find"].triggered.connect(self.find)
233238
actions["find+replace"] = QAction(
234239
qta.icon("mdi6.find-replace"),
235240
"Find/Replace", self.view
236241
)
237-
actions["find+replace"].setShortcut("Ctrl+R")
242+
actions["find+replace"].setShortcut(QKeySequence.Replace)
238243
actions["find+replace"].triggered.connect(self.replace)
239244
# Copy / Paste
240245
actions["copy"] = QAction(
241246
qta.icon("mdi6.content-copy"),
242247
"Copy", self.view
243248
)
244-
actions["copy"].setShortcut("Ctrl+C")
249+
actions["copy"].setShortcut(QKeySequence.Copy)
245250
actions["copy"].triggered.connect(self.copy_to_clipboard)
246251
actions["paste"] = QAction(
247252
qta.icon("mdi6.content-paste"),
248253
"Paste", self.view
249254
)
250-
actions["paste"].setShortcut("Ctrl+V")
255+
actions["paste"].setShortcut(QKeySequence.Paste)
251256
actions["paste"].triggered.connect(self.paste_from_clipboard)
252257
# add/delete row
253258
actions["add_row"] = QAction(
@@ -369,6 +374,23 @@ def setup_actions(self):
369374
))
370375
)
371376

377+
# Undo / Redo
378+
actions["undo"] = QAction(
379+
qta.icon("mdi6.undo"),
380+
"&Undo", self.view
381+
)
382+
actions["undo"].setShortcut(QKeySequence.Undo)
383+
actions["undo"].triggered.connect(self.undo_stack.undo)
384+
actions["undo"].setEnabled(self.undo_stack.canUndo())
385+
self.undo_stack.canUndoChanged.connect(actions["undo"].setEnabled)
386+
actions["redo"] = QAction(
387+
qta.icon("mdi6.redo"),
388+
"&Redo", self.view
389+
)
390+
actions["redo"].setShortcut(QKeySequence.Redo)
391+
actions["redo"].triggered.connect(self.undo_stack.redo)
392+
actions["redo"].setEnabled(self.undo_stack.canRedo())
393+
self.undo_stack.canRedoChanged.connect(actions["redo"].setEnabled)
372394
return actions
373395

374396
def sync_visibility_with_actions(self):
@@ -673,6 +695,9 @@ def check_model(self):
673695
model.reset_invalid_cells()
674696
else:
675697
self.logger.log_message("Model is inconsistent.", color="red")
698+
except Exception as e:
699+
msg = f"PEtab linter failed at some point: {filtered_error(e)}"
700+
self.logger.log_message(msg, color="red")
676701
finally:
677702
# Always remove the capture handler
678703
logger.removeHandler(capture_handler)

0 commit comments

Comments
 (0)