Compare commits

..

3 commits

Author SHA1 Message Date
a85a4721e3
Fixing double redraw when opening a file.
Some checks failed
continuous-integration/drone/push Build is failing
2024-10-15 20:39:03 +02:00
aeaaba759f
Starting latex render locking. 2024-10-15 19:21:40 +02:00
ccddb068a6
Fixing tests for Promises (new ones need to be written) 2024-10-15 18:06:24 +02:00
7 changed files with 156 additions and 75 deletions

View file

@ -529,11 +529,15 @@ class CanvasAPI extends Module {
this.#canvas.loadImageAsync(imgData.source).then(() => { this.#canvas.loadImageAsync(imgData.source).then(() => {
if(this.#redrawCount === currentRedrawCount) if(this.#redrawCount === currentRedrawCount)
callback(imgData) callback(imgData)
else
console.log("1Discard render of", imgData.source, this.#redrawCount, currentRedrawCount)
}) })
} else { } else {
// Callback directly // Callback directly
if(this.#redrawCount === currentRedrawCount) if(this.#redrawCount === currentRedrawCount)
callback(imgData) callback(imgData)
else
console.log("2Discard render of", imgData.source, this.#redrawCount, currentRedrawCount)
} }
} }
const prerendered = Latex.findPrerendered(ltxText, this.textsize, color) const prerendered = Latex.findPrerendered(ltxText, this.textsize, color)

View file

@ -201,7 +201,6 @@ class IOAPI extends Module {
// TODO: Error handling // TODO: Error handling
return return
} }
Canvas.redraw()
this.#alert.show(qsTranslate("io", "Loaded file '%1'.").arg(basename)) this.#alert.show(qsTranslate("io", "Loaded file '%1'.").arg(basename))
this.#saved = true this.#saved = true
this.emit(new LoadedEvent()) this.emit(new LoadedEvent())

View file

