diff --git a/docking/applets/applications/applet.py b/docking/applets/applications/applet.py index a08ade32..d349eb45 100644 --- a/docking/applets/applications/applet.py +++ b/docking/applets/applications/applet.py @@ -35,13 +35,14 @@ class ApplicationsApplet(Applet): id = meta.id name = _("Applications") icon_name = "view-app-grid" + supports_system_icon = True def __init__(self, icon_size: int, config: Config | None = None) -> None: super().__init__(icon_size=icon_size, config=config) self._popup_menu: Gtk.Menu | None = None self.present() - def create_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: + def create_docking_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: return create_icon(size=size) def on_clicked(self) -> None: diff --git a/docking/applets/base.py b/docking/applets/base.py index f5ceb39b..60ac66a3 100644 --- a/docking/applets/base.py +++ b/docking/applets/base.py @@ -51,7 +51,7 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import ABC from collections.abc import Callable from importlib import resources from typing import TYPE_CHECKING, Any @@ -108,6 +108,11 @@ CATALOG_ICON_DIR = "icons/applets" +ICON_SOURCE_PREF_KEY = "icon_source" +ICON_SOURCE_DOCKING = "docking" +ICON_SOURCE_SYSTEM = "system" +ICON_SOURCE_VALUES = frozenset({ICON_SOURCE_DOCKING, ICON_SOURCE_SYSTEM}) + def _icon_name_candidates(name: str) -> tuple[str, ...]: names: list[str] = [name] @@ -299,11 +304,11 @@ def _icon_label_outline_width(*, font_size: int) -> float: class Applet(ABC): - """Base class for dock plugins that render custom icons. + """Base class for dock plugins that render Docking icons. - Each applet owns a DockItem. The applet renders custom Cairo - content to a pixbuf and assigns it to item.icon. The existing - renderer draws it like any other icon -- no renderer changes needed. + Each applet owns a DockItem. Most applets render their own pixbuf, while + simple applets can opt into a user-selected system theme icon. The existing + renderer draws both paths like any other item icon. Lifecycle: __init__ -> create item @@ -315,6 +320,7 @@ class Applet(ABC): id: str name: str icon_name: str + supports_system_icon = False def __init__(self, icon_size: int, config: Config | None = None) -> None: self._config = config @@ -347,9 +353,54 @@ def save_prefs(self, prefs: dict[str, Any]) -> None: self._config.applet_prefs[self.id] = prefs self._config.save() - @abstractmethod def create_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: - """Render custom content to a pixbuf at the given size.""" + """Render the active icon source at the given size.""" + if self.uses_system_icon(): + icon_name = self.system_icon_name() + icon = load_theme_icon(name=icon_name, size=size) + if icon is not None: + self.item.icon_name = icon_name + return icon + + self.item.icon_name = self.icon_name + return self.create_docking_icon(size=size) + + def create_docking_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: + """Render the built-in Docking icon when an applet opts into icon sources.""" + _ = size + raise NotImplementedError( + f"{type(self).__name__} must implement create_icon() " + "or create_docking_icon()" + ) + + def system_icon_name(self) -> str: + """Theme icon name used when the applet is set to System Icon.""" + return self.icon_name + + def icon_source(self) -> str: + """Return the selected icon source, defaulting to the Docking icon.""" + if not self.supports_system_icon: + return ICON_SOURCE_DOCKING + source = self.load_prefs().get(ICON_SOURCE_PREF_KEY) + if source == ICON_SOURCE_SYSTEM: + return ICON_SOURCE_SYSTEM + return ICON_SOURCE_DOCKING + + def uses_system_icon(self) -> bool: + """Whether this applet currently requests a theme icon.""" + return self.icon_source() == ICON_SOURCE_SYSTEM + + def set_icon_source(self, source: str) -> None: + """Persist and present the selected icon source.""" + if not self.supports_system_icon or source not in ICON_SOURCE_VALUES: + return + if source == self.icon_source(): + return + + prefs = self.load_prefs() + prefs[ICON_SOURCE_PREF_KEY] = source + self.save_prefs(prefs) + self.present() def refresh_tooltip(self) -> None: """Sync tooltip/text presentation fields on self.item.""" diff --git a/docking/applets/calculator/applet.py b/docking/applets/calculator/applet.py index ccd29ed9..e9574ebb 100644 --- a/docking/applets/calculator/applet.py +++ b/docking/applets/calculator/applet.py @@ -63,6 +63,7 @@ class CalculatorApplet(Applet): id = meta.id name = _("Calculator") icon_name = "accessories-calculator" + supports_system_icon = True def __init__(self, icon_size: int, config: Config | None = None) -> None: self._popup: Gtk.Window | None = None @@ -74,7 +75,7 @@ def __init__(self, icon_size: int, config: Config | None = None) -> None: super().__init__(icon_size=icon_size, config=config) self.present() - def create_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: + def create_docking_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: return create_icon(size=size) def refresh_tooltip(self) -> None: diff --git a/docking/applets/desktop/applet.py b/docking/applets/desktop/applet.py index 024c61a3..48bb7ada 100644 --- a/docking/applets/desktop/applet.py +++ b/docking/applets/desktop/applet.py @@ -26,12 +26,13 @@ class DesktopApplet(Applet): id = meta.id name = _("Desktop") icon_name = "user-desktop" + supports_system_icon = True def __init__(self, icon_size: int, config: Config | None = None) -> None: super().__init__(icon_size=icon_size, config=config) self.present() - def create_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: + def create_docking_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: return create_icon(size=size) def on_clicked(self) -> None: diff --git a/docking/applets/screenshot/applet.py b/docking/applets/screenshot/applet.py index 0c028d49..220d46a0 100644 --- a/docking/applets/screenshot/applet.py +++ b/docking/applets/screenshot/applet.py @@ -39,6 +39,7 @@ class ScreenshotApplet(Applet): id = meta.id name = _("Screenshot") icon_name = "applets-screenshooter" + supports_system_icon = True def __init__(self, icon_size: int, config: Config | None = None) -> None: self._tool = _detect_tool() @@ -49,7 +50,7 @@ def __init__(self, icon_size: int, config: Config | None = None) -> None: super().__init__(icon_size, config) self.present() - def create_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: + def create_docking_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) cr = cairo.Context(surface) _draw_screenshot_icon(cr=cr, size=size) diff --git a/docking/applets/session/applet.py b/docking/applets/session/applet.py index ac506d4a..0e9f3b54 100644 --- a/docking/applets/session/applet.py +++ b/docking/applets/session/applet.py @@ -27,12 +27,13 @@ class SessionApplet(Applet): id = meta.id name = _("Session") icon_name = "system-log-out" + supports_system_icon = True def __init__(self, icon_size: int, config: Config | None = None) -> None: super().__init__(icon_size, config) self.present() - def create_icon(self, size: int): + def create_docking_icon(self, size: int): return create_session_icon(size=size) def on_clicked(self) -> None: diff --git a/docking/applets/trash/applet.py b/docking/applets/trash/applet.py index 03a8db26..ac015cf4 100644 --- a/docking/applets/trash/applet.py +++ b/docking/applets/trash/applet.py @@ -35,6 +35,7 @@ class TrashApplet(Applet): id = meta.id name = _("Trash") icon_name = "user-trash" + supports_system_icon = True def __init__(self, icon_size: int, config: Config | None = None) -> None: self._desktop = detect_desktop() @@ -44,9 +45,14 @@ def __init__(self, icon_size: int, config: Config | None = None) -> None: super().__init__(icon_size, config) self.present() - def create_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: + def create_docking_icon(self, size: int) -> GdkPixbuf.Pixbuf | None: return create_trash_icon(size=size, item_count=self._item_count) + def system_icon_name(self) -> str: + if self._item_count > 0: + return "user-trash-full" + return "user-trash" + def refresh_tooltip(self) -> None: self.item.name = trash_tooltip(item_count=self._item_count) diff --git a/docking/locale/docking.pot b/docking/locale/docking.pot index 33daec66..f9346bcb 100644 --- a/docking/locale/docking.pot +++ b/docking/locale/docking.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: docking\n" "Report-Msgid-Bugs-To: edumucelli@gmail.com\n" -"POT-Creation-Date: 2026-05-07 01:19+0200\n" +"POT-Creation-Date: 2026-05-08 00:57+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -180,7 +180,7 @@ msgstr "" msgid "Applications" msgstr "" -#: docking/applets/applications/applet.py:76 +#: docking/applets/applications/applet.py:77 msgid "Search applications..." msgstr "" @@ -415,7 +415,7 @@ msgid "Show Level" msgstr "" #: docking/applets/calculator/applet.py:64 -#: docking/applets/calculator/applet.py:81 +#: docking/applets/calculator/applet.py:82 msgid "Calculator" msgstr "" @@ -634,7 +634,7 @@ msgid "Set Alarm" msgstr "" #: docking/applets/clock/applet.py:205 docking/applets/quicknote/applet.py:73 -#: docking/applets/trash/applet.py:108 docking/applets/weather/applet.py:258 +#: docking/applets/trash/applet.py:114 docking/applets/weather/applet.py:258 msgid "Cancel" msgstr "" @@ -1484,7 +1484,7 @@ msgstr "" msgid "Screenshot" msgstr "" -#: docking/applets/screenshot/applet.py:76 +#: docking/applets/screenshot/applet.py:77 #, python-brace-format msgid "Full Screen in {delay}s" msgstr "" @@ -1521,7 +1521,7 @@ msgstr "" msgid "Session" msgstr "" -#: docking/applets/session/applet.py:55 docking/applets/session/state.py:28 +#: docking/applets/session/applet.py:56 docking/applets/session/state.py:28 msgid "Suspend" msgstr "" @@ -1745,19 +1745,19 @@ msgstr "" msgid "Trash" msgstr "" -#: docking/applets/trash/applet.py:59 +#: docking/applets/trash/applet.py:65 msgid "Open Trash" msgstr "" -#: docking/applets/trash/applet.py:62 docking/applets/trash/applet.py:109 +#: docking/applets/trash/applet.py:68 docking/applets/trash/applet.py:115 msgid "Empty Trash" msgstr "" -#: docking/applets/trash/applet.py:103 +#: docking/applets/trash/applet.py:109 msgid "Empty all items from Trash?" msgstr "" -#: docking/applets/trash/applet.py:106 +#: docking/applets/trash/applet.py:112 msgid "All items in the Trash will be permanently deleted." msgstr "" @@ -1986,64 +1986,76 @@ msgstr "" msgid "%d More in Folder" msgstr "" -#: docking/ui/menu.py:387 docking/ui/menu.py:407 docking/ui/menu.py:423 -#: docking/ui/menu.py:471 +#: docking/ui/menu.py:377 +msgid "Icon" +msgstr "" + +#: docking/ui/menu.py:379 +msgid "Docking Icon" +msgstr "" + +#: docking/ui/menu.py:380 +msgid "System Icon" +msgstr "" + +#: docking/ui/menu.py:423 docking/ui/menu.py:443 docking/ui/menu.py:459 +#: docking/ui/menu.py:507 msgid "Remove from Dock" msgstr "" -#: docking/ui/menu.py:400 +#: docking/ui/menu.py:436 msgid "Open" msgstr "" -#: docking/ui/menu.py:430 +#: docking/ui/menu.py:466 msgid "Keep in Dock" msgstr "" -#: docking/ui/menu.py:439 +#: docking/ui/menu.py:475 msgid "Close All" msgstr "" -#: docking/ui/menu.py:439 +#: docking/ui/menu.py:475 msgid "Close" msgstr "" -#: docking/ui/menu.py:456 +#: docking/ui/menu.py:492 msgid "Sort By" msgstr "" -#: docking/ui/menu.py:464 +#: docking/ui/menu.py:500 msgid "Show Hidden Files" msgstr "" -#: docking/ui/menu.py:501 +#: docking/ui/menu.py:537 msgid "Add Applet" msgstr "" -#: docking/ui/menu.py:539 +#: docking/ui/menu.py:575 msgid "No Applets Available" msgstr "" -#: docking/ui/menu.py:546 +#: docking/ui/menu.py:582 msgid "Add Separator" msgstr "" -#: docking/ui/menu.py:556 docking/ui/settings.py:167 +#: docking/ui/menu.py:592 docking/ui/settings.py:167 msgid "Preferences" msgstr "" -#: docking/ui/menu.py:561 +#: docking/ui/menu.py:597 msgid "About" msgstr "" -#: docking/ui/menu.py:566 +#: docking/ui/menu.py:602 msgid "Get Support" msgstr "" -#: docking/ui/menu.py:573 +#: docking/ui/menu.py:609 msgid "Quit" msgstr "" -#: docking/ui/menu.py:610 +#: docking/ui/menu.py:646 msgid "Window" msgstr "" diff --git a/docking/ui/menu.py b/docking/ui/menu.py index 2c78f7e1..f574777d 100644 --- a/docking/ui/menu.py +++ b/docking/ui/menu.py @@ -150,7 +150,12 @@ import docking.platform.launcher as launcher_mod from docking.applets import get_applet_catalog -from docking.applets.base import load_catalog_icon +from docking.applets.base import ( + ICON_SOURCE_DOCKING, + ICON_SOURCE_SYSTEM, + Applet, + load_catalog_icon, +) from docking.applets.identity import ( APPLET_CATEGORY_ORDER, AppletCategory, @@ -367,10 +372,33 @@ def _on_menu_popup_closed(self, menu: Gtk.Menu) -> None: self._cleanup_folder_menu_tree(menu) self._runtime.menu_popup_closed() + def _build_applet_icon_source_menu(self, applet: Applet) -> Gtk.MenuItem: + return _build_radio_submenu( + label=_("Icon"), + items=( + (_("Docking Icon"), ICON_SOURCE_DOCKING), + (_("System Icon"), ICON_SOURCE_SYSTEM), + ), + current=applet.icon_source(), + on_changed=lambda widget, source: self._on_applet_icon_source_changed( + widget, applet, source + ), + ) + + def _on_applet_icon_source_changed( + self, + widget: Gtk.RadioMenuItem, + applet: Applet, + source: str, + ) -> None: + if not widget.get_active(): + return + applet.set_icon_source(source) + def _build_item_menu(self, menu: Gtk.Menu, item: DockItem) -> None: """Build context menu for a specific dock item. - Applets: delegates to applet.get_menu_items() + "Remove from Dock". + Applets: applet actions, optional icon source, and "Remove from Dock". Regular items: desktop actions (quicklists), pin/unpin, close. """ locked = self._config.lock_icons @@ -378,12 +406,20 @@ def _build_item_menu(self, menu: Gtk.Menu, item: DockItem) -> None: if is_applet(desktop_id=item.desktop_id): # Applet-specific menu items applet = self._model.get_applet(item.desktop_id) + applet_items: list[Gtk.MenuItem] = [] + has_icon_source = False if applet: - for mi in applet.get_menu_items(): + applet_items = applet.get_menu_items() + has_icon_source = applet.supports_system_icon is True + for mi in applet_items: menu.append(mi) - if applet.get_menu_items(): + if applet_items and has_icon_source: menu.append(Gtk.SeparatorMenuItem()) + if has_icon_source: + menu.append(self._build_applet_icon_source_menu(applet)) if not locked: + if applet_items or has_icon_source: + menu.append(Gtk.SeparatorMenuItem()) remove = Gtk.MenuItem(label=_("Remove from Dock")) remove.connect( "activate", diff --git a/tests/applets/test_base.py b/tests/applets/test_base.py index 86ba4e60..161e15a9 100644 --- a/tests/applets/test_base.py +++ b/tests/applets/test_base.py @@ -5,7 +5,11 @@ import cairo +import docking.applets.base as base_mod from docking.applets.base import ( + ICON_SOURCE_DOCKING, + ICON_SOURCE_PREF_KEY, + ICON_SOURCE_SYSTEM, Applet, _fit_icon_label_layout, _icon_label_origin, @@ -62,6 +66,26 @@ def refresh_tooltip(self) -> None: self.item.name = f"Rendered {self.render_count}" +class _SystemIconApplet(Applet): + id = "session" + name = "System Icon" + icon_name = "system-log-out" + supports_system_icon = True + + def __init__(self, config=None) -> None: + self.docking_icon = object() + self.render_count = 0 + super().__init__(icon_size=32, config=config) + + def create_docking_icon(self, size: int): + assert size == 32 + self.render_count += 1 + return self.docking_icon + + def system_icon_name(self) -> str: + return "system-preferred" + + class TestAppletBaseHelpers: def test_load_prefs_reads_config_for_applet_id(self): config = MagicMock() @@ -101,6 +125,52 @@ def test_default_hooks_are_safe_and_present_notifies(self): assert applet.item.icon is not None notify.assert_called_once_with() + def test_system_icon_applet_defaults_to_docking_icon(self): + applet = _SystemIconApplet() + + assert applet.icon_source() == ICON_SOURCE_DOCKING + assert applet.create_icon(32) is applet.docking_icon + assert applet.render_count == 1 + assert applet.item.icon_name == "system-log-out" + + def test_system_icon_applet_uses_theme_icon_when_selected(self, monkeypatch): + icon = object() + config = MagicMock() + config.applet_prefs = {"session": {ICON_SOURCE_PREF_KEY: ICON_SOURCE_SYSTEM}} + monkeypatch.setattr(base_mod, "load_theme_icon", lambda **_: icon) + applet = _SystemIconApplet(config=config) + + assert applet.icon_source() == ICON_SOURCE_SYSTEM + assert applet.create_icon(32) is icon + assert applet.render_count == 0 + assert applet.item.icon_name == "system-preferred" + + def test_system_icon_applet_falls_back_to_docking_icon(self, monkeypatch): + config = MagicMock() + config.applet_prefs = {"session": {ICON_SOURCE_PREF_KEY: ICON_SOURCE_SYSTEM}} + monkeypatch.setattr(base_mod, "load_theme_icon", lambda **_: None) + applet = _SystemIconApplet(config=config) + + assert applet.create_icon(32) is applet.docking_icon + assert applet.render_count == 1 + assert applet.item.icon_name == "system-log-out" + + def test_set_icon_source_persists_and_presents(self, monkeypatch): + icon = object() + config = MagicMock() + config.applet_prefs = {"session": {"existing": True}} + monkeypatch.setattr(base_mod, "load_theme_icon", lambda **_: icon) + applet = _SystemIconApplet(config=config) + + applet.set_icon_source(ICON_SOURCE_SYSTEM) + + assert config.applet_prefs["session"] == { + "existing": True, + ICON_SOURCE_PREF_KEY: ICON_SOURCE_SYSTEM, + } + assert applet.item.icon is icon + config.save.assert_called_once_with() + class TestDrawIconLabel: def test_long_label_shrinks_to_fit_max_width(self): diff --git a/tests/applets/test_trash.py b/tests/applets/test_trash.py index b65be7c5..c1900817 100644 --- a/tests/applets/test_trash.py +++ b/tests/applets/test_trash.py @@ -283,6 +283,16 @@ def test_single_item_singular(self, monkeypatch): applet.create_icon(48) assert applet.item.name == "1 item in Trash" + def test_system_icon_name_tracks_empty_state(self, monkeypatch): + applet = _make_applet(monkeypatch, _StubBackend(item_count=0)) + + assert applet.system_icon_name() == "user-trash" + + def test_system_icon_name_tracks_full_state(self, monkeypatch): + applet = _make_applet(monkeypatch, _StubBackend(item_count=5)) + + assert applet.system_icon_name() == "user-trash-full" + class TestTrashAppletMenu: def test_returns_two_items(self, monkeypatch): diff --git a/tests/ui/test_menu_integration.py b/tests/ui/test_menu_integration.py index cd47754f..166a9917 100644 --- a/tests/ui/test_menu_integration.py +++ b/tests/ui/test_menu_integration.py @@ -786,6 +786,44 @@ def test_regular_running_item_menu_actions(self, handler, monkeypatch): next(mi for mi in menu.children if mi.get_label() == "Close All").activate() handler._tracker.close_all.assert_called_once_with("firefox.desktop") + def test_applet_item_menu_includes_icon_source_when_supported(self, handler): + # Given + menu = FakeMenu() + applet_item = DockItem(desktop_id="applet://session") + applet = SimpleNamespace( + supports_system_icon=True, + get_menu_items=MagicMock(return_value=[FakeMenuItem(label="Lock Screen")]), + icon_source=MagicMock(return_value=menu_mod.ICON_SOURCE_DOCKING), + set_icon_source=MagicMock(), + ) + handler._model.get_applet.return_value = applet + + # When + handler._build_item_menu(menu=menu, item=applet_item) + labels = _labels(menu) + icon_menu = next(mi for mi in menu.children if mi.get_label() == "Icon") + icon_options = icon_menu.get_submenu().get_children() + system_option = next( + mi for mi in icon_options if mi.get_label() == "System Icon" + ) + system_option.set_active(True) + system_option.activate() + + # Then + assert labels == [ + "Lock Screen", + "---", + "Icon", + "---", + "Remove from Dock", + ] + assert [mi.get_label() for mi in icon_options] == [ + "Docking Icon", + "System Icon", + ] + applet.get_menu_items.assert_called_once_with() + applet.set_icon_source.assert_called_once_with(menu_mod.ICON_SOURCE_SYSTEM) + def test_applet_item_menu_includes_applet_items_and_remove(self, handler): # Given menu = FakeMenu()