Decoupled History from QML

This commit is contained in:
Ad5001 2024-10-11 01:14:52 +02:00
parent 54363b25bc
commit 2dc9234b22
Signed by: Ad5001
GPG key ID: EF45F9C6AFE20160
10 changed files with 239 additions and 191 deletions

View file

@ -54,29 +54,41 @@ export class BaseEventEmitter {
* @param {function(BaseEvent)} eventListener - The function to be called back when the event is emitted. * @param {function(BaseEvent)} eventListener - The function to be called back when the event is emitted.
*/ */
on(eventType, eventListener) { on(eventType, eventListener) {
if(!this.constructor.emits.includes(eventType)) { if(eventType.includes(" ")) // Listen to several different events with the same listener.
const className = this.constructor.name for(const type of eventType.split(" "))
const eventTypes = this.constructor.emits.join(", ") this.on(type, eventListener)
throw new Error(`Cannot listen to unknown event ${eventType} in class ${className}. ${className} only emits: ${eventTypes}`) 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 {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. * @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. * @returns {boolean} True if the listener was removed, false if it was not found.
*/ */
off(eventType, eventListener) { off(eventType, eventListener) {
if(!this.constructor.emits.includes(eventType)) { if(eventType.includes(" ")) { // Unlisten to several different events with the same listener.
const className = this.constructor.name let found = false
const eventTypes = this.constructor.emits.join(", ") for(const type of eventType.split(" "))
throw new Error(`Cannot listen to unknown event ${eventType} in class ${className}. ${className} only emits: ${eventTypes}`) 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)
} }
/** /**

View file

@ -67,6 +67,19 @@ function stringReplaceAll(from, to) {
return this.split(from).join(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 = { const polyfills = {
2017: [ 2017: [
@ -95,8 +108,8 @@ const polyfills = {
[String.prototype, "replaceAll", stringReplaceAll] [String.prototype, "replaceAll", stringReplaceAll]
], ],
2022: [ 2022: [
[Array.prototype, "at", notPolyfilled("Array.prototype.at")], [Array.prototype, "at", arrayAt],
[String.prototype, "at", notPolyfilled("String.prototype.at")], [String.prototype, "at", arrayAt],
[Object, "hasOwn", notPolyfilled("Object.hasOwn")] [Object, "hasOwn", notPolyfilled("Object.hasOwn")]
], ],
2023: [ 2023: [

View file

@ -17,60 +17,151 @@
*/ */
import { Module } from "./common.mjs" 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 { class HistoryAPI extends Module {
static emits = ["updated", "undone", "redone"]
#helper
constructor() { constructor() {
super("History", { super("History", {
historyObj: HistoryInterface, helper: HelperInterface,
themeTextColor: STRING, themeTextColor: STRING,
imageDepth: NUMBER, imageDepth: NUMBER,
fontSize: NUMBER fontSize: NUMBER
}) })
// History QML object // History QML object
this.history = null /** @type {Action[]} */
this.undoStack = []
/** @type {Action[]} */
this.redoStack = []
this.themeTextColor = "#FF0000" this.themeTextColor = "#FF0000"
this.imageDepth = 2 this.imageDepth = 2
this.fontSize = 28 this.fontSize = 28
} }
initialize({ historyObj, themeTextColor, imageDepth, fontSize }) { /**
super.initialize({ historyObj, themeTextColor, imageDepth, fontSize }) * @param {HelperInterface} historyObj
this.history = 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.themeTextColor = themeTextColor
this.imageDepth = imageDepth this.imageDepth = imageDepth
this.fontSize = fontSize this.fontSize = fontSize
} }
/**
* Undoes the Action at the top of the undo stack and pushes it to the top of the redo stack.
*/
undo() { undo() {
if(!this.initialized) throw new Error("Attempting undo before initialize!") 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() { redo() {
if(!this.initialized) throw new Error("Attempting redo before initialize!") 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() { clear() {
if(!this.initialized) throw new Error("Attempting clear before initialize!") 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) { addToHistory(action) {
if(!this.initialized) throw new Error("Attempting addToHistory before initialize!") 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!") 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() { serialize() {
if(!this.initialized) throw new Error("Attempting serialize before initialize!") 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]
} }
} }

View file

@ -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 { export class CanvasInterface extends Interface {
imageLoaders = OBJECT imageLoaders = OBJECT
/** @type {function(string): CanvasRenderingContext2D} */ /** @type {function(string): CanvasRenderingContext2D} */

View file

@ -21,7 +21,7 @@ import Objects from "./objects.mjs"
import History from "./history.mjs" import History from "./history.mjs"
import Canvas from "./canvas.mjs" import Canvas from "./canvas.mjs"
import Settings from "./settings.mjs" import Settings from "./settings.mjs"
import { DialogInterface, RootInterface, SettingsInterface } from "./interface.mjs" import { DialogInterface, RootInterface } from "./interface.mjs"
class IOAPI extends Module { class IOAPI extends Module {

View file

@ -72,7 +72,11 @@ class SettingsAPI extends Module {
helper: HelperInterface helper: HelperInterface
}) })
} }
/**
*
* @param {HelperInterface} helper
*/
initialize({ helper }) { initialize({ helper }) {
super.initialize({ helper }) super.initialize({ helper })
// Initialize default values. // Initialize default values.

View file

@ -76,18 +76,16 @@ MenuBar {
Action { Action {
text: qsTr("&Undo") text: qsTr("&Undo")
shortcut: StandardKey.Undo shortcut: StandardKey.Undo
onTriggered: history.undo() onTriggered: Modules.History.undo()
icon.name: 'edit-undo' icon.name: 'edit-undo'
icon.color: enabled ? sysPalette.windowText : sysPaletteIn.windowText icon.color: enabled ? sysPalette.windowText : sysPaletteIn.windowText
enabled: history.undoCount > 0
} }
Action { Action {
text: qsTr("&Redo") text: qsTr("&Redo")
shortcut: StandardKey.Redo shortcut: StandardKey.Redo
onTriggered: history.redo() onTriggered: Modules.History.redo()
icon.name: 'edit-redo' icon.name: 'edit-redo'
icon.color: enabled ? sysPalette.windowText : sysPaletteIn.windowText icon.color: enabled ? sysPalette.windowText : sysPaletteIn.windowText
enabled: history.redoCount > 0
} }
Action { Action {
text: qsTr("&Copy plot") text: qsTr("&Copy plot")

View file

@ -64,10 +64,7 @@ Item {
Clears both undo and redo stacks completly. Clears both undo and redo stacks completly.
*/ */
function clear() { function clear() {
undoCount = 0 Modules.History.clear()
redoCount = 0
undoStack = []
redoStack = []
} }
@ -76,18 +73,7 @@ Item {
Serializes history into JSON-able content. Serializes history into JSON-able content.
*/ */
function serialize() { function serialize() {
let undoSt = [], redoSt = []; return Modules.History.serialize()
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]
} }
/*! /*!
@ -95,14 +81,7 @@ Item {
Unserializes both \c undoSt stack and \c redoSt stack from serialized content. Unserializes both \c undoSt stack and \c redoSt stack from serialized content.
*/ */
function unserialize(undoSt, redoSt) { function unserialize(undoSt, redoSt) {
clear(); Modules.History.unserialize(undoSt, redoSt)
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()
} }
/*! /*!
@ -110,16 +89,7 @@ Item {
Adds an instance of HistoryLib.Action to history. Adds an instance of HistoryLib.Action to history.
*/ */
function addToHistory(action) { function addToHistory(action) {
if(action instanceof JS.HistoryLib.Action) { Modules.History.addToHistory(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
}
} }
/*! /*!
@ -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. 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) { function undo(updateObjectList = true) {
if(undoStack.length > 0) { Modules.History.undo()
var action = undoStack.pop()
action.undo()
if(updateObjectList)
objectLists.update()
redoStack.push(action)
undoCount--;
redoCount++;
saved = false
}
} }
/*! /*!
@ -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. 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) { function redo(updateObjectList = true) {
if(redoStack.length > 0) { Modules.History.redo()
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
})
} }
} }

View file

@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
pragma ComponentBehavior: Bound
import QtQuick.Controls import QtQuick.Controls
import QtQuick import QtQuick
import eu.ad5001.LogarithmPlotter.Setting 1.0 as Setting 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. true when the system is running with a dark theme, false otherwise.
*/ */
property bool darkTheme: isDarkTheme() 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 { Setting.TextSetting {
id: filterInput id: filterInput
@ -76,19 +90,22 @@ Item {
id: redoColumn id: redoColumn
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
width: actionWidth width: historyBrowser.actionWidth
Repeater { Repeater {
model: history.redoCount model: historyBrowser.redoCount
HistoryItem { HistoryItem {
id: redoButton id: redoButton
width: actionWidth width: historyBrowser.actionWidth
//height: actionHeight //height: actionHeight
isRedo: true isRedo: true
idx: index
darkTheme: historyBrowser.darkTheme darkTheme: historyBrowser.darkTheme
hidden: !(filterInput.value == "" || content.includes(filterInput.value)) 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} transform: Rotation { origin.x: 30; origin.y: 30; angle: 270}
height: 70 height: 70
width: 20 width: 20
visible: history.redoCount > 0 visible: historyBrowser.redoCount > 0
} }
Rectangle { Rectangle {
id: nowRect id: nowRect
anchors.right: parent.right anchors.right: parent.right
anchors.top: redoColumn.bottom anchors.top: redoColumn.bottom
width: actionWidth width: historyBrowser.actionWidth
height: 40 height: 40
color: sysPalette.highlight color: sysPalette.highlight
Text { Text {
@ -124,20 +141,24 @@ Item {
id: undoColumn id: undoColumn
anchors.right: parent.right anchors.right: parent.right
anchors.top: nowRect.bottom anchors.top: nowRect.bottom
width: actionWidth width: historyBrowser.actionWidth
Repeater { Repeater {
model: history.undoCount model: historyBrowser.undoCount
HistoryItem { HistoryItem {
id: undoButton id: undoButton
width: actionWidth width: historyBrowser.actionWidth
//height: actionHeight //height: actionHeight
isRedo: false isRedo: false
idx: index
darkTheme: historyBrowser.darkTheme darkTheme: historyBrowser.darkTheme
hidden: !(filterInput.value == "" || content.includes(filterInput.value)) 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} transform: Rotation { origin.x: 30; origin.y: 30; angle: 270}
height: 60 height: 60
width: 20 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() let hex = sysPalette.windowText.toString()
// We only check the first parameter, as on all normal OSes, text color is grayscale. // We only check the first parameter, as on all normal OSes, text color is grayscale.
return parseInt(hex.substr(1,2), 16) > 128 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
})
} }
} }

View file

@ -41,17 +41,17 @@ Button {
\qmlproperty bool HistoryItem::isRedo \qmlproperty bool HistoryItem::isRedo
true if the action is in the redo stack, false othewise. 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. Index of the item within the HistoryBrowser list.
*/ */
property int idx required property int index
/*! /*!
\qmlproperty bool HistoryItem::darkTheme \qmlproperty bool HistoryItem::darkTheme
true when the system is running with a dark theme, false otherwise. true when the system is running with a dark theme, false otherwise.
*/ */
property bool darkTheme required property bool darkTheme
/*! /*!
\qmlproperty bool HistoryItem::hidden \qmlproperty bool HistoryItem::hidden
true when the item is filtered out, false otherwise. true when the item is filtered out, false otherwise.
@ -61,7 +61,7 @@ Button {
\qmlproperty int HistoryItem::historyAction \qmlproperty int HistoryItem::historyAction
Associated history action. 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 \qmlproperty int HistoryItem::actionHeight
@ -147,13 +147,6 @@ Button {
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.delay: 200 ToolTip.delay: 200
ToolTip.text: content ToolTip.text: content
onClicked: {
if(isRedo)
history.redoMultipleDefered(history.redoCount-idx)
else
history.undoMultipleDefered(+idx+1)
}
} }