@ -19,10 +19,12 @@
from PySide6.QtCore import QtMsgType, qInstallMessageHandler, QMessageLogContext from PySide6.QtCore import QtMsgType, qInstallMessageHandler, QMessageLogContext
from math import ceil, log10 from math import ceil, log10
from os import path from os import path
from re import compile
CURRENT_PATH = path.dirname(path.realpath(__file__)) CURRENT_PATH = path.dirname(path.realpath(__file__))
SOURCEMAP_PATH = path.realpath(f"{CURRENT_PATH}/../qml/eu/ad5001/LogarithmPlotter/js/index.mjs.map") SOURCEMAP_PATH = path.realpath(f"{CURRENT_PATH}/../qml/eu/ad5001/LogarithmPlotter/js/index.mjs.map")
SOURCEMAP_INDEX = None SOURCEMAP_INDEX = None
INDEX_REG = compile(r"build\/runtime-pyside6\/LogarithmPlotter\/qml\/eu\/ad5001\/LogarithmPlotter\/js\/index.mjs:(\d+)")
class LOG_COLORS: class LOG_COLORS:
@ -77,6 +79,7 @@ def create_log_terminal_message(mode: QtMsgType, context: QMessageLogContext, me
# Check MJS # Check MJS
if line is not None and source_file is not None and source_file.endswith("index.mjs"): if line is not None and source_file is not None and source_file.endswith("index.mjs"):
source_file, line = map_javascript_source(source_file, line) source_file, line = map_javascript_source(source_file, line)
# Parse message
prefix = f"{LOG_COLORS.INVERT}{mode[1]}[{mode[0].upper()}]{LOG_COLORS.RESET_INVERT}" prefix = f"{LOG_COLORS.INVERT}{mode[1]}[{mode[0].upper()}]{LOG_COLORS.RESET_INVERT}"
message = message + LOG_COLORS.RESET message = message + LOG_COLORS.RESET
context = f"{context.function} at {source_file}:{line}" context = f"{context.function} at {source_file}:{line}"

View file

@ -82,6 +82,7 @@ class Latex(QObject):
def __init__(self, cache_path): def __init__(self, cache_path):
QObject.__init__(self) QObject.__init__(self)
self.tempdir = path.join(cache_path, "latex") self.tempdir = path.join(cache_path, "latex")
self.render_pipeline_locks = {}
makedirs(self.tempdir, exist_ok=True) makedirs(self.tempdir, exist_ok=True)
@Property(bool) @Property(bool)
@ -117,19 +118,68 @@ class Latex(QObject):
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
def lock(self, markup_hash, render_hash, promise):
"""
Locks the render pipeline for a given markup hash and render hash.
"""
# print("Locking", markup_hash, render_hash)
if markup_hash not in self.render_pipeline_locks:
self.render_pipeline_locks[markup_hash] = promise
self.render_pipeline_locks[render_hash] = promise
def release_lock(self, markup_hash, render_hash):
"""
Release locks on the markup and render hashes.
"""
# print("Releasing", markup_hash, render_hash)
if markup_hash in self.render_pipeline_locks:
del self.render_pipeline_locks[markup_hash]
del self.render_pipeline_locks[render_hash]
@Slot(str, float, QColor, result=PyPromise) @Slot(str, float, QColor, result=PyPromise)
def renderAsync(self, latex_markup: str, font_size: float, color: QColor) -> PyPromise: def renderAsync(self, latex_markup: str, font_size: float, color: QColor) -> PyPromise:
""" """
Prepares and renders a latex string into a png file asynchronously. Prepares and renders a latex string into a png file asynchronously.
""" """
return PyPromise(self.renderSync, [latex_markup, font_size, color]) markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color)
promise = None
if render_hash in self.render_pipeline_locks:
# A PyPromise for this specific render is already running.
# print("Already running render of", latex_markup)
promise = self.render_pipeline_locks[render_hash]
elif markup_hash in self.render_pipeline_locks:
# A PyPromise with the same markup, but not the same color or font size is already running.
# print("Chaining render of", latex_markup)
existing_promise = self.render_pipeline_locks[markup_hash]
promise = self._create_async_promise(latex_markup, font_size, color)
existing_promise.then(promise.start)
else:
# No such PyPromise is running.
promise = self._create_async_promise(latex_markup, font_size, color)
promise.start()
return promise
def _create_async_promise(self, latex_markup: str, font_size: float, color: QColor) -> PyPromise:
"""
Createsa PyPromise to render a latex string into a PNG file.
Internal method. Use renderAsync that makes use of locks.
"""
markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color)
promise = PyPromise(self.renderSync, [latex_markup, font_size, color], start_automatically=False)
self.lock(markup_hash, render_hash, promise)
# Make the lock release at the end.
def unlock(data, markup_hash=markup_hash, render_hash=render_hash):
self.release_lock(markup_hash, render_hash)
promise.then(unlock, unlock)
return promise
@Slot(str, float, QColor, result=str) @Slot(str, float, QColor, result=str)
def renderSync(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.
""" """
markup_hash, export_path = self.create_export_path(latex_markup, font_size, color) markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color)
if self.latexSupported and not path.exists(export_path + ".png"): if self.latexSupported and not path.exists(export_path + ".png"):
print("Rendering", latex_markup, export_path) print("Rendering", latex_markup, export_path)
# Generating file # Generating file
@ -138,14 +188,12 @@ 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()}'
@ -155,7 +203,7 @@ class Latex(QObject):
""" """
Finds a prerendered image and returns its data if possible, and an empty string if not. Finds a prerendered image and returns its data if possible, and an empty string if not.
""" """
markup_hash, export_path = self.create_export_path(latex_markup, font_size, color) markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color)
data = "" data = ""
if path.exists(export_path + ".png"): if path.exists(export_path + ".png"):
img = QImage(export_path) img = QImage(export_path)
@ -165,10 +213,13 @@ class Latex(QObject):
def create_export_path(self, latex_markup: str, font_size: float, color: QColor): def create_export_path(self, latex_markup: str, font_size: float, color: QColor):
""" """
Standardizes export path for renders. Standardizes export path for renders.
Markup hash is unique for the markup
Render hash is unique for the markup, the font size and the color.
""" """
markup_hash = "render" + str(sha512(latex_markup.encode()).hexdigest()) markup_hash = "render" + str(sha512(latex_markup.encode()).hexdigest())
export_path = path.join(self.tempdir, f'{markup_hash}_{int(font_size)}_{color.rgb()}') render_hash = f'{markup_hash}_{int(font_size)}_{color.rgb()}'
return markup_hash, export_path export_path = path.join(self.tempdir, render_hash)
return markup_hash, render_hash, export_path
def create_latex_doc(self, export_path: str, latex_markup: str): def create_latex_doc(self, export_path: str, latex_markup: str):
""" """

