Decoupled History from QML

This commit is contained in:
Adsooi 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.
*/
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)
}
/**

View file

@ -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: [

View file

@ -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]
}
}

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

View file

@ -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 {

View file

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

View file

@ -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")

View file

@ -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()
}
}

View file

@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
})
}
}

View file

@ -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)
}
}