diff --git a/common/src/module/canvas.mjs b/common/src/module/canvas.mjs index c46c9a1..62649cf 100644 --- a/common/src/module/canvas.mjs +++ b/common/src/module/canvas.mjs @@ -30,16 +30,18 @@ class CanvasAPI extends Module { #canvas = null /** @type {CanvasRenderingContext2D} */ #ctx = null + /** Lock to prevent asynchronous stuff from printing stuff that is outdated. */ + #redrawCount = 0 /** @type {{show(string, string, string)}} */ #drawingErrorDialog = null - - + + constructor() { super("Canvas", { canvas: CanvasInterface, drawingErrorDialog: DialogInterface }) - + /** * * @type {Object.} @@ -207,6 +209,7 @@ class CanvasAPI extends Module { */ redraw() { if(!this.initialized) throw new Error("Attempting redraw before initialize!") + this.#redrawCount = (this.#redrawCount + 1) % 10000 this.#ctx = this.#canvas.getContext("2d") this._computeAxes() this._reset() @@ -519,15 +522,18 @@ class CanvasAPI extends Module { * @param {function(LatexRenderResult|{width: number, height: number, source: string})} callback */ renderLatexImage(ltxText, color, callback) { + const currentRedrawCount = this.#redrawCount const onRendered = (imgData) => { if(!this.#canvas.isImageLoaded(imgData.source) && !this.#canvas.isImageLoading(imgData.source)) { // Wait until the image is loaded to callback. this.#canvas.loadImageAsync(imgData.source).then(() => { - callback(imgData) + if(this.#redrawCount === currentRedrawCount) + callback(imgData) }) } else { // Callback directly - callback(imgData) + if(this.#redrawCount === currentRedrawCount) + callback(imgData) } } const prerendered = Latex.findPrerendered(ltxText, this.textsize, color) diff --git a/common/src/module/interface.mjs b/common/src/module/interface.mjs index b6d462f..a96621c 100644 --- a/common/src/module/interface.mjs +++ b/common/src/module/interface.mjs @@ -84,13 +84,21 @@ export class DialogInterface extends Interface { } export class LatexInterface extends Interface { + supportsAsyncRender = BOOLEAN /** * @param {string} markup - LaTeX markup to render * @param {number} fontSize - Font size (in pt) to render * @param {string} color - Color of the text to render * @returns {string} - Comma separated data of the image (source, width, height) */ - render = FUNCTION + renderSync = FUNCTION + /** + * @param {string} markup - LaTeX markup to render + * @param {number} fontSize - Font size (in pt) to render + * @param {string} color - Color of the text to render + * @returns {Promise} - Comma separated data of the image (source, width, height) + */ + renderAsync = FUNCTION /** * @param {string} markup - LaTeX markup to render * @param {number} fontSize - Font size (in pt) to render diff --git a/common/src/module/latex.mjs b/common/src/module/latex.mjs index a6aa216..830f096 100644 --- a/common/src/module/latex.mjs +++ b/common/src/module/latex.mjs @@ -112,7 +112,12 @@ class LatexAPI extends Module { */ async requestAsyncRender(markup, fontSize, color) { if(!this.initialized) throw new Error("Attempting requestAsyncRender before initialize!") - let args = this.#latex.render(markup, fontSize, color).split(",") + let render + if(this.#latex.supportsAsyncRender) + render = await this.#latex.renderAsync(markup, fontSize, color) + else + render = this.#latex.renderSync(markup, fontSize, color) + const args = render.split(",") return new LatexRenderResult(...args) } diff --git a/common/src/preferences/general.mjs b/common/src/preferences/general.mjs index 81c5e3d..a6957c6 100644 --- a/common/src/preferences/general.mjs +++ b/common/src/preferences/general.mjs @@ -46,8 +46,15 @@ class EnableLatex extends BoolSetting { } } +const ENABLE_LATEX_ASYNC = new BoolSetting( + qsTranslate("general", "Enable asynchronous LaTeX renderer"), + "enable_latex_async", + "new" +) + export default [ CHECK_FOR_UPDATES, RESET_REDO_STACK, - new EnableLatex() + new EnableLatex(), + ENABLE_LATEX_ASYNC ] diff --git a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/Popup/Changelog.qml b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/Popup/Changelog.qml index 844e22a..93d76db 100644 --- a/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/Popup/Changelog.qml +++ b/runtime-pyside6/LogarithmPlotter/qml/eu/ad5001/LogarithmPlotter/Popup/Changelog.qml @@ -45,17 +45,17 @@ Popup { property bool changelogNeedsFetching: true onAboutToShow: if(changelogNeedsFetching) { - Helper.fetchChangelog() - } - - Connections { - target: Helper - function onChangelogFetched(chl) { - changelogNeedsFetching = false; - changelog.text = chl + Helper.fetchChangelog().then((fetchedText) => { + changelogNeedsFetching = false + changelog.text = fetchedText changelogView.contentItem.implicitHeight = changelog.height - // console.log(changelog.height, changelogView.contentItem.implicitHeight) - } + }, (error) => { + const e = qsTranslate("changelog", "Could not fetch update: {}.").replace('{}', error) + console.error(e) + changelogNeedsFetching = false + changelog.text = e + changelogView.contentItem.implicitHeight = changelog.height + }) } ScrollView { diff --git a/runtime-pyside6/LogarithmPlotter/util/config.py b/runtime-pyside6/LogarithmPlotter/util/config.py index a5ee23c..e01deae 100644 --- a/runtime-pyside6/LogarithmPlotter/util/config.py +++ b/runtime-pyside6/LogarithmPlotter/util/config.py @@ -19,13 +19,16 @@ from os import path, environ, makedirs from platform import system from json import load, dumps +from shutil import which + from PySide6.QtCore import QLocale, QTranslator DEFAULT_SETTINGS = { "check_for_updates": True, "reset_redo_stack": True, "last_install_greet": "0", - "enable_latex": False, + "enable_latex": which("latex") is not None and which("dvipng") is not None, + "enable_latex_async": True, "expression_editor": { "autoclose": True, "colorize": True, diff --git a/runtime-pyside6/LogarithmPlotter/util/helper.py b/runtime-pyside6/LogarithmPlotter/util/helper.py index 344e425..7f7afb9 100644 --- a/runtime-pyside6/LogarithmPlotter/util/helper.py +++ b/runtime-pyside6/LogarithmPlotter/util/helper.py @@ -29,13 +29,16 @@ from urllib.error import HTTPError, URLError from LogarithmPlotter import __VERSION__ from LogarithmPlotter.util import config +from LogarithmPlotter.util.promise import PyPromise SHOW_GUI_MESSAGES = "--test-build" not in argv CHANGELOG_VERSION = __VERSION__ +CHANGELOG_CACHE_PATH = path.join(path.dirname(path.realpath(__file__)), "CHANGELOG.md") class InvalidFileException(Exception): pass + def show_message(msg: str) -> None: """ Shows a GUI message if GUI messages are enabled @@ -46,31 +49,30 @@ def show_message(msg: str) -> None: raise InvalidFileException(msg) +def fetch_changelog(): + msg_text = "Unknown changelog error." + try: + # Fetching version + r = urlopen("https://api.ad5001.eu/changelog/logarithmplotter/?version=" + CHANGELOG_VERSION) + lines = r.readlines() + r.close() + msg_text = "".join(map(lambda x: x.decode('utf-8'), lines)).strip() + except HTTPError as e: + msg_text = QCoreApplication.translate("changelog", "Could not fetch changelog: Server error {}.").format( + str(e.code)) + except URLError as e: + msg_text = QCoreApplication.translate("changelog", "Could not fetch update: {}.").format(str(e.reason)) + return msg_text -class ChangelogFetcher(QRunnable): - def __init__(self, helper): - QRunnable.__init__(self) - self.helper = helper - def run(self): - msg_text = "Unknown changelog error." - try: - # Fetching version - r = urlopen("https://api.ad5001.eu/changelog/logarithmplotter/?version=" + CHANGELOG_VERSION) - lines = r.readlines() - r.close() - msg_text = "".join(map(lambda x: x.decode('utf-8'), lines)).strip() - except HTTPError as e: - msg_text = QCoreApplication.translate("changelog", "Could not fetch changelog: Server error {}.").format( - str(e.code)) - except URLError as e: - msg_text = QCoreApplication.translate("changelog", "Could not fetch update: {}.").format(str(e.reason)) - self.helper.changelogFetched.emit(msg_text) +def read_changelog(): + f = open(CHANGELOG_CACHE_PATH, 'r', -1) + data = f.read().strip() + f.close() + return data class Helper(QObject): - changelogFetched = Signal(str) - def __init__(self, cwd: str, tmpfile: str): QObject.__init__(self) self.cwd = cwd @@ -150,15 +152,14 @@ class Helper(QObject): msg = QCoreApplication.translate('main', "Built with PySide6 (Qt) v{} and python v{}") return msg.format(PySide6_version, sys_version.split("\n")[0]) - @Slot() + @Slot(result=PyPromise) def fetchChangelog(self): - changelog_cache_path = path.join(path.dirname(path.realpath(__file__)), "CHANGELOG.md") - if path.exists(changelog_cache_path): + """ + Fetches the changelog and returns a Promise. + """ + if path.exists(CHANGELOG_CACHE_PATH): # We have a cached version of the changelog, for env that don't have access to the internet. - f = open(changelog_cache_path); - self.changelogFetched.emit("".join(f.readlines()).strip()) - f.close() + return PyPromise(read_changelog) else: # Fetch it from the internet. - runnable = ChangelogFetcher(self) - QThreadPool.globalInstance().start(runnable) + return PyPromise(fetch_changelog) diff --git a/runtime-pyside6/LogarithmPlotter/util/js.py b/runtime-pyside6/LogarithmPlotter/util/js.py index dbe60bc..05f30d5 100644 --- a/runtime-pyside6/LogarithmPlotter/util/js.py +++ b/runtime-pyside6/LogarithmPlotter/util/js.py @@ -16,13 +16,13 @@ * along with this program. If not, see . """ from re import Pattern +from typing import Callable from PySide6.QtCore import QMetaObject, QObject, QDateTime from PySide6.QtQml import QJSValue class InvalidAttributeValueException(Exception): pass class NotAPrimitiveException(Exception): pass -class Function: pass class URL: pass class PyJSValue: @@ -78,7 +78,7 @@ class PyJSValue: matcher = [ (lambda: self.qjs_value.isArray(), list), (lambda: self.qjs_value.isBool(), bool), - (lambda: self.qjs_value.isCallable(), Function), + (lambda: self.qjs_value.isCallable(), Callable), (lambda: self.qjs_value.isDate(), QDateTime), (lambda: self.qjs_value.isError(), Exception), (lambda: self.qjs_value.isNull(), None), @@ -103,4 +103,6 @@ class PyJSValue: """ if self.type() not in [bool, float, str, None]: raise NotAPrimitiveException() - return self.qjs_value.toPrimitive().toVariant() \ No newline at end of file + return self.qjs_value.toPrimitive().toVariant() + + diff --git a/runtime-pyside6/LogarithmPlotter/util/latex.py b/runtime-pyside6/LogarithmPlotter/util/latex.py index df32d46..0d720c3 100644 --- a/runtime-pyside6/LogarithmPlotter/util/latex.py +++ b/runtime-pyside6/LogarithmPlotter/util/latex.py @@ -15,18 +15,21 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . """ +from time import sleep -from PySide6.QtCore import QObject, Slot, Property, QCoreApplication +from PySide6.QtCore import QObject, Slot, Property, QCoreApplication, Signal from PySide6.QtGui import QImage, QColor from PySide6.QtWidgets import QMessageBox -from os import path, remove, environ, makedirs +from os import path, remove, makedirs from string import Template from subprocess import Popen, TimeoutExpired, PIPE from hashlib import sha512 from shutil import which from sys import argv +from LogarithmPlotter.util import config +from LogarithmPlotter.util.promise import PyPromise """ Searches for a valid Latex and DVIPNG (http://savannah.nongnu.org/projects/dvipng/) @@ -85,6 +88,10 @@ class Latex(QObject): def latexSupported(self) -> bool: return LATEX_PATH is not None and DVIPNG_PATH is not None + @Property(bool) + def supportsAsyncRender(self) -> bool: + return config.getSetting("enable_latex_async") + @Slot(result=bool) def checkLatexInstallation(self) -> bool: """ @@ -105,13 +112,20 @@ class Latex(QObject): valid_install = False else: try: - self.render("", 14, QColor(0, 0, 0, 255)) + self.renderSync("", 14, QColor(0, 0, 0, 255)) except MissingPackageException: valid_install = False # Should have sent an error message if failed to render return valid_install + @Slot(str, float, QColor, result=PyPromise) + def renderAsync(self, latex_markup: str, font_size: float, color: QColor) -> PyPromise: + """ + Prepares and renders a latex string into a png file asynchronously. + """ + return PyPromise(self.renderSync, [latex_markup, font_size, color]) + @Slot(str, float, QColor, result=str) - def render(self, latex_markup: str, font_size: float, color: QColor) -> str: + def renderSync(self, latex_markup: str, font_size: float, color: QColor) -> str: """ Prepares and renders a latex string into a png file. """ @@ -124,12 +138,14 @@ class Latex(QObject): if not path.exists(latex_path + ".dvi"): self.create_latex_doc(latex_path, latex_markup) self.convert_latex_to_dvi(latex_path) - self.cleanup(latex_path) + # self.cleanup(latex_path) # Creating four pictures of different sizes to better handle dpi. self.convert_dvi_to_png(latex_path, export_path, font_size, color) # self.convert_dvi_to_png(latex_path, export_path+"@2", font_size*2, color) # self.convert_dvi_to_png(latex_path, export_path+"@3", font_size*3, color) # self.convert_dvi_to_png(latex_path, export_path+"@4", font_size*4, color) + else: + sleep(0) img = QImage(export_path) # Small hack, not very optimized since we load the image twice, but you can't pass a QImage to QML and expect it to be loaded return f'{export_path}.png,{img.width()},{img.height()}' diff --git a/runtime-pyside6/LogarithmPlotter/util/promise.py b/runtime-pyside6/LogarithmPlotter/util/promise.py new file mode 100644 index 0000000..f129d41 --- /dev/null +++ b/runtime-pyside6/LogarithmPlotter/util/promise.py @@ -0,0 +1,116 @@ +""" + * LogarithmPlotter - 2D plotter software to make BODE plots, sequences and distribution functions. + * Copyright (C) 2021-2024 Ad5001 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +""" +from typing import Callable + +from PySide6.QtCore import QRunnable, Signal, QObject, Slot, QThreadPool +from PySide6.QtQml import QJSValue + +from LogarithmPlotter.util.js import PyJSValue + + +class InvalidReturnValue(Exception): pass + + +class PyPromiseRunner(QRunnable): + """ + QRunnable for running Promises in different threads. + """ + def __init__(self, runner, promise, args): + QRunnable.__init__(self) + self.runner = runner + self.promise = promise + self.args = args + + def run(self): + try: + data = self.runner(*self.args) + if isinstance(data, QObject): + data = data + elif type(data) in [int, str, float, bool, bytes]: + data = QJSValue(data) + elif data is None: + data = QJSValue.SpecialValue.UndefinedValue + elif isinstance(data, QJSValue): + data = data + elif isinstance(data, PyJSValue): + data = data.qjs_value + else: + raise InvalidReturnValue("Must return either a primitive, a valid QObject, JS Value, or None.") + self.promise.finished.emit(data) + except Exception as e: + try: + self.promise.errored.emit(repr(e)) + except RuntimeError as e2: + # Happens when the PyPromise has already been garbage collected. + # In other words, nothing to report to nowhere. + pass + + +class PyPromise(QObject): + """ + Asynchronous Promise-like object meant to interface between Python and Javascript easily. + Runs to_run in another thread, and calls fulfilled (populated by then) with its return value. + """ + finished = Signal((QJSValue,), (QObject,)) + errored = Signal(Exception) + + def __init__(self, to_run: Callable, args): + QObject.__init__(self) + self._fulfills = [] + self._rejects = [] + self.finished.connect(self._fulfill) + self.errored.connect(self._reject) + self._runner = PyPromiseRunner(to_run, self, args) + QThreadPool.globalInstance().start(self._runner) + + + @Slot(QJSValue, result=QObject) + @Slot(QJSValue, QJSValue, result=QObject) + def then(self, on_fulfill: QJSValue | Callable, on_reject: QJSValue | Callable = None): + """ + Adds listeners for both fulfilment and catching errors of the Promise. + """ + if isinstance(on_fulfill, QJSValue): + self._fulfills.append(PyJSValue(on_fulfill)) + elif isinstance(on_fulfill, Callable): + self._fulfills.append(on_fulfill) + if isinstance(on_reject, QJSValue): + self._rejects.append(PyJSValue(on_reject)) + elif isinstance(on_reject, Callable): + self._rejects.append(on_reject) + return self + + @Slot(QJSValue) + @Slot(QObject) + def _fulfill(self, data): + no_return = [None, QJSValue.SpecialValue.UndefinedValue] + for on_fulfill in self._fulfills: + try: + result = on_fulfill(data) + data = result if result not in no_return else data # Forward data. + except Exception as e: + self._reject(repr(e)) + break + + @Slot(QJSValue) + @Slot(str) + def _reject(self, error): + no_return = [None, QJSValue.SpecialValue.UndefinedValue] + for on_reject in self._rejects: + result = on_reject(error) + error = result if result not in no_return else error # Forward data.