View file

@ -17,12 +17,24 @@
""" """
from typing import Callable from typing import Callable
from PySide6.QtCore import QRunnable, Signal, QObject, Slot, QThreadPool from PySide6.QtCore import QRunnable, Signal, Property, QObject, Slot, QThreadPool
from PySide6.QtQml import QJSValue from PySide6.QtQml import QJSValue
from LogarithmPlotter.util.js import PyJSValue from LogarithmPlotter.util.js import PyJSValue
def check_callable(function: Callable|QJSValue) -> Callable|None:
"""
Checks if the given function can be called (either a python callable
or a QJSValue function), and returns the object that can be called directly.
Returns None if not a function.
"""
if isinstance(function, QJSValue) and function.isCallable():
return PyJSValue(function)
elif isinstance(function, Callable):
return function
return None
class InvalidReturnValue(Exception): pass class InvalidReturnValue(Exception): pass
@ -51,10 +63,10 @@ class PyPromiseRunner(QRunnable):
data = data.qjs_value data = data.qjs_value
else: else:
raise InvalidReturnValue("Must return either a primitive, a valid QObject, JS Value, or None.") raise InvalidReturnValue("Must return either a primitive, a valid QObject, JS Value, or None.")
self.promise.finished.emit(data) self.promise.fulfilled.emit(data)
except Exception as e: except Exception as e:
try: try:
self.promise.errored.emit(repr(e)) self.promise.rejected.emit(repr(e))
except RuntimeError as e2: except RuntimeError as e2:
# Happens when the PyPromise has already been garbage collected. # Happens when the PyPromise has already been garbage collected.
# In other words, nothing to report to nowhere. # In other words, nothing to report to nowhere.
@ -66,18 +78,36 @@ class PyPromise(QObject):
Asynchronous Promise-like object meant to interface between Python and Javascript easily. 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. Runs to_run in another thread, and calls fulfilled (populated by then) with its return value.
""" """
finished = Signal((QJSValue,), (QObject,)) fulfilled = Signal((QJSValue,), (QObject,))
errored = Signal(Exception) rejected = Signal(Exception)
def __init__(self, to_run: Callable, args): def __init__(self, to_run: Callable|QJSValue, args=[], start_automatically=True):
QObject.__init__(self) QObject.__init__(self)
self._fulfills = [] self._fulfills = []
self._rejects = [] self._rejects = []
self.finished.connect(self._fulfill) self._state = "pending"
self.errored.connect(self._reject) self._started = False
self.fulfilled.connect(self._fulfill)
self.rejected.connect(self._reject)
to_run = check_callable(to_run)
if to_run is None:
raise ValueError("New PyPromise created with invalid function")
self._runner = PyPromiseRunner(to_run, self, args) self._runner = PyPromiseRunner(to_run, self, args)
QThreadPool.globalInstance().start(self._runner) if start_automatically:
self._start()
@Slot()
def start(self, *args, **kwargs):
"""
Starts the thread that will run the promise.
"""
if not self._started: # Avoid getting started twice.
QThreadPool.globalInstance().start(self._runner)
self._started = True
@Property(str)
def state(self):
return self._state
@Slot(QJSValue, result=QObject) @Slot(QJSValue, result=QObject)
@Slot(QJSValue, QJSValue, result=QObject) @Slot(QJSValue, QJSValue, result=QObject)
@ -85,23 +115,23 @@ class PyPromise(QObject):
""" """
Adds listeners for both fulfilment and catching errors of the Promise. Adds listeners for both fulfilment and catching errors of the Promise.
""" """
if isinstance(on_fulfill, QJSValue): on_fulfill = check_callable(on_fulfill)
self._fulfills.append(PyJSValue(on_fulfill)) on_reject = check_callable(on_reject)
elif isinstance(on_fulfill, Callable): if on_fulfill is not None:
self._fulfills.append(on_fulfill) self._fulfills.append(on_fulfill)
if isinstance(on_reject, QJSValue): if on_reject is not None:
self._rejects.append(PyJSValue(on_reject))
elif isinstance(on_reject, Callable):
self._rejects.append(on_reject) self._rejects.append(on_reject)
return self return self
@Slot(QJSValue) @Slot(QJSValue)
@Slot(QObject) @Slot(QObject)
def _fulfill(self, data): def _fulfill(self, data):
self._state = "fulfilled"
no_return = [None, QJSValue.SpecialValue.UndefinedValue] no_return = [None, QJSValue.SpecialValue.UndefinedValue]
for on_fulfill in self._fulfills: for on_fulfill in self._fulfills:
try: try:
result = on_fulfill(data) result = on_fulfill(data)
result = result.qjs_value if isinstance(result, PyJSValue) else result
data = result if result not in no_return else data # Forward data. data = result if result not in no_return else data # Forward data.
except Exception as e: except Exception as e:
self._reject(repr(e)) self._reject(repr(e))
@ -110,6 +140,7 @@ class PyPromise(QObject):
@Slot(QJSValue) @Slot(QJSValue)
@Slot(str) @Slot(str)
def _reject(self, error): def _reject(self, error):
self._state = "rejected"
no_return = [None, QJSValue.SpecialValue.UndefinedValue] no_return = [None, QJSValue.SpecialValue.UndefinedValue]
for on_reject in self._rejects: for on_reject in self._rejects:
result = on_reject(error) result = on_reject(error)

