Compare commits

...

4 commits

10 changed files with 219 additions and 55 deletions

View file

@ -30,16 +30,18 @@ class CanvasAPI extends Module {
#canvas = null #canvas = null
/** @type {CanvasRenderingContext2D} */ /** @type {CanvasRenderingContext2D} */
#ctx = null #ctx = null
/** Lock to prevent asynchronous stuff from printing stuff that is outdated. */
#redrawCount = 0
/** @type {{show(string, string, string)}} */ /** @type {{show(string, string, string)}} */
#drawingErrorDialog = null #drawingErrorDialog = null
constructor() { constructor() {
super("Canvas", { super("Canvas", {
canvas: CanvasInterface, canvas: CanvasInterface,
drawingErrorDialog: DialogInterface drawingErrorDialog: DialogInterface
}) })
/** /**
* *
* @type {Object.<string, {expression: Expression, value: number, maxDraw: number}>} * @type {Object.<string, {expression: Expression, value: number, maxDraw: number}>}
@ -207,6 +209,7 @@ class CanvasAPI extends Module {
*/ */
redraw() { redraw() {
if(!this.initialized) throw new Error("Attempting redraw before initialize!") if(!this.initialized) throw new Error("Attempting redraw before initialize!")
this.#redrawCount = (this.#redrawCount + 1) % 10000
this.#ctx = this.#canvas.getContext("2d") this.#ctx = this.#canvas.getContext("2d")
this._computeAxes() this._computeAxes()
this._reset() this._reset()
@ -519,15 +522,18 @@ class CanvasAPI extends Module {
* @param {function(LatexRenderResult|{width: number, height: number, source: string})} callback * @param {function(LatexRenderResult|{width: number, height: number, source: string})} callback
*/ */
renderLatexImage(ltxText, color, callback) { renderLatexImage(ltxText, color, callback) {
const currentRedrawCount = this.#redrawCount
const onRendered = (imgData) => { const onRendered = (imgData) => {
if(!this.#canvas.isImageLoaded(imgData.source) && !this.#canvas.isImageLoading(imgData.source)) { if(!this.#canvas.isImageLoaded(imgData.source) && !this.#canvas.isImageLoading(imgData.source)) {
// Wait until the image is loaded to callback. // Wait until the image is loaded to callback.
this.#canvas.loadImageAsync(imgData.source).then(() => { this.#canvas.loadImageAsync(imgData.source).then(() => {
callback(imgData) if(this.#redrawCount === currentRedrawCount)
callback(imgData)
}) })
} else { } else {
// Callback directly // Callback directly
callback(imgData) if(this.#redrawCount === currentRedrawCount)
callback(imgData)
} }
} }
const prerendered = Latex.findPrerendered(ltxText, this.textsize, color) const prerendered = Latex.findPrerendered(ltxText, this.textsize, color)

View file

@ -84,13 +84,21 @@ export class DialogInterface extends Interface {
} }
export class LatexInterface extends Interface { export class LatexInterface extends Interface {
supportsAsyncRender = BOOLEAN
/** /**
* @param {string} markup - LaTeX markup to render * @param {string} markup - LaTeX markup to render
* @param {number} fontSize - Font size (in pt) to render * @param {number} fontSize - Font size (in pt) to render
* @param {string} color - Color of the text to render * @param {string} color - Color of the text to render
* @returns {string} - Comma separated data of the image (source, width, height) * @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<string>} - Comma separated data of the image (source, width, height)
*/
renderAsync = FUNCTION
/** /**
* @param {string} markup - LaTeX markup to render * @param {string} markup - LaTeX markup to render
* @param {number} fontSize - Font size (in pt) to render * @param {number} fontSize - Font size (in pt) to render

View file

@ -112,7 +112,12 @@ class LatexAPI extends Module {
*/ */
async requestAsyncRender(markup, fontSize, color) { async requestAsyncRender(markup, fontSize, color) {
if(!this.initialized) throw new Error("Attempting requestAsyncRender before initialize!") 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) return new LatexRenderResult(...args)
} }

View file

@ -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 [ export default [
CHECK_FOR_UPDATES, CHECK_FOR_UPDATES,
RESET_REDO_STACK, RESET_REDO_STACK,
new EnableLatex() new EnableLatex(),
ENABLE_LATEX_ASYNC
] ]

View file

@ -45,17 +45,17 @@ Popup {
property bool changelogNeedsFetching: true property bool changelogNeedsFetching: true
onAboutToShow: if(changelogNeedsFetching) { onAboutToShow: if(changelogNeedsFetching) {
Helper.fetchChangelog() Helper.fetchChangelog().then((fetchedText) => {
} changelogNeedsFetching = false
changelog.text = fetchedText
Connections {
target: Helper
function onChangelogFetched(chl) {
changelogNeedsFetching = false;
changelog.text = chl
changelogView.contentItem.implicitHeight = changelog.height 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 { ScrollView {

View file

@ -19,13 +19,16 @@
from os import path, environ, makedirs from os import path, environ, makedirs
from platform import system from platform import system
from json import load, dumps from json import load, dumps
from shutil import which
from PySide6.QtCore import QLocale, QTranslator from PySide6.QtCore import QLocale, QTranslator
DEFAULT_SETTINGS = { DEFAULT_SETTINGS = {
"check_for_updates": True, "check_for_updates": True,
"reset_redo_stack": True, "reset_redo_stack": True,
"last_install_greet": "0", "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": { "expression_editor": {
"autoclose": True, "autoclose": True,
"colorize": True, "colorize": True,

View file

@ -29,13 +29,16 @@ from urllib.error import HTTPError, URLError
from LogarithmPlotter import __VERSION__ from LogarithmPlotter import __VERSION__
from LogarithmPlotter.util import config from LogarithmPlotter.util import config
from LogarithmPlotter.util.promise import PyPromise
SHOW_GUI_MESSAGES = "--test-build" not in argv SHOW_GUI_MESSAGES = "--test-build" not in argv
CHANGELOG_VERSION = __VERSION__ CHANGELOG_VERSION = __VERSION__
CHANGELOG_CACHE_PATH = path.join(path.dirname(path.realpath(__file__)), "CHANGELOG.md")
class InvalidFileException(Exception): pass class InvalidFileException(Exception): pass
def show_message(msg: str) -> None: def show_message(msg: str) -> None:
""" """
Shows a GUI message if GUI messages are enabled Shows a GUI message if GUI messages are enabled
@ -46,31 +49,30 @@ def show_message(msg: str) -> None:
raise InvalidFileException(msg) 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): def read_changelog():
msg_text = "Unknown changelog error." f = open(CHANGELOG_CACHE_PATH, 'r', -1)
try: data = f.read().strip()
# Fetching version f.close()
r = urlopen("https://api.ad5001.eu/changelog/logarithmplotter/?version=" + CHANGELOG_VERSION) return data
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)
class Helper(QObject): class Helper(QObject):
changelogFetched = Signal(str)
def __init__(self, cwd: str, tmpfile: str): def __init__(self, cwd: str, tmpfile: str):
QObject.__init__(self) QObject.__init__(self)
self.cwd = cwd self.cwd = cwd
@ -150,15 +152,14 @@ class Helper(QObject):
msg = QCoreApplication.translate('main', "Built with PySide6 (Qt) v{} and python v{}") msg = QCoreApplication.translate('main', "Built with PySide6 (Qt) v{} and python v{}")
return msg.format(PySide6_version, sys_version.split("\n")[0]) return msg.format(PySide6_version, sys_version.split("\n")[0])
@Slot() @Slot(result=PyPromise)
def fetchChangelog(self): 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. # We have a cached version of the changelog, for env that don't have access to the internet.
f = open(changelog_cache_path); return PyPromise(read_changelog)
self.changelogFetched.emit("".join(f.readlines()).strip())
f.close()
else: else:
# Fetch it from the internet. # Fetch it from the internet.
runnable = ChangelogFetcher(self) return PyPromise(fetch_changelog)
QThreadPool.globalInstance().start(runnable)

View file

@ -16,13 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from re import Pattern from re import Pattern
from typing import Callable
from PySide6.QtCore import QMetaObject, QObject, QDateTime from PySide6.QtCore import QMetaObject, QObject, QDateTime
from PySide6.QtQml import QJSValue from PySide6.QtQml import QJSValue
class InvalidAttributeValueException(Exception): pass class InvalidAttributeValueException(Exception): pass
class NotAPrimitiveException(Exception): pass class NotAPrimitiveException(Exception): pass
class Function: pass
class URL: pass class URL: pass
class PyJSValue: class PyJSValue:
@ -78,7 +78,7 @@ class PyJSValue:
matcher = [ matcher = [
(lambda: self.qjs_value.isArray(), list), (lambda: self.qjs_value.isArray(), list),
(lambda: self.qjs_value.isBool(), bool), (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.isDate(), QDateTime),
(lambda: self.qjs_value.isError(), Exception), (lambda: self.qjs_value.isError(), Exception),
(lambda: self.qjs_value.isNull(), None), (lambda: self.qjs_value.isNull(), None),
@ -103,4 +103,6 @@ class PyJSValue:
""" """
if self.type() not in [bool, float, str, None]: if self.type() not in [bool, float, str, None]:
raise NotAPrimitiveException() raise NotAPrimitiveException()
return self.qjs_value.toPrimitive().toVariant() return self.qjs_value.toPrimitive().toVariant()

View file

@ -15,18 +15,21 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
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.QtGui import QImage, QColor
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
from os import path, remove, environ, makedirs from os import path, remove, makedirs
from string import Template from string import Template
from subprocess import Popen, TimeoutExpired, PIPE from subprocess import Popen, TimeoutExpired, PIPE
from hashlib import sha512 from hashlib import sha512
from shutil import which from shutil import which
from sys import argv 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/) Searches for a valid Latex and DVIPNG (http://savannah.nongnu.org/projects/dvipng/)
@ -85,6 +88,10 @@ class Latex(QObject):
def latexSupported(self) -> bool: def latexSupported(self) -> bool:
return LATEX_PATH is not None and DVIPNG_PATH is not None 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) @Slot(result=bool)
def checkLatexInstallation(self) -> bool: def checkLatexInstallation(self) -> bool:
""" """
@ -105,13 +112,20 @@ class Latex(QObject):
valid_install = False valid_install = False
else: else:
try: try:
self.render("", 14, QColor(0, 0, 0, 255)) self.renderSync("", 14, QColor(0, 0, 0, 255))
except MissingPackageException: except MissingPackageException:
valid_install = False # Should have sent an error message if failed to render valid_install = False # Should have sent an error message if failed to render
return valid_install 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) @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. Prepares and renders a latex string into a png file.
""" """
@ -124,12 +138,14 @@ class Latex(QObject):
if not path.exists(latex_path + ".dvi"): if not path.exists(latex_path + ".dvi"):
self.create_latex_doc(latex_path, latex_markup) self.create_latex_doc(latex_path, latex_markup)
self.convert_latex_to_dvi(latex_path) 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. # 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, 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+"@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+"@3", font_size*3, color)
# self.convert_dvi_to_png(latex_path, export_path+"@4", font_size*4, color) # self.convert_dvi_to_png(latex_path, export_path+"@4", font_size*4, color)
else:
sleep(0)
img = QImage(export_path) 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 # 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()}' return f'{export_path}.png,{img.width()},{img.height()}'

View file

@ -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 <https://www.gnu.org/licenses/>.
"""
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.