diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 1be2bb7..6f27b22 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -1,7 +1,16 @@ from __future__ import annotations import contextlib -from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, List, Optional, Tuple, Type +from typing import ( + TYPE_CHECKING, + ClassVar, + Dict, + Iterable, + List, + Optional, + Tuple, + Type, +) import in_n_out as ino from psygnal import Signal @@ -52,6 +61,11 @@ class Application: The KeyBindings Registry for this application. - injection_store : in_n_out.Store The Injection Store for this application. + - theme_mode : Literal["dark", "light"] | None + Theme mode to use when picking the color of icons. Must be one of "dark", + "light", or None. When `Application.theme_mode` is "dark", icons will be + generated using their "color_dark" color (which should be a light color), + and vice versa. If not provided, backends may guess the current theme mode. """ destroyed = Signal(str) @@ -82,6 +96,7 @@ def __init__( ) self._menus = menus_reg_class() self._keybindings = keybindings_reg_class() + self._theme_mode: Literal[dark, light] | None = None self.injection_store.on_unannotated_required_args = "ignore" @@ -116,6 +131,24 @@ def injection_store(self) -> ino.Store: """Return the `in_n_out.Store` instance associated with this `Application`.""" return self._injection_store + @property + def theme_mode(self) -> Literal[dark, light] | None: + """Return the theme mode for this `Application`.""" + return self._theme_mode + + @theme_mode.setter + def theme_mode(self, value: Literal[dark, light] | None) -> None: + """Set the theme mode for this `Application`. + + Must be one of "dark", "light", or None. + If not provided, backends may guess at the current theme. + """ + if value not in (None, "dark", "light"): + raise ValueError( + f"theme_mode must be one of 'dark', 'light', or None, not {value!r}" + ) + self._theme_mode = value + @classmethod def get_or_create(cls, name: str) -> Application: """Get app named `name` or create and return a new one if it doesn't exist.""" diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index ef293c1..fe8862f 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -91,7 +91,9 @@ def __init__( else: self.setText(command_rule.title) if command_rule.icon: - self.setIcon(to_qicon(command_rule.icon)) + self.setIcon( + to_qicon(command_rule.icon, theme=self._app.theme_mode, parent=self) + ) self.setIconVisibleInMenu(command_rule.icon_visible_in_menu) if command_rule.tooltip: self.setToolTip(command_rule.tooltip) diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index c40a586..569bd8a 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -149,7 +149,9 @@ def __init__( menu_id=submenu.submenu, app=app, title=submenu.title, parent=parent ) if submenu.icon: - self.setIcon(to_qicon(submenu.icon)) + self.setIcon( + to_qicon(submenu.icon, theme=self._app.theme_mode, parent=self) + ) def update_from_context( self, ctx: Mapping[str, object], _recurse: bool = True diff --git a/src/app_model/backends/qt/_util.py b/src/app_model/backends/qt/_util.py index 1dda83e..59e7fcb 100644 --- a/src/app_model/backends/qt/_util.py +++ b/src/app_model/backends/qt/_util.py @@ -2,21 +2,65 @@ from typing import TYPE_CHECKING -from qtpy.QtGui import QIcon +from qtpy.QtGui import QIcon, QPalette +from qtpy.QtWidgets import QApplication if TYPE_CHECKING: from typing import Literal + from qtpy.QtCore import QObject + from app_model.types import Icon -def to_qicon(icon: Icon, theme: Literal["dark", "light"] = "dark") -> QIcon: +def luma(r: float, g: float, b: float) -> float: + """Calculate the relative luminance of a color.""" + r = r / 12.92 if r <= 0.03928 else ((r + 0.055) / 1.055) ** 2.4 + g = g / 12.92 if g <= 0.03928 else ((g + 0.055) / 1.055) ** 2.4 + b = b / 12.92 if b <= 0.03928 else ((b + 0.055) / 1.055) ** 2.4 + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def background_luma(qobj: QObject | None = None) -> float: + """Return background luminance of the first top level widget or QApp.""" + # using hasattr here because it will only work with a QWidget, but some of the + # things calling this function could conceivably only be a QObject + if hasattr(qobj, "palette"): + palette: QPalette = qobj.palette() # type: ignore + elif wdgts := QApplication.topLevelWidgets(): + palette = wdgts[0].palette() + else: # pragma: no cover + palette = QApplication.palette() + window_bgrd = palette.color(QPalette.ColorRole.Window) + return luma(window_bgrd.redF(), window_bgrd.greenF(), window_bgrd.blueF()) + + +LIGHT_COLOR = "#BCB4B4" +DARK_COLOR = "#6B6565" + + +def to_qicon( + icon: Icon, + theme: Literal["dark", "light", None] = None, + color: str | None = None, + parent: QObject | None = None, +) -> QIcon: """Create QIcon from Icon.""" from superqt import QIconifyIcon, fonticon + if theme is None: + theme = "dark" if background_luma(parent) < 0.5 else "light" + if color is None: + # use DARK_COLOR icon for light themes and vice versa + color = ( + (icon.color_dark or LIGHT_COLOR) + if theme == "dark" + else (icon.color_light or DARK_COLOR) + ) + if icn := getattr(icon, theme, ""): if ":" in icn: - return QIconifyIcon(icn) + return QIconifyIcon(icn, color=color) else: - return fonticon.icon(icn) + return fonticon.icon(icn, color=color) return QIcon() # pragma: no cover diff --git a/src/app_model/types/_icon.py b/src/app_model/types/_icon.py index 48ccd46..7048546 100644 --- a/src/app_model/types/_icon.py +++ b/src/app_model/types/_icon.py @@ -20,6 +20,11 @@ class Icon(_BaseModel): "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) + color_dark: Optional[str] = Field( + None, # use light icon for dark themes + description="(Light) icon color to use for themes with dark backgrounds. " + "If not provided, a default is used.", + ) light: Optional[str] = Field( None, description="Icon path when a light theme is used. These may be " @@ -28,6 +33,11 @@ class Icon(_BaseModel): "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) + color_light: Optional[str] = Field( + None, # use dark icon for light themes + description="(Dark) icon color to use for themes with light backgrounds. " + "If not provided, a default is used", + ) @classmethod def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: @@ -41,6 +51,11 @@ def _validate(cls, v: Any) -> "Icon": return v if isinstance(v, str): v = {"dark": v, "light": v} + if isinstance(v, dict): + if "dark" in v: + v.setdefault("light", v["dark"]) + elif "light" in v: + v.setdefault("dark", v["light"]) return cls(**v) # for v2 @@ -49,6 +64,11 @@ def _validate(cls, v: Any) -> "Icon": def _model_val(cls, v: dict) -> dict: if isinstance(v, str): v = {"dark": v, "light": v} + if isinstance(v, dict): + if "dark" in v: + v.setdefault("light", v["dark"]) + elif "light" in v: + v.setdefault("dark", v["light"]) return v @@ -57,6 +77,8 @@ class IconDict(TypedDict): dark: Optional[str] light: Optional[str] + color_dark: Optional[str] + color_light: Optional[str] IconOrDict = Union[Icon, IconDict]