View file

@ -17,7 +17,7 @@
""" """
import pytest import pytest
from os import getcwd, remove from os import getcwd, remove, path
from os.path import join from os.path import join
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from json import loads from json import loads
@ -25,11 +25,12 @@ from shutil import copy2
from PySide6.QtCore import QObject, Signal, QThreadPool from PySide6.QtCore import QObject, Signal, QThreadPool
from PySide6.QtGui import QImage from PySide6.QtGui import QImage
from PySide6.QtQml import QJSValue
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from LogarithmPlotter import __VERSION__ as version from LogarithmPlotter import __VERSION__ as version
from LogarithmPlotter.util import config, helper from LogarithmPlotter.util import config, helper
from LogarithmPlotter.util.helper import ChangelogFetcher, Helper, InvalidFileException from LogarithmPlotter.util.helper import Helper, InvalidFileException
pwd = getcwd() pwd = getcwd()
helper.SHOW_GUI_MESSAGES = False helper.SHOW_GUI_MESSAGES = False
@ -43,41 +44,45 @@ def temporary():
directory.cleanup() directory.cleanup()
class MockHelperSignals(QObject): def create_changelog_callback_asserter(promise, expect_404=False):
changelogFetched = Signal(str) def cb(changelog, expect_404=expect_404):
# print("Got changelog", changelog)
assert isinstance(changelog, QJSValue)
assert changelog.isString()
changlogValue = changelog.toVariant()
assert ('404' in changlogValue) == expect_404
def error(e):
raise eval(e)
promise.then(cb, error)
def __init__(self, expect_404): CHANGELOG_BASE_PATH = path.realpath(path.join(path.dirname(path.realpath(__file__)), "..", "CHANGELOG.md"))
QObject.__init__(self)
self.expect_404 = expect_404
self.changelogFetched.connect(self.changelog_fetched)
self.changelog = None
def changelog_fetched(self, changelog):
self.changelog = changelog
class TestChangelog:
def test_exists(self, qtbot):
helper.CHANGELOG_VERSION = '0.5.0'
mock_helper = MockHelperSignals(False)
fetcher = ChangelogFetcher(mock_helper)
fetcher.run() # Does not raise an exception
qtbot.waitSignal(mock_helper.changelogFetched, timeout=10000)
assert type(mock_helper.changelog) == str
assert '404' not in mock_helper.changelog
def tests_no_exist(self, qtbot):
mock_helper = MockHelperSignals(True)
helper.CHANGELOG_VERSION = '1.0.0'
fetcher = ChangelogFetcher(mock_helper)
fetcher.run()
qtbot.waitSignal(mock_helper.changelogFetched, timeout=10000)
assert type(mock_helper.changelog) == str
assert '404' in mock_helper.changelog
class TestHelper: class TestHelper:
def test_changelog(self, temporary, qtbot):
helper.CHANGELOG_VERSION = '0.5.0'
tmpfile, directory = temporary
obj = Helper(pwd, tmpfile)
promise = obj.fetchChangelog()
create_changelog_callback_asserter(promise, expect_404=False)
qtbot.waitSignal(promise.fulfilled, timeout=10000)
# No exist
helper.CHANGELOG_VERSION = '2.0.0'
tmpfile, directory = temporary
obj = Helper(pwd, tmpfile)
promise = obj.fetchChangelog()
create_changelog_callback_asserter(promise, expect_404=True)
qtbot.waitSignal(promise.fulfilled, timeout=10000)
# Local
tmpfile, directory = temporary
obj = Helper(pwd, tmpfile)
assert path.exists(CHANGELOG_BASE_PATH)
copy2(CHANGELOG_BASE_PATH, helper.CHANGELOG_CACHE_PATH)
assert path.exists(helper.CHANGELOG_CACHE_PATH)
promise = obj.fetchChangelog()
create_changelog_callback_asserter(promise, expect_404=False)
qtbot.waitSignal(promise.fulfilled, timeout=10000) # Local
def test_read(self, temporary): def test_read(self, temporary):
# Test file reading and information loading. # Test file reading and information loading.
tmpfile, directory = temporary tmpfile, directory = temporary
@ -168,15 +173,3 @@ class TestHelper:
obj.setSetting("last_install_greet", obj.getSetting("last_install_greet")) obj.setSetting("last_install_greet", obj.getSetting("last_install_greet"))
obj.setSetting("check_for_updates", obj.getSetting("check_for_updates")) obj.setSetting("check_for_updates", obj.getSetting("check_for_updates"))
obj.setSetting("default_graph.xzoom", obj.getSetting("default_graph.xzoom")) obj.setSetting("default_graph.xzoom", obj.getSetting("default_graph.xzoom"))
def test_fetch_changelog(self, temporary, qtbot):
tmpfile, directory = temporary
obj = Helper(pwd, tmpfile)
copy2("../../CHANGELOG.md", "../../LogarithmPlotter/util/CHANGELOG.md")
obj.fetchChangelog()
assert QThreadPool.globalInstance().activeThreadCount() == 0
qtbot.waitSignal(obj.changelogFetched, timeout=10000)
remove("../../LogarithmPlotter/util/CHANGELOG.md")
obj.fetchChangelog()
assert QThreadPool.globalInstance().activeThreadCount() > 0
qtbot.waitSignal(obj.changelogFetched, timeout=10000)

