Skip to content

feat: support icon color #132

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
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]