diff --git a/phy/gui/qt.py b/phy/gui/qt.py index c189335b..46bf4e71 100644 --- a/phy/gui/qt.py +++ b/phy/gui/qt.py @@ -6,18 +6,18 @@ # Imports # ----------------------------------------------------------------------------- -from contextlib import contextmanager -from datetime import datetime -from functools import wraps, partial import logging import os import os.path as op -from pathlib import Path import shutil import sys import tempfile -from timeit import default_timer import traceback +from contextlib import contextmanager +from datetime import datetime +from functools import partial, wraps +from pathlib import Path +from timeit import default_timer logger = logging.getLogger(__name__) @@ -30,33 +30,73 @@ # https://riverbankcomputing.com/pipermail/pyqt/2014-January/033681.html from OpenGL import GL # noqa -from PyQt5.QtCore import (Qt, QByteArray, QMetaObject, QObject, # noqa - QVariant, QEventLoop, QTimer, QPoint, QTimer, - QThreadPool, QRunnable, - pyqtSignal, pyqtSlot, QSize, QUrl, - QEvent, QCoreApplication, - qInstallMessageHandler, - ) +from PyQt5.QtCore import ( + Qt, + QByteArray, + QMetaObject, + QObject, # noqa + QVariant, + QEventLoop, + QTimer, + QPoint, + QTimer, + QThreadPool, + QRunnable, + pyqtSignal, + pyqtSlot, + QSize, + QUrl, + QEvent, + QCoreApplication, + qInstallMessageHandler, +) from PyQt5.QtGui import ( # noqa - QKeySequence, QIcon, QColor, QMouseEvent, QGuiApplication, - QFontDatabase, QWindow, QOpenGLWindow) -from PyQt5.QtWebEngineWidgets import (QWebEngineView, # noqa - QWebEnginePage, - # QWebSettings, - ) + QKeySequence, + QIcon, + QColor, + QMouseEvent, + QGuiApplication, + QFontDatabase, + QWindow, + QOpenGLWindow, +) +from PyQt5.QtWebEngineWidgets import ( + QWebEngineView, # noqa + QWebEnginePage, + # QWebSettings, +) from PyQt5.QtWebChannel import QWebChannel # noqa -from PyQt5.QtWidgets import (# noqa - QAction, QStatusBar, QMainWindow, QDockWidget, QToolBar, - QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, QScrollArea, - QPushButton, QLabel, QCheckBox, QPlainTextEdit, - QLineEdit, QSlider, QSpinBox, QDoubleSpinBox, - QMessageBox, QApplication, QMenu, QMenuBar, - QInputDialog, QOpenGLWidget) +from PyQt5.QtWidgets import ( # noqa + QAction, + QStatusBar, + QMainWindow, + QDockWidget, + QToolBar, + QWidget, + QHBoxLayout, + QVBoxLayout, + QGridLayout, + QScrollArea, + QPushButton, + QLabel, + QCheckBox, + QPlainTextEdit, + QLineEdit, + QSlider, + QSpinBox, + QDoubleSpinBox, + QMessageBox, + QApplication, + QMenu, + QMenuBar, + QInputDialog, + QOpenGLWidget, +) # Enable high DPI support. # BUG: uncommenting this create scaling bugs on high DPI screens # on Ubuntu. -#QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) +# QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) # ----------------------------------------------------------------------------- @@ -68,11 +108,13 @@ def mockable(f): """Wrap interactive Qt functions that should be mockable in the testing suite.""" + @wraps(f) def wrapped(*args, **kwargs): if _MOCK is not None: return _MOCK return f(*args, **kwargs) + return wrapped @@ -85,9 +127,9 @@ def mock_dialogs(result): """ assert result is not None - globals()['_MOCK'] = result + globals()["_MOCK"] = result yield - globals()['_MOCK'] = None + globals()["_MOCK"] = None # ----------------------------------------------------------------------------- @@ -115,12 +157,14 @@ def require_qt(func): the case. """ + @wraps(func) def wrapped(*args, **kwargs): if not QApplication.instance(): # pragma: no cover logger.warning("Creating a Qt application.") create_app() return func(*args, **kwargs) + return wrapped @@ -135,6 +179,7 @@ def run_app(): # pragma: no cover # Internal utility functions # ----------------------------------------------------------------------------- + @mockable def _button_enum_from_name(name): return getattr(QMessageBox, name.capitalize()) @@ -174,8 +219,7 @@ def _block(until_true, timeout=None): while not until_true() and (default_timer() - t0 < timeout): app = QApplication.instance() - app.processEvents(QEventLoop.AllEvents, - int(timeout * 1000)) + app.processEvents(QEventLoop.AllEvents, int(timeout * 1000)) if not until_true(): logger.error("Timeout in _block().") # NOTE: make sure we remove any busy cursor. @@ -187,13 +231,16 @@ def _block(until_true, timeout=None): def _wait(ms): """Wait for a given number of milliseconds, without blocking the GUI.""" from PyQt5 import QtTest + QtTest.QTest.qWait(ms) def _debug_trace(): # pragma: no cover """Set a tracepoint in the Python debugger that works with Qt.""" - from PyQt5.QtCore import pyqtRemoveInputHook from pdb import set_trace + + from PyQt5.QtCore import pyqtRemoveInputHook + pyqtRemoveInputHook() set_trace() @@ -201,13 +248,31 @@ def _debug_trace(): # pragma: no cover _FONTS = {} +def _get_default_font_family(): + """Get a default font family based on the operating system.""" + if sys.platform == "darwin": # macOS + return "Helvetica Neue" + elif sys.platform == "win32": # Windows + return "Segoe UI" + else: # Linux and others + return "DejaVu Sans" + + def _load_font(name, size=8): """Load a TTF font.""" if name in _FONTS: return _FONTS[name] font_id = QFontDatabase.addApplicationFont(str(_static_abs_path(name))) font_db = QFontDatabase() - font_family = QFontDatabase.applicationFontFamilies(font_id)[0] + font_families = QFontDatabase.applicationFontFamilies(font_id) + + if font_families: + font_family = font_families[0] + else: + # Fallback to OS-appropriate default font if loading fails + font_family = _get_default_font_family() + logger.warning("Failed to load font '%s', using system default '%s'", name, font_family) + font = font_db.font(font_family, None, size) _FONTS[name] = font return font @@ -217,8 +282,9 @@ def _load_font(name, size=8): # Public functions # ----------------------------------------------------------------------------- + @mockable -def prompt(message, buttons=('yes', 'no'), title='Question'): +def prompt(message, buttons=("yes", "no"), title="Question"): """Display a dialog with several buttons to confirm or cancel an action. Parameters @@ -235,7 +301,7 @@ def prompt(message, buttons=('yes', 'no'), title='Question'): """ buttons = [(button, _button_enum_from_name(button)) for button in buttons] arg_buttons = 0 - for (_, button) in buttons: + for _, button in buttons: arg_buttons |= button box = QMessageBox() box.setWindowTitle(title) @@ -246,7 +312,7 @@ def prompt(message, buttons=('yes', 'no'), title='Question'): @mockable -def message_box(message, title='Message', level=None): # pragma: no cover +def message_box(message, title="Message", level=None): # pragma: no cover """Display a message box. Parameters @@ -257,14 +323,15 @@ def message_box(message, title='Message', level=None): # pragma: no cover information, warning, or critical """ - getattr(QMessageBox, level, 'information')(None, title, message) + getattr(QMessageBox, level, "information")(None, title, message) class QtDialogLogger(logging.Handler): """Display a message box for all errors.""" + def emit(self, record): # pragma: no cover msg = self.format(record) - message_box(msg, title='An error has occurred', level='critical') + message_box(msg, title="An error has occurred", level="critical") @mockable @@ -315,9 +382,10 @@ def busy_cursor(activate=True): def screenshot_default_path(widget, dir=None): """Return a default path for the screenshot of a widget.""" from phylib.utils._misc import phy_config_dir - date = datetime.now().strftime('%Y%m%d%H%M%S') - name = 'phy_screenshot_%s_%s.png' % (date, widget.__class__.__name__) - path = (Path(dir) if dir else phy_config_dir() / 'screenshots') / name + + date = datetime.now().strftime("%Y%m%d%H%M%S") + name = "phy_screenshot_%s_%s.png" % (date, widget.__class__.__name__) + path = (Path(dir) if dir else phy_config_dir() / "screenshots") / name path.parent.mkdir(exist_ok=True, parents=True) return path @@ -367,19 +435,20 @@ def is_high_dpi(): return screen_size()[0] > 3000 -def _get_icon(icon, size=64, color='black'): +def _get_icon(icon, size=64, color="black"): """Get a QIcon from a font-awesome icon's hexadecimal code. Cache the PNG in the phy repo, to be staged under version control so that users don't need to install PIL.""" hex_icon = chr(int(icon, 16)) # from https://github.com/Pythonity/icon-font-to-png/blob/master/icon_font_to_png/icon_font.py - static_dir = op.join(op.dirname(op.abspath(__file__)), 'static/icons/') - ttf_file = op.abspath(op.join(static_dir, '../fa-solid-900.ttf')) - output_path = op.join(static_dir, icon + '.png') + static_dir = op.join(op.dirname(op.abspath(__file__)), "static/icons/") + ttf_file = op.abspath(op.join(static_dir, "../fa-solid-900.ttf")) + output_path = op.join(static_dir, icon + ".png") if not op.exists(output_path): # pragma: no cover # Ideally, this should only run on the developer's machine. logger.debug("Saving icon `%s` using the PIL library.", output_path) from PIL import Image, ImageDraw, ImageFont + org_size = size size = max(150, size) @@ -389,8 +458,7 @@ def _get_icon(icon, size=64, color='black'): font = ImageFont.truetype(ttf_file, int(size)) width, height = draw.textsize(hex_icon, font=font) - draw.text( - (float(size - width) / 2, float(size - height) / 2), hex_icon, font=font, fill=color) + draw.text((float(size - width) / 2, float(size - height) / 2), hex_icon, font=font, fill=color) # Get bounding box bbox = image.getbbox() @@ -400,8 +468,7 @@ def _get_icon(icon, size=64, color='black'): draw_mask = ImageDraw.Draw(image_mask) # Draw the icon on the mask - draw_mask.text( - (float(size - width) / 2, float(size - height) / 2), hex_icon, font=font, fill=255) + draw_mask.text((float(size - width) / 2, float(size - height) / 2), hex_icon, font=font, fill=255) # Create a solid color image and apply the mask icon_image = Image.new("RGBA", (size, size), color) @@ -433,13 +500,15 @@ def _get_icon(icon, size=64, color='black'): # Widgets # ----------------------------------------------------------------------------- + def _static_abs_path(rel_path): """Return the absolute path of a static file saved in this repository.""" - return Path(__file__).parent / 'static' / rel_path + return Path(__file__).parent / "static" / rel_path class WebPage(QWebEnginePage): """A Qt web page widget.""" + _raise_on_javascript_error = False def javaScriptConsoleMessage(self, level, msg, line, source): @@ -467,21 +536,21 @@ def set_html(self, html, callback=None): """Set the HTML code.""" self._callback = callback self.loadFinished.connect(self._loadFinished) - static_dir = str(Path(__file__).parent / 'static') + '/' + static_dir = str(Path(__file__).parent / "static") + "/" # Create local file from HTML self.clear_temporary_files() self._tempdir = Path(tempfile.mkdtemp()) - shutil.copytree(static_dir, self._tempdir / 'html') - file_path = self._tempdir / 'html' / 'page.html' - with open(file_path, 'w') as f: + shutil.copytree(static_dir, self._tempdir / "html") + file_path = self._tempdir / "html" / "page.html" + with open(file_path, "w") as f: f.write(html) file_url = QUrl().fromLocalFile(str(file_path)) self.page().setUrl(file_url) def clear_temporary_files(self): """Delete the temporary HTML files""" - if hasattr(self, '_tempdir') and self._tempdir.is_dir(): + if hasattr(self, "_tempdir") and self._tempdir.is_dir(): shutil.rmtree(self._tempdir, ignore_errors=True) def _callable(self, data): @@ -497,8 +566,10 @@ def _loadFinished(self, result): # Threading # ----------------------------------------------------------------------------- + class WorkerSignals(QObject): """Object holding some signals for the workers.""" + finished = pyqtSignal() error = pyqtSignal(tuple) result = pyqtSignal(object) @@ -530,6 +601,7 @@ class Worker(QRunnable): **kwargs : function keyword arguments """ + def __init__(self, fn, *args, **kwargs): super(Worker, self).__init__() self.fn = fn @@ -600,7 +672,7 @@ def __init__(self, delay=None): def _elapsed_enough(self): """Return whether the elapsed time since the last submission is greater than the threshold.""" - return default_timer() - self._last_submission_time > self.delay * .001 + return default_timer() - self._last_submission_time > self.delay * 0.001 def _timer_callback(self): """Callback for the timer.""" @@ -635,14 +707,15 @@ def trigger(self): f(*args, **kwargs) self.pending_functions[key] = None - def stop_waiting(self, delay=.1): + def stop_waiting(self, delay=0.1): """Stop waiting and force the pending actions to execute (almost) immediately.""" # The trigger will occur in `delay` seconds. - self._last_submission_time = default_timer() - (self.delay * .001 - delay) + self._last_submission_time = default_timer() - (self.delay * 0.001 - delay) class AsyncCaller(object): """Call a Python function after a delay.""" + def __init__(self, delay=10): self._delay = delay self._timer = None