From a2cea89ddbd8a98262d0977d037faf866d2eb3f0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 26 Oct 2023 20:17:08 -0400 Subject: [PATCH 01/10] feat: icon color stuff --- demo/model_app.py | 13 +++---- demo/multi_file/actions.py | 16 ++++---- pyproject.toml | 2 +- src/app_model/_app.py | 34 ++++++++++++++++- src/app_model/backends/qt/_qaction.py | 9 +++-- src/app_model/backends/qt/_qmenu.py | 4 +- src/app_model/backends/qt/_util.py | 53 +++++++++++++++++++++++++-- src/app_model/types/_icon.py | 37 +++++++++++++++++-- tests/conftest.py | 6 +-- 9 files changed, 141 insertions(+), 33 deletions(-) diff --git a/demo/model_app.py b/demo/model_app.py index d3ff204..0363ed3 100644 --- a/demo/model_app.py +++ b/demo/model_app.py @@ -1,6 +1,5 @@ from typing import List -from fonticon_fa6 import FA6S from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream from qtpy.QtWidgets import QApplication, QFileDialog, QMessageBox, QTextEdit @@ -160,7 +159,7 @@ class CommandId: ACTIONS: List[types.Action] = [ types.Action( id="new_file", - icon=FA6S.file_circle_plus, + icon="fa6-solid:file-circle-plus", title="New", keybindings=[types.StandardKeyBinding.New], status_tip="Create a new file", @@ -169,7 +168,7 @@ class CommandId: ), types.Action( id="open_file", - icon=FA6S.folder_open, + icon="fa6-solid:folder-open", title="Open...", keybindings=[types.StandardKeyBinding.Open], status_tip="Open an existing file", @@ -178,7 +177,7 @@ class CommandId: ), types.Action( id="save_file", - icon=FA6S.floppy_disk, + icon="fa6-solid:floppy-disk", title="Save", keybindings=[types.StandardKeyBinding.Save], status_tip="Save the document to disk", @@ -203,7 +202,7 @@ class CommandId: ), types.Action( id="cut", - icon=FA6S.scissors, + icon={"light": "fa6-solid:scissors", "color_light": "red"}, # with color title="Cut", keybindings=[types.StandardKeyBinding.Cut], enablement="copyAvailable", @@ -213,7 +212,7 @@ class CommandId: ), types.Action( id="copy", - icon=FA6S.copy, + icon="fa6-solid:copy", title="Copy", keybindings=[types.StandardKeyBinding.Copy], enablement="copyAvailable", @@ -223,7 +222,7 @@ class CommandId: ), types.Action( id="paste", - icon=FA6S.paste, + icon="fa6-solid:paste", title="Paste", keybindings=[types.StandardKeyBinding.Paste], status_tip="Paste the clipboard's contents into the current selection", diff --git a/demo/multi_file/actions.py b/demo/multi_file/actions.py index 9dd2197..397fa3d 100644 --- a/demo/multi_file/actions.py +++ b/demo/multi_file/actions.py @@ -1,7 +1,5 @@ from typing import List -from fonticon_fa6 import FA6S - from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod, MenuRule from . import functions @@ -11,7 +9,7 @@ Action( id=CommandId.OPEN, title="Open", - icon=FA6S.folder_open, + icon="fa6-solid:folder-open", callback=functions.open_file, menus=[MenuRule(id=MenuId.FILE)], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO)], @@ -19,7 +17,7 @@ Action( id=CommandId.CLOSE, title="Close", - icon=FA6S.window_close, + icon="fa6-solid:window-close", callback=functions.close, menus=[MenuRule(id=MenuId.FILE)], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], @@ -27,7 +25,7 @@ Action( id=CommandId.UNDO, title="Undo", - icon=FA6S.undo, + icon="fa6-solid:undo", callback=functions.undo, menus=[MenuRule(id=MenuId.EDIT, group="1_undo_redo")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyZ)], @@ -35,7 +33,7 @@ Action( id=CommandId.REDO, title="Redo", - icon=FA6S.rotate_right, + icon="fa6-solid:rotate-right", callback=functions.redo, menus=[MenuRule(id=MenuId.EDIT, group="1_undo_redo")], keybindings=[ @@ -45,7 +43,7 @@ Action( id=CommandId.CUT, title="Cut", - icon=FA6S.cut, + icon="fa6-solid:cut", callback=functions.cut, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyX)], @@ -53,7 +51,7 @@ Action( id=CommandId.COPY, title="Copy", - icon=FA6S.copy, + icon="fa6-solid:copy", callback=functions.copy, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyC)], @@ -61,7 +59,7 @@ Action( id=CommandId.PASTE, title="Paste", - icon=FA6S.paste, + icon="fa6-solid:paste", callback=functions.paste, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyV)], diff --git a/pyproject.toml b/pyproject.toml index 630a88c..012825c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ [project.optional-dependencies] test = ["pytest>=6.0", "pytest-cov"] test-qt = ["pytest-qt", "fonticon-fontawesome6"] -qt = ["qtpy", "superqt"] +qt = ["qtpy", "superqt[iconify]"] dev = [ "black", "ipython", diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 1be2bb7..b94cabc 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -1,7 +1,17 @@ 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, + Literal, + Optional, + Tuple, + Type, +) import in_n_out as ino from psygnal import Signal @@ -52,6 +62,9 @@ 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 + The theme mode for this application. Must be one of "dark", "light", or None. + If not provided, backends may guess at the current theme. """ destroyed = Signal(str) @@ -82,6 +95,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" @@ -116,6 +130,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.""" diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index c8f78f6..0d5cd69 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -13,8 +13,6 @@ cast, ) -from qtpy.QtWidgets import QAction - from app_model import Application from app_model.expressions import Expr from app_model.types import ToggleRule @@ -23,9 +21,12 @@ from ._util import to_qicon if TYPE_CHECKING: + from PyQt6.QtGui import QAction from qtpy.QtCore import QObject from app_model.types import CommandRule, MenuItem +else: + from qtpy.QtWidgets import QAction class QCommandAction(QAction): @@ -90,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) + ) if command_rule.tooltip: self.setToolTip(command_rule.tooltip) if command_rule.status_tip: diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index c40a586..569bd8a 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -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 diff --git a/src/app_model/backends/qt/_util.py b/src/app_model/backends/qt/_util.py index 11d13e4..addd67c 100644 --- a/src/app_model/backends/qt/_util.py +++ b/src/app_model/backends/qt/_util.py @@ -2,7 +2,9 @@ from typing import TYPE_CHECKING -from qtpy.QtGui import QIcon +from qtpy.QtCore import QObject +from qtpy.QtGui import QIcon, QPalette +from qtpy.QtWidgets import QApplication if TYPE_CHECKING: from typing import Literal @@ -10,10 +12,53 @@ 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: + 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 fonticon + 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 + if theme == "dark": + color = icon.color_dark or LIGHT_COLOR + else: + color = icon.color_light or DARK_COLOR if icn := getattr(icon, theme, ""): - return fonticon.icon(icn) + if ":" in icn: + return QIconifyIcon(icn, color=color) + else: + 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 fc2c863..87b4436 100644 --- a/src/app_model/types/_icon.py +++ b/src/app_model/types/_icon.py @@ -6,6 +6,9 @@ from ._base import _BaseModel +LIGHT_COLOR = "#BCB4B4" +DARK_COLOR = "#6B6565" + class Icon(_BaseModel): """Icons used to represent commands, or submenus. @@ -16,13 +19,27 @@ class Icon(_BaseModel): dark: Optional[str] = Field( None, - description="Icon path when a dark theme is used. These may be superqt " - "fonticon keys, such as `fa6s.arrow_down`", + description="Icon path when a dark theme is used. These may be " + "[iconify keys](https://icon-sets.iconify.design), such as `mdi:content-copy`, " + "or [superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" + " keys, such as `fa6s.arrow_down`", + ) + color_dark: Optional[str] = Field( + LIGHT_COLOR, # use light icon for dark themes + description="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 superqt " - "fonticon keys, such as `fa6s.arrow_down`", + description="Icon path when a light theme is used. These may be " + "[iconify keys](https://icon-sets.iconify.design), such as `mdi:content-copy`, " + "or [superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" + " keys, such as `fa6s.arrow_down`", + ) + color_light: Optional[str] = Field( + DARK_COLOR, # use dark icon for light themes + description="Icon color to use for themes with light backgrounds. If not " + "provided, a default is used", ) @classmethod @@ -37,6 +54,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 +67,11 @@ def _validate(cls, v: Any) -> "Icon": def _model_val(cls, v: Any, handler: Callable[[Any], "Icon"]) -> "Icon": 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 handler(v) @@ -53,6 +80,8 @@ class IconDict(TypedDict): dark: Optional[str] light: Optional[str] + color_dark: Optional[str] + color_light: Optional[str] IconOrDict = Union[Icon, IconDict] diff --git a/tests/conftest.py b/tests/conftest.py index 2a18d96..79dd744 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -113,7 +113,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: Action( id=Commands.COPY, title="Copy", - icon="fa6s.copy", + icon="fa6-solid:copy", # iconify font style works too callback=app.mocks.copy, menus=[{"id": Menus.EDIT, "group": "2_copy_paste"}], keybindings=[{"primary": KeyMod.CtrlCmd | KeyCode.KeyC}], @@ -121,7 +121,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: Action( id=Commands.PASTE, title="Paste", - icon="fa6s.paste", + icon={"light": "fa6s.paste", "color_light": "blue"}, # with color callback=app.mocks.paste, menus=[{"id": Menus.EDIT, "group": "2_copy_paste"}], keybindings=[{"primary": "Ctrl+V", "mac": "Cmd+V"}], @@ -131,7 +131,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: id=Commands.REDO, title="Redo", tooltip="Redo it!", - icon="fa6s.rotate_right", + icon={"light": "fa6-solid:rotate-right", "color_light": "#00ff00"}, enablement="allow_undo_redo", callback="fake_module:run_me", # this is a function in fixtures keybindings=[{"primary": "Ctrl+Shift+Z"}], From 2d1bd899f7a77b3adec6c06c3989f8b3a5e3759f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 26 Oct 2023 21:18:33 -0400 Subject: [PATCH 02/10] docs --- src/app_model/_app.py | 4 +++- tests/conftest.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app_model/_app.py b/src/app_model/_app.py index b94cabc..9a99ab0 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -63,7 +63,9 @@ class Application: - injection_store : in_n_out.Store The Injection Store for this application. - theme_mode : Literal["dark", "light"] | None - The theme mode for this application. Must be one of "dark", "light", or 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). If not provided, backends may guess at the current theme. """ diff --git a/tests/conftest.py b/tests/conftest.py index 79dd744..d553edb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ UNDO_ICON = FA6S.rotate_left except ImportError: - UNDO_ICON = "fa6s.undo" + UNDO_ICON = "fa6-solid:undo" FIXTURES = Path(__file__).parent / "fixtures" From 07179006606977db1e5a7011bb67b543fe5ccc11 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 27 Oct 2023 19:52:45 -0400 Subject: [PATCH 03/10] remove passs --- tests/test_qt/test_qactions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_qt/test_qactions.py b/tests/test_qt/test_qactions.py index 33079e1..f18392c 100644 --- a/tests/test_qt/test_qactions.py +++ b/tests/test_qt/test_qactions.py @@ -5,8 +5,6 @@ from app_model.types import Action, MenuItem, ToggleRule if TYPE_CHECKING: - pass - from app_model import Application from conftest import FullApp From bf80d580259cc3f54c3a24cef2ebaeb359ea4354 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 28 Oct 2023 07:57:33 -0400 Subject: [PATCH 04/10] more docs --- src/app_model/_app.py | 4 ++-- src/app_model/types/_command_rule.py | 5 ++++- src/app_model/types/_icon.py | 25 ++++++++++++------------- src/app_model/types/_menu_rule.py | 5 ++++- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 9a99ab0..c485b84 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -65,8 +65,8 @@ class 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). - If not provided, backends may guess at the current theme. + 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) diff --git a/src/app_model/types/_command_rule.py b/src/app_model/types/_command_rule.py index 2e68b5a..49c5111 100644 --- a/src/app_model/types/_command_rule.py +++ b/src/app_model/types/_command_rule.py @@ -53,7 +53,10 @@ class CommandRule(_BaseModel): icon: Optional[Icon] = Field( None, description="(Optional) Icon used to represent this command, e.g. on buttons " - "or in menus. These may be superqt fonticon keys, such as `fa6s.arrow_down`", + "or in menus. These may be [iconify keys](https://icon-sets.iconify.design), " + "such as `fa6-solid:arrow-down`, or " + "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" + " keys, such as `fa6s.arrow_down`", ) enablement: Optional[expressions.Expr] = Field( None, diff --git a/src/app_model/types/_icon.py b/src/app_model/types/_icon.py index 9d9f636..7048546 100644 --- a/src/app_model/types/_icon.py +++ b/src/app_model/types/_icon.py @@ -4,9 +4,6 @@ from ._base import _BaseModel -LIGHT_COLOR = "#BCB4B4" -DARK_COLOR = "#6B6565" - class Icon(_BaseModel): """Icons used to represent commands, or submenus. @@ -18,26 +15,28 @@ class Icon(_BaseModel): dark: Optional[str] = Field( None, description="Icon path when a dark theme is used. These may be " - "[iconify keys](https://icon-sets.iconify.design), such as `mdi:content-copy`, " - "or [superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" + "[iconify keys](https://icon-sets.iconify.design), such as " + "`fa6-solid:arrow-down`, or " + "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) color_dark: Optional[str] = Field( - LIGHT_COLOR, # use light icon for dark themes - description="Icon color to use for themes with dark backgrounds. If not " - "provided, a default is used.", + 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 " - "[iconify keys](https://icon-sets.iconify.design), such as `mdi:content-copy`, " - "or [superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" + "[iconify keys](https://icon-sets.iconify.design), such as " + "`fa6-solid:arrow-down`, or " + "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) color_light: Optional[str] = Field( - DARK_COLOR, # use dark icon for light themes - description="Icon color to use for themes with light backgrounds. If not " - "provided, a default is used", + 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 diff --git a/src/app_model/types/_menu_rule.py b/src/app_model/types/_menu_rule.py index e75f623..09eb08a 100644 --- a/src/app_model/types/_menu_rule.py +++ b/src/app_model/types/_menu_rule.py @@ -98,7 +98,10 @@ class SubmenuItem(_MenuItemBase): icon: Optional[Icon] = Field( None, description="(Optional) Icon used to represent this submenu. " - "These may be superqt fonticon keys, such as `fa6s.arrow_down`", + "These may be [iconify keys](https://icon-sets.iconify.design), " + "such as `fa6-solid:arrow-down`, or " + "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" + " keys, such as `fa6s.arrow_down`", ) enablement: Optional[expressions.Expr] = Field( None, From eb7288eb5ce71ce5d797b8317eab6ec8daf518bf Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 28 Oct 2023 09:25:47 -0400 Subject: [PATCH 05/10] coverage --- src/app_model/backends/qt/_util.py | 11 ++++++----- tests/test_app.py | 6 ++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/app_model/backends/qt/_util.py b/src/app_model/backends/qt/_util.py index addd67c..66c4440 100644 --- a/src/app_model/backends/qt/_util.py +++ b/src/app_model/backends/qt/_util.py @@ -28,7 +28,7 @@ def background_luma(qobj: QObject | None = None) -> float: palette: QPalette = qobj.palette() # type: ignore elif wdgts := QApplication.topLevelWidgets(): palette = wdgts[0].palette() - else: + 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()) @@ -51,10 +51,11 @@ def to_qicon( theme = "dark" if background_luma(parent) < 0.5 else "light" if color is None: # use DARK_COLOR icon for light themes and vice versa - if theme == "dark": - color = icon.color_dark or LIGHT_COLOR - else: - color = icon.color_light or DARK_COLOR + 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: diff --git a/tests/test_app.py b/tests/test_app.py index 5ffba71..6706946 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -37,6 +37,12 @@ def test_app(full_app: FullApp): app.commands.execute_command(app.Commands.PASTE) app.mocks.paste.assert_called_once() + assert app.theme_mode is None + with pytest.raises(ValueError, match="must be one of 'dark', 'light'"): + app.theme_mode = "foo" # type: ignore + app.theme_mode = "dark" + assert app.theme_mode == "dark" + def test_sorting(full_app: FullApp): groups = list(full_app.menus.iter_menu_groups(full_app.Menus.EDIT)) From 0cc07a61cfea4313d3cd9bb4ee196ce2f4a73a9f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 28 Oct 2023 09:34:55 -0400 Subject: [PATCH 06/10] move import --- src/app_model/backends/qt/_util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app_model/backends/qt/_util.py b/src/app_model/backends/qt/_util.py index 66c4440..59e7fcb 100644 --- a/src/app_model/backends/qt/_util.py +++ b/src/app_model/backends/qt/_util.py @@ -2,13 +2,14 @@ from typing import TYPE_CHECKING -from qtpy.QtCore import QObject 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 From f64f9080858475350a92f0d792231a996b944fdd Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 31 Oct 2023 16:03:34 -0400 Subject: [PATCH 07/10] remove color stuff --- demo/model_app.py | 2 +- src/app_model/_app.py | 24 ------------- src/app_model/backends/qt/_qaction.py | 4 +-- src/app_model/backends/qt/_qmenu.py | 4 +-- src/app_model/backends/qt/_util.py | 52 +++------------------------ src/app_model/types/_icon.py | 12 ------- tests/conftest.py | 4 +-- tests/test_app.py | 6 ---- 8 files changed, 9 insertions(+), 99 deletions(-) diff --git a/demo/model_app.py b/demo/model_app.py index 0363ed3..d57b2cd 100644 --- a/demo/model_app.py +++ b/demo/model_app.py @@ -202,7 +202,7 @@ class CommandId: ), types.Action( id="cut", - icon={"light": "fa6-solid:scissors", "color_light": "red"}, # with color + icon="fa6-solid:scissors", title="Cut", keybindings=[types.StandardKeyBinding.Cut], enablement="copyAvailable", diff --git a/src/app_model/_app.py b/src/app_model/_app.py index c485b84..7518d53 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -62,11 +62,6 @@ 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) @@ -97,7 +92,6 @@ 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" @@ -132,24 +126,6 @@ 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.""" diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index 0d5cd69..8203e1f 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -91,9 +91,7 @@ def __init__( else: self.setText(command_rule.title) if command_rule.icon: - self.setIcon( - to_qicon(command_rule.icon, theme=self._app.theme_mode, parent=self) - ) + self.setIcon(to_qicon(command_rule.icon)) if command_rule.tooltip: self.setToolTip(command_rule.tooltip) if command_rule.status_tip: diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index 569bd8a..c40a586 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -149,9 +149,7 @@ def __init__( menu_id=submenu.submenu, app=app, title=submenu.title, parent=parent ) if submenu.icon: - self.setIcon( - to_qicon(submenu.icon, theme=self._app.theme_mode, parent=self) - ) + self.setIcon(to_qicon(submenu.icon)) def update_from_context( self, ctx: Mapping[str, object], _recurse: bool = True diff --git a/src/app_model/backends/qt/_util.py b/src/app_model/backends/qt/_util.py index 59e7fcb..1dda83e 100644 --- a/src/app_model/backends/qt/_util.py +++ b/src/app_model/backends/qt/_util.py @@ -2,65 +2,21 @@ from typing import TYPE_CHECKING -from qtpy.QtGui import QIcon, QPalette -from qtpy.QtWidgets import QApplication +from qtpy.QtGui import QIcon if TYPE_CHECKING: from typing import Literal - from qtpy.QtCore import QObject - from app_model.types import Icon -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: +def to_qicon(icon: Icon, theme: Literal["dark", "light"] = "dark") -> 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, color=color) + return QIconifyIcon(icn) else: - return fonticon.icon(icn, color=color) + return fonticon.icon(icn) return QIcon() # pragma: no cover diff --git a/src/app_model/types/_icon.py b/src/app_model/types/_icon.py index 7048546..b362173 100644 --- a/src/app_model/types/_icon.py +++ b/src/app_model/types/_icon.py @@ -20,11 +20,6 @@ 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 " @@ -33,11 +28,6 @@ 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]: @@ -77,8 +67,6 @@ class IconDict(TypedDict): dark: Optional[str] light: Optional[str] - color_dark: Optional[str] - color_light: Optional[str] IconOrDict = Union[Icon, IconDict] diff --git a/tests/conftest.py b/tests/conftest.py index d553edb..6bc6771 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,7 +121,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: Action( id=Commands.PASTE, title="Paste", - icon={"light": "fa6s.paste", "color_light": "blue"}, # with color + icon="fa6s.paste", callback=app.mocks.paste, menus=[{"id": Menus.EDIT, "group": "2_copy_paste"}], keybindings=[{"primary": "Ctrl+V", "mac": "Cmd+V"}], @@ -131,7 +131,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: id=Commands.REDO, title="Redo", tooltip="Redo it!", - icon={"light": "fa6-solid:rotate-right", "color_light": "#00ff00"}, + icon="fa6-solid:rotate-right", enablement="allow_undo_redo", callback="fake_module:run_me", # this is a function in fixtures keybindings=[{"primary": "Ctrl+Shift+Z"}], diff --git a/tests/test_app.py b/tests/test_app.py index 6706946..5ffba71 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -37,12 +37,6 @@ def test_app(full_app: FullApp): app.commands.execute_command(app.Commands.PASTE) app.mocks.paste.assert_called_once() - assert app.theme_mode is None - with pytest.raises(ValueError, match="must be one of 'dark', 'light'"): - app.theme_mode = "foo" # type: ignore - app.theme_mode = "dark" - assert app.theme_mode == "dark" - def test_sorting(full_app: FullApp): groups = list(full_app.menus.iter_menu_groups(full_app.Menus.EDIT)) From e358722a4d39837c0ec8baf35a2990a99e2c91d0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Oct 2023 22:07:53 +0000 Subject: [PATCH 08/10] style: [pre-commit.ci] auto fixes [...] --- src/app_model/_app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 7518d53..064743b 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -7,7 +7,6 @@ Dict, Iterable, List, - Literal, Optional, Tuple, Type, From 99e529dca8859d35b0a792994c1bff2bec48abf5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 31 Oct 2023 18:12:14 -0400 Subject: [PATCH 09/10] recover changes --- src/app_model/_app.py | 24 +++++++++++++ src/app_model/backends/qt/_qaction.py | 4 ++- src/app_model/backends/qt/_qmenu.py | 4 ++- src/app_model/backends/qt/_util.py | 52 ++++++++++++++++++++++++--- src/app_model/types/_icon.py | 12 +++++++ 5 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 7518d53..c485b84 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -62,6 +62,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) @@ -92,6 +97,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" @@ -126,6 +132,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.""" diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index ef293c1..fe8862f 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -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) diff --git a/src/app_model/backends/qt/_qmenu.py b/src/app_model/backends/qt/_qmenu.py index c40a586..569bd8a 100644 --- a/src/app_model/backends/qt/_qmenu.py +++ b/src/app_model/backends/qt/_qmenu.py @@ -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 diff --git a/src/app_model/backends/qt/_util.py b/src/app_model/backends/qt/_util.py index 1dda83e..59e7fcb 100644 --- a/src/app_model/backends/qt/_util.py +++ b/src/app_model/backends/qt/_util.py @@ -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 diff --git a/src/app_model/types/_icon.py b/src/app_model/types/_icon.py index b362173..7048546 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( 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 __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: @@ -67,6 +77,8 @@ class IconDict(TypedDict): dark: Optional[str] light: Optional[str] + color_dark: Optional[str] + color_light: Optional[str] IconOrDict = Union[Icon, IconDict] From 6f9aece6a5b3889c46b7e46d46383c8c7e03b70f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Oct 2023 22:12:40 +0000 Subject: [PATCH 10/10] style: [pre-commit.ci] auto fixes [...] --- src/app_model/_app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app_model/_app.py b/src/app_model/_app.py index e29f17a..6f27b22 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -96,7 +96,7 @@ def __init__( ) self._menus = menus_reg_class() self._keybindings = keybindings_reg_class() - self._theme_mode: Literal["dark", "light"] | None = None + self._theme_mode: Literal[dark, light] | None = None self.injection_store.on_unannotated_required_args = "ignore" @@ -132,12 +132,12 @@ def injection_store(self) -> ino.Store: return self._injection_store @property - def theme_mode(self) -> Literal["dark", "light"] | None: + 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: + 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.