View file

@ -54,8 +54,8 @@ class TestLatex:
# Reset # Reset
[latex.DVIPNG_PATH, latex.LATEX_PATH] = bkp [latex.DVIPNG_PATH, latex.LATEX_PATH] = bkp
def test_render(self, latex_obj: latex.Latex) -> None: def test_render_sync(self, latex_obj: latex.Latex) -> None:
result = latex_obj.render(r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255)) result = latex_obj.renderSync(r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255))
# Ensure result format # Ensure result format
assert type(result) == str assert type(result) == str
[path, width, height] = result.split(",") [path, width, height] = result.split(",")
@ -64,17 +64,17 @@ class TestLatex:
assert match(r"\d+", height) assert match(r"\d+", height)
# Ensure it returns errors on invalid latex. # Ensure it returns errors on invalid latex.
with pytest.raises(latex.RenderError): with pytest.raises(latex.RenderError):
latex_obj.render(r"\nonexistant", 14, QColor(0, 0, 0, 255)) latex_obj.renderSync(r"\nonexistant", 14, QColor(0, 0, 0, 255))
# Replace latex bin with one that returns errors # Replace latex bin with one that returns errors
bkp = latex.LATEX_PATH bkp = latex.LATEX_PATH
latex.LATEX_PATH = which("false") latex.LATEX_PATH = which("false")
with pytest.raises(latex.RenderError): with pytest.raises(latex.RenderError):
latex_obj.render(r"\mathrm{f}(x)", 14, QColor(0, 0, 0, 255)) latex_obj.renderSync(r"\mathrm{f}(x)", 14, QColor(0, 0, 0, 255))
latex.LATEX_PATH = bkp latex.LATEX_PATH = bkp
def test_prerendered(self, latex_obj: latex.Latex) -> None: def test_prerendered(self, latex_obj: latex.Latex) -> None:
args = [r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255)] args = [r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255)]
latex_obj.render(*args) latex_obj.renderSync(*args)
prerendered = latex_obj.findPrerendered(*args) prerendered = latex_obj.findPrerendered(*args)
assert type(prerendered) == str assert type(prerendered) == str
[path, width, height] = prerendered.split(",") [path, width, height] = prerendered.split(",")