Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a2cea89
feat: icon color stuff
tlambert03 Oct 27, 2023
2d1bd89
docs
tlambert03 Oct 27, 2023
6161c17
Merge branch 'main' into icon-color-iconify
tlambert03 Oct 27, 2023
0717900
remove passs
tlambert03 Oct 27, 2023
bf80d58
more docs
tlambert03 Oct 28, 2023
4937063
Merge branch 'main' into icon-color-iconify
tlambert03 Oct 28, 2023
eb7288e
coverage
tlambert03 Oct 28, 2023
0cc07a6
move import
tlambert03 Oct 28, 2023
f64f908
remove color stuff
tlambert03 Oct 31, 2023
f684a35
Merge branch 'main' into icon-color
tlambert03 Oct 31, 2023
e358722
style: [pre-commit.ci] auto fixes [...]
pre-commit-ci[bot] Oct 31, 2023
99e529d
recover changes
tlambert03 Oct 31, 2023
d1cd7b6
Merge branch 'icon-color' of https://github.com/tlambert03/app-model …
tlambert03 Oct 31, 2023
6f9aece
style: [pre-commit.ci] auto fixes [...]
pre-commit-ci[bot] Oct 31, 2023
f8620e6
Merge branch 'main' of github.com:pyapp-kit/app-model into icon-color
brisvag Apr 3, 2026
17b44e0
style: [pre-commit.ci] auto fixes [...]
pre-commit-ci[bot] Apr 3, 2026
1889dc1
add event connection for app and qpalette
brisvag Apr 20, 2026
ad09219
style: [pre-commit.ci] auto fixes [...]
pre-commit-ci[bot] Apr 20, 2026
5c58673
Merge branch 'main' into icon-color
tlambert03 Apr 20, 2026
c8a57d9
add qpalette for testing
brisvag Apr 21, 2026
c0bfa48
use event filter
brisvag Apr 22, 2026
4ea87b1
add other theme switching button for testing
brisvag Apr 22, 2026
e559ef2
style: [pre-commit.ci] auto fixes [...]
pre-commit-ci[bot] Apr 22, 2026
dd13cc5
Merge branch 'main' into icon-color
tlambert03 May 15, 2026
39d2e1c
Merge branch 'main' into icon-color
tlambert03 May 18, 2026
f7486dd
cleaner example
brisvag May 19, 2026
59f88f2
move filter to global
brisvag May 19, 2026
39b27d9
happy pyright
brisvag May 19, 2026
d925906
add test for qaction
brisvag May 20, 2026
de2be06
singleton event filter
brisvag May 21, 2026
1744f13
remove print
brisvag May 21, 2026
a6894b9
update tests for icon
brisvag May 26, 2026
c3e7bce
check theme mode fails
brisvag May 26, 2026
e8fa0d9
add checks to minimize impact
brisvag May 26, 2026
3103863
only fire on qapp obj
brisvag May 26, 2026
6714a9e
oops
brisvag May 26, 2026
0cd8d04
allow setting default colors
brisvag May 28, 2026
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
63 changes: 62 additions & 1 deletion demo/model_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import TYPE_CHECKING, cast

from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream
from qtpy.QtGui import QColor, QPalette
from qtpy.QtWidgets import QApplication, QFileDialog, QMessageBox, QTextEdit

from app_model import Application, types
Expand Down Expand Up @@ -149,6 +150,35 @@ def paste(self) -> None:
def close(self) -> bool:
return super().close()

def switch_palette(self) -> None:
if getattr(self, "_old_palette", None):
new_palette, self._old_palette = self._old_palette, QApplication.palette()
QApplication.setPalette(new_palette)
return

# make the dark palette first time
self._old_palette = QApplication.palette()

palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.Base, QColor(35, 35, 35))

palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white)

palette.setColor(QPalette.ColorRole.Highlight, QColor(80, 80, 80))
palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.white)
QApplication.setPalette(palette)

def switch_theme_mode(self) -> None:
modes = (None, "dark", "light")
current = modes.index(self._app.theme_mode)
next_theme = modes[(current + 1) % 3]
self._app.theme_mode = next_theme
if sb := self.statusBar():
sb.showMessage(f"Current app theme: {next_theme}")


# Actions defined declaratively outside of QMainWindow class ...
# menus and toolbars will be made and added automatically
Expand Down Expand Up @@ -213,7 +243,11 @@ class CommandId:
),
types.Action(
id="cut",
icon="fa6-solid:scissors",
icon={
"light": "fa6-solid:scissors",
"color_dark": "#ff0000",
"color_light": "#0000ff",
},
title="Cut",
keybindings=[types.StandardKeyBinding.Cut],
enablement="copyAvailable",
Expand Down Expand Up @@ -248,6 +282,33 @@ class CommandId:
menus=[{"id": MenuId.HELP}],
callback=MainWindow.about,
),
types.Action(
id="switch_palette",
icon="fa6-solid:palette",
title="Switch dark/light theme",
status_tip=(
"Switch between dark and light Qt Palette. This affects "
"icons, unless a theme has been explicitly set on the application level."
),
menus=[{"id": MenuId.HELP}],
callback=MainWindow.switch_palette,
),
types.Action(
id="switch_theme_mode",
icon={
"dark": "fa6-solid:sun",
"light": "fa6-solid:moon",
"color_dark": "#ff0000",
"color_light": "#0000ff",
},
title="Rotate between dark, light, and unset theme.",
status_tip=(
"Rotate between dark, light, and unset theme. This affects "
"theme icons and has precedence over the theming based on the QPalette."
),
menus=[{"id": MenuId.HELP}],
callback=MainWindow.switch_theme_mode,
),
Comment thread
brisvag marked this conversation as resolved.
]


