diff --git a/common/src/module/canvas.mjs b/common/src/module/canvas.mjs index 913442f..62649cf 100644 --- a/common/src/module/canvas.mjs +++ b/common/src/module/canvas.mjs @@ -529,15 +529,11 @@ class CanvasAPI extends Module { this.#canvas.loadImageAsync(imgData.source).then(() => { if(this.#redrawCount === currentRedrawCount) callback(imgData) - else - console.log("1Discard render of", imgData.source, this.#redrawCount, currentRedrawCount) }) } else { // Callback directly if(this.#redrawCount === currentRedrawCount) callback(imgData) - else - console.log("2Discard render of", imgData.source, this.#redrawCount, currentRedrawCount) } } const prerendered = Latex.findPrerendered(ltxText, this.textsize, color) diff --git a/common/src/module/io.mjs b/common/src/module/io.mjs index 92096e9..472b061 100644 --- a/common/src/module/io.mjs +++ b/common/src/module/io.mjs @@ -201,6 +201,7 @@ class IOAPI extends Module { // TODO: Error handling return } + Canvas.redraw() this.#alert.show(qsTranslate("io", "Loaded file '%1'.").arg(basename)) this.#saved = true this.emit(new LoadedEvent()) diff --git a/runtime-pyside6/LogarithmPlotter/util/debug.py b/runtime-pyside6/LogarithmPlotter/util/debug.py index f899fd5..8b57687 100644 --- a/runtime-pyside6/LogarithmPlotter/util/debug.py +++ b/runtime-pyside6/LogarithmPlotter/util/debug.py @@ -19,12 +19,10 @@ from PySide6.QtCore import QtMsgType, qInstallMessageHandler, QMessageLogContext from math import ceil, log10 from os import path -from re import compile CURRENT_PATH = path.dirname(path.realpath(__file__)) SOURCEMAP_PATH = path.realpath(f"{CURRENT_PATH}/../qml/eu/ad5001/LogarithmPlotter/js/index.mjs.map") SOURCEMAP_INDEX = None -INDEX_REG = compile(r"build\/runtime-pyside6\/LogarithmPlotter\/qml\/eu\/ad5001\/LogarithmPlotter\/js\/index.mjs:(\d+)") class LOG_COLORS: @@ -79,7 +77,6 @@ def create_log_terminal_message(mode: QtMsgType, context: QMessageLogContext, me # Check 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) - # Parse message prefix = f"{LOG_COLORS.INVERT}{mode[1]}[{mode[0].upper()}]{LOG_COLORS.RESET_INVERT}" message = message + LOG_COLORS.RESET context = f"{context.function} at {source_file}:{line}" diff --git a/runtime-pyside6/LogarithmPlotter/util/latex.py b/runtime-pyside6/LogarithmPlotter/util/latex.py index f36f07f..0d720c3 100644 --- a/runtime-pyside6/LogarithmPlotter/util/latex.py +++ b/runtime-pyside6/LogarithmPlotter/util/latex.py @@ -82,7 +82,6 @@ class Latex(QObject): def __init__(self, cache_path): QObject.__init__(self) self.tempdir = path.join(cache_path, "latex") - self.render_pipeline_locks = {} makedirs(self.tempdir, exist_ok=True) @Property(bool) @@ -117,69 +116,20 @@ class Latex(QObject): except MissingPackageException: valid_install = False # Should have sent an error message if failed to render 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) def renderAsync(self, latex_markup: str, font_size: float, color: QColor) -> PyPromise: """ Prepares and renders a latex string into a png file asynchronously. """ - 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 + return PyPromise(self.renderSync, [latex_markup, font_size, color]) @Slot(str, float, QColor, result=str) def renderSync(self, latex_markup: str, font_size: float, color: QColor) -> str: """ Prepares and renders a latex string into a png file. """ - markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color) + markup_hash, export_path = self.create_export_path(latex_markup, font_size, color) if self.latexSupported and not path.exists(export_path + ".png"): print("Rendering", latex_markup, export_path) # Generating file @@ -188,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()}' @@ -203,7 +155,7 @@ class Latex(QObject): """ Finds a prerendered image and returns its data if possible, and an empty string if not. """ - markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color) + markup_hash, export_path = self.create_export_path(latex_markup, font_size, color) data = "" if path.exists(export_path + ".png"): img = QImage(export_path) @@ -213,13 +165,10 @@ class Latex(QObject): def create_export_path(self, latex_markup: str, font_size: float, color: QColor): """ 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()) - render_hash = f'{markup_hash}_{int(font_size)}_{color.rgb()}' - export_path = path.join(self.tempdir, render_hash) - return markup_hash, render_hash, export_path + export_path = path.join(self.tempdir, f'{markup_hash}_{int(font_size)}_{color.rgb()}') + return markup_hash, export_path def create_latex_doc(self, export_path: str, latex_markup: str): """ diff --git a/runtime-pyside6/LogarithmPlotter/util/promise.py b/runtime-pyside6/LogarithmPlotter/util/promise.py index e475b36..f129d41 100644 --- a/runtime-pyside6/LogarithmPlotter/util/promise.py +++ b/runtime-pyside6/LogarithmPlotter/util/promise.py @@ -17,24 +17,12 @@ """ from typing import Callable -from PySide6.QtCore import QRunnable, Signal, Property, QObject, Slot, QThreadPool +from PySide6.QtCore import QRunnable, Signal, QObject, Slot, QThreadPool from PySide6.QtQml import QJSValue 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 @@ -63,10 +51,10 @@ class PyPromiseRunner(QRunnable): data = data.qjs_value else: raise InvalidReturnValue("Must return either a primitive, a valid QObject, JS Value, or None.") - self.promise.fulfilled.emit(data) + self.promise.finished.emit(data) except Exception as e: try: - self.promise.rejected.emit(repr(e)) + 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. @@ -78,36 +66,18 @@ 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. """ - fulfilled = Signal((QJSValue,), (QObject,)) - rejected = Signal(Exception) + finished = Signal((QJSValue,), (QObject,)) + errored = Signal(Exception) - def __init__(self, to_run: Callable|QJSValue, args=[], start_automatically=True): + def __init__(self, to_run: Callable, args): QObject.__init__(self) self._fulfills = [] self._rejects = [] - self._state = "pending" - 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.finished.connect(self._fulfill) + self.errored.connect(self._reject) self._runner = PyPromiseRunner(to_run, self, args) - 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 + QThreadPool.globalInstance().start(self._runner) + @Slot(QJSValue, result=QObject) @Slot(QJSValue, QJSValue, result=QObject) @@ -115,23 +85,23 @@ class PyPromise(QObject): """ Adds listeners for both fulfilment and catching errors of the Promise. """ - on_fulfill = check_callable(on_fulfill) - on_reject = check_callable(on_reject) - if on_fulfill is not None: + if isinstance(on_fulfill, QJSValue): + self._fulfills.append(PyJSValue(on_fulfill)) + elif isinstance(on_fulfill, Callable): self._fulfills.append(on_fulfill) - if on_reject is not None: + 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): - self._state = "fulfilled" no_return = [None, QJSValue.SpecialValue.UndefinedValue] for on_fulfill in self._fulfills: try: 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. except Exception as e: self._reject(repr(e)) @@ -140,7 +110,6 @@ class PyPromise(QObject): @Slot(QJSValue) @Slot(str) def _reject(self, error): - self._state = "rejected" no_return = [None, QJSValue.SpecialValue.UndefinedValue] for on_reject in self._rejects: result = on_reject(error) diff --git a/runtime-pyside6/tests/test_helper.py b/runtime-pyside6/tests/test_helper.py index 5cfb239..73dfb23 100644 --- a/runtime-pyside6/tests/test_helper.py +++ b/runtime-pyside6/tests/test_helper.py @@ -17,7 +17,7 @@ """ import pytest -from os import getcwd, remove, path +from os import getcwd, remove from os.path import join from tempfile import TemporaryDirectory from json import loads @@ -25,12 +25,11 @@ from shutil import copy2 from PySide6.QtCore import QObject, Signal, QThreadPool from PySide6.QtGui import QImage -from PySide6.QtQml import QJSValue from PySide6.QtWidgets import QApplication from LogarithmPlotter import __VERSION__ as version from LogarithmPlotter.util import config, helper -from LogarithmPlotter.util.helper import Helper, InvalidFileException +from LogarithmPlotter.util.helper import ChangelogFetcher, Helper, InvalidFileException pwd = getcwd() helper.SHOW_GUI_MESSAGES = False @@ -44,45 +43,41 @@ def temporary(): directory.cleanup() -def create_changelog_callback_asserter(promise, expect_404=False): - 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) +class MockHelperSignals(QObject): + changelogFetched = Signal(str) -CHANGELOG_BASE_PATH = path.realpath(path.join(path.dirname(path.realpath(__file__)), "..", "CHANGELOG.md")) + def __init__(self, expect_404): + 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: - 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): # Test file reading and information loading. tmpfile, directory = temporary @@ -173,3 +168,15 @@ class TestHelper: obj.setSetting("last_install_greet", obj.getSetting("last_install_greet")) obj.setSetting("check_for_updates", obj.getSetting("check_for_updates")) 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) \ No newline at end of file diff --git a/runtime-pyside6/tests/test_latex.py b/runtime-pyside6/tests/test_latex.py index 6fd5c97..d74f9f1 100644 --- a/runtime-pyside6/tests/test_latex.py +++ b/runtime-pyside6/tests/test_latex.py @@ -54,8 +54,8 @@ class TestLatex: # Reset [latex.DVIPNG_PATH, latex.LATEX_PATH] = bkp - def test_render_sync(self, latex_obj: latex.Latex) -> None: - result = latex_obj.renderSync(r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255)) + def test_render(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)) # Ensure result format assert type(result) == str [path, width, height] = result.split(",") @@ -64,17 +64,17 @@ class TestLatex: assert match(r"\d+", height) # Ensure it returns errors on invalid latex. with pytest.raises(latex.RenderError): - latex_obj.renderSync(r"\nonexistant", 14, QColor(0, 0, 0, 255)) + latex_obj.render(r"\nonexistant", 14, QColor(0, 0, 0, 255)) # Replace latex bin with one that returns errors bkp = latex.LATEX_PATH latex.LATEX_PATH = which("false") with pytest.raises(latex.RenderError): - latex_obj.renderSync(r"\mathrm{f}(x)", 14, QColor(0, 0, 0, 255)) + latex_obj.render(r"\mathrm{f}(x)", 14, QColor(0, 0, 0, 255)) latex.LATEX_PATH = bkp 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)] - latex_obj.renderSync(*args) + latex_obj.render(*args) prerendered = latex_obj.findPrerendered(*args) assert type(prerendered) == str [path, width, height] = prerendered.split(",")