From d6d5b9c787d6804cf3b91ae9fa6983bd1cfa610b Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 13 May 2022 14:40:57 +0200 Subject: [PATCH 01/15] StyledSvgIconEngine optimization --- orangecanvas/gui/svgiconengine.py | 38 +++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/orangecanvas/gui/svgiconengine.py b/orangecanvas/gui/svgiconengine.py index 32808a6a3..768fa5443 100644 --- a/orangecanvas/gui/svgiconengine.py +++ b/orangecanvas/gui/svgiconengine.py @@ -83,6 +83,23 @@ def clone(self): return SvgIconEngine(self.__contents) +_QPalette_Text = QPalette.Text +_QPalette_HighlightedText = QPalette.HighlightedText +_QPalette_WindowText = QPalette.WindowText +_QPalette_Window = QPalette.Window + +_QPalette_Active = QPalette.Active +_QPalette_Disabled = QPalette.Disabled + +_QIcon_Active = QIcon.Active +_QIcon_Selected = QIcon.Selected +_QIcon_Disabled = QIcon.Disabled + +_QIcon_Active_Modes = (QIcon.Active, QIcon.Selected) + +_Qt_KeepAspectRatio = Qt.KeepAspectRatio + + class StyledSvgIconEngine(QIconEngine): """ A basic styled icon engine based on a QPalette colors. @@ -182,13 +199,12 @@ def __renderStyledPixmap( self, size: QSize, mode: QIcon.Mode, state: QIcon.State, palette: QPalette ) -> QPixmap: - active = mode in (QIcon.Active, QIcon.Selected) - disabled = mode == QIcon.Disabled - cg = QPalette.Disabled if disabled else QPalette.Active - role = QPalette.HighlightedText if active else QPalette.WindowText - namespace = "{}:{}/{}/".format( - __name__, __class__.__name__, self.__cache_key) - style_key = "{}-{}-{}".format(hex(palette.cacheKey()), cg, role) + active = mode in _QIcon_Active_Modes + disabled = mode == _QIcon_Disabled + cg = _QPalette_Disabled if disabled else _QPalette_Active + role = _QPalette_HighlightedText if active else _QPalette_WindowText + namespace = f"{__name__}:{__class__.__name__}/{self.__cache_key}/" + style_key = f"{hex(palette.cacheKey())}-{cg}-{role}" renderer = self.__styled_contents_cache.get(style_key) if renderer is None: css = render_svg_color_scheme_css(palette, state) @@ -202,11 +218,9 @@ def __renderStyledPixmap( dsize = renderer.defaultSize() # type: QSize if not dsize.isNull(): - dsize.scale(size, Qt.KeepAspectRatio) + dsize.scale(size, _Qt_KeepAspectRatio) size = dsize - - pmcachekey = namespace + style_key + \ - "/{}x{}".format(size.width(), size.height()) + pmcachekey = f"{namespace}{style_key}/{size.width()}x{size.height()}" pm = QPixmapCache.find(pmcachekey) if pm is None or pm.isNull(): pm = QPixmap(size) @@ -216,7 +230,7 @@ def __renderStyledPixmap( painter.end() QPixmapCache.insert(pmcachekey, pm) - style = QApplication.style() + self.__style = style = QApplication.style() if style is not None: opt = QStyleOption() opt.palette = palette From 9f51595ac14b70130b395a6d6e3431f519a02851 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 22 Apr 2022 15:15:46 +0200 Subject: [PATCH 02/15] utils/image: Add image utilities --- orangecanvas/utils/image.py | 117 ++++++++++++++++++++++++++++++++++++ setup.py | 1 + 2 files changed, 118 insertions(+) create mode 100644 orangecanvas/utils/image.py diff --git a/orangecanvas/utils/image.py b/orangecanvas/utils/image.py new file mode 100644 index 000000000..06b34180c --- /dev/null +++ b/orangecanvas/utils/image.py @@ -0,0 +1,117 @@ +from typing import Sequence + +import numpy as np + +from AnyQt.QtGui import QImage, QColor + + +def qrgb( + r: Sequence[int], g: Sequence[int], b: Sequence[int] +) -> Sequence[int]: + """A vectorized `qRgb`.""" + r, g, b = map(lambda a: np.asarray(a, dtype=np.uint32), (r, g, b)) + return (0xff << 24) | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff) + + +def qrgba( + r: Sequence[int], g: Sequence[int], b: Sequence[int], a: Sequence[int] +) -> Sequence[int]: + """A vectorized `qRgba`.""" + r, g, b, a = map(lambda a: np.asarray(a, dtype=np.uint32), (r, g, b, a)) + return ((a & 0xff) << 24) | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff) + + +def qgray( + r: Sequence[int], g: Sequence[int], b: Sequence[int] +) -> Sequence[int]: + """A vectorized `qGray`.""" + r, g, b = map(lambda a: np.asarray(a, dtype=np.uint16), (r, g, b)) + return (r * 11 + g * 16 + b * 5) // 32 + + +def qred(rgb: Sequence[int]) -> Sequence[int]: + """A vectorized `qRed`.""" + rgb = np.asarray(rgb, np.uint32) + return (rgb >> 16) & 0xff + + +def qgreen(rgb: Sequence[int]) -> Sequence[int]: + """A vectorized `qGreen`.""" + rgb = np.asarray(rgb, np.uint32) + return (rgb >> 8) & 0xff + + +def qblue(rgb: Sequence[int]) -> Sequence[int]: + """A vectorized `qBlue`.""" + rgb = np.asarray(rgb, np.uint32) + return rgb & 0xff + + +def qalpha(rgb: Sequence[int]) -> Sequence[int]: + """A vectorized `qAlpha`.""" + rgb = np.asarray(rgb, np.uint32) + return (rgb >> 24) & 0xff + + +def grayscale_invert( + src: QImage, foreground: QColor, background: QColor +) -> QImage: + """ + Convert the `src` image to grayscale and invert it into background + to foreground (gray) range. + + Parameters + ---------- + src: QImage + foreground: QColor + background: QColor + + Returns + ------- + image: QImage + """ + image = src.convertToFormat(QImage.Format_ARGB32) + size = image.size() + w, h = shape = size.width(), size.height() + buffer = image.bits().asarray(w * h * 4) + view = np.frombuffer(buffer, np.uint32).reshape(shape) + r, g, b, a = qred(view), qgreen(view), qblue(view), qalpha(view) + gray = qgray(r, g, b) + factor = gray / 255 + foreground = qgray(foreground.red(), foreground.blue(), foreground.green()) + background = qgray(background.red(), background.blue(), background.green()) + if foreground > background: + minv_, maxv_ = background, foreground + else: + minv_, maxv_ = foreground, background + inv = (1 - factor) * (maxv_ - minv_) + minv_ + inv = np.asarray(inv, np.uint8) + rgba = qrgba(inv, inv, inv, a) + res = QImage(w, h, QImage.Format_ARGB32) + return qimage_copy_from_buffer(res, rgba) + + +def qimage_copy_from_buffer(image: QImage, data: np.ndarray) -> QImage: + """ + Copy the `data` to `image`. + + Parameters + ---------- + image: QImage + The destination image. + data: np.ndarray + The raw source data in the same format as the `image`. + """ + w, h = image.width(), image.height() + if data.shape != (w, h): + raise ValueError( + f"Wrong data.shape (expected ({w}, {h}) got {data.shape})" + ) + d = image.depth() // 8 + dtype = { + 1: np.uint8, 2: np.uint16, 4: np.uint32, 8: np.uint64 + }[d] + dest = image.bits().asarray(w * h * d) + dest = np.frombuffer(dest, dtype).reshape((w, h)) + dest[:] = data + return image diff --git a/setup.py b/setup.py index 39a89f83d..c1dddce79 100755 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ "dictdiffer", "qasync", "importlib_metadata; python_version<'3.8'", + "numpy", ) From 27809ae09fcb3deae3ea3204afa2dded915748e2 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 21 Jan 2022 11:23:13 +0100 Subject: [PATCH 03/15] iconengine: Add a 'SymbolIconEngine' --- orangecanvas/gui/iconengine.py | 154 ++++++++++++++++++++++ orangecanvas/gui/tests/test_iconengine.py | 29 ++++ 2 files changed, 183 insertions(+) create mode 100644 orangecanvas/gui/iconengine.py create mode 100644 orangecanvas/gui/tests/test_iconengine.py diff --git a/orangecanvas/gui/iconengine.py b/orangecanvas/gui/iconengine.py new file mode 100644 index 000000000..4ba4c1848 --- /dev/null +++ b/orangecanvas/gui/iconengine.py @@ -0,0 +1,154 @@ +from itertools import count +from contextlib import contextmanager +from typing import Optional + +from AnyQt.QtCore import QObject, QSize, QRect +from AnyQt.QtGui import ( + QIconEngine, QPalette, QIcon, QPixmap, QPixmapCache, QImage, QPainter +) +from AnyQt.QtWidgets import QApplication, QStyleOption + +from orangecanvas.gui.utils import luminance +from orangecanvas.utils.image import grayscale_invert + +__all__ = [ + "StyledIconEngine", + "SymbolIconEngine", +] + + +_cache_id_gen = count() + + +class StyledIconEngine(QIconEngine): + """ + An abstract base class for icon engines that adapt to effective palette. + """ + __slots__ = ("__palette", "__styleObject") + + def __init__(self, *args, palette: Optional[QPalette] = None, + styleObject: Optional[QObject] = None, **kwargs): + self.__palette = QPalette(palette) if palette is not None else None + self.__styleObject = styleObject + super().__init__(*args, **kwargs) + + @staticmethod + def paletteFromStyleObject(obj: QObject) -> Optional[QPalette]: + palette = obj.property("palette") + if isinstance(palette, QPalette): + return palette + else: + return None + + __paletteOverride = None + + @staticmethod + @contextmanager + def setOverridePalette(palette: QPalette): + """ + Temporarily override used QApplication.palette() with this class. + + This can be used when the icon is drawn on a non default background + and as such might not contrast with it when using the default palette, + and neither paint device nor styleObject can be used for this. + """ + old = StyledIconEngine.__paletteOverride + try: + StyledIconEngine.__paletteOverride = palette + yield + finally: + StyledIconEngine.__paletteOverride = old + + @staticmethod + def paletteOverride() -> Optional[QPalette]: + return StyledIconEngine.__paletteOverride + + def effectivePalette(self) -> QPalette: + if StyledIconEngine.__paletteOverride is not None: + return StyledIconEngine.__paletteOverride + if self.__palette is not None: + return self.__palette + elif self.__styleObject is not None: + palette = self.paletteFromStyleObject(self.__styleObject) + if palette is not None: + return palette + return QApplication.palette() + + +# shorthands for eliminating runtime attr load in hot path +_QIcon_Active_Modes = (QIcon.Active, QIcon.Selected) +_QIcon_Disabled = QIcon.Disabled + +_QPalette_Active = QPalette.Active +_QPalette_WindowText = QPalette.WindowText +_QPalette_Disabled = QPalette.Disabled +_QPalette_HighlightedText = QPalette.HighlightedText + + +class SymbolIconEngine(StyledIconEngine): + """ + A *Symbolic* icon engine adapter for turning simple grayscale base icon + to current effective appearance. + + Arguments + --------- + base: QIcon + The base icon. + """ + def __init__(self, base: QIcon): + super().__init__() + self.__base = QIcon(base) + self.__cache_key = next(_cache_id_gen) + + def paint( + self, painter: QPainter, rect: QRect, mode: QIcon.Mode, + state: QIcon.State + ) -> None: + if not self.__base.isNull(): + palette = self.effectivePalette() + size = rect.size() + dpr = painter.device().devicePixelRatioF() + size = size * dpr + pm = self.__renderStyledPixmap(size, mode, state, palette) + painter.drawPixmap(rect, pm) + + def pixmap(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> QPixmap: + return self.__renderStyledPixmap(size, mode, state, self.effectivePalette()) + + def __renderStyledPixmap( + self, size: QSize, mode: QIcon.Mode, state: QIcon.State, + palette: QPalette + ) -> QPixmap: + active = mode in _QIcon_Active_Modes + disabled = mode == _QIcon_Disabled + cg = _QPalette_Disabled if disabled else _QPalette_Active + role = _QPalette_WindowText if active else _QPalette_HighlightedText + namespace = f"{__name__}:SymbolIconEngine/{self.__cache_key}" + cachekey = f"{size.width()}x{size.height()}" + style_key = f"{hex(palette.cacheKey())}-{cg}-{role}" + pmcachekey = f"{namespace}/{cachekey}/{style_key}" + pm = QPixmapCache.find(pmcachekey) + if pm is None or pm.isNull(): + color = palette.color(QPalette.Text) + src = self.__base.pixmap(size, mode, state) + src = src.toImage().convertToFormat(QImage.Format_ARGB32_Premultiplied) + if luminance(color) > 0.5: + dest = grayscale_invert( + src, + palette.color(QPalette.Text), + palette.color(QPalette.Base), + ) + else: + dest = src + pm = QPixmap.fromImage(dest) + QPixmapCache.insert(pmcachekey, pm) + + self.__style = style = QApplication.style() + if style is not None: + opt = QStyleOption() + opt.palette = palette + pm = style.generatedIconPixmap(mode, pm, opt) + return pm + + def clone(self) -> 'QIconEngine': + return SymbolIconEngine(self.__base) diff --git a/orangecanvas/gui/tests/test_iconengine.py b/orangecanvas/gui/tests/test_iconengine.py new file mode 100644 index 000000000..7ae89cfb7 --- /dev/null +++ b/orangecanvas/gui/tests/test_iconengine.py @@ -0,0 +1,29 @@ +from AnyQt.QtCore import QSize, Qt +from AnyQt.QtGui import QIcon, QPixmap, QColor, QPalette + +from ..iconengine import SymbolIconEngine + +from ..test import QAppTestCase + + +class TestSymbolIconEngine(QAppTestCase): + def test(self): + pm = QPixmap(10, 10) + pm.fill(QColor(0, 0, 0)) + base = QIcon() + base.addPixmap(pm) + engine = SymbolIconEngine(base) + palette = QPalette() + palette.setColor(QPalette.Text, QColor(200, 200, 200)) + palette.setColor(QPalette.Base, QColor(Qt.black)) + with SymbolIconEngine.setOverridePalette(palette): + img = engine.pixmap(QSize(10, 10), QIcon.Active, QIcon.Off).toImage() + pixel = QColor(img.pixel(5, 5)) + self.assertEqual(QColor(pixel).name(), palette.text().color().name()) + + palette.setColor(QPalette.Text, QColor(Qt.black)) + palette.setColor(QPalette.Base, QColor(Qt.white)) + with SymbolIconEngine.setOverridePalette(palette): + img = engine.pixmap(QSize(10, 10), QIcon.Active, QIcon.Off).toImage() + pixel = QColor(img.pixel(5, 5)) + self.assertEqual(QColor(pixel).name(), QColor(0, 0, 0).name()) From edc86d4564e0f7b895a94ef6a42c4ae937253582 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 4 Nov 2022 12:45:15 +0100 Subject: [PATCH 04/15] svgiconengine: Refactor to use common StyledIconEngine as base --- orangecanvas/gui/svgiconengine.py | 82 +++++-------------------------- 1 file changed, 12 insertions(+), 70 deletions(-) diff --git a/orangecanvas/gui/svgiconengine.py b/orangecanvas/gui/svgiconengine.py index 768fa5443..86ecb9b6e 100644 --- a/orangecanvas/gui/svgiconengine.py +++ b/orangecanvas/gui/svgiconengine.py @@ -1,6 +1,4 @@ import io -from contextlib import contextmanager - from typing import IO, Optional from itertools import count @@ -9,10 +7,10 @@ from AnyQt.QtCore import Qt, QSize, QRect, QRectF, QObject from AnyQt.QtGui import ( QIconEngine, QIcon, QPixmap, QPainter, QPixmapCache, QPalette, QColor, - QPaintDevice ) from AnyQt.QtSvg import QSvgRenderer from AnyQt.QtWidgets import QStyleOption, QApplication +from .iconengine import StyledIconEngine from .utils import luminance, merged_color @@ -21,7 +19,7 @@ class SvgIconEngine(QIconEngine): """ - An svg icon engine reimplementation drawing from in-memory svg contents. + A svg icon engine reimplementation drawing from in-memory svg contents. Arguments --------- @@ -100,11 +98,11 @@ def clone(self): _Qt_KeepAspectRatio = Qt.KeepAspectRatio -class StyledSvgIconEngine(QIconEngine): +class StyledSvgIconEngine(StyledIconEngine): """ A basic styled icon engine based on a QPalette colors. - This engine can draw css styled svg icons of specific format so as to + This engine can draw css styled svg icons of specific format to conform to the current color scheme based on effective `QPalette`. (Loosely based on KDE's KIconLoader) @@ -123,8 +121,8 @@ class StyledSvgIconEngine(QIconEngine): `QApplication.palette` is used. """ __slots__ = ( - "__contents", "__styled_contents_cache", "__palette", "__renderer", - "__cache_key", "__style_object", + "__contents", "__styled_contents_cache", "__renderer", + "__cache_key", ) def __init__( @@ -134,7 +132,7 @@ def __init__( palette: Optional[QPalette] = None, styleObject: Optional[QObject] = None, ) -> None: - super().__init__() + super().__init__(palette=palette, styleObject=styleObject) self.__contents = contents self.__styled_contents_cache = {} if palette is not None and styleObject is not None: @@ -144,56 +142,19 @@ def __init__( self.__cache_key = next(_cache_id_gen) self.__style_object = styleObject - @staticmethod - def __paletteFromPaintDevice(dev: QPaintDevice) -> Optional[QPalette]: - if isinstance(dev, QObject): - palette_ = dev.property("palette") - if isinstance(palette_, QPalette): - return palette_ - return None - - @staticmethod - def __paletteFromStyleObject(obj: QObject) -> Optional[QPalette]: - palette = obj.property("palette") - if isinstance(palette, QPalette): - return palette - else: - return None - def paint(self, painter, rect, mode, state): # type: (QPainter, QRect, QIcon.Mode, QIcon.State) -> None if self.__renderer.isValid(): - if self.__paletteOverride is not None: - palette = self.__paletteOverride - elif self.__palette is None: - palette = self.__paletteFromPaintDevice(painter.device()) - if palette is None: - palette = self._palette() - else: - palette = self._palette() + palette = self.effectivePalette() size = rect.size() dpr = painter.device().devicePixelRatioF() size = size * dpr pm = self.__renderStyledPixmap(size, mode, state, palette) painter.drawPixmap(rect, pm) - def _palette(self) -> QPalette: - if self.__paletteOverride is not None: - return self.__paletteOverride - if self.__palette is not None: - return self.__palette - elif self.__style_object is not None: - palette = self.__paletteFromStyleObject(self.__style_object) - if palette is not None: - return palette - - if self.__paletteOverride is not None: - return QPalette(self.__paletteOverride) - return QApplication.palette() - def pixmap(self, size, mode, state): # type: (QSize, QIcon.Mode, QIcon.State) -> QPixmap - return self.__renderStyledPixmap(size, mode, state, self._palette()) + return self.__renderStyledPixmap(size, mode, state, self.effectivePalette()) def __renderStyledPixmap( self, size: QSize, mode: QIcon.Mode, state: QIcon.State, @@ -203,7 +164,7 @@ def __renderStyledPixmap( disabled = mode == _QIcon_Disabled cg = _QPalette_Disabled if disabled else _QPalette_Active role = _QPalette_HighlightedText if active else _QPalette_WindowText - namespace = f"{__name__}:{__class__.__name__}/{self.__cache_key}/" + namespace = f"{__name__}:{__class__.__name__}/{self.__cache_key}" style_key = f"{hex(palette.cacheKey())}-{cg}-{role}" renderer = self.__styled_contents_cache.get(style_key) if renderer is None: @@ -220,7 +181,7 @@ def __renderStyledPixmap( if not dsize.isNull(): dsize.scale(size, _Qt_KeepAspectRatio) size = dsize - pmcachekey = f"{namespace}{style_key}/{size.width()}x{size.height()}" + pmcachekey = f"{namespace}/{style_key}/{size.width()}x{size.height()}" pm = QPixmapCache.find(pmcachekey) if pm is None or pm.isNull(): pm = QPixmap(size) @@ -244,25 +205,6 @@ def clone(self) -> 'QIconEngine': styleObject=self.__style_object ) - __paletteOverride = None - - @classmethod - @contextmanager - def setOverridePalette(cls, palette: QPalette): - """ - Temporarily override used QApplication.palette() with this class. - - This can be used when the icon is drawn on a non default background - and as such might not contrast with it when using the default palette, - and neither paint device nor styleObject can be used for this. - """ - old = StyledSvgIconEngine.__paletteOverride - try: - StyledSvgIconEngine.__paletteOverride = palette - yield - finally: - StyledSvgIconEngine.__paletteOverride = old - #: Like KDE's KIconLoader TEMPLATE = """ @@ -300,7 +242,7 @@ def _hexrgb_solid(color: QColor) -> str: # (in hex or rgba syntax) so we pre-multiply with alpha to get solid # gray scale. if color.alpha() != 255: - contrast = QColor(Qt.black) if luminance(color) else QColor(Qt.white) + contrast = QColor(Qt.black) if luminance(color) > 0.5 else QColor(Qt.white) color = merged_color(color, contrast, color.alphaF()) return color.name(QColor.HexRgb) From dd47f15ca6bc806544a992161a6609eeede0c093 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 4 Nov 2022 13:52:11 +0100 Subject: [PATCH 05/15] resources: Load styled icons --- orangecanvas/resources.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/orangecanvas/resources.py b/orangecanvas/resources.py index e2e9f62eb..622d26198 100644 --- a/orangecanvas/resources.py +++ b/orangecanvas/resources.py @@ -5,12 +5,12 @@ import os import glob import pkgutil - from typing import Tuple, Dict, Optional, List, IO from AnyQt.QtCore import QObject from AnyQt.QtGui import QIcon +from orangecanvas.gui.iconengine import SymbolIconEngine from orangecanvas.gui.svgiconengine import StyledSvgIconEngine @@ -83,6 +83,8 @@ def search_paths_from_description(desc): class resource_loader(object): + package = None + def __init__(self, search_paths=[]): self._search_paths = [] self.add_search_paths(search_paths) @@ -92,7 +94,9 @@ def from_description(cls, desc): """Construct an resource from a Widget or Category description. """ paths = search_paths_from_description(desc) - return icon_loader(search_paths=paths) + loader = icon_loader(search_paths=paths) + loader.package = desc.package + return loader def add_search_paths(self, paths): """Add `paths` to the list of search paths. @@ -196,10 +200,25 @@ def get(self, name, default=None): cache_key = tuple(icons) icon = QIcon() + if cache_key in self._icon_cache: + return QIcon(self._icon_cache[cache_key]) + + if len(icons) == 1 and icons[0].lower().endswith(".svg"): + if self.package is not None: + try: + contents = pkgutil.get_data(self.package, name) + except FileNotFoundError: + pass + else: + if b'current-color-scheme' in contents: + icon = QIcon(StyledSvgIconEngine(contents)) + self._icon_cache[cache_key] = icon + return icon if icons: if cache_key not in self._icon_cache: for path in icons: icon.addFile(path) + icon = QIcon(SymbolIconEngine(icon)) self._icon_cache[cache_key] = icon else: icon = self._icon_cache[cache_key] From 1d126f31bf3b52f801a648d721ede7adad9b84d7 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 24 Jan 2022 15:04:25 +0100 Subject: [PATCH 06/15] discovery: Record category package --- orangecanvas/registry/discovery.py | 4 +++- orangecanvas/registry/utils.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/orangecanvas/registry/discovery.py b/orangecanvas/registry/discovery.py index 01ac34979..b9bc85a83 100644 --- a/orangecanvas/registry/discovery.py +++ b/orangecanvas/registry/discovery.py @@ -66,7 +66,9 @@ def default_category_for_module(module): module = __import__(module, fromlist=[""]) name = default_category_name_for_module(module) qualified_name = module.__name__ - return CategoryDescription(name=name, qualified_name=qualified_name) + return CategoryDescription( + name=name, qualified_name=qualified_name, package=module.__package__ + ) class WidgetDiscovery(object): diff --git a/orangecanvas/registry/utils.py b/orangecanvas/registry/utils.py index d1238277e..247234cfe 100644 --- a/orangecanvas/registry/utils.py +++ b/orangecanvas/registry/utils.py @@ -146,6 +146,7 @@ def category_from_package_globals(package): return CategoryDescription( name=name, qualified_name=qualified_name, + package=qualified_name, description=description, long_description=long_description, help=help, From 966e667ae57301566f42dec877d5af22ea6cc931 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 24 Jan 2022 14:29:17 +0100 Subject: [PATCH 07/15] nodeitem: Override palette for possibly styled icons --- orangecanvas/canvas/items/nodeitem.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/orangecanvas/canvas/items/nodeitem.py b/orangecanvas/canvas/items/nodeitem.py index 0b22fb6d6..b1413938b 100644 --- a/orangecanvas/canvas/items/nodeitem.py +++ b/orangecanvas/canvas/items/nodeitem.py @@ -37,6 +37,8 @@ from .utils import ( saturated, radial_gradient, linspace, qpainterpath_sub_path, clip ) +from ... import styles +from ...gui.iconengine import StyledIconEngine from ...gui.utils import disconnected from ...scheme.node import UserMessage @@ -1152,7 +1154,10 @@ def paint(self, painter, option, widget=None): QPainter.SmoothPixmapTransform, self.__transformationMode == Qt.SmoothTransformation ) - self.__icon.paint(painter, target, Qt.AlignCenter, mode) + palette = self.palette() + # assuming light-ish background color + with StyledIconEngine.setOverridePalette(palette): + self.__icon.paint(painter, target, Qt.AlignCenter, mode) class NodeItem(QGraphicsWidget): @@ -1335,6 +1340,8 @@ def setIcon(self, icon): self.icon_item = GraphicsIconItem( self.shapeItem, icon=icon, iconSize=QSize(36, 36) ) + # assuming light-ish color background + self.icon_item.setPalette(styles.breeze_light()) self.icon_item.setPos(-18, -18) def setColor(self, color, selectedColor=None): From ad194f269b4aac32a76f28741c1efdc349cffe73 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Thu, 27 Jan 2022 11:17:24 +0100 Subject: [PATCH 08/15] widgettoolbox: Record base color in created gradient brush The color is frequently requested from the brush as used in a QPalette. --- orangecanvas/application/widgettoolbox.py | 5 ++--- orangecanvas/gui/utils.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/orangecanvas/application/widgettoolbox.py b/orangecanvas/application/widgettoolbox.py index 5f328fa88..52b9a3d53 100644 --- a/orangecanvas/application/widgettoolbox.py +++ b/orangecanvas/application/widgettoolbox.py @@ -25,7 +25,7 @@ from ..gui.toolbox import ToolBox from ..gui.toolgrid import ToolGrid from ..gui.quickhelp import StatusTipPromoter -from ..gui.utils import create_gradient +from ..gui.utils import create_gradient_brush from ..registry.qt import QtWidgetRegistry @@ -425,8 +425,7 @@ def __insertItem(self, item, index): if isinstance(highlight, QBrush) and highlight.style() != Qt.NoBrush: if not highlight.gradient(): value = highlight.color().value() - gradient = create_gradient(highlight.color()) - highlight = QBrush(gradient) + highlight = create_gradient_brush(highlight.color()) highlight_foreground = Qt.black if value > 128 else Qt.white palette = button.palette() diff --git a/orangecanvas/gui/utils.py b/orangecanvas/gui/utils.py index 1f9ac143f..a59e8095f 100644 --- a/orangecanvas/gui/utils.py +++ b/orangecanvas/gui/utils.py @@ -308,6 +308,17 @@ def create_gradient(base_color: QColor, stop=QPointF(0, 0), return grad +def create_gradient_brush(color: QColor, stop=QPointF(0, 0), + finalStop=QPointF(0, 1)) -> QBrush: + """ + Create a linear gradient brush using `color` as a base. + """ + grad = create_gradient(color, stop, finalStop) + brush = QBrush(grad) + brush.setColor(color) # also record the base color + return brush + + def create_css_gradient(base_color: QColor, stop=QPointF(0, 0), finalStop=QPointF(0, 1)) -> str: """ From ecd91e1dfe83858b332541064c27ac98a049a72d Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 28 Jan 2022 09:56:05 +0100 Subject: [PATCH 09/15] quickmenu: Override effective palette for possibly styled icons --- orangecanvas/document/quickmenu.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/orangecanvas/document/quickmenu.py b/orangecanvas/document/quickmenu.py index 2d5d1a85f..7d4ed22b9 100644 --- a/orangecanvas/document/quickmenu.py +++ b/orangecanvas/document/quickmenu.py @@ -35,8 +35,10 @@ from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from .usagestatistics import UsageStatistics +from .. import styles from ..gui.framelesswindow import FramelessWindow from ..gui.lineedit import LineEdit +from ..gui.iconengine import StyledIconEngine from ..gui.tooltree import ToolTree, FlattenedTreeItemModel from ..gui.utils import StyledWidget_paintEvent, innerGlowBackgroundPixmap, innerShadowPixmap from ..registry.qt import QtWidgetRegistry @@ -90,7 +92,8 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn decRect = QRectF(decTl, decBr) # draw icon pixmap - dec.paint(painter, decRect.toAlignedRect()) + with StyledIconEngine.setOverridePalette(styles.breeze_light()): + dec.paint(painter, decRect.toAlignedRect()) # draw display rect = QRect(opt.rect) @@ -697,15 +700,16 @@ def paintEvent(self, event): self.initStyleOption(opt) if self.__showMenuIndicator and self.isChecked(): opt.features |= QStyleOptionToolButton.HasMenu - if self.__flat: - # Use default widget background/border styling. - StyledWidget_paintEvent(self, event) + with StyledIconEngine.setOverridePalette(styles.breeze_light()): + if self.__flat: + # Use default widget background/border styling. + StyledWidget_paintEvent(self, event) - p = QStylePainter(self) - p.drawControl(QStyle.CE_ToolButtonLabel, opt) - else: - p = QStylePainter(self) - p.drawComplexControl(QStyle.CC_ToolButton, opt) + p = QStylePainter(self) + p.drawControl(QStyle.CE_ToolButtonLabel, opt) + else: + p = QStylePainter(self) + p.drawComplexControl(QStyle.CC_ToolButton, opt) # if checked, no shadow if self.isChecked(): From 7df36d3fcf101014515ca99ffc726099987c907e Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 21 Mar 2022 12:47:10 +0100 Subject: [PATCH 10/15] toolbox: Override palette when drawing on custom background --- orangecanvas/gui/toolbox.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/orangecanvas/gui/toolbox.py b/orangecanvas/gui/toolbox.py index c9517080f..dfd242dbe 100644 --- a/orangecanvas/gui/toolbox.py +++ b/orangecanvas/gui/toolbox.py @@ -24,6 +24,8 @@ Qt, QObject, QSize, QRect, QPoint, QSignalMapper ) from AnyQt.QtCore import Signal, Property +from .iconengine import StyledIconEngine +from .. import styles from ..utils import set_flag from .utils import brush_darker, ScrollBar @@ -218,7 +220,8 @@ def __paintEventNoStyle(self): icon_area_rect = icon_area_rect icon_rect = QRect(QPoint(0, 0), opt.iconSize) icon_rect.moveCenter(icon_area_rect.center()) - opt.icon.paint(p, icon_rect, Qt.AlignCenter, mode, state) + with StyledIconEngine.setOverridePalette(styles.breeze_light()): + opt.icon.paint(p, icon_rect, Qt.AlignCenter, mode, state) p.restore() From e4d0d5d1b9ac6d4803b673473678f6f301d12d66 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 13 Apr 2022 12:41:36 +0200 Subject: [PATCH 11/15] canvastooldock: Override palette when drawing on custom background --- orangecanvas/application/canvastooldock.py | 28 +++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/orangecanvas/application/canvastooldock.py b/orangecanvas/application/canvastooldock.py index 0474043b5..46743dda0 100644 --- a/orangecanvas/application/canvastooldock.py +++ b/orangecanvas/application/canvastooldock.py @@ -8,9 +8,11 @@ from AnyQt.QtWidgets import ( QWidget, QSplitter, QVBoxLayout, QAction, QSizePolicy, QApplication, - QToolButton, QTreeView) -from AnyQt.QtGui import QPalette, QBrush, QDrag, QResizeEvent, QHideEvent - + QToolButton, QTreeView +) +from AnyQt.QtGui import ( + QPalette, QBrush, QDrag, QResizeEvent, QHideEvent, QPaintEvent +) from AnyQt.QtCore import ( Qt, QSize, QObject, QPropertyAnimation, QEvent, QRect, QPoint, QAbstractItemModel, QModelIndex, QPersistentModelIndex, QEventLoop, @@ -18,7 +20,9 @@ ) from AnyQt.QtCore import pyqtProperty as Property, pyqtSignal as Signal -from ..gui.toolgrid import ToolGrid +from .. import styles +from ..gui.iconengine import StyledIconEngine +from ..gui.toolgrid import ToolGrid, ToolGridButton from ..gui.toolbar import DynamicResizeToolBar from ..gui.quickhelp import QuickHelp from ..gui.framelesswindow import FramelessWindow @@ -299,6 +303,12 @@ def toogleQuickHelpAction(self): return self.toggleQuickHelpAction() +class _ToolGridButton(ToolGridButton): + def paintEvent(self, event: QPaintEvent) -> None: + with StyledIconEngine.setOverridePalette(styles.breeze_light()): + super().paintEvent(event) + + class QuickCategoryToolbar(ToolGrid): """A toolbar with category buttons.""" def __init__(self, parent=None, buttonSize=QSize(), iconSize=QSize(), @@ -355,8 +365,14 @@ def createButtonForAction(self, action): """ Create a button for the action. """ - button = super().createButtonForAction(action) - + button = _ToolGridButton( + self, + toolButtonStyle=self.toolButtonStyle(), + iconSize=self.iconSize(), + ) + button.setDefaultAction(action) + if self.buttonSize().isValid(): + button.setFixedSize(self.buttonSize()) item = action.data() # QPersistentModelIndex assert isinstance(item, QPersistentModelIndex) From ed8c6f31e68def0603fbf4d9f746061b43d6f724 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 30 Sep 2022 12:46:59 +0200 Subject: [PATCH 12/15] application: Increase default QPixmapCache size --- orangecanvas/application/application.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/orangecanvas/application/application.py b/orangecanvas/application/application.py index 25254ee10..6073d5017 100644 --- a/orangecanvas/application/application.py +++ b/orangecanvas/application/application.py @@ -9,6 +9,7 @@ import AnyQt from AnyQt.QtWidgets import QApplication +from AnyQt.QtGui import QPixmapCache from AnyQt.QtCore import ( Qt, QUrl, QEvent, QSettings, QLibraryInfo, pyqtSignal as Signal, QT_VERSION_INFO @@ -231,6 +232,7 @@ def configureStyle(): if theme and theme in styles.colorthemes: palette = styles.colorthemes[theme]() QApplication.setPalette(palette) + QPixmapCache.setCacheLimit(64 * (2 ** 10)) __restart_command: Optional[List[str]] = None From 10d9917147db02cc78e05e76d8f2fc375a12c71e Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 4 Nov 2022 13:22:41 +0100 Subject: [PATCH 13/15] styles: Create the named palettes only once --- orangecanvas/styles/__init__.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/orangecanvas/styles/__init__.py b/orangecanvas/styles/__init__.py index 0cf5ee663..a36629374 100644 --- a/orangecanvas/styles/__init__.py +++ b/orangecanvas/styles/__init__.py @@ -2,12 +2,15 @@ """ import os import re -from typing import Mapping, Callable, Tuple, List +from functools import wraps +from typing import Mapping, Callable, Tuple, List, TypeVar import pkg_resources from AnyQt.QtCore import Qt from AnyQt.QtGui import QPalette, QColor +_T = TypeVar("_T") + def _make_palette( base: QColor, text: QColor, window: QColor, @@ -42,6 +45,19 @@ def _make_palette( return palette +def _once(f: _T) -> _T: + palette = None + + @wraps(f) + def wrapper(): + nonlocal palette + if palette is None: + palette = f() + return QPalette(palette) + return wrapper + + +@_once def breeze_light() -> QPalette: # 'Breeze-Light' color scheme from KDE. return _make_palette( @@ -59,6 +75,7 @@ def breeze_light() -> QPalette: ) +@_once def breeze_dark() -> QPalette: # 'Breeze Dark' color scheme from KDE. return _make_palette( @@ -76,6 +93,7 @@ def breeze_dark() -> QPalette: ) +@_once def zion_reversed() -> QPalette: # 'Zion Reversed' color scheme from KDE. window = QColor(16, 16, 16) @@ -94,6 +112,7 @@ def zion_reversed() -> QPalette: ) +@_once def dark(): window = QColor(0x30, 0x30, 0x30) return _make_palette( From 85f94554ed813869ef21f600a7960d7c16092433 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 5 May 2023 13:17:18 +0200 Subject: [PATCH 14/15] svgiconengine: Mixin correct background color --- orangecanvas/gui/svgiconengine.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/orangecanvas/gui/svgiconengine.py b/orangecanvas/gui/svgiconengine.py index 86ecb9b6e..b63f72254 100644 --- a/orangecanvas/gui/svgiconengine.py +++ b/orangecanvas/gui/svgiconengine.py @@ -232,7 +232,7 @@ def clone(self) -> 'QIconEngine': """ -def _hexrgb_solid(color: QColor) -> str: +def _hexrgb_solid(color: QColor, contrast: QColor = None) -> str: """ Return a #RRGGBB color string from color. If color has alpha component multipy the color components with alpha to get a solid color. @@ -242,7 +242,8 @@ def _hexrgb_solid(color: QColor) -> str: # (in hex or rgba syntax) so we pre-multiply with alpha to get solid # gray scale. if color.alpha() != 255: - contrast = QColor(Qt.black) if luminance(color) > 0.5 else QColor(Qt.white) + if contrast is None: + contrast = QColor(Qt.black) if luminance(color) > 0.5 else QColor(Qt.white) color = merged_color(color, contrast, color.alphaF()) return color.name(QColor.HexRgb) @@ -256,10 +257,11 @@ def render_svg_color_scheme_css(palette: QPalette, state: QIcon.State) -> str: complement = QColor(Qt.white) if lum > 0.5 else QColor(Qt.black) contrast = QColor(Qt.black) if lum > 0.5 else QColor(Qt.white) return TEMPLATE.format( - text=_hexrgb_solid(palette.color(text)), + text=_hexrgb_solid(palette.color(text), palette.color(background)), background=_hexrgb_solid(palette.color(background)), highlight=_hexrgb_solid(palette.color(hligh)), - disabled_text=_hexrgb_solid(palette.color(QPalette.Disabled, text)), + disabled_text=_hexrgb_solid(palette.color(QPalette.Disabled, text), + palette.color(QPalette.Disabled, background)), contrast=_hexrgb_solid(contrast), complement=_hexrgb_solid(complement), ) From 43b52f252e29c2c455af468b7fdaf751d3f573cb Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 2 Jun 2023 11:37:07 +0200 Subject: [PATCH 15/15] iconengine: Avoid double scaling with global scaling factor in SymbolIconEngine --- orangecanvas/gui/iconengine.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/orangecanvas/gui/iconengine.py b/orangecanvas/gui/iconengine.py index 4ba4c1848..26c294ff5 100644 --- a/orangecanvas/gui/iconengine.py +++ b/orangecanvas/gui/iconengine.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from typing import Optional -from AnyQt.QtCore import QObject, QSize, QRect +from AnyQt.QtCore import Qt, QObject, QSize, QRect, QT_VERSION_INFO from AnyQt.QtGui import ( QIconEngine, QPalette, QIcon, QPixmap, QPixmapCache, QImage, QPainter ) @@ -130,7 +130,7 @@ def __renderStyledPixmap( pm = QPixmapCache.find(pmcachekey) if pm is None or pm.isNull(): color = palette.color(QPalette.Text) - src = self.__base.pixmap(size, mode, state) + src = qicon_pixmap(self.__base, size, 1.0, mode, state) src = src.toImage().convertToFormat(QImage.Format_ARGB32_Premultiplied) if luminance(color) > 0.5: dest = grayscale_invert( @@ -152,3 +152,27 @@ def __renderStyledPixmap( def clone(self) -> 'QIconEngine': return SymbolIconEngine(self.__base) + + +def qicon_pixmap( + base: QIcon, size: QSize, scale: float, mode: QIcon.Mode, + state: QIcon.State +) -> QPixmap: + """ + Like QIcon.pixmap(size: QSize, scale: float, ...) overload in Qt6. + + On Qt 6 this directly calls the corresponding overload. + On Qt 5 this is emulated by painting on a suitable constructed pixmap. + """ + size = base.actualSize(size * scale, mode, state) + pm = QPixmap(size) + pm.setDevicePixelRatio(scale) + pm.fill(Qt.transparent) + p = QPainter(pm) + base.paint(p, 0, 0, size.width(), size.height(), Qt.AlignCenter, mode, state) + p.end() + return pm + + +if QT_VERSION_INFO >= (6, 0): + qicon_pixmap = QIcon.pixmap