diff --git a/src/app_model/_app.py b/src/app_model/_app.py index ded4504..cf12e63 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -68,6 +68,11 @@ class Application: (Optionally) provide a context to use for this application. If a `MutableMapping` is provided, it will be used to create a `Context` instance. If `None` (the default), a new `Context` instance will be created. + 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. Attributes ---------- @@ -127,6 +132,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" @@ -167,6 +173,24 @@ def context(self) -> Context: """Return the [`Context`][app_model.expressions.Context] for this application.""" # noqa E501 return self._context + @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 fbec462..1206926 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -101,7 +101,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.status_tip: self.setStatusTip(command_rule.status_tip) diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index bfa8a64..f9734f3 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -139,7 +139,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]) -> None: """Update the enabled state of this menu item from `ctx`.""" diff --git a/src/app_model/backends/qt/_util.py b/src/app_model/backends/qt/_util.py index 45348e2..6e99a09 100644 --- a/src/app_model/backends/qt/_util.py +++ b/src/app_model/backends/qt/_util.py @@ -3,23 +3,67 @@ from typing import TYPE_CHECKING from qtpy.QtCore import QUrl -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 icn.startswith("file://"): return QIcon(QUrl(icn).toLocalFile()) elif ":" 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 a076488..d15cbdb 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( default=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 _validate(cls, v: Any) -> "Icon": @@ -37,6 +47,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 @@ -45,6 +60,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 @@ -53,6 +73,8 @@ class IconDict(TypedDict): dark: Optional[str] light: Optional[str] + color_dark: Optional[str] + color_light: Optional[str] IconOrDict = Union[Icon, IconDict]