Expand Down
27 changes: 27 additions & 0 deletions src/app_model/_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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 All @@ -83,6 +88,7 @@ class Application:
"""

destroyed = Signal(str)
theme_mode_changed = Signal(str)
_instances: ClassVar[dict[str, Application]] = {}

def __init__(
Expand Down Expand Up @@ -126,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 @@ -166,6 +173,26 @@ 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}"
)
if value != self._theme_mode:
self._theme_mode = value
self.theme_mode_changed(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
12 changes: 9 additions & 3 deletions src/app_model/backends/qt/_qaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,16 @@ def __init__(
self.setText(command_rule.short_title) # pragma: no cover
else:
self.setText(command_rule.title)
if command_rule.icon:
self.setIcon(to_qicon(command_rule.icon))
self.setIconVisibleInMenu(command_rule.icon_visible_in_menu)
self._update_icon()
self.setIconVisibleInMenu(self._cmd_rule.icon_visible_in_menu)
if command_rule.status_tip:
self.setStatusTip(command_rule.status_tip)
if command_rule.toggled is not None:
self.setCheckable(True)
self._refresh()
tooltip_with_keybinding = f"{self._tooltip} {self._keybinding_tooltip}".rstrip()
self.setToolTip(tooltip_with_keybinding)
self._app.theme_mode_changed.connect(self._update_icon)

def setText(self, text: str | None) -> None:
super().setText(text)
Expand All @@ -120,6 +120,12 @@ def _update_keybinding(self) -> None:
tooltip_with_keybinding = f"{self._tooltip} {self._keybinding_tooltip}".rstrip()
self.setToolTip(tooltip_with_keybinding)

def _update_icon(self) -> None:
if self._cmd_rule.icon:
self.setIcon(
to_qicon(self._cmd_rule.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`."""
self.setEnabled(expr.eval(ctx) if (expr := self._cmd_rule.enablement) else True)
Expand Down
17 changes: 15 additions & 2 deletions src/app_model/backends/qt/_qmainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from typing import TYPE_CHECKING

from qtpy.QtCore import Qt
from qtpy.QtWidgets import QMainWindow, QWidget
from qtpy.QtCore import QEvent, Qt
from qtpy.QtWidgets import QApplication, QMainWindow, QWidget

from app_model import Application

Expand All @@ -12,13 +12,17 @@
if TYPE_CHECKING:
from collections.abc import Collection, Mapping, Sequence

from qtpy.QtCore import QObject


class QModelMainWindow(QMainWindow):
"""QMainWindow with app-model support."""

def __init__(self, app: Application | str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._app = Application.get_or_create(app) if isinstance(app, str) else app
if qapp := QApplication.instance():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually... are you guys even using QModelMainWindow? It's not a mandatory feature for using app-model. So maybe it's not the best place to install the event filter? We could install it at the same place you did before, but just have a module-level variable ensuring it only ever gets installed once (could even potentially store the even filter object on the app itself and check for it's presence?). This spot here requires the user to be using the highest level feature (the main window) in order to benefit from a pretty low level feature (icons in menus/toolbars)

qapp.installEventFilter(self)

def setModelMenuBar(
self, menu_ids: Mapping[str, str] | Sequence[str | tuple[str, str]]
Expand Down Expand Up @@ -50,3 +54,12 @@ def addModelToolBar(
else:
self.addToolBar(toolbar)
return toolbar

def eventFilter(self, a0: QObject | None, a1: QEvent | None) -> bool:
if a1 is not None and a1.type() in (
QEvent.Type.ApplicationPaletteChange,
QEvent.Type.PaletteChange,
QEvent.Type.StyleChange,
):
self._app.theme_mode_changed(self._app.theme_mode)
return False
10 changes: 8 additions & 2 deletions src/app_model/backends/qt/_qmenu.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,14 @@ def __init__(
super().__init__(
menu_id=submenu.submenu, app=app, title=submenu.title, parent=parent
)
if submenu.icon:
self.setIcon(to_qicon(submenu.icon))
self._update_icon()
self._app.theme_mode_changed.connect(self._update_icon)

def _update_icon(self) -> None:
if self._submenu.icon:
self.setIcon(
Comment thread
brisvag marked this conversation as resolved.
Outdated
to_qicon(self._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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just pointing out (for myself). This is an observable change for all users, even if they aren't touching the theme stuff. The old default icon color was black, and will now be gray. Probably not a big deal, but could perhaps be mentioned in the main PR comment for visibility



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: str | None = 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: str | None = 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: str | None = 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: str | None
light: str | None
color_dark: str | None
color_light: str | None
Comment on lines +76 to +77

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these may have been the names I used when I took a first stab at implementing this... but I'm realizing now it's a footgun. Do you think that color_dark sounds like "the color of the icon when it should be dark" (e.g. for the light theme) ... rather than "the color of the icon when the theme is dark". If so, maybe let's make it dark_theme_color and light_theme_color? (more verbose, but less confusing)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the same issue present with dark and light? I would favour consistency here... I guess we could do dark_theme and dark_theme_color, but that breaks backward compat :P



IconOrDict: TypeAlias = Icon | IconDict
Loading
Loading