Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/app_model/_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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."""
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 @@ -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)
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 @@ -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`."""
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 @@ -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
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(
default=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 _validate(cls, v: Any) -> "Icon":
Expand All @@ -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
Expand All @@ -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


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