From 2dc9234b228bf23c2ccfbad987d51d91ca99aad3 Mon Sep 17 00:00:00 2001 From: Ad5001 Date: Fri, 11 Oct 2024 01:14:52 +0200 Subject: [PATCH] Decoupled History from QML --- common/src/events.mjs | 36 ++++-- common/src/lib/polyfills/js.mjs | 17 ++- common/src/module/history.mjs | 117 +++++++++++++++-- common/src/module/interface.mjs | 18 --- common/src/module/io.mjs | 2 +- common/src/module/settings.mjs | 6 +- .../eu/ad5001/LogarithmPlotter/AppMenuBar.qml | 6 +- .../LogarithmPlotter/History/History.qml | 122 +----------------- .../History/HistoryBrowser.qml | 89 +++++++++++-- .../LogarithmPlotter/History/HistoryItem.qml | 17 +-- 10 files changed, 239 insertions(+), 191 deletions(-) diff --git a/common/src/events.mjs b/common/src/events.mjs index 4e95db6..fbc8228 100644 --- a/common/src/events.mjs +++ b/common/src/events.mjs @@ -54,29 +54,41 @@ export class BaseEventEmitter { * @param {function(BaseEvent)} eventListener - The function to be called back when the event is emitted. */ on(eventType, eventListener) { - if(!this.constructor.emits.includes(eventType)) { - const className = this.constructor.name - const eventTypes = this.constructor.emits.join(", ") - throw new Error(`Cannot listen to unknown event ${eventType} in class ${className}. ${className} only emits: ${eventTypes}`) + if(eventType.includes(" ")) // Listen to several different events with the same listener. + for(const type of eventType.split(" ")) + this.on(type, eventListener) + else { + console.log("Listening to", eventType) + if(!this.constructor.emits.includes(eventType)) { + const className = this.constructor.name + const eventTypes = this.constructor.emits.join(", ") + throw new Error(`Cannot listen to unknown event ${eventType} in class ${className}. ${className} only emits: ${eventTypes}`) + } + if(!this.#listeners[eventType].has(eventListener)) + this.#listeners[eventType].add(eventListener) } - if(!this.#listeners[eventType].has(eventListener)) - this.#listeners[eventType].add(eventListener) } /** - * Remvoes a listener from an event that can be emitted by this object. + * Removes a listener from an event that can be emitted by this object. * * @param {string} eventType - Name of the event that was listened to. Throws an error if this object does not emit this kind of event. * @param {function(BaseEvent)} eventListener - The function previously registered as a listener. * @returns {boolean} True if the listener was removed, false if it was not found. */ off(eventType, eventListener) { - if(!this.constructor.emits.includes(eventType)) { - const className = this.constructor.name - const eventTypes = this.constructor.emits.join(", ") - throw new Error(`Cannot listen to unknown event ${eventType} in class ${className}. ${className} only emits: ${eventTypes}`) + if(eventType.includes(" ")) { // Unlisten to several different events with the same listener. + let found = false + for(const type of eventType.split(" ")) + found ||= this.off(eventType, eventListener) + } else { + if(!this.constructor.emits.includes(eventType)) { + const className = this.constructor.name + const eventTypes = this.constructor.emits.join(", ") + throw new Error(`Cannot listen to unknown event ${eventType} in class ${className}. ${className} only emits: ${eventTypes}`) + } + return this.#listeners[eventType].delete(eventListener) } - return this.#listeners[eventType].delete(eventListener) } /** diff --git a/common/src/lib/polyfills/js.mjs b/common/src/lib/polyfills/js.mjs index b412716..f8b910b 100644 --- a/common/src/lib/polyfills/js.mjs +++ b/common/src/lib/polyfills/js.mjs @@ -67,6 +67,19 @@ function stringReplaceAll(from, to) { return this.split(from).join(to) } +/** + * Returns the value of an element of the array at a given index. + * Accepts negative indexes. + * @this {Array|string} + * @param {number} index + * @return {*} + */ +function arrayAt(index) { + if(typeof index !== "number") + throw new Error(`${index} is not a number`) + return index >= 0 ? this[index] : this[this.length + index] +} + const polyfills = { 2017: [ @@ -95,8 +108,8 @@ const polyfills = { [String.prototype, "replaceAll", stringReplaceAll] ], 2022: [ - [Array.prototype, "at", notPolyfilled("Array.prototype.at")], - [String.prototype, "at", notPolyfilled("String.prototype.at")], + [Array.prototype, "at", arrayAt], + [String.prototype, "at", arrayAt], [Object, "hasOwn", notPolyfilled("Object.hasOwn")] ], 2023: [ diff --git a/common/src/module/history.mjs b/common/src/module/history.mjs index 8cd3f60..7406925 100644 --- a/common/src/module/history.mjs +++ b/common/src/module/history.mjs @@ -17,60 +17,151 @@ */ import { Module } from "./common.mjs" -import { HistoryInterface, NUMBER, STRING } from "./interface.mjs" +import { HelperInterface, HistoryInterface, NUMBER, STRING } from "./interface.mjs" +import { BaseEvent } from "../events.mjs" +import { Action, Actions } from "../history/index.mjs" + +class UpdatedEvent extends BaseEvent { + constructor() { + super("updated") + } +} + +class UndoneEvent extends BaseEvent { + constructor(action) { + super("undone") + this.undid = action + } +} + +class RedoneEvent extends BaseEvent { + constructor(action) { + super("redone") + this.redid = action + } +} + class HistoryAPI extends Module { + static emits = ["updated", "undone", "redone"] + + #helper + constructor() { super("History", { - historyObj: HistoryInterface, + helper: HelperInterface, themeTextColor: STRING, imageDepth: NUMBER, fontSize: NUMBER }) // History QML object - this.history = null + /** @type {Action[]} */ + this.undoStack = [] + /** @type {Action[]} */ + this.redoStack = [] + this.themeTextColor = "#FF0000" this.imageDepth = 2 this.fontSize = 28 } - initialize({ historyObj, themeTextColor, imageDepth, fontSize }) { - super.initialize({ historyObj, themeTextColor, imageDepth, fontSize }) - this.history = historyObj + /** + * @param {HelperInterface} historyObj + * @param {string} themeTextColor + * @param {number} imageDepth + * @param {number} fontSize + */ + initialize({ helper, themeTextColor, imageDepth, fontSize }) { + super.initialize({ helper, themeTextColor, imageDepth, fontSize }) + this.#helper = helper this.themeTextColor = themeTextColor this.imageDepth = imageDepth this.fontSize = fontSize } + /** + * Undoes the Action at the top of the undo stack and pushes it to the top of the redo stack. + */ undo() { if(!this.initialized) throw new Error("Attempting undo before initialize!") - this.history.undo() + if(this.undoStack.length > 0) { + const action = this.undoStack.pop() + action.undo() + this.redoStack.push(action) + this.emit(new UndoneEvent(action)) + } } + /** + * Redoes the Action at the top of the redo stack and pushes it to the top of the undo stack. + */ redo() { if(!this.initialized) throw new Error("Attempting redo before initialize!") - this.history.redo() + if(this.redoStack.length > 0) { + const action = this.redoStack.pop() + action.redo() + this.undoStack.push(action) + this.emit(new RedoneEvent(action)) + } } + /** + * Clears both undo and redo stacks completely. + */ clear() { if(!this.initialized) throw new Error("Attempting clear before initialize!") - this.history.clear() + this.undoStack = [] + this.redoStack = [] + this.emit(new UpdatedEvent()) } + /** + * Adds an instance of HistoryLib.Action to history. + * @param action + */ addToHistory(action) { if(!this.initialized) throw new Error("Attempting addToHistory before initialize!") - this.history.addToHistory(action) + if(action instanceof Action) { + console.log("Added new entry to history: " + action.getReadableString()) + this.undoStack.push(action) + if(this.#helper.getSettingBool("reset_redo_stack")) + this.redoStack = [] + this.emit(new UpdatedEvent()) + } } - unserialize(...data) { + /** + * Unserializes both the undo stack and redo stack from serialized content. + * @param {[string, any[]][]} undoSt + * @param {[string, any[]][]} redoSt + */ + unserialize(undoSt, redoSt) { if(!this.initialized) throw new Error("Attempting unserialize before initialize!") - this.history.unserialize(...data) + this.clear() + for(const [name, args] of undoSt) + this.undoStack.push( + new Actions[name](...args) + ) + for(const [name, args] of redoSt) + this.redoStack.push( + new Actions[name](...args) + ) + this.emit(new UpdatedEvent()) } + /** + * Serializes history into JSON-able content. + * @return {[[string, any[]], [string, any[]]]} + */ serialize() { if(!this.initialized) throw new Error("Attempting serialize before initialize!") - return this.history.serialize() + let undoSt = [], redoSt = []; + for(const action of this.undoStack) + undoSt.push([ action.type(), action.export() ]) + for(const action of this.redoStack) + redoSt.push([ action.type(), action.export() ]) + return [undoSt, redoSt] } } diff --git a/common/src/module/interface.mjs b/common/src/module/interface.mjs index 606ec4d..1ee65a3 100644 --- a/common/src/module/interface.mjs +++ b/common/src/module/interface.mjs @@ -60,24 +60,6 @@ export class Interface { } -export class SettingsInterface extends Interface { - width = NUMBER - height = NUMBER - xmin = NUMBER - ymax = NUMBER - xzoom = NUMBER - yzoom = NUMBER - xaxisstep = STRING - yaxisstep = STRING - xlabel = STRING - ylabel = STRING - linewidth = NUMBER - textsize = NUMBER - logscalex = BOOLEAN - showxgrad = BOOLEAN - showygrad = BOOLEAN -} - export class CanvasInterface extends Interface { imageLoaders = OBJECT /** @type {function(string): CanvasRenderingContext2D} */ diff --git a/common/src/module/io.mjs b/common/src/module/io.mjs index 0b4c6fa..8ca4b2b 100644 --- a/common/src/module/io.mjs +++ b/common/src/module/io.mjs @@ -21,7 +21,7 @@ import Objects from "./objects.mjs" import History from "./history.mjs" import Canvas from "./canvas.mjs" import Settings from "./settings.mjs" -import { DialogInterface, RootInterface, SettingsInterface } from "./interface.mjs" +import { DialogInterface, RootInterface } from "./interface.mjs" class IOAPI extends Module { diff --git a/common/src/module/settings.mjs b/common/src/module/settings.mjs index 44a6593..1530839 100644 --- a/common/src/module/settings.mjs +++ b/common/src/module/settings.mjs @@ -72,7 +72,11 @@ class SettingsAPI extends Module { helper: HelperInterface }) } - + + /** + * + * @param {HelperInterface} helper + */ initialize({ helper }) { super.initialize({ helper }) // Initialize default values. diff --git a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/AppMenuBar.qml b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/AppMenuBar.qml index f26fd5b..8c3f2d1 100644 --- a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/AppMenuBar.qml +++ b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/AppMenuBar.qml @@ -76,18 +76,16 @@ MenuBar { Action { text: qsTr("&Undo") shortcut: StandardKey.Undo - onTriggered: history.undo() + onTriggered: Modules.History.undo() icon.name: 'edit-undo' icon.color: enabled ? sysPalette.windowText : sysPaletteIn.windowText - enabled: history.undoCount > 0 } Action { text: qsTr("&Redo") shortcut: StandardKey.Redo - onTriggered: history.redo() + onTriggered: Modules.History.redo() icon.name: 'edit-redo' icon.color: enabled ? sysPalette.windowText : sysPaletteIn.windowText - enabled: history.redoCount > 0 } Action { text: qsTr("&Copy plot") diff --git a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/History.qml b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/History.qml index 47db5f3..88579e7 100644 --- a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/History.qml +++ b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/History.qml @@ -64,10 +64,7 @@ Item { Clears both undo and redo stacks completly. */ function clear() { - undoCount = 0 - redoCount = 0 - undoStack = [] - redoStack = [] + Modules.History.clear() } @@ -76,18 +73,7 @@ Item { Serializes history into JSON-able content. */ function serialize() { - let undoSt = [], redoSt = []; - for(let i = 0; i < undoCount; i++) - undoSt.push([ - undoStack[i].type(), - undoStack[i].export() - ]); - for(let i = 0; i < redoCount; i++) - redoSt.push([ - redoStack[i].type(), - redoStack[i].export() - ]); - return [undoSt, redoSt] + return Modules.History.serialize() } /*! @@ -95,14 +81,7 @@ Item { Unserializes both \c undoSt stack and \c redoSt stack from serialized content. */ function unserialize(undoSt, redoSt) { - clear(); - for(let i = 0; i < undoSt.length; i++) - undoStack.push(new JS.HistoryLib.Actions[undoSt[i][0]](...undoSt[i][1])) - for(let i = 0; i < redoSt.length; i++) - redoStack.push(new JS.HistoryLib.Actions[redoSt[i][0]](...redoSt[i][1])) - undoCount = undoSt.length; - redoCount = redoSt.length; - objectLists.update() + Modules.History.unserialize(undoSt, redoSt) } /*! @@ -110,16 +89,7 @@ Item { Adds an instance of HistoryLib.Action to history. */ function addToHistory(action) { - if(action instanceof JS.HistoryLib.Action) { - console.log("Added new entry to history: " + action.getReadableString()) - undoStack.push(action) - undoCount++; - if(Helper.getSettingBool("reset_redo_stack")) { - redoStack = [] - redoCount = 0 - } - saved = false - } + Modules.History.addToHistory(action) } /*! @@ -128,16 +98,7 @@ Item { By default, will update the graph and the object list. This behavior can be disabled by setting the \c updateObjectList to false. */ function undo(updateObjectList = true) { - if(undoStack.length > 0) { - var action = undoStack.pop() - action.undo() - if(updateObjectList) - objectLists.update() - redoStack.push(action) - undoCount--; - redoCount++; - saved = false - } + Modules.History.undo() } /*! @@ -146,77 +107,6 @@ Item { By default, will update the graph and the object list. This behavior can be disabled by setting the \c updateObjectList to false. */ function redo(updateObjectList = true) { - if(redoStack.length > 0) { - var action = redoStack.pop() - action.redo() - if(updateObjectList) - objectLists.update() - undoStack.push(action) - undoCount++; - redoCount--; - saved = false - } - } - - /*! - \qmlmethod void History::undoMultipleDefered(int toUndoCount) - Undoes several HistoryLib.Action at the top of the undo stack and pushes them to the top of the redo stack. - It undoes them deferedly to avoid overwhelming the computer while creating a cool short accelerated summary of all changes. - */ - function undoMultipleDefered(toUndoCount) { - undoTimer.toUndoCount = toUndoCount; - undoTimer.start() - if(toUndoCount > 0) - saved = false - } - - - /*! - \qmlmethod void History::redoMultipleDefered(int toRedoCount) - Redoes several HistoryLib.Action at the top of the redo stack and pushes them to the top of the undo stack. - It redoes them deferedly to avoid overwhelming the computer while creating a cool short accelerated summary of all changes. - */ - function redoMultipleDefered(toRedoCount) { - redoTimer.toRedoCount = toRedoCount; - redoTimer.start() - if(toRedoCount > 0) - saved = false - } - - Timer { - id: undoTimer - interval: 5; running: false; repeat: true - property int toUndoCount: 0 - onTriggered: { - if(toUndoCount > 0) { - historyObj.undo(toUndoCount % 4 == 1) // Only redraw once every 4 changes. - toUndoCount--; - } else { - running = false; - } - } - } - - Timer { - id: redoTimer - interval: 5; running: false; repeat: true - property int toRedoCount: 0 - onTriggered: { - if(toRedoCount > 0) { - historyObj.redo(toRedoCount % 4 == 1) // Only redraw once every 4 changes. - toRedoCount--; - } else { - running = false; - } - } - } - - Component.onCompleted: { - Modules.History.initialize({ - historyObj, - themeTextColor: sysPalette.windowText.toString(), - imageDepth: Screen.devicePixelRatio, - fontSize: 14 - }) + Modules.History.redo() } } diff --git a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/HistoryBrowser.qml b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/HistoryBrowser.qml index 926b42a..d79cc84 100644 --- a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/HistoryBrowser.qml +++ b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/HistoryBrowser.qml @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +pragma ComponentBehavior: Bound + import QtQuick.Controls import QtQuick import eu.ad5001.LogarithmPlotter.Setting 1.0 as Setting @@ -46,6 +48,18 @@ Item { true when the system is running with a dark theme, false otherwise. */ property bool darkTheme: isDarkTheme() + + /*! + \qmlproperty int HistoryBrowser::undoCount + Number of actions in the undo stack. + */ + property int undoCount: 0 + + /*! + \qmlproperty int HistoryBrowser::redoCount + Number of actions in the redo stack. + */ + property int redoCount: 0 Setting.TextSetting { id: filterInput @@ -76,19 +90,22 @@ Item { id: redoColumn anchors.right: parent.right anchors.top: parent.top - width: actionWidth + width: historyBrowser.actionWidth Repeater { - model: history.redoCount + model: historyBrowser.redoCount HistoryItem { id: redoButton - width: actionWidth + width: historyBrowser.actionWidth //height: actionHeight isRedo: true - idx: index darkTheme: historyBrowser.darkTheme hidden: !(filterInput.value == "" || content.includes(filterInput.value)) + onClicked: { + redoTimer.toRedoCount = Modules.History.redoStack.length-index + redoTimer.start() + } } } } @@ -101,14 +118,14 @@ Item { transform: Rotation { origin.x: 30; origin.y: 30; angle: 270} height: 70 width: 20 - visible: history.redoCount > 0 + visible: historyBrowser.redoCount > 0 } Rectangle { id: nowRect anchors.right: parent.right anchors.top: redoColumn.bottom - width: actionWidth + width: historyBrowser.actionWidth height: 40 color: sysPalette.highlight Text { @@ -124,20 +141,24 @@ Item { id: undoColumn anchors.right: parent.right anchors.top: nowRect.bottom - width: actionWidth + width: historyBrowser.actionWidth Repeater { - model: history.undoCount + model: historyBrowser.undoCount HistoryItem { id: undoButton - width: actionWidth + width: historyBrowser.actionWidth //height: actionHeight isRedo: false - idx: index darkTheme: historyBrowser.darkTheme hidden: !(filterInput.value == "" || content.includes(filterInput.value)) + + onClicked: { + undoTimer.toUndoCount = +index+1 + undoTimer.start() + } } } } @@ -150,7 +171,39 @@ Item { transform: Rotation { origin.x: 30; origin.y: 30; angle: 270} height: 60 width: 20 - visible: history.undoCount > 0 + visible: historyBrowser.undoCount > 0 + } + } + } + + Timer { + id: undoTimer + interval: 5; running: false; repeat: true + property int toUndoCount: 0 + onTriggered: { + if(toUndoCount > 0) { + Modules.History.undo() + if(toUndoCount % 3 === 1) + Modules.Canvas.requestPaint() + toUndoCount--; + } else { + running = false; + } + } + } + + Timer { + id: redoTimer + interval: 5; running: false; repeat: true + property int toRedoCount: 0 + onTriggered: { + if(toRedoCount > 0) { + Modules.History.redo() + if(toRedoCount % 3 === 1) + Modules.Canvas.requestPaint() + toRedoCount--; + } else { + running = false; } } } @@ -163,6 +216,18 @@ Item { let hex = sysPalette.windowText.toString() // We only check the first parameter, as on all normal OSes, text color is grayscale. return parseInt(hex.substr(1,2), 16) > 128 - + } + + Component.onCompleted: { + Modules.History.initialize({ + helper: Helper, + themeTextColor: sysPalette.windowText.toString(), + imageDepth: Screen.devicePixelRatio, + fontSize: 14 + }) + Modules.History.on("updated undone redone", () => { + undoCount = Modules.History.undoStack.length + redoCount = Modules.History.redoStack.length + }) } } diff --git a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/HistoryItem.qml b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/HistoryItem.qml index f1e20a0..f37eb3b 100644 --- a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/HistoryItem.qml +++ b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/History/HistoryItem.qml @@ -41,17 +41,17 @@ Button { \qmlproperty bool HistoryItem::isRedo true if the action is in the redo stack, false othewise. */ - property bool isRedo + required property bool isRedo /*! - \qmlproperty int HistoryItem::idx + \qmlproperty int HistoryItem::index Index of the item within the HistoryBrowser list. */ - property int idx + required property int index /*! \qmlproperty bool HistoryItem::darkTheme true when the system is running with a dark theme, false otherwise. */ - property bool darkTheme + required property bool darkTheme /*! \qmlproperty bool HistoryItem::hidden true when the item is filtered out, false otherwise. @@ -61,7 +61,7 @@ Button { \qmlproperty int HistoryItem::historyAction Associated history action. */ - readonly property var historyAction: isRedo ? history.redoStack[idx] : history.undoStack[history.undoCount-idx-1] + readonly property var historyAction: isRedo ? Modules.History.redoStack.at(index) : Modules.History.undoStack.at(-index-1) /*! \qmlproperty int HistoryItem::actionHeight @@ -147,13 +147,6 @@ Button { ToolTip.visible: hovered ToolTip.delay: 200 ToolTip.text: content - - onClicked: { - if(isRedo) - history.redoMultipleDefered(history.redoCount-idx) - else - history.undoMultipleDefered(+idx+1) - } }