diff --git a/gui-js/apps/minsky-electron/src/app/events/electron.events.ts b/gui-js/apps/minsky-electron/src/app/events/electron.events.ts index 73e725692..34c16da71 100644 --- a/gui-js/apps/minsky-electron/src/app/events/electron.events.ts +++ b/gui-js/apps/minsky-electron/src/app/events/electron.events.ts @@ -150,6 +150,15 @@ ipcMain.handle( } ); +ipcMain.handle( + events.GODLEY_POPUP_UNDO, + async (event, payload: { itemId: string; changes: number }) => { + const godley = new GodleyIcon(minsky.namedItems.elem(payload.itemId)); + await godley.popup.undo(payload.changes); + event.sender.send(events.GODLEY_POPUP_REFRESH); + } +); + ipcMain.on( events.REFRESH_ALL_GODLEY_POPUPS, async () => { WindowManager.refreshAllGodleyPopups(); diff --git a/gui-js/apps/minsky-electron/src/app/managers/GodleyMenuManager.ts b/gui-js/apps/minsky-electron/src/app/managers/GodleyMenuManager.ts index c837ce598..8950cfaf3 100644 --- a/gui-js/apps/minsky-electron/src/app/managers/GodleyMenuManager.ts +++ b/gui-js/apps/minsky-electron/src/app/managers/GodleyMenuManager.ts @@ -20,7 +20,7 @@ export class GodleyMenuManager { const godley = new GodleyIcon(minsky.namedItems.elem(itemInfo.id)); const menu = Menu.buildFromTemplate([ scope.getGodleyFileMenuItem(window, godley), - scope.getGodleyEditMenuItem(godley), + scope.getGodleyEditMenuItem(window, godley, itemInfo.id), scope.getGodleyViewMenuItem(window), scope.getGodleyOptionsMenuItem(window), new MenuItem({ @@ -188,7 +188,9 @@ export class GodleyMenuManager { } private static getGodleyEditMenuItem( - godley: GodleyIcon + window: Electron.BrowserWindow, + godley: GodleyIcon, + itemId: string ) { return new MenuItem({ label: 'Edit', @@ -196,12 +198,18 @@ export class GodleyMenuManager { { label: 'Undo', accelerator: 'CmdOrCtrl + z', - click: () => {godley.popup.undo(1);}, + click: () => window.webContents?.send(events.GODLEY_POPUP_UNDO_REQUEST, { + itemId, + changes: 1, + }), }, { label: 'Redo', accelerator: 'CmdOrCtrl + y', - click: () => {godley.popup.undo(-1);}, + click: () => window.webContents?.send(events.GODLEY_POPUP_UNDO_REQUEST, { + itemId, + changes: -1, + }), }, { label: 'Title', @@ -281,4 +289,3 @@ export class GodleyMenuManager { menu.popup(); } } - diff --git a/gui-js/libs/shared/src/lib/constants/constants.ts b/gui-js/libs/shared/src/lib/constants/constants.ts index 5471e1d10..b473fbd87 100644 --- a/gui-js/libs/shared/src/lib/constants/constants.ts +++ b/gui-js/libs/shared/src/lib/constants/constants.ts @@ -35,6 +35,8 @@ export const events = { GET_CURRENT_WINDOW: 'get-current-window', GET_PREFERENCES: 'get-preferences', GODLEY_POPUP_REFRESH: 'godley-popup-refresh', + GODLEY_POPUP_UNDO: 'godley-popup-undo', + GODLEY_POPUP_UNDO_REQUEST: 'godley-popup-undo-request', GODLEY_VIEW_MOUSEDOWN: 'godley-view-mousedown', GODLEY_VIEW_IMPORT_STOCK: 'godley-view-import-stock', HELP_FOR: 'help-for', diff --git a/gui-js/libs/ui-components/src/lib/godley-widget-view/godley-widget-view.component.ts b/gui-js/libs/ui-components/src/lib/godley-widget-view/godley-widget-view.component.ts index c37714693..f01e2991d 100644 --- a/gui-js/libs/ui-components/src/lib/godley-widget-view/godley-widget-view.component.ts +++ b/gui-js/libs/ui-components/src/lib/godley-widget-view/godley-widget-view.component.ts @@ -104,6 +104,10 @@ export class GodleyWidgetViewComponent implements OnDestroy, OnInit, AfterViewIn this.windowId = (await this.electronService.getCurrentWindow()).id; if(!this.canvasMode) { + this.electronService.on(events.GODLEY_POPUP_UNDO_REQUEST, async (e, payload) => { + await this.finishEditing(); + await this.electronService.invoke(events.GODLEY_POPUP_UNDO, payload); + }); this.electronService.on(events.GODLEY_POPUP_REFRESH, async e => { await this.hardRefresh(); }); @@ -256,6 +260,7 @@ export class GodleyWidgetViewComponent implements OnDestroy, OnInit, AfterViewIn async onRowAdd(i) { await this.finishEditing(); + await this.namedItemSubCommand.pushHistory(); this.godleyIcon.table.insertRow(i+3); await this.hardRefresh(); } @@ -268,24 +273,28 @@ export class GodleyWidgetViewComponent implements OnDestroy, OnInit, AfterViewIn async onRowMove(i, n) { await this.finishEditing(); + await this.namedItemSubCommand.pushHistory(); this.godleyIcon.table.moveRow(i+2,n); await this.hardRefresh(); } async onColumnAdd(i) { await this.finishEditing(); + await this.namedItemSubCommand.pushHistory(); this.godleyIcon.table.insertCol(i+2); await this.hardRefresh(); } async onColumnDelete(i) { await this.finishEditing(); + await this.namedItemSubCommand.pushHistory(); this.godleyIcon.table.deleteCol(i+2); await this.hardRefresh(); } async onColumnMove(i, n) { await this.finishEditing(); + await this.namedItemSubCommand.pushHistory(); this.godleyIcon.table.moveCol(i+1,n); await this.hardRefresh(); } diff --git a/gui-js/package-lock.json b/gui-js/package-lock.json index 01b266512..339a74ff4 100644 --- a/gui-js/package-lock.json +++ b/gui-js/package-lock.json @@ -14170,21 +14170,6 @@ } } }, - "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -16826,21 +16811,6 @@ } } }, - "node_modules/jsdom/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/jsdom/node_modules/lru-cache": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -24986,21 +24956,6 @@ } } }, - "node_modules/whatwg-url/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -25308,24 +25263,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", diff --git a/model/godleyIcon.cc b/model/godleyIcon.cc index 7661d5f0b..189c792be 100644 --- a/model/godleyIcon.cc +++ b/model/godleyIcon.cc @@ -222,8 +222,15 @@ namespace minsky } void GodleyIcon::setCell(int row, int col, const string& newVal) + { + setCellImpl(row, col, newVal, true); + } + + void GodleyIcon::setCellImpl(int row, int col, const string& newVal, + bool recordHistory) { if (!table.cellInTable(row,col)) return; + if (recordHistory) popup.pushHistory(); // if this operation is clearing an initial condition cell, set it to 0 string& c=table.cell(row,col); if (newVal.empty() && !c.empty() && table.initialConditionRow(row)) @@ -241,6 +248,7 @@ namespace minsky { if (row>0 && row<=table.rows()) { + popup.pushHistory(); table.deleteRow(row-1); // if shared column data is deleted, remove it from the other tables too for (size_t col=1; col summarise() const; /// updates the variable lists with the Godley table - void update(); + /// @param preserveTable - do no populate table's initial conditions from variable init conditions + void update(bool preserveTable=false); GodleyIcon* clone() const override { auto r=new GodleyIcon(*this); diff --git a/model/godleyTableWindow.cc b/model/godleyTableWindow.cc index f726ede34..b3e37dd4e 100644 --- a/model/godleyTableWindow.cc +++ b/model/godleyTableWindow.cc @@ -483,6 +483,7 @@ namespace minsky void GodleyTableEditor::mouseDown(double x, double y) { + pushHistory(); // catch exception, as the intention here is to allow the user to fix a problem try {update();} catch (...) {} @@ -545,6 +546,7 @@ namespace minsky void GodleyTableEditor::mouseUp(double x, double y) { + pushHistory(); button1=false; x/=zoomFactor; y/=zoomFactor; @@ -633,6 +635,7 @@ namespace minsky void GodleyTableEditor::keyPress(int keySym, const std::string& utf8) { + pushHistory(); auto& table=m_godleyIcon.table; if (selectedCellInTable() && (selectedCol!=0 || selectedRow!=1)) // Cell (1,0) is off-limits. For ticket 1064 @@ -777,6 +780,7 @@ namespace minsky void GodleyTableEditor::cut() { if (!selectedCellInTable()) return; + pushHistory(); copy(); if (selectIdx==insertIdx) // delete entire cell @@ -800,6 +804,7 @@ namespace minsky void GodleyTableEditor::paste() { if (!selectedCellInTable()) return; + pushHistory(); delSelection(); auto& str=m_godleyIcon.table.cell(selectedRow,selectedCol); auto stringToInsert=cminsky().clipboard.getClipboard(); @@ -857,7 +862,11 @@ namespace minsky void GodleyTableEditor::addStockVarByCol(int c) { - if (c>0) m_godleyIcon.table.insertCol(c+1); + if (c>0) + { + pushHistory(); + m_godleyIcon.table.insertCol(c+1); + } } void GodleyTableEditor::importStockVar(const string& name, double x) @@ -870,6 +879,7 @@ namespace minsky { if (c>0 && size_t(c)=0) - m_godleyIcon.table.deleteCol(c+1); + { + pushHistory(); + m_godleyIcon.table.deleteCol(c+1); + } } void GodleyTableEditor::addFlow(double y) @@ -897,8 +910,11 @@ namespace minsky void GodleyTableEditor::addFlowByRow(int r) { - if (r>0) - m_godleyIcon.table.insertRow(r+1); + if (r>0) + { + pushHistory(); + m_godleyIcon.table.insertRow(r+1); + } } void GodleyTableEditor::deleteFlow(double y) @@ -910,7 +926,10 @@ namespace minsky void GodleyTableEditor::deleteFlowByRow(int r) { if (r>1) // Cannot delete flow in Initial Conditions row. For ticket 1064 - m_godleyIcon.deleteRow(r+1); + { + pushHistory(); + m_godleyIcon.deleteRow(r+1); + } } namespace { @@ -1013,25 +1032,53 @@ namespace { void GodleyTableEditor::pushHistory() { - while (history.size()>maxHistory) history.pop_front(); + if (historyPtr0 && history[historyPtr-1]==m_godleyIcon.table) + return; + history.erase(history.begin()+historyPtr, history.end()); + } // Perform deep comparison of Godley tables in history to avoid spurious noAssetClass columns from arising during undo. For ticket 1118. if (history.empty() || !(history.back()==m_godleyIcon.table)) { history.push_back(m_godleyIcon.table); } + while (history.size()>maxHistory) + { + history.pop_front(); + if (historyPtr>0) --historyPtr; + } historyPtr=history.size(); } void GodleyTableEditor::undo(int changes) { - if (historyPtr==history.size()) - pushHistory(); - historyPtr-=changes; + const bool pendingChange=historyPtr==0 || + !(history[historyPtr-1]==m_godleyIcon.table); + if (pendingChange) + { + pushHistory(); + if (changes<0) return; + } + if (changes>0 && static_cast(changes)>=historyPtr) + return; + if (changes<0 && + static_cast(-static_cast(changes))> + history.size()-historyPtr) + return; + if (changes>0) + historyPtr-=changes; + else if (changes<0) + historyPtr+=-static_cast(changes); if (historyPtr > 0 && historyPtr <= history.size()) { auto& d=history[historyPtr-1]; // Perform deep comparison of Godley tables in history to avoid spurious noAssetClass columns from arising during undo. For ticket 1118. if (d.getData().empty()) return; // should not happen - m_godleyIcon.table=d; + const GodleyTable restoredTable=d; + m_godleyIcon.table=restoredTable; + m_godleyIcon.update(true); + adjustWidgets(); + requestRedrawCanvas(); } } diff --git a/test/00/godleyTable.sh b/test/00/godleyTable.sh index b6c20e8af..5d69abe62 100755 --- a/test/00/godleyTable.sh +++ b/test/00/godleyTable.sh @@ -75,6 +75,40 @@ assert minsky.canvas.item.table.rowSum(1)=='0' assert minsky.canvas.item.table.rowSum(2)=='b' assert minsky.canvas.item.table.getVariables()()==['a', 'b'] +# popup undo/redo tracks cell edits and discards stale redo states +originalCell = item.table.getCell(2, 1) +item.setCell(2, 1, 'changed') +item.popup.undo(1) +assert item.table.getCell(2, 1)==originalCell +item.popup.undo(-1) +assert item.table.getCell(2, 1)=='changed' +item.popup.undo(1) +item.setCell(2, 1, 'branched') +item.popup.undo(-1) +assert item.table.getCell(2, 1)=='branched' +item.popup.undo(1) +assert item.table.getCell(2, 1)==originalCell +item.popup.undo(-1) +assert item.table.getCell(2, 1)=='branched' + +# updating after undo must not change a restored blank initial condition +item.popup.pushHistory() +item.table.setCell(1, 1, '') +item.setCell(2, 1, 'changed') +item.popup.undo(1) +assert item.table.getCell(1, 1)=='' +item.popup.undo(-1) +assert item.table.getCell(2, 1)=='changed' + +# row edits made through the popup are also undoable +rows = item.table.rows() +item.popup.addFlowByRow(rows-1) +assert item.table.rows()==rows+1 +item.popup.undo(1) +assert item.table.rows()==rows +item.popup.undo(-1) +assert item.table.rows()==rows+1 + minsky.canvas.deleteItem() assert minsky.model.numItems()==0 EOF diff --git a/test/testMinsky.cc b/test/testMinsky.cc index 5e8aa0551..61ff2f006 100644 --- a/test/testMinsky.cc +++ b/test/testMinsky.cc @@ -959,6 +959,25 @@ TEST(TensorOps, evalOpEvaluate) EXPECT_EQ("",godley1.cell(2,2)); } + TEST_F(MinskySuite, godleyCrossColumnMoveUndo) + { + auto g=new GodleyIcon; + model->addItem(g); + g->table.resize(2,3); + g->table.cell(0,1)="a"; + g->table.cell(0,2)="b"; + g->table.cell(1,1)="flow"; + g->popup.pushHistory(); + + g->moveCell({1,1,1,2}); + EXPECT_EQ("",g->table.cell(1,1)); + EXPECT_EQ("flow",g->table.cell(1,2)); + + g->popup.undo(1); + EXPECT_EQ("flow",g->table.cell(1,1)); + EXPECT_EQ("",g->table.cell(1,2)); + } + TEST_F(MinskySuite,godleyRowSums) { auto g1=new GodleyIcon; model->addItem(g1);