From 9ff01e757b1e64097cac36c05b540b4d05b86ae5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 16 Aug 2023 12:08:13 -0400 Subject: [PATCH 01/23] build: misc updates to repo (#180) --- .github/workflows/test_and_deploy.yml | 19 +++++---------- CONTRIBUTING.md | 2 +- README.md | 2 +- docs/index.md | 2 +- pyproject.toml | 13 +++++----- setup.py | 29 ----------------------- src/superqt/__init__.py | 5 ++-- tests/test_sliders/test_labeled_slider.py | 4 +--- tests/test_sliders/test_range_slider.py | 4 +--- 9 files changed, 20 insertions(+), 60 deletions(-) delete mode 100644 setup.py diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index a7a43076..5be982a9 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -1,5 +1,9 @@ name: Test +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: push: branches: @@ -43,14 +47,6 @@ jobs: platform: windows-latest backend: pyside6 - # python 3.7 - - python-version: 3.7 - platform: macos-latest - backend: pyqt5 - - python-version: 3.7 - platform: windows-latest - backend: pyside2 - # legacy Qt - python-version: 3.8 platform: ubuntu-latest @@ -63,11 +59,6 @@ jobs: backend: "pyqt5==5.14.*" steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -167,6 +158,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37828969..bd805ff8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ pytest All widgets must be well-tested, and should work on: -- Python 3.7 and above +- Python 3.8 and above - PyQt5 (5.11 and above) & PyQt6 - PySide2 (5.11 and above) & PySide6 - macOS, Windows, & Linux diff --git a/README.md b/README.md index 4d084420..7426b94c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ that are not provided in the native QtWidgets module. Components are tested on: - macOS, Windows, & Linux -- Python 3.7 and above +- Python 3.8 and above - PyQt5 (5.11 and above) & PyQt6 - PySide2 (5.11 and above) & PySide6 diff --git a/docs/index.md b/docs/index.md index 3d9b8608..c04534ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ QtWidgets module. Components are tested on: - macOS, Windows, & Linux -- Python 3.7 and above +- Python 3.8 and above - PyQt5 (5.11 and above) & PyQt6 - PySide2 (5.11 and above) & PySide6 diff --git a/pyproject.toml b/pyproject.toml index b67e8306..b45abf1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,9 @@ build-backend = "hatchling.build" name = "superqt" description = "Missing widgets and components for PyQt/PySide" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = { text = "BSD 3-Clause License" } -authors = [{ email = "talley.lambert@gmail.com" }, { name = "Talley Lambert" }] +authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }] keywords = [ "qt", "pyqt", @@ -28,7 +28,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -93,19 +92,21 @@ src_paths = ["src/superqt", "tests"] # https://github.com/charliermarsh/ruff [tool.ruff] line-length = 88 -target-version = "py37" +target-version = "py38" src = ["src", "tests"] select = [ "E", # style errors "F", # flakes + "W", # flakes "D", # pydocstyle "I", # isort "UP", # pyupgrade "S", # bandit - "C", # flake8-comprehensions + "C4", # flake8-comprehensions "B", # flake8-bugbear "A001", # flake8-builtins "RUF", # ruff-specific rules + "TID", # tidy imports ] ignore = [ "D100", # Missing docstring in public module @@ -118,7 +119,6 @@ ignore = [ "D401", # First line should be in imperative mood "D413", # Missing blank line after last section "D416", # Section name should end with a colon - "C901", # Function is too complex ] @@ -180,5 +180,4 @@ ignore = [ "CONTRIBUTING.md", "codecov.yml", ".ruff_cache/**/*", - "setup.py", ] diff --git a/setup.py b/setup.py deleted file mode 100644 index 57275ee5..00000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -import sys - -sys.stderr.write( - """ -=============================== -Unsupported installation method -=============================== -superqt does not support installation with `python setup.py install`. -Please use `python -m pip install .` instead. -""" -) -sys.exit(1) - - -# The below code will never execute, however GitHub is particularly -# picky about where it finds Python packaging metadata. -# See: https://github.com/github/feedback/discussions/6456 -# -# To be removed once GitHub catches up. - -setup( # noqa: F821 - name="superqt", - install_requires=[ - "packaging", - "pygments>=2.4.0", - "qtpy>=1.1.0", - "typing-extensions", - ], -) diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index c61bc2c8..be9e088b 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -1,9 +1,10 @@ """superqt is a collection of Qt components for python.""" +from importlib.metadata import PackageNotFoundError, version from typing import TYPE_CHECKING, Any try: - from ._version import version as __version__ -except ImportError: + __version__ = version("superqt") +except PackageNotFoundError: __version__ = "unknown" if TYPE_CHECKING: diff --git a/tests/test_sliders/test_labeled_slider.py b/tests/test_sliders/test_labeled_slider.py index 25525e06..feaac1ce 100644 --- a/tests/test_sliders/test_labeled_slider.py +++ b/tests/test_sliders/test_labeled_slider.py @@ -1,4 +1,3 @@ -import sys from typing import Any, Iterable from unittest.mock import Mock @@ -26,8 +25,7 @@ def test_slider_connect_works(qtbot): def _assert_types(args: Iterable[Any], type_: type): # sourcery skip: comprehension-to-generator - if sys.version_info >= (3, 8): - assert all(isinstance(v, type_) for v in args), "invalid type" + assert all(isinstance(v, type_) for v in args), "invalid type" @pytest.mark.parametrize("cls", [QLabeledDoubleSlider, QLabeledSlider]) diff --git a/tests/test_sliders/test_range_slider.py b/tests/test_sliders/test_range_slider.py index 87b56757..29bc08d9 100644 --- a/tests/test_sliders/test_range_slider.py +++ b/tests/test_sliders/test_range_slider.py @@ -1,5 +1,4 @@ import math -import sys from itertools import product from typing import Any, Iterable from unittest.mock import Mock @@ -218,8 +217,7 @@ def test_wheel(cls, orientation, qtbot): def _assert_types(args: Iterable[Any], type_: type): # sourcery skip: comprehension-to-generator - if sys.version_info >= (3, 8): - assert all(isinstance(v, type_) for v in args), "invalid type" + assert all(isinstance(v, type_) for v in args), "invalid type" @pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS) From 39b6a0596fe600eff9308f14678e5653045bf98c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 17 Aug 2023 09:20:11 -0400 Subject: [PATCH 02/23] fix: fix parameter inspection on ensure_thread decorators (alternate) (#185) * fix: use different approach * test: apply fixes * back to signature * fix get_max_args * IMPORT THE FUTURE * try or return None * check for callable * Update test_utils.py Co-authored-by: Grzegorz Bokota * style: [pre-commit.ci] auto fixes [...] --------- Co-authored-by: Grzegorz Bokota Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/superqt/utils/_ensure_thread.py | 30 +++++++---- src/superqt/utils/_util.py | 23 +++++++++ tests/test_ensure_thread.py | 80 +++++++++++++++++++++++++++++ tests/test_utils.py | 64 +++++++++++++++++++++++ 4 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 src/superqt/utils/_util.py diff --git a/src/superqt/utils/_ensure_thread.py b/src/superqt/utils/_ensure_thread.py index 926adae1..ab699a0a 100644 --- a/src/superqt/utils/_ensure_thread.py +++ b/src/superqt/utils/_ensure_thread.py @@ -3,7 +3,7 @@ from concurrent.futures import Future from functools import wraps -from typing import TYPE_CHECKING, Callable, ClassVar, overload +from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload from qtpy.QtCore import ( QCoreApplication, @@ -15,6 +15,8 @@ Slot, ) +from ._util import get_max_args + if TYPE_CHECKING: from typing import TypeVar @@ -28,7 +30,7 @@ class CallCallable(QObject): finished = Signal(object) instances: ClassVar[list[CallCallable]] = [] - def __init__(self, callable, *args, **kwargs): + def __init__(self, callable: Callable, args: tuple, kwargs: dict): super().__init__() self._callable = callable self._args = args @@ -88,15 +90,17 @@ def ensure_main_thread( """ def _out_func(func_): + max_args = get_max_args(func_) + @wraps(func_) - def _func(*args, **kwargs): + def _func(*args, _max_args_=max_args, **kwargs): return _run_in_thread( func_, QCoreApplication.instance().thread(), await_return, timeout, - *args, - **kwargs, + args[:_max_args_], + kwargs, ) return _func @@ -150,10 +154,13 @@ def ensure_object_thread( """ def _out_func(func_): + max_args = get_max_args(func_) + @wraps(func_) - def _func(self, *args, **kwargs): + def _func(*args, _max_args_=max_args, **kwargs): + thread = args[0].thread() # self return _run_in_thread( - func_, self.thread(), await_return, timeout, self, *args, **kwargs + func_, thread, await_return, timeout, args[:_max_args_], kwargs ) return _func @@ -166,9 +173,9 @@ def _run_in_thread( thread: QThread, await_return: bool, timeout: int, - *args, - **kwargs, -): + args: tuple, + kwargs: dict, +) -> Any: future = Future() # type: ignore if thread is QThread.currentThread(): result = func(*args, **kwargs) @@ -176,7 +183,8 @@ def _run_in_thread( future.set_result(result) return future return result - f = CallCallable(func, *args, **kwargs) + + f = CallCallable(func, args, kwargs) f.moveToThread(thread) f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection) QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore # noqa diff --git a/src/superqt/utils/_util.py b/src/superqt/utils/_util.py new file mode 100644 index 00000000..bdb9d615 --- /dev/null +++ b/src/superqt/utils/_util.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from inspect import signature +from typing import Callable + + +def get_max_args(func: Callable) -> int | None: + """Return the maximum number of positional arguments that func can accept.""" + if not callable(func): + raise TypeError(f"{func!r} is not callable") + + try: + sig = signature(func) + except Exception: + return None + + max_args = 0 + for param in sig.parameters.values(): + if param.kind == param.VAR_POSITIONAL: + return None + if param.kind in {param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD}: + max_args += 1 + return max_args diff --git a/tests/test_ensure_thread.py b/tests/test_ensure_thread.py index f4bde0ac..e35afeef 100644 --- a/tests/test_ensure_thread.py +++ b/tests/test_ensure_thread.py @@ -1,7 +1,10 @@ import inspect import os +import threading import time from concurrent.futures import Future, TimeoutError +from functools import wraps +from unittest.mock import Mock import pytest from qtpy.QtCore import QCoreApplication, QObject, QThread, Signal @@ -217,3 +220,80 @@ def test_object_thread(qtbot): assert ob.thread() is thread with qtbot.waitSignal(thread.finished): thread.quit() + + +@pytest.mark.parametrize("mode", ["method", "func", "wrapped"]) +@pytest.mark.parametrize("deco", [ensure_main_thread, ensure_object_thread]) +def test_ensure_thread_sig_inspection(deco, mode): + class Emitter(QObject): + sig = Signal(int, int, int) + + obj = Emitter() + mock = Mock() + + if mode == "method": + + class Receiver(QObject): + @deco + def func(self, a: int, b: int): + mock(a, b) + + r = Receiver() + obj.sig.connect(r.func) + elif deco == ensure_object_thread: + return # not compatible with function types + + elif mode == "wrapped": + + def wr(fun): + @wraps(fun) + def wr2(*args): + mock(*args) + return fun(*args) * 2 + + return wr2 + + @deco + @wr + def wrapped_func(a, b): + return a + b + + obj.sig.connect(wrapped_func) + + elif mode == "func": + + @deco + def func(a: int, b: int) -> None: + mock(a, b) + + obj.sig.connect(func) + + # this is the crux of the test... + # we emit 3 args, but the function only takes 2 + # this should normally work fine in Qt. + # testing here that the decorator doesn't break it. + obj.sig.emit(1, 2, 3) + mock.assert_called_once_with(1, 2) + + +def test_main_thread_function(qtbot): + """Testing decorator on a function rather than QObject method.""" + + mock = Mock() + + class Emitter(QObject): + sig = Signal(int, int, int) + + @ensure_main_thread + def func(x: int) -> None: + mock(x, QThread.currentThread()) + + e = Emitter() + e.sig.connect(func) + + with qtbot.waitSignal(e.sig): + thread = threading.Thread(target=e.sig.emit, args=(1, 2, 3)) + thread.start() + thread.join() + + mock.assert_called_once_with(1, QCoreApplication.instance().thread()) diff --git a/tests/test_utils.py b/tests/test_utils.py index f80c6063..c7942718 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,6 +3,7 @@ from qtpy.QtCore import QObject, Signal from superqt.utils import signals_blocked +from superqt.utils._util import get_max_args def test_signal_blocker(qtbot): @@ -27,3 +28,66 @@ class Emitter(QObject): qtbot.wait(10) receiver.assert_not_called() + + +def test_get_max_args_simple(): + def fun1(): + pass + + assert get_max_args(fun1) == 0 + + def fun2(a): + pass + + assert get_max_args(fun2) == 1 + + def fun3(a, b=1): + pass + + assert get_max_args(fun3) == 2 + + def fun4(a, *, b=2): + pass + + assert get_max_args(fun4) == 1 + + def fun5(a, *b): + pass + + assert get_max_args(fun5) is None + + assert get_max_args(print) is None + + +def test_get_max_args_wrapped(): + from functools import partial, wraps + + def fun1(a, b): + pass + + assert get_max_args(partial(fun1, 1)) == 1 + + def dec(fun): + @wraps(fun) + def wrapper(*args, **kwargs): + return fun(*args, **kwargs) + + return wrapper + + assert get_max_args(dec(fun1)) == 2 + + +def test_get_max_args_methods(): + class A: + def fun1(self): + pass + + def fun2(self, a): + pass + + def __call__(self, a, b=1): + pass + + assert get_max_args(A().fun1) == 0 + assert get_max_args(A().fun2) == 1 + assert get_max_args(A()) == 2 From 41ea4e89074f8a3b44c63aa33ca116f215d23503 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 17 Aug 2023 09:40:06 -0400 Subject: [PATCH 03/23] docs: document signals blocked (#186) --- docs/utilities/signal_utils.md | 3 +++ src/superqt/utils/_misc.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 docs/utilities/signal_utils.md diff --git a/docs/utilities/signal_utils.md b/docs/utilities/signal_utils.md new file mode 100644 index 00000000..cf629700 --- /dev/null +++ b/docs/utilities/signal_utils.md @@ -0,0 +1,3 @@ +# Signal Utilities + +::: superqt.utils.signals_blocked diff --git a/src/superqt/utils/_misc.py b/src/superqt/utils/_misc.py index 085f1918..ef4b33e1 100644 --- a/src/superqt/utils/_misc.py +++ b/src/superqt/utils/_misc.py @@ -7,7 +7,24 @@ @contextmanager def signals_blocked(obj: "QObject") -> Iterator[None]: - """Context manager to temporarily block signals emitted by QObject: `obj`.""" + """Context manager to temporarily block signals emitted by QObject: `obj`. + + Parameters + ---------- + obj : QObject + The QObject whose signals should be blocked. + + Examples + -------- + ```python + from qtpy.QtWidgets import QSpinBox + from superqt import signals_blocked + + spinbox = QSpinBox() + with signals_blocked(spinbox): + spinbox.setValue(10) + ``` + """ previous = obj.blockSignals(True) try: yield From 1da26ce7c29fbc1fe6c0ec47db7bcf20132fe2ca Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 17 Aug 2023 10:51:53 -0400 Subject: [PATCH 04/23] test: change wait pattern (#187) * test: change wait pattern * style: [pre-commit.ci] auto fixes [...] --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/test_threadworker.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_threadworker.py b/tests/test_threadworker.py index 4e5e3c9c..58d11750 100644 --- a/tests/test_threadworker.py +++ b/tests/test_threadworker.py @@ -1,4 +1,5 @@ import inspect +import threading import time import warnings from functools import partial @@ -280,15 +281,20 @@ def returned_handler(value): def test_nested_threads_start(qtbot): mock1 = Mock() mock2 = Mock() + event = threading.Event() + + def call_mock(_e=event): + def nested_func(): + mock2() + _e.set() - def call_mock(): mock1() - worker2 = qthreading.create_worker(mock2) + worker2 = qthreading.create_worker(nested_func) worker2.start() worker = qthreading.create_worker(call_mock) worker.start() - qtbot.wait(20) + event.wait(timeout=2) mock1.assert_called_once() mock2.assert_called_once() From 64dfb43d9e4579a6d7408f3b5638324653febdc6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 17 Aug 2023 11:05:02 -0400 Subject: [PATCH 05/23] fix: fix callback of throttled/debounced decorated functions with mismatched args (#184) * fix: fix throttled inspection * build: change typing-ext deps * fix: use inspect.signature * use get_max_args * fix: fix typing --- .github/workflows/test_and_deploy.yml | 2 +- pyproject.toml | 2 +- src/superqt/utils/_throttler.py | 126 ++++++++++++-------------- tests/test_throttler.py | 29 ++++++ 4 files changed, 88 insertions(+), 71 deletions(-) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 5be982a9..c343528e 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -100,7 +100,7 @@ jobs: run: | python -m pip install -U pip python -m pip install -e .[test,pyqt5] - python -m pip install qtpy==1.1.0 typing-extensions==3.10.0.0 + python -m pip install qtpy==1.1.0 typing-extensions==3.7.4.3 - name: Test uses: aganders3/headless-gui@v1.2 diff --git a/pyproject.toml b/pyproject.toml index b45abf1c..d1f31990 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "packaging", "pygments>=2.4.0", "qtpy>=1.1.0", - "typing-extensions", + "typing-extensions >=3.7.4.3,!=3.10.0.0", ] # extras diff --git a/src/superqt/utils/_throttler.py b/src/superqt/utils/_throttler.py index 0065c8f8..d5f69b97 100644 --- a/src/superqt/utils/_throttler.py +++ b/src/superqt/utils/_throttler.py @@ -26,17 +26,19 @@ SOFTWARE. """ -import sys +from __future__ import annotations + from concurrent.futures import Future from enum import IntFlag, auto from functools import wraps -from typing import TYPE_CHECKING, Callable, Generic, Optional, TypeVar, Union, overload +from typing import TYPE_CHECKING, Callable, Generic, TypeVar, overload from qtpy.QtCore import QObject, Qt, QTimer, Signal +from ._util import get_max_args + if TYPE_CHECKING: - from qtpy.QtCore import SignalInstance - from typing_extensions import Literal, ParamSpec + from typing_extensions import ParamSpec P = ParamSpec("P") # maintain runtime compatibility with older typing_extensions @@ -70,7 +72,7 @@ def __init__( self, kind: Kind, emissionPolicy: EmissionPolicy, - parent: Optional[QObject] = None, + parent: QObject | None = None, ) -> None: super().__init__(parent) @@ -166,7 +168,7 @@ class QSignalThrottler(GenericSignalThrottler): def __init__( self, policy: EmissionPolicy = EmissionPolicy.Leading, - parent: Optional[QObject] = None, + parent: QObject | None = None, ) -> None: super().__init__(Kind.Throttler, policy, parent) @@ -181,7 +183,7 @@ class QSignalDebouncer(GenericSignalThrottler): def __init__( self, policy: EmissionPolicy = EmissionPolicy.Trailing, - parent: Optional[QObject] = None, + parent: QObject | None = None, ) -> None: super().__init__(Kind.Debouncer, policy, parent) @@ -189,30 +191,44 @@ def __init__( # below here part is unique to superqt (not from KD) -if TYPE_CHECKING: - from typing_extensions import Protocol - - class ThrottledCallable(Generic[P, R], Protocol): - triggered: "SignalInstance" +class ThrottledCallable(GenericSignalThrottler, Generic[P, R]): + def __init__( + self, + func: Callable[P, R], + kind: Kind, + emissionPolicy: EmissionPolicy, + parent: QObject | None = None, + ) -> None: + super().__init__(kind, emissionPolicy, parent) - def cancel(self) -> None: - ... + self._future: Future[R] = Future() + self.__wrapped__ = func - def flush(self) -> None: - ... + self._args: tuple = () + self._kwargs: dict = {} + self.triggered.connect(self._set_future_result) - def set_timeout(self, timeout: int) -> None: - ... + # even if we were to compile __call__ with a signature matching that of func, + # PySide wouldn't correctly inspect the signature of the ThrottledCallable + # instance: https://bugreports.qt.io/browse/PYSIDE-2423 + # so we do it ourselfs and limit the number of positional arguments + # that we pass to func + self._max_args: int | None = get_max_args(func) - if sys.version_info < (3, 9): + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa + if not self._future.done(): + self._future.cancel() - def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future: - ... + self._future = Future() + self._args = args + self._kwargs = kwargs - else: + self.throttle() + return self._future - def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future[R]: - ... + def _set_future_result(self): + result = self.__wrapped__(*self._args[: self._max_args], **self._kwargs) + self._future.set_result(result) @overload @@ -221,28 +237,26 @@ def qthrottled( timeout: int = 100, leading: bool = True, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, -) -> "ThrottledCallable[P, R]": +) -> ThrottledCallable[P, R]: ... @overload def qthrottled( - func: Optional["Literal[None]"] = None, + func: None = ..., timeout: int = 100, leading: bool = True, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, -) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]: +) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ... def qthrottled( - func: Optional[Callable[P, R]] = None, + func: Callable[P, R] | None = None, timeout: int = 100, leading: bool = True, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, -) -> Union[ - "ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"] -]: +) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]: """Creates a throttled function that invokes func at most once per timeout. The throttled function comes with a `cancel` method to cancel delayed func @@ -280,28 +294,26 @@ def qdebounced( timeout: int = 100, leading: bool = False, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, -) -> "ThrottledCallable[P, R]": +) -> ThrottledCallable[P, R]: ... @overload def qdebounced( - func: Optional["Literal[None]"] = None, + func: None = ..., timeout: int = 100, leading: bool = False, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, -) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]: +) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ... def qdebounced( - func: Optional[Callable[P, R]] = None, + func: Callable[P, R] | None = None, timeout: int = 100, leading: bool = False, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, -) -> Union[ - "ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"] -]: +) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]: """Creates a debounced function that delays invoking `func`. `func` will not be invoked until `timeout` ms have elapsed since the last time @@ -337,41 +349,17 @@ def qdebounced( def _make_decorator( - func: Optional[Callable[P, R]], + func: Callable[P, R] | None, timeout: int, leading: bool, timer_type: Qt.TimerType, kind: Kind, -) -> Union[ - "ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"] -]: - def deco(func: Callable[P, R]) -> "ThrottledCallable[P, R]": +) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]: + def deco(func: Callable[P, R]) -> ThrottledCallable[P, R]: policy = EmissionPolicy.Leading if leading else EmissionPolicy.Trailing - throttle = GenericSignalThrottler(kind, policy) - throttle.setTimerType(timer_type) - throttle.setTimeout(timeout) - last_f = None - future: Optional[Future] = None - - @wraps(func) - def inner(*args: "P.args", **kwargs: "P.kwargs") -> Future: - nonlocal last_f - nonlocal future - if last_f is not None: - throttle.triggered.disconnect(last_f) - if future is not None and not future.done(): - future.cancel() - - future = Future() - last_f = lambda: future.set_result(func(*args, **kwargs)) # noqa - throttle.triggered.connect(last_f) - throttle.throttle() - return future - - inner.cancel = throttle.cancel - inner.flush = throttle.flush - inner.set_timeout = throttle.setTimeout - inner.triggered = throttle.triggered - return inner # type: ignore + obj = ThrottledCallable(func, kind, policy) + obj.setTimerType(timer_type) + obj.setTimeout(timeout) + return wraps(func)(obj) return deco(func) if func is not None else deco diff --git a/tests/test_throttler.py b/tests/test_throttler.py index f0c9daa8..577a4826 100644 --- a/tests/test_throttler.py +++ b/tests/test_throttler.py @@ -1,5 +1,8 @@ from unittest.mock import Mock +import pytest +from qtpy.QtCore import QObject, Signal + from superqt.utils import qdebounced, qthrottled @@ -41,3 +44,29 @@ def f2() -> str: qtbot.wait(5) assert mock1.call_count == 2 assert mock2.call_count == 10 + + +@pytest.mark.parametrize("deco", [qthrottled, qdebounced]) +def test_ensure_throttled_sig_inspection(deco, qtbot): + mock = Mock() + + class Emitter(QObject): + sig = Signal(int, int, int) + + @deco + def func(a: int, b: int): + """docstring""" + mock(a, b) + + obj = Emitter() + obj.sig.connect(func) + + # this is the crux of the test... + # we emit 3 args, but the function only takes 2 + # this should normally work fine in Qt. + # testing here that the decorator doesn't break it. + with qtbot.waitSignal(func.triggered, timeout=1000): + obj.sig.emit(1, 2, 3) + mock.assert_called_once_with(1, 2) + assert func.__doc__ == "docstring" + assert func.__name__ == "func" From 504adf8bd0b8302b11f252222dddff099cc8eb4e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 17 Aug 2023 11:37:37 -0400 Subject: [PATCH 06/23] chore: changelog v0.5.1 --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf612fb..3d9c2e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [Unreleased](https://github.com/pyapp-kit/superqt/tree/HEAD) + +[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...HEAD) + +**Fixed bugs:** + +- fix: fix parameter inspection on ensure\_thread decorators \(alternate\) [\#185](https://github.com/pyapp-kit/superqt/pull/185) ([tlambert03](https://github.com/tlambert03)) +- fix: fix callback of throttled/debounced decorated functions with mismatched args [\#184](https://github.com/pyapp-kit/superqt/pull/184) ([tlambert03](https://github.com/tlambert03)) + +**Documentation updates:** + +- docs: document signals blocked [\#186](https://github.com/pyapp-kit/superqt/pull/186) ([tlambert03](https://github.com/tlambert03)) + +**Merged pull requests:** + +- test: change wait pattern [\#187](https://github.com/pyapp-kit/superqt/pull/187) ([tlambert03](https://github.com/tlambert03)) +- build: drop python3.7, misc updates to repo [\#180](https://github.com/pyapp-kit/superqt/pull/180) ([tlambert03](https://github.com/tlambert03)) + ## [v0.5.0](https://github.com/pyapp-kit/superqt/tree/v0.5.0) (2023-08-06) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.1...v0.5.0) From 8457563f49f4aad940ad9c47259f195c9165cef5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bokota Date: Fri, 18 Aug 2023 19:30:03 +0200 Subject: [PATCH 07/23] Implement throttling of methods (#188) * Implement throttling of methods * style: [pre-commit.ci] auto fixes [...] * fix line length * chek if object instance is Qt object * handle `self._name` being None or empty string * fix throttling method * handle staticmethod * use descriptor * try fix staticmethods * move descriptor to a separate class * move __set_name__ * simplify code and restore timer information * inspire tlamber suggestions * clean code * add weakref dict as fallback --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/superqt/utils/_throttler.py | 73 ++++++++++++++++++++++++++++++--- tests/test_throttler.py | 61 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/src/superqt/utils/_throttler.py b/src/superqt/utils/_throttler.py index d5f69b97..925433c6 100644 --- a/src/superqt/utils/_throttler.py +++ b/src/superqt/utils/_throttler.py @@ -32,6 +32,7 @@ from enum import IntFlag, auto from functools import wraps from typing import TYPE_CHECKING, Callable, Generic, TypeVar, overload +from weakref import WeakKeyDictionary from qtpy.QtCore import QObject, Qt, QTimer, Signal @@ -202,18 +203,26 @@ def __init__( super().__init__(kind, emissionPolicy, parent) self._future: Future[R] = Future() + if isinstance(func, staticmethod): + self._func = func.__func__ + else: + self._func = func + self.__wrapped__ = func self._args: tuple = () self._kwargs: dict = {} self.triggered.connect(self._set_future_result) + self._name = None + + self._obj_dkt = WeakKeyDictionary() # even if we were to compile __call__ with a signature matching that of func, # PySide wouldn't correctly inspect the signature of the ThrottledCallable # instance: https://bugreports.qt.io/browse/PYSIDE-2423 # so we do it ourselfs and limit the number of positional arguments # that we pass to func - self._max_args: int | None = get_max_args(func) + self._max_args: int | None = get_max_args(self._func) def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa if not self._future.done(): @@ -227,9 +236,45 @@ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa return self._future def _set_future_result(self): - result = self.__wrapped__(*self._args[: self._max_args], **self._kwargs) + result = self._func(*self._args[: self._max_args], **self._kwargs) self._future.set_result(result) + def __set_name__(self, owner, name): + if not isinstance(self.__wrapped__, staticmethod): + self._name = name + + def _get_throttler(self, instance, owner, parent, obj): + throttler = ThrottledCallable( + self.__wrapped__.__get__(instance, owner), + self._kind, + self._emissionPolicy, + parent=parent, + ) + throttler.setTimerType(self.timerType()) + throttler.setTimeout(self.timeout()) + try: + setattr( + obj, + self._name, + throttler, + ) + except AttributeError: + self._obj_dkt[obj] = throttler + return throttler + + def __get__(self, instance, owner): + if instance is None or not self._name: + return self + + if instance in self._obj_dkt: + return self._obj_dkt[instance] + + parent = self.parent() + if parent is None and isinstance(instance, QObject): + parent = instance + + return self._get_throttler(instance, owner, parent, instance) + @overload def qthrottled( @@ -237,6 +282,7 @@ def qthrottled( timeout: int = 100, leading: bool = True, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, + parent: QObject | None = None, ) -> ThrottledCallable[P, R]: ... @@ -247,6 +293,7 @@ def qthrottled( timeout: int = 100, leading: bool = True, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, + parent: QObject | None = None, ) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ... @@ -256,6 +303,7 @@ def qthrottled( timeout: int = 100, leading: bool = True, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, + parent: QObject | None = None, ) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]: """Creates a throttled function that invokes func at most once per timeout. @@ -284,8 +332,11 @@ def qthrottled( - `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the desired interval - `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy + parent: QObject or None + Parent object for timer. If using qthrottled as function it may be usefull + for cleaning data """ - return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler) + return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler, parent) @overload @@ -294,6 +345,7 @@ def qdebounced( timeout: int = 100, leading: bool = False, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, + parent: QObject | None = None, ) -> ThrottledCallable[P, R]: ... @@ -304,6 +356,7 @@ def qdebounced( timeout: int = 100, leading: bool = False, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, + parent: QObject | None = None, ) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ... @@ -313,6 +366,7 @@ def qdebounced( timeout: int = 100, leading: bool = False, timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer, + parent: QObject | None = None, ) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]: """Creates a debounced function that delays invoking `func`. @@ -344,8 +398,11 @@ def qdebounced( - `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the desired interval - `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy + parent: QObject or None + Parent object for timer. If using qthrottled as function it may be usefull + for cleaning data """ - return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer) + return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer, parent) def _make_decorator( @@ -354,10 +411,16 @@ def _make_decorator( leading: bool, timer_type: Qt.TimerType, kind: Kind, + parent: QObject | None = None, ) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]: def deco(func: Callable[P, R]) -> ThrottledCallable[P, R]: + nonlocal parent + + instance: object | None = getattr(func, "__self__", None) + if isinstance(instance, QObject) and parent is None: + parent = instance policy = EmissionPolicy.Leading if leading else EmissionPolicy.Trailing - obj = ThrottledCallable(func, kind, policy) + obj = ThrottledCallable(func, kind, policy, parent=parent) obj.setTimerType(timer_type) obj.setTimeout(timeout) return wraps(func)(obj) diff --git a/tests/test_throttler.py b/tests/test_throttler.py index 577a4826..884d98e4 100644 --- a/tests/test_throttler.py +++ b/tests/test_throttler.py @@ -4,6 +4,7 @@ from qtpy.QtCore import QObject, Signal from superqt.utils import qdebounced, qthrottled +from superqt.utils._throttler import ThrottledCallable def test_debounced(qtbot): @@ -26,6 +27,66 @@ def f2() -> str: assert mock2.call_count == 10 +def test_debouncer_method(qtbot): + class A(QObject): + def __init__(self): + super().__init__() + self.count = 0 + + def callback(self): + self.count += 1 + + a = A() + assert all(not isinstance(x, ThrottledCallable) for x in a.children()) + b = qdebounced(a.callback, timeout=4) + assert any(isinstance(x, ThrottledCallable) for x in a.children()) + for _ in range(10): + b() + + qtbot.wait(5) + + assert a.count == 1 + + +def test_debouncer_method_definition(qtbot): + mock1 = Mock() + mock2 = Mock() + + class A(QObject): + def __init__(self): + super().__init__() + self.count = 0 + + @qdebounced(timeout=4) + def callback(self): + self.count += 1 + + @qdebounced(timeout=4) + @staticmethod + def call1(): + mock1() + + @staticmethod + @qdebounced(timeout=4) + def call2(): + mock2() + + a = A() + assert all(not isinstance(x, ThrottledCallable) for x in a.children()) + for _ in range(10): + a.callback(1) + A.call1(34) + a.call1(22) + a.call2(22) + A.call2(32) + + qtbot.wait(5) + + assert a.count == 1 + mock1.assert_called_once() + mock2.assert_called_once() + + def test_throttled(qtbot): mock1 = Mock() mock2 = Mock() From 462eeada9302158961386ddfed111591f6c5416c Mon Sep 17 00:00:00 2001 From: Grzegorz Bokota Date: Fri, 18 Aug 2023 20:20:11 +0200 Subject: [PATCH 08/23] fix: Add descriptive exception when fail to add instance to weakref dictionary (#189) * add weakref information and test * more information * Update src/superqt/utils/_throttler.py --------- Co-authored-by: Talley Lambert --- src/superqt/utils/_throttler.py | 10 +++++++++- tests/test_throttler.py | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/superqt/utils/_throttler.py b/src/superqt/utils/_throttler.py index 925433c6..b628f1d6 100644 --- a/src/superqt/utils/_throttler.py +++ b/src/superqt/utils/_throttler.py @@ -259,7 +259,15 @@ def _get_throttler(self, instance, owner, parent, obj): throttler, ) except AttributeError: - self._obj_dkt[obj] = throttler + try: + self._obj_dkt[obj] = throttler + except TypeError as e: + raise TypeError( + "To use qthrottled or qdebounced as a method decorator, " + "objects must have `__dict__` or be weak referenceable. " + "Please either add `__weakref__` to `__slots__` or use" + "qthrottled/qdebounced as a function (not a decorator)." + ) from e return throttler def __get__(self, instance, owner): diff --git a/tests/test_throttler.py b/tests/test_throttler.py index 884d98e4..7d283060 100644 --- a/tests/test_throttler.py +++ b/tests/test_throttler.py @@ -87,6 +87,41 @@ def call2(): mock2.assert_called_once() +def test_class_with_slots(qtbot): + class A: + __slots__ = ("count", "__weakref__") + + def __init__(self): + self.count = 0 + + @qdebounced(timeout=4) + def callback(self): + self.count += 1 + + a = A() + for _ in range(10): + a.callback() + + qtbot.wait(5) + assert a.count == 1 + + +@pytest.mark.usefixtures("qapp") +def test_class_with_slots_except(): + class A: + __slots__ = ("count",) + + def __init__(self): + self.count = 0 + + @qdebounced(timeout=4) + def callback(self): + self.count += 1 + + with pytest.raises(TypeError, match="To use qthrottled or qdebounced"): + A().callback() + + def test_throttled(qtbot): mock1 = Mock() mock2 = Mock() From 619daae13ffdf005409c0409ca2492afad4ae4bb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 18 Aug 2023 15:00:16 -0400 Subject: [PATCH 09/23] chore: changelog v0.5.2 --- CHANGELOG.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d9c2e0d..a2a9d781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,20 @@ # Changelog -## [Unreleased](https://github.com/pyapp-kit/superqt/tree/HEAD) +## [v0.5.2](https://github.com/pyapp-kit/superqt/tree/v0.5.2) (2023-08-18) -[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...HEAD) +[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.1...v0.5.2) + +**Implemented enhancements:** + +- feat: allow throttler/debouncer as method decorator [\#188](https://github.com/pyapp-kit/superqt/pull/188) ([Czaki](https://github.com/Czaki)) + +**Fixed bugs:** + +- fix: Add descriptive exception when fail to add instance to weakref dictionary [\#189](https://github.com/pyapp-kit/superqt/pull/189) ([Czaki](https://github.com/Czaki)) + +## [v0.5.1](https://github.com/pyapp-kit/superqt/tree/v0.5.1) (2023-08-17) + +[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...v0.5.1) **Fixed bugs:** From 7fcba7a485770fd83335d97e0282f841472dc0ab Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 20 Aug 2023 09:52:14 -0400 Subject: [PATCH 10/23] fix: remove dupes/aliases in QEnumCombo (#190) * fix: remove dupes/aliases in QEnumCombo * test: add test --- src/superqt/combobox/_enum_combobox.py | 4 +++- tests/test_enum_comb_box.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/superqt/combobox/_enum_combobox.py b/src/superqt/combobox/_enum_combobox.py index 211f0c67..fb127936 100644 --- a/src/superqt/combobox/_enum_combobox.py +++ b/src/superqt/combobox/_enum_combobox.py @@ -49,7 +49,9 @@ def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False): self._allow_none = allow_none and enum is not None if allow_none: super().addItem(NONE_STRING) - super().addItems(list(map(_get_name, self._enum_class.__members__.values()))) + names = map(_get_name, self._enum_class.__members__.values()) + _names = dict.fromkeys(names) # remove duplicates/aliases, keep order + super().addItems(list(_names)) def enumClass(self) -> Optional[EnumMeta]: """Return current Enum class.""" diff --git a/tests/test_enum_comb_box.py b/tests/test_enum_comb_box.py index 2ee24df5..82c23f98 100644 --- a/tests/test_enum_comb_box.py +++ b/tests/test_enum_comb_box.py @@ -11,6 +11,8 @@ class Enum1(Enum): b = 2 c = 3 + ALIAS = a + class Enum2(Enum): d = 1 From ed960f4994bb5a61cd75c76bb79b5b4503ed6e0a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 21 Aug 2023 17:12:39 -0400 Subject: [PATCH 11/23] feat: add error `exceptions_as_dialog` context manager to catch and show Exceptions (#191) * feat: add error messagebox context * typing * Update src/superqt/utils/_errormsg_context.py Co-authored-by: Grzegorz Bokota * add tests * style: [pre-commit.ci] auto fixes [...] * docs: add docs * test button result * format doc * docs: update docs * docs * add dialog example * pass flags * skip mac ci pyside6 --------- Co-authored-by: Grzegorz Bokota Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/utilities/error_dialog_contexts.md | 3 + mkdocs.yml | 1 + src/superqt/utils/__init__.py | 2 + src/superqt/utils/_errormsg_context.py | 165 ++++++++++++++++++++++++ tests/test_utils.py | 52 +++++++- 5 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 docs/utilities/error_dialog_contexts.md create mode 100644 src/superqt/utils/_errormsg_context.py diff --git a/docs/utilities/error_dialog_contexts.md b/docs/utilities/error_dialog_contexts.md new file mode 100644 index 00000000..ef9150f3 --- /dev/null +++ b/docs/utilities/error_dialog_contexts.md @@ -0,0 +1,3 @@ +# Error message context manager + +::: superqt.utils.exceptions_as_dialog diff --git a/mkdocs.yml b/mkdocs.yml index 873e81a5..8105b863 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,6 +25,7 @@ theme: # - navigation.tabs - search.highlight - search.suggest + - content.code.copy markdown_extensions: - admonition diff --git a/src/superqt/utils/__init__.py b/src/superqt/utils/__init__.py index de578c56..3a9b8bdf 100644 --- a/src/superqt/utils/__init__.py +++ b/src/superqt/utils/__init__.py @@ -14,10 +14,12 @@ "signals_blocked", "thread_worker", "WorkerBase", + "exceptions_as_dialog", ) from ._code_syntax_highlight import CodeSyntaxHighlight from ._ensure_thread import ensure_main_thread, ensure_object_thread +from ._errormsg_context import exceptions_as_dialog from ._message_handler import QMessageHandler from ._misc import signals_blocked from ._qthreading import ( diff --git a/src/superqt/utils/_errormsg_context.py b/src/superqt/utils/_errormsg_context.py new file mode 100644 index 00000000..d3be46d2 --- /dev/null +++ b/src/superqt/utils/_errormsg_context.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import traceback +from contextlib import AbstractContextManager +from typing import TYPE_CHECKING, cast + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QErrorMessage, QMessageBox, QWidget + +if TYPE_CHECKING: + from types import TracebackType + + +_DEFAULT_FLAGS = Qt.WindowType.Dialog | Qt.WindowType.MSWindowsFixedSizeDialogHint + + +class exceptions_as_dialog(AbstractContextManager): + """Context manager that shows a dialog when an exception is raised. + + See examples below for common usage patterns. + + To determine whether an exception was raised or not, check the `exception` + attribute after the context manager has exited. If `use_error_message` is `False` + (the default), you can also access the `dialog` attribute to get/manipulate the + `QMessageBox` instance. + + Parameters + ---------- + exceptions : type[BaseException] | tuple[type[BaseException], ...], optional + The exception(s) to catch, by default `Exception` (i.e. all exceptions). + icon : QMessageBox.Icon, optional + The icon to show in the QMessageBox, by default `QMessageBox.Icon.Critical` + title : str, optional + The title of the `QMessageBox`, by default `"An error occurred"`. + msg_template : str, optional + The message to show in the `QMessageBox`. The message will be formatted + using three variables: + + - `exc_value`: the exception instance + - `exc_type`: the exception type + - `tb`: the traceback as a string + + The default template is the content of the exception: `"{exc_value}"` + buttons : QMessageBox.StandardButton, optional + The buttons to show in the `QMessageBox`, by default + `QMessageBox.StandardButton.Ok` + parent : QWidget | None, optional + The parent widget of the `QMessageBox`, by default `None` + use_error_message : bool | QErrorMessage, optional + Whether to use a `QErrorMessage` instead of a `QMessageBox`. By default + `False`. `QErrorMessage` shows a checkbox that the user can check to + prevent seeing the message again (based on the text of the formatted + `msg_template`.) If `True`, the global `QMessageError.qtHandler()` + instance is used to maintain a history of dismissed messages. You may also pass + a `QErrorMessage` instance to use a specific instance. If `use_error_message` is + True, or if you pass your own `QErrorMessage` instance, the `parent` argument + is ignored. + + Attributes + ---------- + dialog : QMessageBox | None + The `QMessageBox` instance that was created (if `use_error_message` was + `False`). This can be used, among other things, to determine the result of + the dialog (e.g. `dialog.result()`) or to manipulate the dialog (e.g. + `dialog.setDetailedText("some text")`). + exception : BaseException | None + Will hold the exception instance if an exception was raised and caught. + + Examplez + ------- + ```python + from qtpy.QtWidgets import QApplication + from superqt.utils import exceptions_as_dialog + + app = QApplication([]) + + with exceptions_as_dialog() as ctx: + raise Exception("This will be caught and shown in a QMessageBox") + + # you can access the exception instance here + assert ctx.exception is not None + + # with exceptions_as_dialog(ValueError): + # 1 / 0 # ZeroDivisionError is not caught, so this will raise + + with exceptions_as_dialog(msg_template="Error: {exc_value}"): + raise Exception("This message will be inserted at 'exc_value'") + + for _i in range(3): + with exceptions_as_dialog(AssertionError, use_error_message=True): + assert False, "Uncheck the checkbox to ignore this in the future" + + # use ctx.dialog to get the result of the dialog + btns = QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel + with exceptions_as_dialog(buttons=btns) as ctx: + raise Exception("This will be caught and shown in a QMessageBox") + print(ctx.dialog.result()) # prints which button was clicked + + app.exec() # needed only for the use_error_message example to show + ``` + """ + + dialog: QMessageBox | None + exception: BaseException | None + exec_result: int | None = None + + def __init__( + self, + exceptions: type[BaseException] | tuple[type[BaseException], ...] = Exception, + icon: QMessageBox.Icon = QMessageBox.Icon.Critical, + title: str = "An error occurred", + msg_template: str = "{exc_value}", + buttons: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok, + parent: QWidget | None = None, + flags: Qt.WindowType = _DEFAULT_FLAGS, + use_error_message: bool | QErrorMessage = False, + ): + self.exceptions = exceptions + self.msg_template = msg_template + self.exception = None + self.dialog = None + + self._err_msg = use_error_message + + if not use_error_message: + # the message will be overwritten in __exit__ + self.dialog = QMessageBox( + icon, title, "An error occurred", buttons, parent, flags + ) + + def __enter__(self) -> exceptions_as_dialog: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> bool: + if not (exc_value is not None and isinstance(exc_value, self.exceptions)): + return False # let it propagate + + # save the exception for later + self.exception = exc_value + + # format the message using the context variables + if "{tb}" in self.msg_template: + _tb = "\n".join(traceback.format_exception(exc_type, exc_value, tb)) + else: + _tb = "" + text = self.msg_template.format(exc_value=exc_value, exc_type=exc_type, tb=_tb) + + # show the dialog + if self._err_msg: + msg = ( + self._err_msg + if isinstance(self._err_msg, QErrorMessage) + else QErrorMessage.qtHandler() + ) + cast("QErrorMessage", msg).showMessage(text) + elif self.dialog is not None: # it won't be if use_error_message=False + self.dialog.setText(text) + self.dialog.exec() + + return True # swallow the exception diff --git a/tests/test_utils.py b/tests/test_utils.py index c7942718..d3b60609 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,13 @@ +import os +import sys from unittest.mock import Mock -from qtpy.QtCore import QObject, Signal +import pytest +import qtpy +from qtpy.QtCore import QObject, QTimer, Signal +from qtpy.QtWidgets import QApplication, QErrorMessage, QMessageBox -from superqt.utils import signals_blocked +from superqt.utils import exceptions_as_dialog, signals_blocked from superqt.utils._util import get_max_args @@ -91,3 +96,46 @@ def __call__(self, a, b=1): assert get_max_args(A().fun1) == 0 assert get_max_args(A().fun2) == 1 assert get_max_args(A()) == 2 + + +MAC_CI_PYSIDE6 = bool( + sys.platform == "darwin" and os.getenv("CI") and qtpy.API_NAME == "PySide6" +) + + +@pytest.mark.skipif(MAC_CI_PYSIDE6, reason="still hangs on mac ci with pyside6") +def test_exception_context(qtbot, qapp: QApplication) -> None: + def accept(): + for wdg in qapp.topLevelWidgets(): + if isinstance(wdg, QMessageBox): + wdg.button(QMessageBox.StandardButton.Ok).click() + + with exceptions_as_dialog(): + QTimer.singleShot(0, accept) + raise Exception("This will be caught and shown in a QMessageBox") + + with pytest.raises(ZeroDivisionError), exceptions_as_dialog(ValueError): + 1 / 0 # noqa + + with exceptions_as_dialog(msg_template="Error: {exc_value}"): + QTimer.singleShot(0, accept) + raise Exception("This message will be used as 'exc_value'") + + err = QErrorMessage() + with exceptions_as_dialog(use_error_message=err): + QTimer.singleShot(0, err.accept) + raise AssertionError("Uncheck the checkbox to ignore this in the future") + + # tb formatting smoke test, and return value checking + exc = ValueError("Bad Val") + with exceptions_as_dialog( + msg_template="{tb}", + buttons=QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) as ctx: + qtbot.addWidget(ctx.dialog) + QTimer.singleShot(100, accept) + raise exc + + assert isinstance(ctx.dialog, QMessageBox) + assert ctx.dialog.result() == QMessageBox.StandardButton.Ok + assert ctx.exception is exc From 599dff7d029ce0499f73d52b61769358fb7ae346 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 21 Aug 2023 17:14:13 -0400 Subject: [PATCH 12/23] chore: changelog v0.5.3 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2a9d781..f74c23a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [v0.5.3](https://github.com/pyapp-kit/superqt/tree/v0.5.3) (2023-08-21) + +[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.2...v0.5.3) + +**Implemented enhancements:** + +- feat: add error `exceptions_as_dialog` context manager to catch and show Exceptions [\#191](https://github.com/pyapp-kit/superqt/pull/191) ([tlambert03](https://github.com/tlambert03)) + +**Fixed bugs:** + +- fix: remove dupes/aliases in QEnumCombo [\#190](https://github.com/pyapp-kit/superqt/pull/190) ([tlambert03](https://github.com/tlambert03)) + ## [v0.5.2](https://github.com/pyapp-kit/superqt/tree/v0.5.2) (2023-08-18) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.1...v0.5.2) From f676d7e17116c943b6d66a1a8d38f1d7890b1d7e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 31 Aug 2023 09:54:39 -0400 Subject: [PATCH 13/23] fix: fix mysterious segfault (#192) --- src/superqt/sliders/_labeled.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/superqt/sliders/_labeled.py b/src/superqt/sliders/_labeled.py index bfe453c6..10de575c 100644 --- a/src/superqt/sliders/_labeled.py +++ b/src/superqt/sliders/_labeled.py @@ -135,8 +135,8 @@ def __init__(self, *args, **kwargs) -> None: fp = self.style().styleHint(QStyle.StyleHint.SH_Button_FocusPolicy) self.setFocusPolicy(Qt.FocusPolicy(fp)) - self._slider = self._slider_class() - self._label = SliderLabel(self._slider, connect=self._setValue) + self._slider = self._slider_class(parent=self) + self._label = SliderLabel(self._slider, connect=self._setValue, parent=self) self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue self._rename_signals() @@ -145,12 +145,15 @@ def __init__(self, *args, **kwargs) -> None: self._slider.sliderMoved.connect(self.sliderMoved.emit) self._slider.sliderPressed.connect(self.sliderPressed.emit) self._slider.sliderReleased.connect(self.sliderReleased.emit) - self._slider.valueChanged.connect(self._label.setValue) - self._slider.valueChanged.connect(self.valueChanged.emit) + self._slider.valueChanged.connect(self._on_slider_value_changed) self._label.editingFinished.connect(self.editingFinished) self.setOrientation(orientation) + def _on_slider_value_changed(self, v): + self._label.setValue(v) + self.valueChanged.emit(v) + def _setValue(self, value: float): """Convert the value from float to int before setting the slider value.""" self._slider.setValue(int(value)) From 8525efd98c3515db6c7e86207721f9ca3ebde028 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 31 Aug 2023 09:56:01 -0400 Subject: [PATCH 14/23] chore: changelog v0.5.4 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f74c23a5..5c6496b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [v0.5.4](https://github.com/pyapp-kit/superqt/tree/v0.5.4) (2023-08-31) + +[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.3...v0.5.4) + +**Fixed bugs:** + +- fix: fix mysterious segfault [\#192](https://github.com/pyapp-kit/superqt/pull/192) ([tlambert03](https://github.com/tlambert03)) + ## [v0.5.3](https://github.com/pyapp-kit/superqt/tree/v0.5.3) (2023-08-21) [Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.2...v0.5.3) From 6993c88311d102bbee5da6c9c9cebb381549f129 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 5 Sep 2023 09:30:56 -0400 Subject: [PATCH 15/23] ci: [pre-commit.ci] autoupdate (#193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.0.281 → v0.0.287](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.281...v0.0.287) - [github.com/abravalheri/validate-pyproject: v0.13 → v0.14](https://github.com/abravalheri/validate-pyproject/compare/v0.13...v0.14) - [github.com/pre-commit/mirrors-mypy: v1.4.1 → v1.5.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.4.1...v1.5.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 54045c8f..cc8b6e78 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,18 +17,18 @@ repos: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.281 + rev: v0.0.287 hooks: - id: ruff args: ["--fix"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.13 + rev: v0.14 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.4.1 + rev: v1.5.1 hooks: - id: mypy exclude: tests|examples From 60f442789fabe0284a7ef2a4e7933e55a38be277 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 10 Sep 2023 19:59:11 -0400 Subject: [PATCH 16/23] Add colormap combobox and utils (#195) * feat: add colormap combobox * working on styles * add comment * style: [pre-commit.ci] auto fixes [...] * progress on combo * style: [pre-commit.ci] auto fixes [...] * decent styles * move stuff around * adding tests * add numpy for tests * add cmap to tests * fix type * fix for pyqt * remove topointf * better lineedit styles * better add colormap * increate linux atol * cast to int * more tests * tests * try fix * try fix test * again * skip pyside * test import * fix lineedit * add checkerboard for transparency --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- examples/colormap_combo_box.py | 19 +++ pyproject.toml | 3 +- src/superqt/__init__.py | 6 + src/superqt/cmap/__init__.py | 23 +++ src/superqt/cmap/_catalog_combo.py | 94 ++++++++++ src/superqt/cmap/_cmap_combo.py | 218 ++++++++++++++++++++++++ src/superqt/cmap/_cmap_item_delegate.py | 107 ++++++++++++ src/superqt/cmap/_cmap_line_edit.py | 129 ++++++++++++++ src/superqt/cmap/_cmap_utils.py | 162 ++++++++++++++++++ src/superqt/combobox/__init__.py | 16 +- src/superqt/utils/__init__.py | 18 +- src/superqt/utils/_img_utils.py | 40 +++++ tests/test_cmap.py | 162 ++++++++++++++++++ 13 files changed, 994 insertions(+), 3 deletions(-) create mode 100644 examples/colormap_combo_box.py create mode 100644 src/superqt/cmap/__init__.py create mode 100644 src/superqt/cmap/_catalog_combo.py create mode 100644 src/superqt/cmap/_cmap_combo.py create mode 100644 src/superqt/cmap/_cmap_item_delegate.py create mode 100644 src/superqt/cmap/_cmap_line_edit.py create mode 100644 src/superqt/cmap/_cmap_utils.py create mode 100644 src/superqt/utils/_img_utils.py create mode 100644 tests/test_cmap.py diff --git a/examples/colormap_combo_box.py b/examples/colormap_combo_box.py new file mode 100644 index 00000000..6b5a9b60 --- /dev/null +++ b/examples/colormap_combo_box.py @@ -0,0 +1,19 @@ +from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget + +from superqt.cmap import CmapCatalogComboBox, QColormapComboBox + +app = QApplication([]) + +wdg = QWidget() +layout = QVBoxLayout(wdg) + +catalog_combo = CmapCatalogComboBox(interpolation="linear") + +selected_cmap_combo = QColormapComboBox(allow_user_colormaps=True) +selected_cmap_combo.addColormaps(["viridis", "plasma", "magma", "inferno", "turbo"]) + +layout.addWidget(catalog_combo) +layout.addWidget(selected_cmap_combo) + +wdg.show() +app.exec() diff --git a/pyproject.toml b/pyproject.toml index d1f31990..a406fa9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] -test = ["pint", "pytest", "pytest-cov", "pytest-qt"] +test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap"] dev = [ "black", "ipython", @@ -61,6 +61,7 @@ dev = [ ] docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]"] quantity = ["pint"] +cmap = ["cmap >=0.1.1"] pyside2 = ["pyside2"] # see issues surrounding usage of Generics in pyside6.5.x # https://github.com/pyapp-kit/superqt/pull/177 diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index be9e088b..fe1a7a1a 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -8,6 +8,7 @@ __version__ = "unknown" if TYPE_CHECKING: + from .combobox import QColormapComboBox from .spinbox._quantity import QQuantity from .collapsible import QCollapsible @@ -31,6 +32,7 @@ "ensure_object_thread", "QDoubleRangeSlider", "QCollapsible", + "QColormapComboBox", "QDoubleSlider", "QElidingLabel", "QElidingLineEdit", @@ -54,4 +56,8 @@ def __getattr__(name: str) -> Any: from .spinbox._quantity import QQuantity return QQuantity + if name == "QColormapComboBox": + from .cmap import QColormapComboBox + + return QColormapComboBox raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/superqt/cmap/__init__.py b/src/superqt/cmap/__init__.py new file mode 100644 index 00000000..458110d2 --- /dev/null +++ b/src/superqt/cmap/__init__.py @@ -0,0 +1,23 @@ +try: + import cmap +except ImportError as e: + raise ImportError( + "The cmap package is required to use superqt colormap utilities. " + "Install it with `pip install cmap` or `pip install superqt[cmap]`." + ) from e +else: + del cmap + +from ._catalog_combo import CmapCatalogComboBox +from ._cmap_combo import QColormapComboBox +from ._cmap_item_delegate import QColormapItemDelegate +from ._cmap_line_edit import QColormapLineEdit +from ._cmap_utils import draw_colormap + +__all__ = [ + "QColormapItemDelegate", + "draw_colormap", + "QColormapLineEdit", + "CmapCatalogComboBox", + "QColormapComboBox", +] diff --git a/src/superqt/cmap/_catalog_combo.py b/src/superqt/cmap/_catalog_combo.py new file mode 100644 index 00000000..a1af4520 --- /dev/null +++ b/src/superqt/cmap/_catalog_combo.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Container + +from cmap import Colormap +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QKeyEvent +from qtpy.QtWidgets import QComboBox, QCompleter, QWidget + +from ._cmap_item_delegate import QColormapItemDelegate +from ._cmap_line_edit import QColormapLineEdit +from ._cmap_utils import try_cast_colormap + +if TYPE_CHECKING: + from cmap._catalog import Category, Interpolation + + +class CmapCatalogComboBox(QComboBox): + """A combo box for selecting a colormap from the entire cmap catalog. + + Parameters + ---------- + parent : QWidget, optional + The parent widget. + prefer_short_names : bool, optional + If True (default), short names (without the namespace prefix) will be + preferred over fully qualified names. In cases where the same short name is + used in multiple namespaces, they will *all* be referred to by their fully + qualified (namespaced) name. + categories : Container[Category], optional + If provided, only return names from the given categories. + interpolation : Interpolation, optional + If provided, only return names that have the given interpolation method. + """ + + currentColormapChanged = Signal(Colormap) + + def __init__( + self, + parent: QWidget | None = None, + *, + categories: Container[Category] = (), + prefer_short_names: bool = True, + interpolation: Interpolation | None = None, + ) -> None: + super().__init__(parent) + + # get valid names according to preferences + word_list = sorted( + Colormap.catalog().unique_keys( + prefer_short_names=prefer_short_names, + categories=categories, + interpolation=interpolation, + ) + ) + + # initialize the combobox + self.addItems(word_list) + self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.setEditable(True) + self.setDuplicatesEnabled(False) + # (must come before setCompleter) + self.setLineEdit(QColormapLineEdit(self)) + + # setup the completer + completer = QCompleter(word_list) + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + completer.setFilterMode(Qt.MatchFlag.MatchContains) + completer.setModel(self.model()) + self.setCompleter(completer) + + # set the delegate for both the popup and the combobox + delegate = QColormapItemDelegate() + if popup := completer.popup(): + popup.setItemDelegate(delegate) + self.setItemDelegate(delegate) + + self.currentTextChanged.connect(self._on_text_changed) + + def currentColormap(self) -> Colormap | None: + """Returns the currently selected Colormap or None if not yet selected.""" + return try_cast_colormap(self.currentText()) + + def keyPressEvent(self, e: QKeyEvent | None) -> None: + if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): + # select the first completion when pressing enter if the popup is visible + if (completer := self.completer()) and completer.completionCount(): + self.lineEdit().setText(completer.currentCompletion()) # type: ignore + return super().keyPressEvent(e) + + def _on_text_changed(self, text: str) -> None: + if (cmap := try_cast_colormap(text)) is not None: + self.currentColormapChanged.emit(cmap) diff --git a/src/superqt/cmap/_cmap_combo.py b/src/superqt/cmap/_cmap_combo.py new file mode 100644 index 00000000..65326e44 --- /dev/null +++ b/src/superqt/cmap/_cmap_combo.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Sequence + +from cmap import Colormap +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QButtonGroup, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from superqt.utils import signals_blocked + +from ._catalog_combo import CmapCatalogComboBox +from ._cmap_item_delegate import QColormapItemDelegate +from ._cmap_line_edit import QColormapLineEdit +from ._cmap_utils import try_cast_colormap + +if TYPE_CHECKING: + from cmap._colormap import ColorStopsLike + + +CMAP_ROLE = Qt.ItemDataRole.UserRole + 1 + + +class QColormapComboBox(QComboBox): + """A drop down menu for selecting colors. + + Parameters + ---------- + parent : QWidget, optional + The parent widget. + allow_user_colormaps : bool, optional + Whether the user can add custom colormaps by clicking the "Add + Colormap..." item. Default is False. Can also be set with + `setUserAdditionsAllowed`. + add_colormap_text: str, optional + The text to display for the "Add Colormap..." item. + Default is "Add Colormap...". + """ + + currentColormapChanged = Signal(Colormap) + + def __init__( + self, + parent: QWidget | None = None, + *, + allow_user_colormaps: bool = False, + add_colormap_text: str = "Add Colormap...", + ) -> None: + # init QComboBox + super().__init__(parent) + self._add_color_text: str = add_colormap_text + self._allow_user_colors: bool = allow_user_colormaps + self._last_cmap: Colormap | None = None + + self.setLineEdit(_PopupColormapLineEdit(self)) + self.lineEdit().setReadOnly(True) + self.setItemDelegate(QColormapItemDelegate(self)) + + self.currentIndexChanged.connect(self._on_index_changed) + # there's a little bit of a potential bug here: + # if the user clicks on the "Add Colormap..." item + # then an indexChanged signal will be emitted, but it may not + # actually represent a "true" change in the index if they dismiss the dialog + self.activated.connect(self._on_activated) + + self.setUserAdditionsAllowed(allow_user_colormaps) + + def userAdditionsAllowed(self) -> bool: + """Returns whether the user can add custom colors.""" + return self._allow_user_colors + + def setUserAdditionsAllowed(self, allow: bool) -> None: + """Sets whether the user can add custom colors.""" + self._allow_user_colors = bool(allow) + + idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole) + if idx < 0: + if self._allow_user_colors: + self.addItem(self._add_color_text) + elif not self._allow_user_colors: + self.removeItem(idx) + + def clear(self) -> None: + super().clear() + self.setUserAdditionsAllowed(self._allow_user_colors) + + def itemColormap(self, index: int) -> Colormap | None: + """Returns the color of the item at the given index.""" + return self.itemData(index, CMAP_ROLE) + + def addColormap(self, cmap: ColorStopsLike) -> None: + """Adds the colormap to the QComboBox.""" + if (_cmap := try_cast_colormap(cmap)) is None: + raise ValueError(f"Invalid colormap value: {cmap!r}") + + for i in range(self.count()): + if item := self.itemColormap(i): + if item.name == _cmap.name: + return # no duplicates # pragma: no cover + + had_items = self.count() > int(self._allow_user_colors) + # add the new color and set the background color of that item + self.addItem(_cmap.name.rsplit(":", 1)[-1]) + self.setItemData(self.count() - 1, _cmap, CMAP_ROLE) + if not had_items: # first item added + self._on_index_changed(self.count() - 1) + + # make sure the "Add Colormap..." item is last + idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole) + if idx >= 0: + with signals_blocked(self): + self.removeItem(idx) + self.addItem(self._add_color_text) + + def addColormaps(self, colors: Sequence[Any]) -> None: + """Adds colors to the QComboBox.""" + for color in colors: + self.addColormap(color) + + def currentColormap(self) -> Colormap | None: + """Returns the currently selected Colormap or None if not yet selected.""" + return self.currentData(CMAP_ROLE) + + def setCurrentColormap(self, color: Any) -> None: + """Adds the color to the QComboBox and selects it.""" + if not (cmap := try_cast_colormap(color)): + raise ValueError(f"Invalid colormap value: {color!r}") + + for idx in range(self.count()): + if (item := self.itemColormap(idx)) and item.name == cmap.name: + self.setCurrentIndex(idx) + + def _on_activated(self, index: int) -> None: + if self.itemText(index) != self._add_color_text: + return + + dlg = _CmapNameDialog(self, Qt.WindowType.Sheet) + if dlg.exec() and (cmap := dlg.combo.currentColormap()): + # add the color and select it, without adding duplicates + for i in range(self.count()): + if (item := self.itemColormap(i)) and cmap.name == item.name: + self.setCurrentIndex(i) + return + self.addColormap(cmap) + self.currentIndexChanged.emit(self.currentIndex()) + elif self._last_cmap is not None: + # user canceled, restore previous color without emitting signal + idx = self.findData(self._last_cmap, CMAP_ROLE) + if idx >= 0: + with signals_blocked(self): + self.setCurrentIndex(idx) + + def _on_index_changed(self, index: int) -> None: + colormap = self.itemData(index, CMAP_ROLE) + if isinstance(colormap, Colormap): + self.currentColormapChanged.emit(colormap) + self.lineEdit().setColormap(colormap) + self._last_cmap = colormap + + +CATEGORIES = ("sequential", "diverging", "cyclic", "qualitative", "miscellaneous") + + +class _CmapNameDialog(QDialog): + def __init__(self, *args: Any) -> None: + super().__init__(*args) + + self.combo = CmapCatalogComboBox() + + B = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + btns = QDialogButtonBox(B) + btns.accepted.connect(self.accept) + btns.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(self.combo) + + self._btn_group = QButtonGroup(self) + self._btn_group.setExclusive(False) + for cat in CATEGORIES: + box = QCheckBox(cat) + self._btn_group.addButton(box) + box.setChecked(True) + box.toggled.connect(self._on_check_toggled) + layout.addWidget(box) + + layout.addWidget(btns) + self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum) + self.resize(self.sizeHint()) + + def _on_check_toggled(self) -> None: + # get valid names according to preferences + word_list = Colormap.catalog().unique_keys( + prefer_short_names=True, + categories={b.text() for b in self._btn_group.buttons() if b.isChecked()}, + ) + self.combo.clear() + self.combo.addItems(sorted(word_list)) + + +class _PopupColormapLineEdit(QColormapLineEdit): + def mouseReleaseEvent(self, _: Any) -> None: + """Show parent popup when clicked. + + Without this, only the down arrow will show the popup. And if mousePressEvent + is used instead, the popup will show and then immediately hide. + """ + parent = self.parent() + if parent and hasattr(parent, "showPopup"): + parent.showPopup() diff --git a/src/superqt/cmap/_cmap_item_delegate.py b/src/superqt/cmap/_cmap_item_delegate.py new file mode 100644 index 00000000..05785e98 --- /dev/null +++ b/src/superqt/cmap/_cmap_item_delegate.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import cast + +from cmap import Colormap +from qtpy.QtCore import QModelIndex, QObject, QPersistentModelIndex, QRect, QSize, Qt +from qtpy.QtGui import QColor, QPainter +from qtpy.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem + +from ._cmap_utils import CMAP_ROLE, draw_colormap, pick_font_color, try_cast_colormap + +DEFAULT_SIZE = QSize(80, 22) +DEFAULT_BORDER_COLOR = QColor(Qt.GlobalColor.transparent) + + +class QColormapItemDelegate(QStyledItemDelegate): + """Delegate that draws colormaps into a QAbstractItemView item. + + Parameters + ---------- + parent : QObject, optional + The parent object. + item_size : QSize, optional + The size hint for each item, by default QSize(80, 22). + fractional_colormap_width : float, optional + The fraction of the widget width to use for the colormap swatch. If the + colormap is full width (greater than 0.75), the swatch will be drawn behind + the text. Otherwise, the swatch will be drawn to the left of the text. + Default is 0.33. + padding : int, optional + The padding (in pixels) around the edge of the item, by default 1. + checkerboard_size : int, optional + Size (in pixels) of the checkerboard pattern to draw behind colormaps with + transparency, by default 4. If 0, no checkerboard is drawn. + """ + + def __init__( + self, + parent: QObject | None = None, + *, + item_size: QSize = DEFAULT_SIZE, + fractional_colormap_width: float = 1, + padding: int = 1, + checkerboard_size: int = 4, + ) -> None: + super().__init__(parent) + self._item_size = item_size + self._colormap_fraction = fractional_colormap_width + self._padding = padding + self._border_color: QColor | None = DEFAULT_BORDER_COLOR + self._checkerboard_size = checkerboard_size + + def sizeHint( + self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex + ) -> QSize: + return super().sizeHint(option, index).expandedTo(self._item_size) + + def paint( + self, + painter: QPainter, + option: QStyleOptionViewItem, + index: QModelIndex | QPersistentModelIndex, + ) -> None: + self.initStyleOption(option, index) + rect = cast("QRect", option.rect) # type: ignore + selected = option.state & QStyle.StateFlag.State_Selected # type: ignore + text = index.data(Qt.ItemDataRole.DisplayRole) + colormap: Colormap | None = index.data(CMAP_ROLE) or try_cast_colormap(text) + + if not colormap: # pragma: no cover + return super().paint(painter, option, index) + + painter.save() + rect.adjust(self._padding, self._padding, -self._padding, -self._padding) + cmap_rect = QRect(rect) + cmap_rect.setWidth(int(rect.width() * self._colormap_fraction)) + + lighter = 110 if selected else 100 + border = self._border_color if selected else None + draw_colormap( + painter, + colormap, + cmap_rect, + lighter=lighter, + border_color=border, + checkerboard_size=self._checkerboard_size, + ) + + # # make new rect with the remaining space + text_rect = QRect(rect) + + if self._colormap_fraction > 0.75: + text_align = Qt.AlignmentFlag.AlignCenter + alpha = 230 if selected else 140 + text_color = pick_font_color(colormap, alpha=alpha) + else: + text_align = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + text_color = QColor(Qt.GlobalColor.black) + text_rect.adjust( + cmap_rect.width() + self._padding + 4, 0, -self._padding - 2, 0 + ) + + painter.setPen(text_color) + # cast to int works all the way back to Qt 5.12... + # but the enum only works since Qt 5.14 + painter.drawText(text_rect, int(text_align), text) + painter.restore() diff --git a/src/superqt/cmap/_cmap_line_edit.py b/src/superqt/cmap/_cmap_line_edit.py new file mode 100644 index 00000000..a2d6665a --- /dev/null +++ b/src/superqt/cmap/_cmap_line_edit.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from cmap import Colormap +from qtpy.QtCore import Qt +from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette +from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget + +from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap + +MISSING = QStyle.StandardPixmap.SP_TitleBarContextHelpButton + + +class QColormapLineEdit(QLineEdit): + """A QLineEdit that shows a colormap swatch. + + When the current text is a valid colormap name from the `cmap` package, a swatch + of the colormap will be shown to the left of the text (if `fractionalColormapWidth` + is less than .75) or behind the text (for when the colormap fills the full width). + + If the current text is not a valid colormap name, a swatch of the fallback colormap + will be shown instead (by default, a gray colormap) if `fractionalColormapWidth` is + less than .75. + + Parameters + ---------- + parent : QWidget, optional + The parent widget. + fractional_colormap_width : float, optional + The fraction of the widget width to use for the colormap swatch. If the + colormap is full width (greater than 0.75), the swatch will be drawn behind + the text. Otherwise, the swatch will be drawn to the left of the text. + Default is 0.33. + fallback_cmap : Colormap | str | None, optional + The colormap to use when the current text is not a recognized colormap. + by default "gray". + missing_icon : QIcon | QStyle.StandardPixmap, optional + The icon to show when the current text is not a recognized colormap and + `fractionalColormapWidth` is less than .75. Default is a question mark. + checkerboard_size : int, optional + Size (in pixels) of the checkerboard pattern to draw behind colormaps with + transparency, by default 4. If 0, no checkerboard is drawn. + """ + + def __init__( + self, + parent: QWidget | None = None, + *, + fractional_colormap_width: float = 0.33, + fallback_cmap: Colormap | str | None = "gray", + missing_icon: QIcon | QStyle.StandardPixmap = MISSING, + checkerboard_size: int = 4, + ) -> None: + super().__init__(parent) + self.setFractionalColormapWidth(fractional_colormap_width) + self.setMissingColormap(fallback_cmap) + self._checkerboard_size = checkerboard_size + + if isinstance(missing_icon, QStyle.StandardPixmap): + self._missing_icon: QIcon = self.style().standardIcon(missing_icon) + elif isinstance(missing_icon, QIcon): + self._missing_icon = missing_icon + else: # pragma: no cover + raise TypeError("missing_icon must be a QIcon or QStyle.StandardPixmap") + + self._cmap: Colormap | None = None # current colormap + self.textChanged.connect(self.setColormap) + + def setFractionalColormapWidth(self, fraction: float) -> None: + self._colormap_fraction: float = float(fraction) + align = Qt.AlignmentFlag.AlignVCenter + if self._cmap_is_full_width(): + align |= Qt.AlignmentFlag.AlignCenter + else: + align |= Qt.AlignmentFlag.AlignLeft + self.setAlignment(align) + + def fractionalColormapWidth(self) -> float: + return self._colormap_fraction + + def setMissingColormap(self, cmap: Colormap | str | None) -> None: + self._missing_cmap: Colormap | None = try_cast_colormap(cmap) + + def colormap(self) -> Colormap | None: + return self._cmap + + def setColormap(self, cmap: Colormap | str | None) -> None: + self._cmap = try_cast_colormap(cmap) + + # set self font color to contrast with the colormap + if self._cmap and self._cmap_is_full_width(): + text = pick_font_color(self._cmap) + else: + text = QApplication.palette().color(QPalette.ColorRole.Text) + + palette = self.palette() + palette.setColor(QPalette.ColorRole.Text, text) + self.setPalette(palette) + + def _cmap_is_full_width(self): + return self._colormap_fraction >= 0.75 + + def paintEvent(self, e: QPaintEvent) -> None: + # don't draw the background + # otherwise it will cover the colormap during super().paintEvent + # FIXME: this appears to need to be reset during every paint event... + # otherwise something is resetting it + palette = self.palette() + palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent) + self.setPalette(palette) + + cmap_rect = self.rect().adjusted(2, 0, 0, 0) + cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction)) + + left_margin = 6 + if not self._cmap_is_full_width(): + # leave room for the colormap + left_margin += cmap_rect.width() + self.setTextMargins(left_margin, 2, 0, 0) + + if self._cmap: + draw_colormap( + self, self._cmap, cmap_rect, checkerboard_size=self._checkerboard_size + ) + elif not self._cmap_is_full_width(): + if self._missing_cmap: + draw_colormap(self, self._missing_cmap, cmap_rect) + self._missing_icon.paint(QPainter(self), cmap_rect.adjusted(4, 4, 0, -4)) + + super().paintEvent(e) # draw text (must come after draw_colormap) diff --git a/src/superqt/cmap/_cmap_utils.py b/src/superqt/cmap/_cmap_utils.py new file mode 100644 index 00000000..6dae1a9d --- /dev/null +++ b/src/superqt/cmap/_cmap_utils.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from contextlib import suppress +from typing import TYPE_CHECKING, Any + +from cmap import Colormap +from qtpy.QtCore import QPointF, QRect, QRectF, Qt +from qtpy.QtGui import QColor, QLinearGradient, QPaintDevice, QPainter + +if TYPE_CHECKING: + from cmap._colormap import ColorStopsLike + +CMAP_ROLE = Qt.ItemDataRole.UserRole + 1 + + +def draw_colormap( + painter_or_device: QPainter | QPaintDevice, + cmap: Colormap | ColorStopsLike, + rect: QRect | QRectF | None = None, + border_color: QColor | str | None = None, + border_width: int = 1, + lighter: int = 100, + checkerboard_size: int = 4, +) -> None: + """Draw a colormap onto a QPainter or QPaintDevice. + + Parameters + ---------- + painter_or_device : QPainter | QPaintDevice + A `QPainter` instance or a `QPaintDevice` (e.g. a QWidget or QPixmap) onto + which to paint the colormap. + cmap : Colormap | Any + `cmap.Colormap` instance, or anything that can be converted to one (such as a + string name of a colormap in the `cmap` catalog). + https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects + rect : QRect | QRectF | None, optional + A rect onto which to draw. If `None`, the `painter.viewport()` will be + used. by default `None` + border_color : QColor | str | None + If not `None`, a border of color `border_color` and width `border_width` is + included around the edge, by default None. + border_width : int, optional + The width of the border to draw (provided `border_color` is not `None`), + by default 2 + lighter : int, optional + Percentage by which to lighten (or darken) the colors. Greater than 100 + lightens, less than 100 darkens, by default 100 (i.e. no change). + checkerboard_size : bool, optional + Size (in pixels) of the checkerboard pattern to draw, by default 5. + If 0, no checkerboard is drawn. + + Examples + -------- + ```python + from qtpy.QtGui import QPixmap + from qtpy.QtWidgets import QWidget + from superqt.utils import draw_colormap + + viridis = 'viridis' # or cmap.Colormap('viridis') + + class W(QWidget): + def paintEvent(self, event) -> None: + draw_colormap(self, viridis, event.rect()) + + # or draw onto a QPixmap + pm = QPixmap(200, 200) + draw_colormap(pm, viridis) + ``` + """ + if isinstance(painter_or_device, QPainter): + painter = painter_or_device + elif isinstance(painter_or_device, QPaintDevice): + painter = QPainter(painter_or_device) + else: + raise TypeError( + "Expected a QPainter or QPaintDevice instance, " + f"got {type(painter_or_device)!r} instead." + ) + + if (cmap_ := try_cast_colormap(cmap)) is None: + raise TypeError( + f"Expected a Colormap instance or something that can be " + f"converted to one, got {cmap!r} instead." + ) + + if rect is None: + rect = painter.viewport() + + painter.setPen(Qt.PenStyle.NoPen) + + if border_width and border_color is not None: + # draw rect, and then contract it by border_width + painter.setPen(QColor(border_color)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(rect) + rect = rect.adjusted(border_width, border_width, -border_width, -border_width) + + if checkerboard_size: + _draw_checkerboard(painter, rect, checkerboard_size) + + if ( + cmap_.interpolation == "nearest" + or getattr(cmap_.color_stops, "_interpolation", "") == "nearest" + ): + # XXX: this is a little bit of a hack. + # when the interpolation is nearest, the last stop is often at 1.0 + # which means that the last color is not drawn. + # to fix this, we shrink the drawing area slightly + # it might not work well with unenvenly-spaced stops + # (but those are uncommon for categorical colormaps) + width = rect.width() - rect.width() / len(cmap_.color_stops) + for stop in cmap_.color_stops: + painter.setBrush(QColor(stop.color.hex).lighter(lighter)) + painter.drawRect(rect.adjusted(int(stop.position * width), 0, 0, 0)) + else: + gradient = QLinearGradient(QPointF(rect.topLeft()), QPointF(rect.topRight())) + for stop in cmap_.color_stops: + gradient.setColorAt(stop.position, QColor(stop.color.hex).lighter(lighter)) + painter.setBrush(gradient) + painter.drawRect(rect) + + +def _draw_checkerboard( + painter: QPainter, rect: QRect | QRectF, checker_size: int +) -> None: + darkgray = QColor("#969696") + lightgray = QColor("#C8C8C8") + sz = checker_size + h, w = rect.height(), rect.width() + left, top = rect.left(), rect.top() + full_rows = h // sz + full_cols = w // sz + for row in range(int(full_rows) + 1): + szh = sz if row < full_rows else int(h % sz) + for col in range(int(full_cols) + 1): + szw = sz if col < full_cols else int(w % sz) + color = lightgray if (row + col) % 2 == 0 else darkgray + painter.fillRect(int(col * sz + left), int(row * sz + top), szw, szh, color) + + +def try_cast_colormap(val: Any) -> Colormap | None: + """Try to cast `val` to a Colormap instance, return None if it fails.""" + if isinstance(val, Colormap): + return val + with suppress(Exception): + return Colormap(val) + return None + + +def pick_font_color(cmap: Colormap, at_stop: float = 0.49, alpha: int = 255) -> QColor: + """Pick a font shade that contrasts with the given colormap at `at_stop`.""" + if _is_dark(cmap, at_stop): + return QColor(0, 0, 0, alpha) + else: + return QColor(255, 255, 255, alpha) + + +def _is_dark(cmap: Colormap, at_stop: float, threshold: float = 110) -> bool: + """Return True if the color at `at_stop` is dark according to `threshold`.""" + color = cmap(at_stop) + r, g, b, a = color.rgba8 + return (r * 0.299 + g * 0.587 + b * 0.114) > threshold diff --git a/src/superqt/combobox/__init__.py b/src/superqt/combobox/__init__.py index 656baede..91664718 100644 --- a/src/superqt/combobox/__init__.py +++ b/src/superqt/combobox/__init__.py @@ -1,4 +1,18 @@ +from typing import TYPE_CHECKING, Any + from ._enum_combobox import QEnumComboBox from ._searchable_combo_box import QSearchableComboBox -__all__ = ("QEnumComboBox", "QSearchableComboBox") +if TYPE_CHECKING: + from superqt.cmap import QColormapComboBox + + +__all__ = ("QEnumComboBox", "QSearchableComboBox", "QColormapComboBox") + + +def __getattr__(name: str) -> Any: # pragma: no cover + if name == "QColormapComboBox": + from superqt.cmap import QColormapComboBox + + return QColormapComboBox + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/superqt/utils/__init__.py b/src/superqt/utils/__init__.py index 3a9b8bdf..55d485e1 100644 --- a/src/superqt/utils/__init__.py +++ b/src/superqt/utils/__init__.py @@ -1,8 +1,16 @@ +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from superqt.cmap import draw_colormap + __all__ = ( "CodeSyntaxHighlight", "create_worker", + "qimage_to_array", + "draw_colormap", "ensure_main_thread", "ensure_object_thread", + "exceptions_as_dialog", "FunctionWorker", "GeneratorWorker", "new_worker_qthread", @@ -14,12 +22,12 @@ "signals_blocked", "thread_worker", "WorkerBase", - "exceptions_as_dialog", ) from ._code_syntax_highlight import CodeSyntaxHighlight from ._ensure_thread import ensure_main_thread, ensure_object_thread from ._errormsg_context import exceptions_as_dialog +from ._img_utils import qimage_to_array from ._message_handler import QMessageHandler from ._misc import signals_blocked from ._qthreading import ( @@ -31,3 +39,11 @@ thread_worker, ) from ._throttler import QSignalDebouncer, QSignalThrottler, qdebounced, qthrottled + + +def __getattr__(name: str) -> Any: # pragma: no cover + if name == "draw_colormap": + from superqt.cmap import draw_colormap + + return draw_colormap + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/superqt/utils/_img_utils.py b/src/superqt/utils/_img_utils.py new file mode 100644 index 00000000..d5a920dc --- /dev/null +++ b/src/superqt/utils/_img_utils.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING + +from qtpy.QtGui import QImage + +if TYPE_CHECKING: + import numpy as np + + +def qimage_to_array(img: QImage) -> "np.ndarray": + """Convert QImage to an array. + + Parameters + ---------- + img : QImage + QImage to be converted. + + Returns + ------- + arr : np.ndarray + Numpy array of type uint8 and shape (h, w, 4). Index [0, 0] is the + upper-left corner of the rendered region. + """ + import numpy as np + + # cast to ARGB32 if necessary + if img.format() != QImage.Format.Format_ARGB32: + img = img.convertToFormat(QImage.Format.Format_ARGB32) + + h, w, c = img.height(), img.width(), 4 + + # pyside returns a memoryview, pyqt returns a sizeless void pointer + b = img.constBits() # Returns a pointer to the first pixel data. + if hasattr(b, "setsize"): + b.setsize(h * w * c) + + # reshape to h, w, c + arr = np.frombuffer(b, np.uint8).reshape(h, w, c) + + # reverse channel colors for numpy + return arr.take([2, 1, 0, 3], axis=2) diff --git a/tests/test_cmap.py b/tests/test_cmap.py new file mode 100644 index 00000000..24108770 --- /dev/null +++ b/tests/test_cmap.py @@ -0,0 +1,162 @@ +import platform +from unittest.mock import patch + +import numpy as np +import pytest +from qtpy import API_NAME + +try: + from cmap import Colormap +except ImportError: + pytest.skip("cmap not installed", allow_module_level=True) + +from qtpy.QtCore import QRect +from qtpy.QtGui import QPainter, QPixmap +from qtpy.QtWidgets import QStyleOptionViewItem, QWidget + +from superqt import QColormapComboBox +from superqt.cmap import ( + CmapCatalogComboBox, + QColormapItemDelegate, + QColormapLineEdit, + _cmap_combo, + draw_colormap, +) +from superqt.utils import qimage_to_array + + +def test_draw_cmap(qtbot): + # draw into a QWidget + wdg = QWidget() + qtbot.addWidget(wdg) + draw_colormap(wdg, "viridis") + # draw into any QPaintDevice + draw_colormap(QPixmap(), "viridis") + # pass a painter an explicit colormap and a rect + draw_colormap(QPainter(), Colormap(("red", "yellow", "blue")), QRect()) + # test with a border + draw_colormap(wdg, "viridis", border_color="red", border_width=2) + + with pytest.raises(TypeError, match="Expected a QPainter or QPaintDevice instance"): + draw_colormap(QRect(), "viridis") # type: ignore + + with pytest.raises(TypeError, match="Expected a Colormap instance or something"): + draw_colormap(QPainter(), "not a recognized string or cmap", QRect()) + + +def test_cmap_draw_result(): + """Test that the image drawn actually looks correct.""" + # draw into any QPaintDevice + w = 100 + h = 20 + pix = QPixmap(w, h) + cmap = Colormap("viridis") + draw_colormap(pix, cmap) + + ary1 = cmap(np.tile(np.linspace(0, 1, w), (h, 1)), bytes=True) + ary2 = qimage_to_array(pix.toImage()) + + # there are some subtle differences between how qimage draws and how + # cmap draws, so we can't assert that the arrays are exactly equal. + # they are visually indistinguishable, and numbers are close within 4 (/255) values + # and linux, for some reason, is a bit more different`` + atol = 8 if platform.system() == "Linux" else 4 + np.testing.assert_allclose(ary1, ary2, atol=atol) + + cmap2 = Colormap(("#230777",), name="MyMap") + draw_colormap(pix, cmap2) # include transparency + + +def test_catalog_combo(qtbot): + wdg = CmapCatalogComboBox() + qtbot.addWidget(wdg) + wdg.show() + + wdg.setCurrentText("viridis") + assert wdg.currentColormap() == Colormap("viridis") + + +def test_cmap_combo(qtbot): + wdg = QColormapComboBox(allow_user_colormaps=True) + qtbot.addWidget(wdg) + wdg.show() + assert wdg.userAdditionsAllowed() + + with qtbot.waitSignal(wdg.currentColormapChanged): + wdg.addColormaps([Colormap("viridis"), "magma", ("red", "blue", "green")]) + assert wdg.currentColormap().name.split(":")[-1] == "viridis" + + with pytest.raises(ValueError, match="Invalid colormap"): + wdg.addColormap("not a recognized string or cmap") + + assert wdg.currentColormap().name.split(":")[-1] == "viridis" + assert wdg.currentIndex() == 0 + assert wdg.count() == 4 # includes "Add Colormap..." + wdg.setCurrentColormap("magma") + assert wdg.count() == 4 # make sure we didn't duplicate + assert wdg.currentIndex() == 1 + + if API_NAME == "PySide2": + return # the rest fails on CI... but works locally + + # click the Add Colormap... item + with qtbot.waitSignal(wdg.currentColormapChanged): + with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=True): + wdg._on_activated(wdg.count() - 1) + + assert wdg.count() == 5 + # this could potentially fail in the future if cmap catalog changes + # but mocking the return value of the dialog is also annoying + assert wdg.itemColormap(3).name.split(":")[-1] == "accent" + + # click the Add Colormap... item, but cancel the dialog + with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=False): + wdg._on_activated(wdg.count() - 1) + + +def test_cmap_item_delegate(qtbot): + wdg = CmapCatalogComboBox() + qtbot.addWidget(wdg) + view = wdg.view() + delegate = view.itemDelegate() + assert isinstance(delegate, QColormapItemDelegate) + + # smoke tests: + painter = QPainter() + option = QStyleOptionViewItem() + index = wdg.model().index(0, 0) + delegate._colormap_fraction = 1 + delegate.paint(painter, option, index) + delegate._colormap_fraction = 0.33 + delegate.paint(painter, option, index) + + assert delegate.sizeHint(option, index) == delegate._item_size + + +def test_cmap_line_edit(qtbot, qapp): + wdg = QColormapLineEdit() + qtbot.addWidget(wdg) + wdg.show() + + wdg.setColormap("viridis") + assert wdg.colormap() == Colormap("viridis") + wdg.setText("magma") # also works if the name is recognized + assert wdg.colormap() == Colormap("magma") + qapp.processEvents() + qtbot.wait(10) # force the paintEvent + + wdg.setFractionalColormapWidth(1) + assert wdg.fractionalColormapWidth() == 1 + wdg.update() + qapp.processEvents() + qtbot.wait(10) # force the paintEvent + + wdg.setText("not-a-cmap") + assert wdg.colormap() is None + # or + + wdg.setFractionalColormapWidth(0.3) + wdg.setColormap(None) + assert wdg.colormap() is None + qapp.processEvents() + qtbot.wait(10) # force the paintEvent From 658995a0b45a172e1b4e68ab6f89059d22708e9d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Sep 2023 08:56:37 -0400 Subject: [PATCH 17/23] feat: add QColorComboBox for picking single colors (#194) * feat: add QColorCombo * more features * test: add some tests * fix: import the future * more tests * style: [pre-commit.ci] auto fixes [...] --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- examples/color_combo_box.py | 23 ++ src/superqt/__init__.py | 13 +- src/superqt/combobox/__init__.py | 12 +- src/superqt/combobox/_color_combobox.py | 287 ++++++++++++++++++++++++ tests/test_color_combo.py | 86 +++++++ 5 files changed, 412 insertions(+), 9 deletions(-) create mode 100644 examples/color_combo_box.py create mode 100644 src/superqt/combobox/_color_combobox.py create mode 100644 tests/test_color_combo.py diff --git a/examples/color_combo_box.py b/examples/color_combo_box.py new file mode 100644 index 00000000..446582c4 --- /dev/null +++ b/examples/color_combo_box.py @@ -0,0 +1,23 @@ +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QApplication + +from superqt import QColorComboBox + +app = QApplication([]) +w = QColorComboBox() +# adds an item "Add Color" that opens a QColorDialog when clicked +w.setUserColorsAllowed(True) + +# colors can be any argument that can be passed to QColor +# (tuples and lists will be expanded to QColor(*color) +COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo", "violet"] +w.addColors(COLORS) + +# as with addColors, colors will be cast to QColor when using setColors +w.setCurrentColor("indigo") + +w.resize(200, 50) +w.show() + +w.currentColorChanged.connect(print) +app.exec_() diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index fe1a7a1a..f03ea3ff 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -7,12 +7,8 @@ except PackageNotFoundError: __version__ = "unknown" -if TYPE_CHECKING: - from .combobox import QColormapComboBox - from .spinbox._quantity import QQuantity - from .collapsible import QCollapsible -from .combobox import QEnumComboBox, QSearchableComboBox +from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox from .elidable import QElidingLabel, QElidingLineEdit from .selection import QSearchableListWidget, QSearchableTreeWidget from .sliders import ( @@ -30,9 +26,10 @@ __all__ = [ "ensure_main_thread", "ensure_object_thread", - "QDoubleRangeSlider", "QCollapsible", + "QColorComboBox", "QColormapComboBox", + "QDoubleRangeSlider", "QDoubleSlider", "QElidingLabel", "QElidingLineEdit", @@ -50,6 +47,10 @@ "QSearchableTreeWidget", ] +if TYPE_CHECKING: + from .combobox import QColormapComboBox + from .spinbox._quantity import QQuantity + def __getattr__(name: str) -> Any: if name == "QQuantity": diff --git a/src/superqt/combobox/__init__.py b/src/superqt/combobox/__init__.py index 91664718..f14b5584 100644 --- a/src/superqt/combobox/__init__.py +++ b/src/superqt/combobox/__init__.py @@ -1,13 +1,19 @@ from typing import TYPE_CHECKING, Any +from ._color_combobox import QColorComboBox from ._enum_combobox import QEnumComboBox from ._searchable_combo_box import QSearchableComboBox -if TYPE_CHECKING: - from superqt.cmap import QColormapComboBox +__all__ = ( + "QColorComboBox", + "QColormapComboBox", + "QEnumComboBox", + "QSearchableComboBox", +) -__all__ = ("QEnumComboBox", "QSearchableComboBox", "QColormapComboBox") +if TYPE_CHECKING: + from superqt.cmap import QColormapComboBox def __getattr__(name: str) -> Any: # pragma: no cover diff --git a/src/superqt/combobox/_color_combobox.py b/src/superqt/combobox/_color_combobox.py new file mode 100644 index 00000000..cdf04933 --- /dev/null +++ b/src/superqt/combobox/_color_combobox.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import warnings +from contextlib import suppress +from enum import IntEnum, auto +from typing import Any, Literal, Sequence, cast + +from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QRect, QSize, Qt, Signal +from qtpy.QtGui import QColor, QPainter +from qtpy.QtWidgets import ( + QAbstractItemDelegate, + QColorDialog, + QComboBox, + QLineEdit, + QStyle, + QStyleOptionViewItem, + QWidget, +) + +from superqt.utils import signals_blocked + +_NAME_MAP = {QColor(x).name(): x for x in QColor.colorNames()} + +COLOR_ROLE = Qt.ItemDataRole.BackgroundRole + + +class InvalidColorPolicy(IntEnum): + """Policy for handling invalid colors.""" + + Ignore = auto() + Warn = auto() + Raise = auto() + + +class _ColorComboLineEdit(QLineEdit): + """A read-only line edit that shows the parent ComboBox popup when clicked.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setReadOnly(True) + # hide any original text + self.setStyleSheet("color: transparent") + self.setText("") + + def mouseReleaseEvent(self, _: Any) -> None: + """Show parent popup when clicked. + + Without this, only the down arrow will show the popup. And if mousePressEvent + is used instead, the popup will show and then immediately hide. + """ + parent = self.parent() + if hasattr(parent, "showPopup"): + parent.showPopup() + + +class _ColorComboItemDelegate(QAbstractItemDelegate): + """Delegate that draws color squares in the ComboBox. + + This provides more control than simply setting various data roles on the item, + and makes for a nicer appearance. Importantly, it prevents the color from being + obscured on hover. + """ + + def sizeHint( + self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex + ) -> QSize: + return QSize(20, 20) + + def paint( + self, + painter: QPainter, + option: QStyleOptionViewItem, + index: QModelIndex | QPersistentModelIndex, + ) -> None: + color: QColor | None = index.data(COLOR_ROLE) + rect = cast("QRect", option.rect) # type: ignore + state = cast("QStyle.StateFlag", option.state) # type: ignore + selected = state & QStyle.StateFlag.State_Selected + border = QColor("lightgray") + + if not color: + # not a color square, just draw the text + text_color = Qt.GlobalColor.black if selected else Qt.GlobalColor.gray + painter.setPen(text_color) + text = index.data(Qt.ItemDataRole.DisplayRole) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) + return + + # slightly larger border for rect + pen = painter.pen() + pen.setWidth(2) + pen.setColor(border) + painter.setPen(pen) + + if selected: + # if hovering, give a slight highlight and draw the color name + painter.setBrush(color.lighter(110)) + painter.drawRect(rect) + # use user friendly color name if available + name = _NAME_MAP.get(color.name(), color.name()) + painter.setPen(_pick_font_color(color)) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, name) + else: # not hovering + painter.setBrush(color) + painter.drawRect(rect) + + +class QColorComboBox(QComboBox): + """A drop down menu for selecting colors. + + Parameters + ---------- + parent : QWidget, optional + The parent widget. + allow_user_colors : bool, optional + Whether to show an "Add Color" item that opens a QColorDialog when clicked. + Whether the user can add custom colors by clicking the "Add Color" item. + Default is False. Can also be set with `setUserColorsAllowed`. + add_color_text: str, optional + The text to display for the "Add Color" item. Default is "Add Color...". + """ + + currentColorChanged = Signal(QColor) + + def __init__( + self, + parent: QWidget | None = None, + *, + allow_user_colors: bool = False, + add_color_text: str = "Add Color...", + ) -> None: + # init QComboBox + super().__init__(parent) + self._invalid_policy: InvalidColorPolicy = InvalidColorPolicy.Ignore + self._add_color_text: str = add_color_text + self._allow_user_colors: bool = allow_user_colors + self._last_color: QColor = QColor() + + self.setLineEdit(_ColorComboLineEdit(self)) + self.setItemDelegate(_ColorComboItemDelegate()) + + self.currentIndexChanged.connect(self._on_index_changed) + self.activated.connect(self._on_activated) + + self.setUserColorsAllowed(allow_user_colors) + + def setInvalidColorPolicy( + self, policy: InvalidColorPolicy | int | Literal["Raise", "Ignore", "Warn"] + ) -> None: + """Sets the policy for handling invalid colors.""" + if isinstance(policy, str): + policy = InvalidColorPolicy[policy] + elif isinstance(policy, int): + policy = InvalidColorPolicy(policy) + elif not isinstance(policy, InvalidColorPolicy): + raise TypeError(f"Invalid policy type: {type(policy)!r}") + self._invalid_policy = policy + + def invalidColorPolicy(self) -> InvalidColorPolicy: + """Returns the policy for handling invalid colors.""" + return self._invalid_policy + + InvalidColorPolicy = InvalidColorPolicy + + def userColorsAllowed(self) -> bool: + """Returns whether the user can add custom colors.""" + return self._allow_user_colors + + def setUserColorsAllowed(self, allow: bool) -> None: + """Sets whether the user can add custom colors.""" + self._allow_user_colors = bool(allow) + + idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole) + if idx < 0: + if self._allow_user_colors: + self.addItem(self._add_color_text) + elif not self._allow_user_colors: + self.removeItem(idx) + + def clear(self) -> None: + """Clears the QComboBox of all entries (leaves "Add colors" if enabled).""" + super().clear() + self.setUserColorsAllowed(self._allow_user_colors) + + def addColor(self, color: Any) -> None: + """Adds the color to the QComboBox.""" + _color = _cast_color(color) + if not _color.isValid(): + if self._invalid_policy == InvalidColorPolicy.Raise: + raise ValueError(f"Invalid color: {color!r}") + elif self._invalid_policy == InvalidColorPolicy.Warn: + warnings.warn(f"Ignoring invalid color: {color!r}", stacklevel=2) + return + + c = self.currentColor() + if self.findData(_color) > -1: # avoid duplicates + return + + # add the new color and set the background color of that item + self.addItem("", _color) + self.setItemData(self.count() - 1, _color, COLOR_ROLE) + if not c or not c.isValid(): + self._on_index_changed(self.count() - 1) + + # make sure the "Add Color" item is last + idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole) + if idx >= 0: + with signals_blocked(self): + self.removeItem(idx) + self.addItem(self._add_color_text) + + def itemColor(self, index: int) -> QColor | None: + """Returns the color of the item at the given index.""" + return self.itemData(index, COLOR_ROLE) + + def addColors(self, colors: Sequence[Any]) -> None: + """Adds colors to the QComboBox.""" + for color in colors: + self.addColor(color) + + def currentColor(self) -> QColor | None: + """Returns the currently selected QColor or None if not yet selected.""" + return self.currentData(COLOR_ROLE) + + def setCurrentColor(self, color: Any) -> None: + """Adds the color to the QComboBox and selects it.""" + idx = self.findData(_cast_color(color), COLOR_ROLE) + if idx >= 0: + self.setCurrentIndex(idx) + + def currentColorName(self) -> str | None: + """Returns the name of the currently selected QColor or black if None.""" + color = self.currentColor() + return color.name() if color else "#000000" + + def _on_activated(self, index: int) -> None: + if self.itemText(index) != self._add_color_text: + return + + # show temporary text while dialog is open + self.lineEdit().setStyleSheet("background-color: white; color: gray;") + self.lineEdit().setText("Pick a Color ...") + try: + color = QColorDialog.getColor() + finally: + self.lineEdit().setText("") + + if color.isValid(): + # add the color and select it + self.addColor(color) + elif self._last_color.isValid(): + # user canceled, restore previous color without emitting signal + idx = self.findData(self._last_color, COLOR_ROLE) + if idx >= 0: + with signals_blocked(self): + self.setCurrentIndex(idx) + hex_ = self._last_color.name() + self.lineEdit().setStyleSheet(f"background-color: {hex_};") + return + + def _on_index_changed(self, index: int) -> None: + color = self.itemData(index, COLOR_ROLE) + if isinstance(color, QColor): + self.lineEdit().setStyleSheet(f"background-color: {color.name()};") + self.currentColorChanged.emit(color) + self._last_color = color + + +def _cast_color(val: Any) -> QColor: + with suppress(TypeError): + color = QColor(val) + if color.isValid(): + return color + if isinstance(val, (tuple, list)): + with suppress(TypeError): + color = QColor(*val) + if color.isValid(): + return color + return QColor() + + +def _pick_font_color(color: QColor) -> QColor: + """Pick a font shade that contrasts with the given color.""" + if (color.red() * 0.299 + color.green() * 0.587 + color.blue() * 0.114) > 80: + return QColor(0, 0, 0, 128) + else: + return QColor(255, 255, 255, 128) diff --git a/tests/test_color_combo.py b/tests/test_color_combo.py new file mode 100644 index 00000000..5656f55e --- /dev/null +++ b/tests/test_color_combo.py @@ -0,0 +1,86 @@ +from unittest.mock import patch + +import pytest +from qtpy import API_NAME +from qtpy.QtGui import QColor, QPainter +from qtpy.QtWidgets import QStyleOptionViewItem + +from superqt import QColorComboBox +from superqt.combobox import _color_combobox + + +def test_q_color_combobox(qtbot): + wdg = QColorComboBox() + qtbot.addWidget(wdg) + wdg.show() + wdg.setUserColorsAllowed(True) + + # colors can be any argument that can be passed to QColor + # (tuples and lists will be expanded to QColor(*color) + COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo"] + wdg.addColors(COLORS) + + colors = [wdg.itemColor(i) for i in range(wdg.count())] + assert colors == [ + QColor("red"), + QColor("orange"), + QColor("yellow"), + QColor("green"), + QColor("blue"), + QColor("indigo"), + None, # "Add Color" item + ] + + # as with addColors, colors will be cast to QColor when using setColors + wdg.setCurrentColor("indigo") + assert wdg.currentColor() == QColor("indigo") + assert wdg.currentColorName() == "#4b0082" + + wdg.clear() + assert wdg.count() == 1 # "Add Color" item + wdg.setUserColorsAllowed(False) + assert not wdg.count() + + wdg.setInvalidColorPolicy(wdg.InvalidColorPolicy.Ignore) + wdg.setInvalidColorPolicy(2) + wdg.setInvalidColorPolicy("Raise") + with pytest.raises(TypeError): + wdg.setInvalidColorPolicy(1.0) # type: ignore + + with pytest.raises(ValueError): + wdg.addColor("invalid") + + +def test_q_color_delegate(qtbot): + wdg = QColorComboBox() + view = wdg.view() + delegate = wdg.itemDelegate() + qtbot.addWidget(wdg) + wdg.show() + + # smoke tests: + painter = QPainter() + option = QStyleOptionViewItem() + index = wdg.model().index(0, 0) + delegate.paint(painter, option, index) + + wdg.addColors(["red", "orange", "yellow"]) + view.selectAll() + index = wdg.model().index(1, 0) + delegate.paint(painter, option, index) + + +@pytest.mark.skipif(API_NAME == "PySide2", reason="hangs on CI") +def test_activated(qtbot): + wdg = QColorComboBox() + qtbot.addWidget(wdg) + wdg.show() + wdg.setUserColorsAllowed(True) + + with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor("red")): + wdg._on_activated(wdg.count() - 1) # "Add Color" item + assert wdg.currentColor() == QColor("red") + + with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor()): + wdg._on_activated(wdg.count() - 1) # "Add Color" item + assert wdg.currentColor() == QColor("red") From 1e3cc27686e3dad665a50eea9a075ddbf68849ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Sep 2023 08:56:46 -0400 Subject: [PATCH 18/23] ci(dependabot): bump actions/checkout from 3 to 4 (#196) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test_and_deploy.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index c343528e..1ae0446a 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -59,7 +59,7 @@ jobs: backend: "pyqt5==5.14.*" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 @@ -90,7 +90,7 @@ jobs: name: qtpy minreq runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: tlambert03/setup-qt-libs@v1.4 - uses: actions/setup-python@v4 with: @@ -111,12 +111,12 @@ jobs: name: napari tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 path: superqt - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: repository: napari/napari path: napari-repo @@ -143,7 +143,7 @@ jobs: name: Check Manifest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.x" @@ -157,7 +157,7 @@ jobs: if: ${{ github.repository == 'pyapp-kit/superqt' && contains(github.ref, 'tags') }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python From 717b7e3d96fdd345a237df4c326550a2a2ed14b6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 11 Sep 2023 13:12:40 -0400 Subject: [PATCH 19/23] ci: add hatch matrix --- pyproject.toml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a406fa9d..7cab5be1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,30 @@ source = "vcs" [tool.hatch.build.targets.sdist] include = ["src", "tests", "CHANGELOG.md"] +# these let you run tests across all backends easily with: +# hatch run test:test +[tool.hatch.envs.test] + +[tool.hatch.envs.test.scripts] +test = "pytest" + +[[tool.hatch.envs.test.matrix]] +qt = ["pyside6", "pyqt6"] +python = ["3.11"] + +[[tool.hatch.envs.test.matrix]] +qt = ["pyside2", "pyqt5", "pyqt5.12"] +python = ["3.8"] + +[tool.hatch.envs.test.overrides] +matrix.qt.extra-dependencies = [ + {value = "pyside2", if = ["pyside2"]}, + {value = "pyside6", if = ["pyside6"]}, + {value = "pyqt5", if = ["pyqt5"]}, + {value = "pyqt6", if = ["pyqt6"]}, + {value = "pyqt5==5.12", if = ["pyqt5.12"]}, +] + # https://pycqa.github.io/isort/docs/configuration/options.html [tool.isort] profile = "black" @@ -159,12 +183,16 @@ warn_unused_ignores = false allow_redefinition = true # https://coverage.readthedocs.io/en/6.4/config.html +[tool.coverage.run] +source = ["src/superqt"] + [tool.coverage.report] exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "@overload", "except ImportError", + "\\.\\.\\." ] # https://github.com/mgedmin/check-manifest#configuration From 66da7113e96179ad41d816a273e8611669e1b2d0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Sep 2023 08:08:23 -0400 Subject: [PATCH 20/23] refactor: Labeled slider updates (#197) * refactor: some slider updates * fix tests * finish * finish * import future --- examples/labeled.py | 1 + src/superqt/sliders/_labeled.py | 382 +++++++++++++++++++------------- 2 files changed, 232 insertions(+), 151 deletions(-) diff --git a/examples/labeled.py b/examples/labeled.py index 0cddffd8..d92e0ae3 100644 --- a/examples/labeled.py +++ b/examples/labeled.py @@ -14,6 +14,7 @@ w = QWidget() qls = QLabeledSlider(ORIENTATION) +qls.setEdgeLabelMode(qls.EdgeLabelMode.LabelIsRange | qls.EdgeLabelMode.LabelIsValue) qls.valueChanged.connect(lambda e: print("qls valueChanged", e)) qls.setRange(0, 500) qls.setValue(300) diff --git a/src/superqt/sliders/_labeled.py b/src/superqt/sliders/_labeled.py index 10de575c..c35c2406 100644 --- a/src/superqt/sliders/_labeled.py +++ b/src/superqt/sliders/_labeled.py @@ -1,13 +1,16 @@ +from __future__ import annotations + import contextlib -from enum import IntEnum +from enum import IntEnum, IntFlag, auto from functools import partial -from typing import Any +from typing import Any, overload from qtpy.QtCore import QPoint, QSize, Qt, Signal from qtpy.QtGui import QFontMetrics, QValidator from qtpy.QtWidgets import ( QAbstractSlider, QApplication, + QBoxLayout, QDoubleSpinBox, QHBoxLayout, QSlider, @@ -25,79 +28,82 @@ class LabelPosition(IntEnum): NoLabel = 0 - LabelsAbove = 1 - LabelsBelow = 2 - LabelsRight = 1 - LabelsLeft = 2 + LabelsAbove = auto() + LabelsBelow = auto() + LabelsRight = LabelsAbove + LabelsLeft = LabelsBelow -class EdgeLabelMode(IntEnum): +class EdgeLabelMode(IntFlag): NoLabel = 0 - LabelIsRange = 1 - LabelIsValue = 2 + LabelIsRange = auto() + LabelIsValue = auto() class _SliderProxy: _slider: QSlider - def value(self): + def value(self) -> int: return self._slider.value() - def setValue(self, value) -> None: + def setValue(self, value: int) -> None: self._slider.setValue(value) - def sliderPosition(self): + def sliderPosition(self) -> int: return self._slider.sliderPosition() - def setSliderPosition(self, pos) -> None: + def setSliderPosition(self, pos: int) -> None: self._slider.setSliderPosition(pos) - def minimum(self): + def minimum(self) -> int: return self._slider.minimum() - def setMinimum(self, minimum): + def setMinimum(self, minimum: int) -> None: self._slider.setMinimum(minimum) - def maximum(self): + def maximum(self) -> int: return self._slider.maximum() - def setMaximum(self, maximum): + def setMaximum(self, maximum: int) -> None: self._slider.setMaximum(maximum) def singleStep(self): return self._slider.singleStep() - def setSingleStep(self, step): + def setSingleStep(self, step: int) -> None: self._slider.setSingleStep(step) - def pageStep(self): + def pageStep(self) -> int: return self._slider.pageStep() - def setPageStep(self, step) -> None: + def setPageStep(self, step: int) -> None: self._slider.setPageStep(step) - def setRange(self, min, max) -> None: + def setRange(self, min: int, max: int) -> None: self._slider.setRange(min, max) - def tickInterval(self): + def tickInterval(self) -> int: return self._slider.tickInterval() - def setTickInterval(self, interval) -> None: + def setTickInterval(self, interval: int) -> None: self._slider.setTickInterval(interval) - def tickPosition(self): + def tickPosition(self) -> QSlider.TickPosition: return self._slider.tickPosition() - def setTickPosition(self, pos) -> None: + def setTickPosition(self, pos: QSlider.TickPosition) -> None: self._slider.setTickPosition(pos) - def __getattr__(self, name) -> Any: + def __getattr__(self, name: Any) -> Any: return getattr(self._slider, name) -def _handle_overloaded_slider_sig(args, kwargs): +def _handle_overloaded_slider_sig( + args: tuple, kwargs: dict +) -> tuple[QWidget | None, Qt.Orientation]: + """Maintaining signature of QSlider.__init__.""" parent = None - orientation = Qt.Orientation.Vertical + orientation = Qt.Orientation.Horizontal errmsg = ( "TypeError: arguments did not match any overloaded call:\n" " QSlider(parent: QWidget = None)\n" @@ -123,11 +129,20 @@ def _handle_overloaded_slider_sig(args, kwargs): class QLabeledSlider(_SliderProxy, QAbstractSlider): editingFinished = Signal() - EdgeLabelMode = EdgeLabelMode _slider_class = QSlider _slider: QSlider - def __init__(self, *args, **kwargs) -> None: + @overload + def __init__(self, parent: QWidget | None = ...) -> None: + ... + + @overload + def __init__( + self, orientation: Qt.Orientation, parent: QWidget | None = ... + ) -> None: + ... + + def __init__(self, *args: Any, **kwargs: Any) -> None: parent, orientation = _handle_overloaded_slider_sig(args, kwargs) super().__init__(parent) @@ -141,7 +156,7 @@ def __init__(self, *args, **kwargs) -> None: self._rename_signals() self._slider.actionTriggered.connect(self.actionTriggered.emit) - self._slider.rangeChanged.connect(self.rangeChanged.emit) + self._slider.rangeChanged.connect(self._on_slider_range_changed) self._slider.sliderMoved.connect(self.sliderMoved.emit) self._slider.sliderPressed.connect(self.sliderPressed.emit) self._slider.sliderReleased.connect(self.sliderReleased.emit) @@ -150,19 +165,9 @@ def __init__(self, *args, **kwargs) -> None: self.setOrientation(orientation) - def _on_slider_value_changed(self, v): - self._label.setValue(v) - self.valueChanged.emit(v) - - def _setValue(self, value: float): - """Convert the value from float to int before setting the slider value.""" - self._slider.setValue(int(value)) - - def _rename_signals(self): - # for subclasses - pass + # ------------------- public API ------------------- - def setOrientation(self, orientation): + def setOrientation(self, orientation: Qt.Orientation) -> None: """Set orientation, value will be 'horizontal' or 'vertical'.""" self._slider.setOrientation(orientation) marg = (0, 0, 0, 0) @@ -194,11 +199,21 @@ def edgeLabelMode(self) -> EdgeLabelMode: return self._edge_label_mode def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None: - """Set the `EdgeLabelMode`.""" + """Set the `EdgeLabelMode`. + + Parameters + ---------- + opt : EdgeLabelMode + To show no label, use `EdgeLabelMode.NoLabel`. To show the value + of the slider, use `EdgeLabelMode.LabelIsValue`. To show + `value / maximum`, use + `EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange`. + """ if opt is EdgeLabelMode.LabelIsRange: raise ValueError( "mode must be one of 'EdgeLabelMode.NoLabel' or " - "'EdgeLabelMode.LabelIsValue'." + "'EdgeLabelMode.LabelIsValue' or" + "'EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange'." ) self._edge_label_mode = opt @@ -206,15 +221,39 @@ def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None: self._label.hide() w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0 self.layout().setContentsMargins(0, 0, w, 0) - else: + if opt & EdgeLabelMode.LabelIsValue: if self.isVisible(): self._label.show() self._label.setMode(opt) self._label.setValue(self._slider.value()) self.layout().setContentsMargins(0, 0, 0, 0) + self._on_slider_range_changed(self.minimum(), self.maximum()) QApplication.processEvents() + # putting this after labelMode methods for the sake of mypy + EdgeLabelMode = EdgeLabelMode + + # --------------------- private api -------------------- + + def _on_slider_range_changed(self, min_: int, max_: int) -> None: + slash = " / " if self._edge_label_mode & EdgeLabelMode.LabelIsValue else "" + if self._edge_label_mode & EdgeLabelMode.LabelIsRange: + self._label.setSuffix(f"{slash}{max_}") + self.rangeChanged.emit(min_, max_) + + def _on_slider_value_changed(self, v: Any) -> None: + self._label.setValue(v) + self.valueChanged.emit(v) + + def _setValue(self, value: float) -> None: + """Convert the value from float to int before setting the slider value.""" + self._slider.setValue(int(value)) + + def _rename_signals(self) -> None: + # for subclasses + pass + class QLabeledDoubleSlider(QLabeledSlider): _slider_class = QDoubleSlider @@ -223,15 +262,25 @@ class QLabeledDoubleSlider(QLabeledSlider): _fsliderMoved = Signal(float) _frangeChanged = Signal(float, float) - def __init__(self, *args, **kwargs) -> None: + @overload + def __init__(self, parent: QWidget | None = ...) -> None: + ... + + @overload + def __init__( + self, orientation: Qt.Orientation, parent: QWidget | None = ... + ) -> None: + ... + + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.setDecimals(2) - def _setValue(self, value: float): + def _setValue(self, value: float) -> None: """Convert the value from float to int before setting the slider value.""" self._slider.setValue(value) - def _rename_signals(self): + def _rename_signals(self) -> None: self.valueChanged = self._fvalueChanged self.sliderMoved = self._fsliderMoved self.rangeChanged = self._frangeChanged @@ -239,7 +288,7 @@ def _rename_signals(self): def decimals(self) -> int: return self._label.decimals() - def setDecimals(self, prec: int): + def setDecimals(self, prec: int) -> None: self._label.setDecimals(prec) @@ -247,12 +296,20 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): _valueChanged = Signal(tuple) editingFinished = Signal() - LabelPosition = LabelPosition - EdgeLabelMode = EdgeLabelMode _slider_class = QRangeSlider _slider: QRangeSlider - def __init__(self, *args, **kwargs) -> None: + @overload + def __init__(self, parent: QWidget | None = ...) -> None: + ... + + @overload + def __init__( + self, orientation: Qt.Orientation, parent: QWidget | None = ... + ) -> None: + ... + + def __init__(self, *args: Any, **kwargs: Any) -> None: parent, orientation = _handle_overloaded_slider_sig(args, kwargs) super().__init__(parent) self._rename_signals() @@ -290,14 +347,13 @@ def __init__(self, *args, **kwargs) -> None: self._on_range_changed(self._slider.minimum(), self._slider.maximum()) self.setOrientation(orientation) - def _rename_signals(self): - self.valueChanged = self._valueChanged + # --------------------- public API ------------------- def handleLabelPosition(self) -> LabelPosition: """Return where/whether labels are shown adjacent to slider handles.""" return self._handle_label_position - def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition: + def setHandleLabelPosition(self, opt: LabelPosition) -> None: """Set where/whether labels are shown adjacent to slider handles.""" self._handle_label_position = opt for lbl in self._handle_labels: @@ -311,7 +367,7 @@ def edgeLabelMode(self) -> EdgeLabelMode: """Return current `EdgeLabelMode`.""" return self._edge_label_mode - def setEdgeLabelMode(self, opt: EdgeLabelMode): + def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None: """Set `EdgeLabelMode`, controls what is shown at the min/max labels.""" self._edge_label_mode = opt if not self._edge_label_mode: @@ -333,7 +389,63 @@ def setEdgeLabelMode(self, opt: EdgeLabelMode): QApplication.processEvents() self._reposition_labels() - def _reposition_labels(self): + def setRange(self, min: int, max: int) -> None: + self._on_range_changed(min, max) + + def setOrientation(self, orientation: Qt.Orientation) -> None: + """Set orientation, value will be 'horizontal' or 'vertical'.""" + self._slider.setOrientation(orientation) + if orientation == Qt.Orientation.Vertical: + layout: QBoxLayout = QVBoxLayout() + layout.setSpacing(1) + layout.addWidget(self._max_label) + layout.addWidget(self._slider) + layout.addWidget(self._min_label) + # TODO: set margins based on label width + if self._handle_label_position == LabelPosition.LabelsLeft: + marg = (30, 0, 0, 0) + elif self._handle_label_position == LabelPosition.NoLabel: + marg = (0, 0, 0, 0) + else: + marg = (0, 0, 20, 0) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + else: + layout = QHBoxLayout() + layout.setSpacing(7) + if self._handle_label_position == LabelPosition.LabelsBelow: + marg = (0, 0, 0, 25) + elif self._handle_label_position == LabelPosition.NoLabel: + marg = (0, 0, 0, 0) + else: + marg = (0, 25, 0, 0) + layout.addWidget(self._min_label) + layout.addWidget(self._slider) + layout.addWidget(self._max_label) + + # remove old layout + old_layout = self.layout() + if old_layout is not None: + QWidget().setLayout(old_layout) + + self.setLayout(layout) + layout.setContentsMargins(*marg) + super().setOrientation(orientation) + QApplication.processEvents() + self._reposition_labels() + + def resizeEvent(self, a0) -> None: + super().resizeEvent(a0) + self._reposition_labels() + + # putting this after methods above for the sake of mypy + LabelPosition = LabelPosition + EdgeLabelMode = EdgeLabelMode + + # ------------- private methods ---------------- + def _rename_signals(self) -> None: + self.valueChanged = self._valueChanged + + def _reposition_labels(self) -> None: if ( not self._handle_labels or self._handle_label_position == LabelPosition.NoLabel @@ -372,7 +484,7 @@ def _reposition_labels(self): label.show() self.update() - def _min_label_edited(self, val): + def _min_label_edited(self, val: float) -> None: if self._edge_label_mode == EdgeLabelMode.LabelIsRange: self.setMinimum(val) else: @@ -381,7 +493,7 @@ def _min_label_edited(self, val): self.setValue(v) self._reposition_labels() - def _max_label_edited(self, val): + def _max_label_edited(self, val: float) -> None: if self._edge_label_mode == EdgeLabelMode.LabelIsRange: self.setMaximum(val) else: @@ -390,7 +502,7 @@ def _max_label_edited(self, val): self.setValue(v) self._reposition_labels() - def _on_value_changed(self, v): + def _on_value_changed(self, v: tuple[int, ...]) -> None: if self._edge_label_mode == EdgeLabelMode.LabelIsValue: self._min_label.setValue(v[0]) self._max_label.setValue(v[-1]) @@ -411,7 +523,7 @@ def _on_value_changed(self, v): label.setValue(val) self._reposition_labels() - def _on_range_changed(self, min, max): + def _on_range_changed(self, min: int, max: int) -> None: if (min, max) != (self._slider.minimum(), self._slider.maximum()): self._slider.setRange(min, max) for lbl in self._handle_labels: @@ -425,72 +537,34 @@ def _on_range_changed(self, min, max): # super().setValue(value) # self.sliderChange(QSlider.SliderValueChange) - def setRange(self, min, max) -> None: - self._on_range_changed(min, max) - - def setOrientation(self, orientation): - """Set orientation, value will be 'horizontal' or 'vertical'.""" - self._slider.setOrientation(orientation) - if orientation == Qt.Orientation.Vertical: - layout = QVBoxLayout() - layout.setSpacing(1) - layout.addWidget(self._max_label) - layout.addWidget(self._slider) - layout.addWidget(self._min_label) - # TODO: set margins based on label width - if self._handle_label_position == LabelPosition.LabelsLeft: - marg = (30, 0, 0, 0) - elif self._handle_label_position == LabelPosition.NoLabel: - marg = (0, 0, 0, 0) - else: - marg = (0, 0, 20, 0) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - else: - layout = QHBoxLayout() - layout.setSpacing(7) - if self._handle_label_position == LabelPosition.LabelsBelow: - marg = (0, 0, 0, 25) - elif self._handle_label_position == LabelPosition.NoLabel: - marg = (0, 0, 0, 0) - else: - marg = (0, 25, 0, 0) - layout.addWidget(self._min_label) - layout.addWidget(self._slider) - layout.addWidget(self._max_label) - - # remove old layout - old_layout = self.layout() - if old_layout is not None: - QWidget().setLayout(old_layout) - - self.setLayout(layout) - layout.setContentsMargins(*marg) - super().setOrientation(orientation) - QApplication.processEvents() - self._reposition_labels() - - def resizeEvent(self, a0) -> None: - super().resizeEvent(a0) - self._reposition_labels() - class QLabeledDoubleRangeSlider(QLabeledRangeSlider): _slider_class = QDoubleRangeSlider _slider: QDoubleRangeSlider _frangeChanged = Signal(float, float) - def __init__(self, *args, **kwargs) -> None: + @overload + def __init__(self, parent: QWidget | None = ...) -> None: + ... + + @overload + def __init__( + self, orientation: Qt.Orientation, parent: QWidget | None = ... + ) -> None: + ... + + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.setDecimals(2) - def _rename_signals(self): + def _rename_signals(self) -> None: super()._rename_signals() self.rangeChanged = self._frangeChanged def decimals(self) -> int: return self._min_label.decimals() - def setDecimals(self, prec: int): + def setDecimals(self, prec: int) -> None: self._min_label.setDecimals(prec) self._max_label.setDecimals(prec) for lbl in self._handle_labels: @@ -521,56 +595,26 @@ def __init__( self.editingFinished.connect(self._silent_clear_focus) self._update_size() - def _silent_clear_focus(self): - with signals_blocked(self): - self.clearFocus() - def setDecimals(self, prec: int) -> None: super().setDecimals(prec) self._update_size() - def _update_size(self, *_): - # fontmetrics to measure the width of text - fm = QFontMetrics(self.font()) - h = self.sizeHint().height() - fixed_content = self.prefix() + self.suffix() + " " - - if self._mode == EdgeLabelMode.LabelIsValue: - # determine width based on min/max/specialValue - mintext = self.textFromValue(self.minimum())[:18] + fixed_content - maxtext = self.textFromValue(self.maximum())[:18] + fixed_content - w = max(0, _fm_width(fm, mintext)) - w = max(w, _fm_width(fm, maxtext)) - if self.specialValueText(): - w = max(w, _fm_width(fm, self.specialValueText())) - else: - w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3 - - w += 3 # cursor blinking space - # get the final size hint - opt = QStyleOptionSpinBox() - self.initStyleOption(opt) - size = self.style().sizeFromContents( - QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self - ) - self.setFixedSize(size) - def setValue(self, val: Any) -> None: super().setValue(val) if self._mode == EdgeLabelMode.LabelIsRange: self._update_size() - def setMaximum(self, max: int) -> None: + def setMaximum(self, max: float) -> None: super().setMaximum(max) if self._mode == EdgeLabelMode.LabelIsValue: self._update_size() - def setMinimum(self, min: int) -> None: + def setMinimum(self, min: float) -> None: super().setMinimum(min) if self._mode == EdgeLabelMode.LabelIsValue: self._update_size() - def setMode(self, opt: EdgeLabelMode): + def setMode(self, opt: EdgeLabelMode) -> None: # when the edge labels are controlling slider range, # we want them to have a big range, but not have a huge label self._mode = opt @@ -585,14 +629,50 @@ def setMode(self, opt: EdgeLabelMode): self._slider.rangeChanged.connect(self.setRange) self._update_size() - def validate(self, input: str, pos: int): + # --------------- private ---------------- + + def _silent_clear_focus(self) -> None: + with signals_blocked(self): + self.clearFocus() + + def _update_size(self, *_: Any) -> None: + # fontmetrics to measure the width of text + fm = QFontMetrics(self.font()) + h = self.sizeHint().height() + fixed_content = self.prefix() + self.suffix() + " " + + if self._mode & EdgeLabelMode.LabelIsValue: + # determine width based on min/max/specialValue + mintext = self.textFromValue(self.minimum())[:18] + maxtext = self.textFromValue(self.maximum())[:18] + w = max(0, _fm_width(fm, mintext + fixed_content)) + w = max(w, _fm_width(fm, maxtext + fixed_content)) + if self.specialValueText(): + w = max(w, _fm_width(fm, self.specialValueText())) + if self._mode & EdgeLabelMode.LabelIsRange: + w += 8 # it seems as thought suffix() is not enough + else: + w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3 + + w += 3 # cursor blinking space + # get the final size hint + opt = QStyleOptionSpinBox() + self.initStyleOption(opt) + size = self.style().sizeFromContents( + QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self + ) + self.setFixedSize(size) + + def validate( + self, input_: str | None, pos: int + ) -> tuple[QValidator.State, str, int]: # fake like an integer spinbox - if "." in input and self.decimals() < 1: - return QValidator.Invalid, input, len(input) - return super().validate(input, pos) + if input_ and "." in input_ and self.decimals() < 1: + return QValidator.State.Invalid, input_, len(input_) + return super().validate(input_, pos) -def _fm_width(fm, text): +def _fm_width(fm: QFontMetrics, text: str) -> int: if hasattr(fm, "horizontalAdvance"): return fm.horizontalAdvance(text) return fm.width(text) From bace50fbb8ab1bbbedfafdcc9499525fde5c859a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Sep 2023 11:24:55 -0400 Subject: [PATCH 21/23] docs: update fonticon docs (#198) --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/ISSUE_TEMPLATE/feature.md | 7 +++ docs/utilities/fonticon.md | 67 +++++++++++++++++++--------- pyproject.toml | 1 + 4 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/feature.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0875ca61..8772ed28 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a report to help us improve title: '' -labels: '' +labels: 'bug' assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 00000000..8954faa7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,7 @@ +--- +name: Feature request +about: Request a new feature +title: '' +labels: 'enhancement' +assignees: '' +--- diff --git a/docs/utilities/fonticon.md b/docs/utilities/fonticon.md index aeb0ec19..8652f9e7 100644 --- a/docs/utilities/fonticon.md +++ b/docs/utilities/fonticon.md @@ -28,21 +28,44 @@ app.exec() ## Font Icon plugins -Ready-made fonticon packs are available as plugins: +Ready-made fonticon packs are available as plugins. -### [Font Awesome 5](https://fontawesome.com/v5/search) +A great way to search across most available icons libraries from a single +search interface is to use glyphsearch: + +If a font library you'd like to use is unavailable as a superqt plugin, +please [open a feature request](https://github.com/pyapp-kit/superqt/issues/new/choose) + + +### Font Awesome 6 + +Browse available icons at + +```bash +pip install fonticon-fontawesome6 +``` + +### Font Awesome 5 + +Browse available icons at ```bash pip install fonticon-fontawesome5 ``` -### [Font Awesome 6](https://fontawesome.com/v6/search) +### Material Design Icons 7 + +Browse available icons at ```bash -pip install fonticon-fontawesome6 +pip install fonticon-materialdesignicons7 ``` -### [Material Design Icons](https://materialdesignicons.com/) +### Material Design Icons 6 + +Browse available icons at +(note that the search defaults to v7, see changes from v6 in [the +changelog](https://pictogrammers.com/docs/library/mdi/releases/changelog/)) ```bash pip install fonticon-materialdesignicons6 @@ -55,7 +78,7 @@ pip install fonticon-materialdesignicons6 - `superqt.fonticon` is a pluggable system, and font icon packs may use the `"superqt.fonticon"` -entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples: +entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples: - - @@ -64,24 +87,24 @@ entry point to register themselves with superqt. See [`fonticon-cookiecutter`]( ## API ::: superqt.fonticon.icon - options: - heading_level: 3 +options: +heading_level: 3 ::: superqt.fonticon.setTextIcon - options: - heading_level: 3 +options: +heading_level: 3 ::: superqt.fonticon.font - options: - heading_level: 3 +options: +heading_level: 3 ::: superqt.fonticon.IconOpts - options: - heading_level: 3 +options: +heading_level: 3 ::: superqt.fonticon.addFont - options: - heading_level: 3 +options: +heading_level: 3 ## Animations @@ -89,13 +112,13 @@ the `animation` parameter to `icon()` accepts a subclass of `Animation` that will be ::: superqt.fonticon.Animation - options: - heading_level: 3 +options: +heading_level: 3 ::: superqt.fonticon.pulse - options: - heading_level: 3 +options: +heading_level: 3 ::: superqt.fonticon.spin - options: - heading_level: 3 +options: +heading_level: 3 diff --git a/pyproject.toml b/pyproject.toml index 7cab5be1..20f44533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ font-mi6 = ["fonticon-materialdesignicons6"] font-mi7 = ["fonticon-materialdesignicons7"] [project.urls] +Documentation = "https://pyapp-kit.github.io/superqt/" Source = "https://github.com/pyapp-kit/superqt" Tracker = "https://github.com/pyapp-kit/superqt/issues" Changelog = "https://github.com/pyapp-kit/superqt/blob/main/CHANGELOG.md" From df2034d5dc353a2dfc338f7e9521cf0526fe5c7e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Sep 2023 13:47:15 -0400 Subject: [PATCH 22/23] docs: add cmap and QSearchableTreeWidget to docs (#199) --- docs/index.md | 2 +- docs/utilities/cmap.md | 12 +++++ docs/utilities/index.md | 1 + docs/widgets/colormap_catalog.md | 35 ++++++++++++++ docs/widgets/index.md | 3 ++ docs/widgets/qcolorcombobox.md | 27 +++++++++++ docs/widgets/qcolormap.md | 67 +++++++++++++++++++++++++++ docs/widgets/qsearchabletreewidget.md | 37 +++++++++++++++ mkdocs.yml | 7 +-- pyproject.toml | 2 +- src/superqt/cmap/_cmap_combo.py | 8 +++- 11 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 docs/utilities/cmap.md create mode 100644 docs/widgets/colormap_catalog.md create mode 100644 docs/widgets/qcolorcombobox.md create mode 100644 docs/widgets/qcolormap.md create mode 100644 docs/widgets/qsearchabletreewidget.md diff --git a/docs/index.md b/docs/index.md index c04534ff..d8be1181 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,4 +26,4 @@ conda install -c conda-forge superqt ## Usage -See the [Widgets](./widgets/) and [Utilities](./utilities/) pages for features offered by superqt. +See the [Widgets](./widgets/index.md) and [Utilities](./utilities/index.md) pages for features offered by superqt. diff --git a/docs/utilities/cmap.md b/docs/utilities/cmap.md new file mode 100644 index 00000000..06b571dd --- /dev/null +++ b/docs/utilities/cmap.md @@ -0,0 +1,12 @@ +# Colormap utilities + +See also: + +- [`superqt.QColormapComboBox`](../widgets/qcolormap.md) +- [`superqt.cmap.CmapCatalogComboBox`](../widgets/colormap_catalog.md) + +::: superqt.cmap.draw_colormap + +::: superqt.cmap.QColormapLineEdit + +::: superqt.cmap.QColormapItemDelegate diff --git a/docs/utilities/index.md b/docs/utilities/index.md index dbe6c356..4ff6ecc5 100644 --- a/docs/utilities/index.md +++ b/docs/utilities/index.md @@ -29,3 +29,4 @@ | ----------- | --------------------- | | [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. | | [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. | +| [`draw_colormap`](./cmap.md) | Function that draws a colormap into any QPaintDevice. | diff --git a/docs/widgets/colormap_catalog.md b/docs/widgets/colormap_catalog.md new file mode 100644 index 00000000..48da710b --- /dev/null +++ b/docs/widgets/colormap_catalog.md @@ -0,0 +1,35 @@ +# CmapCatalogComboBox + +Searchable `QComboBox` variant that contains the +[entire cmap colormap catalog](https://cmap-docs.readthedocs.io/en/latest/catalog/) + +!!! note "requires cmap" + + This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library + to provide colormaps. You can install it with: + + ```shell + # use the `cmap` extra to include colormap support + pip install superqt[cmap] + ``` + +You can limit the colormaps shown by setting the `categories` or +`interpolation` keyword arguments. + +```python +from qtpy.QtWidgets import QApplication + +from superqt.cmap import CmapCatalogComboBox + +app = QApplication([]) + +catalog_combo = CmapCatalogComboBox(interpolation="linear") +catalog_combo.setCurrentText("viridis") +catalog_combo.show() + +app.exec() +``` + +{{ show_widget(130) }} + +{{ show_members('superqt.cmap.CmapCatalogComboBox') }} diff --git a/docs/widgets/index.md b/docs/widgets/index.md index 6e80a6ec..c3409e7f 100644 --- a/docs/widgets/index.md +++ b/docs/widgets/index.md @@ -24,6 +24,9 @@ The following are QWidget subclasses: | [`QEnumComboBox`](./qenumcombobox.md) | `QComboBox` that populates the combobox from a python `Enum` | | [`QSearchableComboBox`](./qsearchablecombobox.md) | `QComboBox` variant that filters available options based on text input | | [`QSearchableListWidget`](./qsearchablelistwidget.md) | `QListWidget` variant with search field that filters available options | +| [`QSearchableTreeWidget`](./qsearchabletreewidget.md) | `QTreeWidget` variant with search field that filters available options | +| [`QColorComboBox`](./qcolorcombobox.md) | `QComboBox` to select from a specified set of colors | +| [`QColormapComboBox`](./qcolormap.md) | `QComboBox` to select from a specified set of colormaps. | ## Frames and containers diff --git a/docs/widgets/qcolorcombobox.md b/docs/widgets/qcolorcombobox.md new file mode 100644 index 00000000..96916651 --- /dev/null +++ b/docs/widgets/qcolorcombobox.md @@ -0,0 +1,27 @@ +# QColorComboBox + +`QComboBox` designed to select from a specific set of colors. + +```python +from qtpy.QtWidgets import QApplication + +from superqt import QColorComboBox + +app = QApplication([]) + +colors = QColorComboBox() +colors.addColors(['red', 'green', 'blue']) + +# show an "Add Color" item that opens a QColorDialog when clicked +colors.setUserColorsAllowed(True) + +# emits a QColor when changed +colors.currentColorChanged.connect(print) +colors.show() + +app.exec_() +``` + +{{ show_widget(100) }} + +{{ show_members('superqt.QColorComboBox') }} diff --git a/docs/widgets/qcolormap.md b/docs/widgets/qcolormap.md new file mode 100644 index 00000000..13004dc7 --- /dev/null +++ b/docs/widgets/qcolormap.md @@ -0,0 +1,67 @@ +# QColormapComboBox + +`QComboBox` variant to select from a specific set of colormaps. + +!!! note "requires cmap" + + This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library + to provide colormaps. You can install it with: + + ```shell + # use the `cmap` extra to include colormap support + pip install superqt[cmap] + ``` + +### ColorMapLike objects + +Colormaps may be specified in a variety of ways, such as by name (string), an iterable of a color/color-like objects, or as +a [`cmap.Colormap`][] instance. See [cmap documentation for details on +all ColormapLike types](https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects) + +### Example + +```python +from cmap import Colormap +from qtpy.QtWidgets import QApplication + +from superqt import QColormapComboBox + +app = QApplication([]) + +cmap_combo = QColormapComboBox() +# see note above about colormap-like objects +# as names from the cmap catalog +cmap_combo.addColormaps(["viridis", "plasma", "magma", "gray"]) +# as a sequence of colors, linearly interpolated +cmap_combo.addColormap(("#0f0", "slateblue", "#F3A003A0")) +# as a `cmap.Colormap` instance with custom name: +cmap_combo.addColormap(Colormap(("green", "white", "orange"), name="MyMap")) + +cmap_combo.show() +app.exec() +``` + +{{ show_widget(200) }} + +### Style Customization + +Note that both the LineEdit and the dropdown can be styled to have the colormap +on the left, or fill the entire width of the widget. + +To make the CombBox label colormap fill the entire width of the widget: + +```python +from superqt.cmap import QColormapLineEdit +cmap_combo.setLineEdit(QColormapLineEdit()) +``` + +To make the CombBox dropdown colormaps fill +less than the entire width of the widget: + +```python +from superqt.cmap import QColormapItemDelegate +delegate = QColormapItemDelegate(fractional_colormap_width=0.33) +cmap_combo.setItemDelegate(delegate) +``` + +{{ show_members('superqt.QColormapComboBox') }} diff --git a/docs/widgets/qsearchabletreewidget.md b/docs/widgets/qsearchabletreewidget.md new file mode 100644 index 00000000..a95ebc2c --- /dev/null +++ b/docs/widgets/qsearchabletreewidget.md @@ -0,0 +1,37 @@ +# QSearchableTreeWidget + +`QSearchableTreeWidget` combines a +[`QTreeWidget`](https://doc.qt.io/qt-6/qtreewidget.html) and a `QLineEdit` for showing a mapping that can be searched by key. + +This is intended to be used with a read-only mapping and be conveniently created +using `QSearchableTreeWidget.fromData(data)`. If the mapping changes, the +easiest way to update this is by calling `setData`. + + +```python +from qtpy.QtWidgets import QApplication + +from superqt import QSearchableTreeWidget + +app = QApplication([]) + +data = { + "none": None, + "str": "test", + "int": 42, + "list": [2, 3, 5], + "dict": { + "float": 0.5, + "tuple": (22, 99), + "bool": False, + }, +} +tree = QSearchableTreeWidget.fromData(data) +tree.show() + +app.exec_() +``` + +{{ show_widget() }} + +{{ show_members('superqt.QSearchableTreeWidget') }} diff --git a/mkdocs.yml b/mkdocs.yml index 8105b863..b53619d0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,10 +7,7 @@ repo_name: pyapp-kit/superqt repo_url: https://github.com/pyapp-kit/superqt # Copyright -copyright: Copyright © 2021 - 2022 Talley Lambert - -extra_css: - - stylesheets/extra.css +copyright: Copyright © 2021 - 2022 watch: - src @@ -44,7 +41,6 @@ markdown_extensions: plugins: - search - autorefs - - mkdocstrings - macros: module_name: docs/_macros - mkdocstrings: @@ -52,6 +48,7 @@ plugins: python: import: - https://docs.python.org/3/objects.inv + - https://cmap-docs.readthedocs.io/en/latest/objects.inv options: show_source: false docstring_style: numpy diff --git a/pyproject.toml b/pyproject.toml index 20f44533..8e3c4a17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dev = [ "rich", "types-Pygments", ] -docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]"] +docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]", "pint", "cmap"] quantity = ["pint"] cmap = ["cmap >=0.1.1"] pyside2 = ["pyside2"] diff --git a/src/superqt/cmap/_cmap_combo.py b/src/superqt/cmap/_cmap_combo.py index 65326e44..aa9d78c1 100644 --- a/src/superqt/cmap/_cmap_combo.py +++ b/src/superqt/cmap/_cmap_combo.py @@ -78,7 +78,13 @@ def userAdditionsAllowed(self) -> bool: return self._allow_user_colors def setUserAdditionsAllowed(self, allow: bool) -> None: - """Sets whether the user can add custom colors.""" + """Sets whether the user can add custom colors. + + If enabled, an "Add Colormap..." item will be added to the end of the + list. When clicked, a dialog will be shown to allow the user to select + a colormap from the + [cmap catalog](https://cmap-docs.readthedocs.io/en/latest/catalog/). + """ self._allow_user_colors = bool(allow) idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole) From 409d19e5c2bb819fc2c95dc1251867df6f0384e5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 12 Sep 2023 13:54:59 -0400 Subject: [PATCH 23/23] fix: sliderMoved event (#200) --- examples/{labeled.py => labeled_sliders.py} | 0 examples/{basic.py => range_slider.py} | 1 - src/superqt/sliders/_labeled.py | 1 + 3 files changed, 1 insertion(+), 1 deletion(-) rename examples/{labeled.py => labeled_sliders.py} (100%) rename examples/{basic.py => range_slider.py} (82%) diff --git a/examples/labeled.py b/examples/labeled_sliders.py similarity index 100% rename from examples/labeled.py rename to examples/labeled_sliders.py diff --git a/examples/basic.py b/examples/range_slider.py similarity index 82% rename from examples/basic.py rename to examples/range_slider.py index 4d245f9b..079103ae 100644 --- a/examples/basic.py +++ b/examples/range_slider.py @@ -5,7 +5,6 @@ app = QApplication([]) -slider = QRangeSlider(Qt.Orientation.Horizontal) slider = QRangeSlider(Qt.Orientation.Horizontal) slider.setValue((20, 80)) diff --git a/src/superqt/sliders/_labeled.py b/src/superqt/sliders/_labeled.py index c35c2406..38bb9d41 100644 --- a/src/superqt/sliders/_labeled.py +++ b/src/superqt/sliders/_labeled.py @@ -325,6 +325,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._slider = self._slider_class() self._slider.valueChanged.connect(self.valueChanged.emit) self._slider.rangeChanged.connect(self.rangeChanged.emit) + self.sliderMoved = self._slider._slidersMoved self._min_label = SliderLabel( self._slider,