Skip to content
Open
35 changes: 34 additions & 1 deletion src/app_model/_app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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."""
Expand Down
4 changes: 3 additions & 1 deletion src/app_model/backends/qt/_qaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion src/app_model/backends/qt/_qmenu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 48 additions & 4 deletions src/app_model/backends/qt/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions src/app_model/types/_icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand All @@ -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]:
Expand All @@ -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
Expand All @@ -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


Expand All @@ -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]