From 7f0f6f5e794c668b2ffaf7c18a69e11224c604aa Mon Sep 17 00:00:00 2001 From: Himadri Sekhar Basu Date: Sun, 19 Oct 2025 22:54:05 +0530 Subject: [PATCH] Fix for gnome-shell and optimize daemon - Fix setting gnome-shell user themes - Add option to set accent in gnome desktops - Optimize the theme manger daemon to change themes more smoothely. --- src/ThemeManager/DesktopTheme.py | 388 +++++++++++++++++-------------- src/ThemeManager/common.py | 34 +-- src/ThemeManager/gui.py | 39 ++-- src/ThemeManager/indicator.py | 208 +++++++++-------- src/ThemeManager/main.py | 35 +-- src/ThemeManager/tm_daemon.py | 169 ++++++++------ 6 files changed, 455 insertions(+), 418 deletions(-) diff --git a/src/ThemeManager/DesktopTheme.py b/src/ThemeManager/DesktopTheme.py index 8a8fb79..ecc8725 100644 --- a/src/ThemeManager/DesktopTheme.py +++ b/src/ThemeManager/DesktopTheme.py @@ -1,191 +1,223 @@ -# Copyright (C) 2021-2024 Himadri Sekhar Basu -# -# This file is part of theme-manager. -# -# theme-manager is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# theme-manager is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with theme-manager. If not, see -# or write to the Free Software Foundation, Inc., 51 Franklin Street, -# Fifth Floor, Boston, MA 02110-1301, USA.. -# -# Author: Himadri Sekhar Basu -# - -# import the necessary modules! +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Theme Manager – Desktop Theme Handler +# Copyright (C) 2021–2025 Himadri Sekhar Basu +# Licensed under GPLv3 or later +# ----------------------------------------------------------------------------- + import gettext import locale import logging -import os import subprocess +from pathlib import Path +from typing import Dict +import gi -# imports from current package -from ThemeManager.cli_args import APP, LOCALE_DIR -from ThemeManager.common import TMBackend +gi.require_version('Gio', '2.0') +from gi.repository import Gio +from ThemeManager.cli_args import APP, LOCALE_DIR +from ThemeManager.common import TMBackend, _async -# i18n +# ------------------------- i18n Setup ------------------------- locale.bindtextdomain(APP, LOCALE_DIR) gettext.bindtextdomain(APP, LOCALE_DIR) gettext.textdomain(APP) _ = gettext.gettext +# ------------------------- Logger ----------------------------- +logger = logging.getLogger("ThemeManager.DesktopTheme") + + +class DesktopTheme: + """Handles reading and setting of desktop environment themes.""" + + def __init__(self): + self.manager = TMBackend() + + # ------------------------- Utility Helpers ------------------------- + + def run_command(self, command: str) -> str: + """Run a shell command and return stripped output.""" + try: + result = subprocess.check_output(command, stderr=subprocess.PIPE, shell=True) + return result.decode('utf-8', "strict").strip().strip("'") + except subprocess.CalledProcessError as e: + logger.error(_("Command failed: %s"), command) + logger.debug(_("Error details: %s"), e) + return "" + + def set_gsetting(self, schema: str, key: str, value: str): + """Safe wrapper for gsettings set.""" + cmd = f"gsettings set {schema} {key} {value}" + logger.debug(_("Applying setting: %s"), cmd) + try: + subprocess.run(cmd, shell=True, check=True) + except subprocess.CalledProcessError: + logger.warning(_("Failed to set gsetting: %s %s"), schema, key) + + def write_schema_to_xml(self, schema_id: str, output_file: Path): + """Generate missing schema XML if user-theme is absent.""" + content = f""" + + + '' + Theme name + The name of the theme, to be loaded from ~/.themes/name/gnome-shell + + +""" + output_file.parent.mkdir(parents=True, exist_ok=True) + output_file.write_text(content, encoding="utf-8") + logger.info(_("Wrote schema file: %s"), output_file) + + # ----------------------- Core Functionality ----------------------- + + @_async + def set_desktop_theme(self, state: Dict, nexttheme: list): + """Apply the selected theme asynchronously across DEs.""" + de = state['DE'].lower() + logger.info(_("Applying desktop theme for DE: %s"), de) + + try: + if de == "x-cinnamon": + mapping = { + "org.cinnamon.theme": ("name", nexttheme[3]), + "org.cinnamon.desktop.interface": [ + ("gtk-theme", nexttheme[4]), + ("icon-theme", nexttheme[7]), + ("cursor-theme", nexttheme[8]), + ], + "org.cinnamon.desktop.wm.preferences": ("theme", nexttheme[5]), + "org.x.apps.portal": ("color-scheme", nexttheme[6]), + } + self.apply_theme_map(mapping) + + elif de in ["gnome", "ubuntu:gnome", "unity"]: + self.apply_gnome_theme(nexttheme) + + elif de == "mate": + mapping = { + "org.mate.interface": [ + ("gtk-theme", nexttheme[4]), + ("icon-theme", nexttheme[7]) + ], + "org.mate.Marco.general": ("theme", nexttheme[5]), + "org.gnome.desktop.interface": ("color-scheme", nexttheme[6]), + "org.mate.peripherals-mouse": ("cursor-theme", nexttheme[8]), + } + self.apply_theme_map(mapping) + + # Plank theme (dock) + if self.manager.plank_theme: + try: + self.set_gsetting("net.launchpad.plank.dock.settings", "theme", nexttheme[9]) + except subprocess.CalledProcessError: + self.set_gsetting( + "net.launchpad.plank.dock.settings:/net/launchpad/plank/docks/dock1/", + "theme", nexttheme[9] + ) + + logger.info(_("Theme applied successfully. Variant: %s"), nexttheme[1]) + logger.debug(_("Themes used: %s"), { + "System": nexttheme[4], + "DE Theme": nexttheme[3], + "Decoration": nexttheme[5], + "Icon": nexttheme[7], + "Cursor": nexttheme[8], + "Plank": nexttheme[9], + }) + + except Exception as e: + logger.error(_("Error applying theme: %s"), e) + + def apply_gnome_theme(self, nexttheme: list): + """Handle GNOME/Unity-specific theme schema setup.""" + schema_id = 'org.gnome.shell.extensions.user-theme' + schema_source = Gio.SettingsSchemaSource.get_default() + schema_exists = schema_source.lookup(schema_id, True) + + if not schema_exists: + schema_dir = Path.home() / ".local/share/glib-2.0/schemas" + output_file = schema_dir / f"{schema_id}.gschema.xml" + self.write_schema_to_xml(schema_id, output_file) + subprocess.run(f"glib-compile-schemas {schema_dir}", shell=True, check=False) + logger.info(_("Compiled new GSettings schema at %s"), schema_dir) + + mapping = { + schema_id: ("name", nexttheme[3]), + "org.gnome.desktop.interface": [ + ("gtk-theme", nexttheme[4]), + ("icon-theme", nexttheme[7]), + ("cursor-theme", nexttheme[8]), + ("color-scheme", nexttheme[6]), + ("accent-color", nexttheme[1]), + ], + "org.gnome.desktop.wm.preferences": ("theme", nexttheme[5]) + } + self.apply_theme_map(mapping) + + def apply_theme_map(self, mapping: Dict[str, list]): + """Iterate through mapping and apply all theme settings.""" + for schema, entries in mapping.items(): + if isinstance(entries, tuple): + self.set_gsetting(schema, entries[0], entries[1]) + elif isinstance(entries, list): + for key, value in entries: + self.set_gsetting(schema, key, value) + + def get_desktop_theme(self, state: Dict, systheme: str, colvariants: list) -> Dict: + """Retrieve current theme configuration from the system.""" + de = state['DE'].lower() + logger.info(_("Reading desktop theme for DE: %s"), de) + themes = {} + + schema_map = { + "x-cinnamon": { + "Applications": "org.cinnamon.desktop.interface gtk-theme", + "Decoration": "org.cinnamon.desktop.wm.preferences theme", + "DE Theme": "org.cinnamon.theme name", + "Icon": "org.cinnamon.desktop.interface icon-theme", + "Cursor": "org.cinnamon.desktop.interface cursor-theme", + }, + "gnome": { + "Applications": "org.gnome.desktop.interface gtk-theme", + "Decoration": "org.gnome.desktop.wm.preferences theme", + "DE Theme": "org.gnome.shell.extensions.user-theme name", + "Icon": "org.gnome.desktop.interface icon-theme", + "Cursor": "org.gnome.desktop.interface cursor-theme", + }, + "mate": { + "Applications": "org.mate.interface gtk-theme", + "Decoration": "org.mate.Marco.general theme", + "Icon": "org.mate.interface icon-theme", + "Cursor": "org.mate.peripherals-mouse cursor-theme", + }, + } + + try: + if de in schema_map: + for k, cmd in schema_map[de].items(): + themes[k] = self.run_command(f"gsettings get {cmd}") + + if self.manager.plank_theme: + try: + themes["Plank"] = self.run_command("gsettings get net.launchpad.plank.dock.settings theme") + except subprocess.CalledProcessError: + themes["Plank"] = self.run_command( + "gsettings get net.launchpad.plank.dock.settings:/net/launchpad/plank/docks/dock1/ theme" + ) + + # Detect variant + variant = "Default" + for v in colvariants: + if v and v.lower() in themes.get("System", "").lower(): + variant = v + break + + logger.info(_("Detected theme variant: %s"), variant) + return {"Variant": variant, "Themes": themes} -# logger -module_logger = logging.getLogger('ThemeManager.DesktopTheme') - -class desktop_theme(): - - def __init__(self): - self.manager = TMBackend() - pass - - def set_desktop_theme(self, state, nexttheme): - module_logger.info("Desktop session: %s", state['DE']) - thisDE = state['DE'].lower() - if thisDE == "x-cinnamon": - # When the DE is cinnamon set - # Desktop theme - os.system("gsettings set org.cinnamon.theme name %s" % nexttheme[3]) - # Gtk theme - os.system("gsettings set org.cinnamon.desktop.interface gtk-theme %s" % nexttheme[4]) - # Window border/Metacity - os.system("gsettings set org.cinnamon.desktop.wm.preferences theme %s" % nexttheme[5]) - # Color pref for gtk4 apps - os.system("gsettings set org.x.apps.portal color-scheme %s" % nexttheme[6]) - # Icon theme - if self.manager.icon_theme: - os.system("gsettings set org.cinnamon.desktop.interface icon-theme %s" % nexttheme[7]) - # Cursor theme - if self.manager.cursor_theme: - os.system("gsettings set org.cinnamon.desktop.interface cursor-theme %s" % nexttheme[8]) - - elif (thisDE == "gnome" or thisDE == "ubuntu:gnome" or thisDE == "unity"): - # When the DE is gnome set - # Shell theme - os.system("gsettings set org.gnome.shell.extensions.user-theme name %s" % nexttheme[3]) - # Gtk theme - os.system("gsettings set org.gnome.desktop.interface gtk-theme %s" % nexttheme[4]) - # Window border/Metacity - os.system("gsettings set org.gnome.desktop.wm.preferences theme %s" % nexttheme[5]) - # Color pref for gtk4 apps - os.system("gsettings set org.gnome.desktop.interface color-scheme %s" % nexttheme[6]) - # Icon theme - if self.manager.icon_theme: - os.system("gsettings set org.gnome.desktop.interface icon-theme %s" % nexttheme[7]) - # Cursor theme - if self.manager.cursor_theme: - os.system("gsettings set org.gnome.desktop.interface cursor-theme %s" % nexttheme[8]) - - elif (thisDE == "mate"): - # When the DE is mate set - # Gtk theme - os.system("gsettings set org.mate.interface gtk-theme %s" % nexttheme[4]) - # Window border/Metacity - os.system("gsettings set org.mate.Marco.general theme %s" % nexttheme[5]) - # Color pref for gtk4 apps - os.system("gsettings set org.gnome.desktop.interface color-scheme %s" % nexttheme[6]) - # Icon theme - if self.manager.icon_theme: - os.system("gsettings set org.mate.interface icon-theme %s" % nexttheme[7]) - # Cursor theme - if self.manager.cursor_theme: - os.system("gsettings set org.mate.peripherals-mouse cursor-theme %s" % nexttheme[8]) - - # Plank theme - if self.manager.plank_theme: - try: - os.system("gsettings set net.launchpad.plank.dock.settings theme %s" % nexttheme[9]) - except subprocess.CalledProcessError: - os.system("gsettings set net.launchpad.plank.dock.settings:/net/launchpad/plank/docks/dock1/ theme %s" % nexttheme[9]) - except: - module_logger.error("Either 'plank' is not installed or the plank theme %s is not present." % nexttheme[9]) - - themes = {} - themes["System"] = nexttheme[4] - themes["DE Theme"] = nexttheme[3] - themes["Decoration"] = nexttheme[5] - themes["Icon"] = nexttheme[7] - themes["Cursor"] = nexttheme[8] - themes["Plank"] = nexttheme[9] - module_logger.info("Updated with Colour Variant: %s, Themes: %s" % (nexttheme[1], themes)) - - def get_desktop_theme(self, state, systheme, colvariants): - module_logger.info("Desktop session: %s", state['DE']) - thisDE = state['DE'].lower() - currenttheme = {"Variant": '', "Last Updated": '', "Themes": ''} - themes = {} - if thisDE == "x-cinnamon": - # When the DE is cinnamon get - # Gtk theme - themes["System"] = self.run_command("gsettings get org.cinnamon.desktop.interface gtk-theme") - # Window border/Metacity - themes["Decoration"] = self.run_command("gsettings get org.cinnamon.desktop.wm.preferences theme") - # Desktop/shell theme - themes["DE Theme"] = self.run_command("gsettings get org.cinnamon.theme name") - # Icon theme - themes["Icon"] = self.run_command("gsettings get org.cinnamon.desktop.interface icon-theme") - # Cursor theme - themes["Cursor"] = self.run_command("gsettings get org.cinnamon.desktop.interface cursor-theme") - - elif (thisDE == "gnome" or thisDE == "ubuntu:gnome" or thisDE == "unity"): - # When the DE is gnome/unity get - # Gtk theme - themes["System"] = self.run_command("gsettings get org.gnome.desktop.interface gtk-theme") - # Desktop/shell theme - themes["DE Theme"] = self.run_command("gsettings get org.gnome.desktop.wm.preferences theme") - # Icon theme - themes["Icon"] = self.run_command("gsettings get org.gnome.desktop.interface icon-theme") - # Cursor theme - themes["Cursor"] = self.run_command("gsettings get org.gnome.desktop.interface cursor-theme") - - elif (thisDE == "mate"): - # When the DE is mate get - # Gtk theme - currenttheme.append(self.run_command("gsettings get org.mate.interface gtk-theme")) - # Window border/Metacity - currenttheme.append(self.run_command("gsettings get org.mate.Marco.general theme")) - # Icon theme - currenttheme.append(self.run_command("gsettings get org.mate.interface icon-theme")) - # Cursor theme - currenttheme.append(self.run_command("gsettings get org.mate.peripherals-mouse cursor-theme")) - - # Plank theme - if self.manager.plank_theme: - try: - # backward compatibility for ubuntu <23.04 - themes["Plank"] = self.run_command("gsettings get net.launchpad.plank.dock.settings theme") - except subprocess.CalledProcessError: - # For Ubuntu >=23.04 - themes["Plank"] = self.run_command("gsettings get net.launchpad.plank.dock.settings:/net/launchpad/plank/docks/dock1/ theme") - except: - module_logger.error("'plank' is not installed.") - - currenttheme["Themes"] = themes - - # get current colour variant from key: System - for i in range(len(colvariants)): - if len(colvariants[i]) != 0: - if colvariants[i].lower() in themes["System"].lower(): - currenttheme["Variant"] = colvariants[i] - break - else: - # if string length of colour variant is zero - # that means we are using default theme - currenttheme["Variant"] = "Default" - - module_logger.info("Current Colour Variant: %s, Current Themes: %s" % (currenttheme["Variant"], currenttheme["Themes"])) - - return currenttheme - - def run_command(self, command): - output = subprocess.check_output(command, stderr = subprocess.PIPE, shell = True) - return output.decode('utf-8', "strict").strip('\n').strip('\'') + except Exception as e: + logger.error(_("Error retrieving desktop theme: %s"), e) + return {"Variant": "Unknown", "Themes": {}} diff --git a/src/ThemeManager/common.py b/src/ThemeManager/common.py index 0820911..3d78d09 100644 --- a/src/ThemeManager/common.py +++ b/src/ThemeManager/common.py @@ -1,24 +1,10 @@ -# Copyright (C) 2021-2024 Himadri Sekhar Basu -# -# This file is part of theme-manager. -# -# theme-manager is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# theme-manager is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with theme-manager. If not, see -# or write to the Free Software Foundation, Inc., 51 Franklin Street, -# Fifth Floor, Boston, MA 02110-1301, USA.. -# -# Author: Himadri Sekhar Basu -# +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Theme Manager – Desktop Theme Handler +# Copyright (C) 2021–2025 Himadri Sekhar Basu +# Licensed under GPLv3 or later +# ----------------------------------------------------------------------------- + # import the necessary modules! import configparser @@ -371,7 +357,11 @@ def prep_theme_variants(self, state, theme_styles): nxt_theme = [timestamp, currentcolor, stateflag, shelltheme, gtktheme, wmtheme, color_pref, icontheme, cursrtheme, planktheme] themes = {} - themes["System"] = gtktheme + if currentcolor: + themes["accent"] = currentcolor + else: + themes["accent"] = "green" + themes["Applications"] = gtktheme themes["DE Theme"] = shelltheme themes["Decoration"] = wmtheme themes["Icon"] = icontheme diff --git a/src/ThemeManager/gui.py b/src/ThemeManager/gui.py index 01c4a80..6d1b6dd 100644 --- a/src/ThemeManager/gui.py +++ b/src/ThemeManager/gui.py @@ -1,24 +1,10 @@ -# Copyright (C) 2021-2024 Himadri Sekhar Basu -# -# This file is part of theme-manager. -# -# theme-manager is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# theme-manager is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with theme-manager. If not, see -# or write to the Free Software Foundation, Inc., 51 Franklin Street, -# Fifth Floor, Boston, MA 02110-1301, USA.. -# -# Author: Himadri Sekhar Basu -# +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Theme Manager – Desktop Theme Handler +# Copyright (C) 2021–2025 Himadri Sekhar Basu +# Licensed under GPLv3 or later +# ----------------------------------------------------------------------------- + # import the necessary modules! import gettext @@ -39,8 +25,8 @@ from ThemeManager.logger import LoggerWindow from ThemeManager.cli_args import APP, LOCALE_DIR from ThemeManager.common import CONFIG_FILE, UI_PATH, __version__, theme_styles, _async, TMBackend -from ThemeManager.indicator import TMIndicator -from ThemeManager.DesktopTheme import desktop_theme +from ThemeManager.indicator import ThemeIndicator +from ThemeManager.DesktopTheme import DesktopTheme from ThemeManager.time_chooser import TimeChooserButton # from ThemeManager.LoginTheme import login_theme @@ -80,7 +66,7 @@ def __init__(self, application): self.application = application self.settings = Gio.Settings(schema_id="org.mamolinux.theme-manager") self.manager = TMBackend() - self.destop_manager = desktop_theme() + self.destop_manager = DesktopTheme() self.icon_theme = Gtk.IconTheme.get_default() # Set the Glade file @@ -352,14 +338,15 @@ def current_status(self): ''' Show current theme info in status bar. ''' - status = "DE: %s, \tState: %s, \tVariant: %s, \tLast Updated: %s, \tThemes: %s" % (self.state['DE'], self.state['State'], self.currenttheme["Variant"], self.currenttheme["Last Updated"], self.currenttheme["Themes"]) + module_logger.debug(("%s", self.currenttheme)) + status = "DE: %s, \tState: %s, \tVariant: %s, \tLast Updated: %s, \tThemes: %s" % (self.state['DE'], self.state['State'], self.currenttheme["Variant"], "", self.currenttheme["Themes"]) context_id = self.statusbar.get_context_id("status") self.statusbar.push(context_id, status) def start_indicator(self, widget): module_logger.info("Initiaing Theme Manager Indicator from main window.") - _async(TMIndicator()) + _async(ThemeIndicator()) def run_TMwindow(): diff --git a/src/ThemeManager/indicator.py b/src/ThemeManager/indicator.py index 031cd09..66f4dba 100644 --- a/src/ThemeManager/indicator.py +++ b/src/ThemeManager/indicator.py @@ -1,119 +1,127 @@ -# Copyright (C) 2021-2024 Himadri Sekhar Basu -# -# This file is part of theme-manager. -# -# theme-manager is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# theme-manager is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with theme-manager. If not, see -# or write to the Free Software Foundation, Inc., 51 Franklin Street, -# Fifth Floor, Boston, MA 02110-1301, USA.. -# -# Author: Himadri Sekhar Basu -# +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Theme Manager – Desktop Theme Handler +# Copyright (C) 2021–2025 Himadri Sekhar Basu +# Licensed under GPLv3 or later +# ----------------------------------------------------------------------------- + # import the necessary modules! import gettext import locale import logging - -# third-party library import gi + gi.require_version('AppIndicator3', '0.1') -from gi.repository import AppIndicator3 gi.require_version('Gtk', '3.0') -from gi.repository import Gtk -# imports from current package +from gi.repository import AppIndicator3, Gtk, GLib + from ThemeManager.about_window import AboutWindow from ThemeManager.logger import LoggerWindow from ThemeManager.cli_args import APP, LOCALE_DIR from ThemeManager.common import theme_styles -from ThemeManager.tm_daemon import TMState_monitor -from ThemeManager.DesktopTheme import desktop_theme - +from ThemeManager.tm_daemon import TMStateMonitor +from ThemeManager.DesktopTheme import DesktopTheme -# i18n +# ------------------------- i18n Setup ------------------------- locale.bindtextdomain(APP, LOCALE_DIR) gettext.bindtextdomain(APP, LOCALE_DIR) gettext.textdomain(APP) _ = gettext.gettext -# logger -module_logger = logging.getLogger('ThemeManager.indicator') - -class TMIndicator(): - """Class for system tray icon. - - This class will show Theme Manager icon in system tray. - """ - def __init__(self): - module_logger.debug("Initiaing Appindicator.") - self.indicator = AppIndicator3.Indicator.new(APP, APP, AppIndicator3.IndicatorCategory.SYSTEM_SERVICES) - self.indicator.set_title(_('Theme Manager')) - self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) - - self.destop_manager = desktop_theme() - self.daemon = TMState_monitor() - self.theme_styles = theme_styles - self.daemon.startdaemons() - - # create menu - self.indicator.set_menu(self.__create_menu()) - Gtk.main() - - def __create_menu(self): - menu = Gtk.Menu() - - # Add "Next Theme" option in drop-down menu - item_next_theme = Gtk.ImageMenuItem(_('Next Theme')) - item_next_theme.set_image(Gtk.Image.new_from_icon_name("next", Gtk.IconSize.MENU)) - item_next_theme.connect("activate", self.next_theme) - menu.append(item_next_theme) - - # Add "Show Logs" option in drop-down menu - item_show_log = Gtk.ImageMenuItem(_('Show Logs')) - item_show_log.set_image(Gtk.Image.new_from_icon_name("text-x-log", Gtk.IconSize.MENU)) - item_show_log.connect("activate", self.show_logs, Gtk.Window()) - menu.append(item_show_log) - - # Add "About" option in drop-down menu - item_about = Gtk.ImageMenuItem(_('About')) - item_about.set_image(Gtk.Image.new_from_icon_name("help-about-symbolic", Gtk.IconSize.MENU)) - item_about.connect("activate", self.open_about, Gtk.Window()) - menu.append(item_about) - - # Add "Quit" option in drop-down menu - item_quit = Gtk.ImageMenuItem(_('Quit')) - item_quit.set_image(Gtk.Image.new_from_icon_name("stock_close", Gtk.IconSize.MENU)) - item_quit.connect("activate", self.__quit) - menu.append(item_quit) - - menu.show_all() - - return menu - - def next_theme(self, *args): - module_logger.info("User requested change using Next button from indicator.") - self.state = self.daemon.manager.get_state_info() - self.nexttheme = self.daemon.manager.prep_theme_variants(self.state, self.theme_styles) - self.daemon.destop_manager.set_desktop_theme(self.state, self.nexttheme) - - def show_logs(self, signal, widget): - loggerwindow = LoggerWindow(widget) - loggerwindow.show() - - def open_about(self, signal, widget): - about_window = AboutWindow(widget) - about_window.show() - - def __quit(self, *args): - Gtk.main_quit() +# ------------------------- Logger ----------------------------- +logger = logging.getLogger("ThemeManager.Indicator") + + +class ThemeIndicator: + """System tray icon and controller for Theme Manager.""" + + def __init__(self): + logger.debug("Initializing AppIndicator for Theme Manager...") + self.indicator = AppIndicator3.Indicator.new( + APP, APP, AppIndicator3.IndicatorCategory.SYSTEM_SERVICES + ) + self.indicator.set_title(_("Theme Manager")) + self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) + + # Core managers + self.desktop_manager = DesktopTheme() + self.daemon = TMStateMonitor() + self.theme_styles = theme_styles + self.daemon.start() + + # Attach GTK menu + self.indicator.set_menu(self._create_menu()) + + logger.info(_("Theme Manager Indicator initialized successfully.")) + Gtk.main() + + # ------------------------- Menu Creation ------------------------- + + def _create_menu(self): + """Construct the indicator menu.""" + menu = Gtk.Menu() + + items = [ + (_("Next Theme"), "next", self.next_theme), + (_("Show Logs"), "text-x-log", self.show_logs), + (_("About"), "help-about-symbolic", self.open_about), + (_("Quit"), "application-exit", self._quit), + ] + + for label, icon, handler in items: + item = Gtk.ImageMenuItem(label) + item.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU)) + item.connect("activate", handler) + menu.append(item) + + menu.show_all() + return menu + + # ------------------------- Menu Handlers ------------------------- + def next_theme(self, *args, **kwargs): + """Trigger theme change manually.""" + # Use translation safely + _translate = _ # Ensure we reference the global translation function + + try: + logger.info(_translate("User triggered Next Theme from indicator.")) + # state = self.daemon.manager.get_state_info() + # logger.debug(_translate("State: %s"), state) + # nxt_theme = self.daemon.manager.prep_theme_variants(state, self.theme_styles) + # logger.debug(_translate("Next Themes: %s"), state) + + # Run asynchronously to avoid blocking GTK loop + # GLib.idle_add(self.desktop_manager.set_desktop_theme, state, nxt_theme) + GLib.idle_add(self.daemon._update_theme) + except Exception as e: + # Catch errors safely, with translation + logger.error(_translate("Error in next_theme: %s"), e) + + def show_logs(self, _action=None, _widget=None): + """Open Logger window.""" + logger.debug(_("Opening Logger Window...")) + win = LoggerWindow(Gtk.Window()) + win.show() + + def open_about(self, _action=None, _widget=None): + """Open About dialog.""" + logger.debug(_("Opening About Window...")) + win = AboutWindow(Gtk.Window()) + win.show() + + def _quit(self, *args, **kwargs): + """Exit indicator cleanly.""" + logger.info(_("Exiting Theme Manager Indicator...")) + self.daemon.stop() + Gtk.main_quit() + + +# ------------------------- Entry Point ------------------------- +if __name__ == "__main__": + try: + ThemeIndicator() + except KeyboardInterrupt: + Gtk.main_quit() + logger.info(_("Theme Manager Indicator exited cleanly.")) diff --git a/src/ThemeManager/main.py b/src/ThemeManager/main.py index 874d8ec..459f1f5 100644 --- a/src/ThemeManager/main.py +++ b/src/ThemeManager/main.py @@ -1,24 +1,10 @@ -# Copyright (C) 2021-2024 Himadri Sekhar Basu -# -# This file is part of theme-manager. -# -# theme-manager is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# theme-manager is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with theme-manager. If not, see -# or write to the Free Software Foundation, Inc., 51 Franklin Street, -# Fifth Floor, Boston, MA 02110-1301, USA.. -# -# Author: Himadri Sekhar Basu -# +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Theme Manager – Desktop Theme Handler +# Copyright (C) 2021–2025 Himadri Sekhar Basu +# Licensed under GPLv3 or later +# ----------------------------------------------------------------------------- + # import the necessary modules! import gettext @@ -30,7 +16,7 @@ # imports from current package from ThemeManager.common import APP, LOCALE_DIR, LOGFILE, __version__ from ThemeManager.cli_args import command_line_args -from ThemeManager.indicator import TMIndicator +from ThemeManager.indicator import ThemeIndicator from ThemeManager.gui import run_TMwindow @@ -91,7 +77,10 @@ def start_TM (): args.start_window = False # initiaing app indicator module_logger.debug("Initiaing Theme Manager Indicator.") - TMIndicator() + try: + ThemeIndicator() + except KeyboardInterrupt: + logger.info(_("Theme Manager Indicator exited cleanly.")) if args.start_window: # initiaing app window diff --git a/src/ThemeManager/tm_daemon.py b/src/ThemeManager/tm_daemon.py index 0a8e0f3..6b0f5d8 100644 --- a/src/ThemeManager/tm_daemon.py +++ b/src/ThemeManager/tm_daemon.py @@ -1,82 +1,113 @@ -# Copyright (C) 2021-2024 Himadri Sekhar Basu -# -# This file is part of theme-manager. -# -# theme-manager is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# theme-manager is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with theme-manager. If not, see -# or write to the Free Software Foundation, Inc., 51 Franklin Street, -# Fifth Floor, Boston, MA 02110-1301, USA.. -# -# Author: Himadri Sekhar Basu -# - -# import the necessary modules! +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Theme Manager Daemon +# Copyright (C) 2021–2025 Himadri Sekhar Basu +# Licensed under GPLv3 or later +# ----------------------------------------------------------------------------- + import gettext import locale import logging - +import threading from time import sleep -# imports from current package from ThemeManager.cli_args import APP, LOCALE_DIR from ThemeManager.common import theme_styles, _async, TMBackend -from ThemeManager.DesktopTheme import desktop_theme - +from ThemeManager.DesktopTheme import DesktopTheme -# i18n +# ------------------------- i18n Setup ------------------------- locale.bindtextdomain(APP, LOCALE_DIR) gettext.bindtextdomain(APP, LOCALE_DIR) gettext.textdomain(APP) _ = gettext.gettext -# logger -module_logger = logging.getLogger('ThemeManager.tm_daemon') - -class TMState_monitor(): - def __init__(self): - module_logger.debug("Initiaing Theme Manager daemon.") - self.manager = TMBackend() - self.theme_styles = theme_styles - self.destop_manager = desktop_theme() - self.last_state = 'Unknown' - - def startdaemons(self): - module_logger.debug("Initiaing state change daemon.") - statechange_daemon = _async(self.on_statechange) - statechange_daemon() - sleep(10) - - module_logger.debug("Initiaing auto-change at regular interval daemon.") - autouser_request_daemon = _async(self.on_autouser_request) - autouser_request_daemon() - - def on_statechange(self): - module_logger.info("Starting to monitor state change.") - while True: - self.state = self.manager.get_state_info() - currentstate = self.state['State'].lower() - module_logger.debug("Now state: "+currentstate) - module_logger.debug("Old state: "+self.last_state) - if self.last_state != currentstate: - self.last_state = currentstate - self.nexttheme = self.manager.prep_theme_variants(self.state, self.theme_styles) - self.destop_manager.set_desktop_theme(self.state, self.nexttheme) - sleep(60) # check once in a minute whether the state is changed - - def on_autouser_request(self): - module_logger.info("Starting auto-change at regular interval.") - while True: - self.state = self.manager.get_state_info() - self.nexttheme = self.manager.prep_theme_variants(self.state, self.theme_styles) - self.destop_manager.set_desktop_theme(self.state, self.nexttheme) - sleep(self.manager.theme_interval_in_sec) +# ------------------------- Logger ----------------------------- +logger = logging.getLogger("ThemeManager.tm_daemon") + + +class TMStateMonitor: + """Background daemon to monitor and apply desktop theme changes.""" + + def __init__(self): + logger.debug(_("Initializing Theme Manager Daemon.")) + self.manager = TMBackend() + self.theme_styles = theme_styles + self.desktop_manager = DesktopTheme() + self.last_state = None + self._stop_event = threading.Event() + + # ------------------------- Public API ------------------------- + + def start(self): + """Start daemon threads for state and interval monitoring.""" + logger.info(_("Starting Theme Manager Daemon threads...")) + + self._run_async(self._monitor_state_change, delay=0) + self._run_async(self._auto_change_interval, delay=10) + + def stop(self): + """Gracefully stop daemon threads.""" + logger.info(_("Stopping Theme Manager Daemon...")) + self._stop_event.set() + + # ------------------------- Internal Helpers ------------------------- + + def _run_async(self, target, delay=0): + """Helper to start async daemon thread with optional delay.""" + @_async + def runner(): + if delay: + sleep(delay) + target() + runner() + + def _update_theme(self): + """Prepare and apply next theme based on current system state.""" + self.state = self.manager.get_state_info() + next_theme = self.manager.prep_theme_variants(self.state, self.theme_styles) + self.desktop_manager.set_desktop_theme(self.state, next_theme) + + # ------------------------- Daemon Threads ------------------------- + + def _monitor_state_change(self): + """Continuously monitor system state and react to theme changes.""" + logger.info(_("State-change daemon started.")) + while not self._stop_event.is_set(): + try: + state = self.manager.get_state_info() + current_state = state.get("State", "unknown").lower() + logger.debug(_("Current state: %s, Last: %s"), current_state, self.last_state) + + if current_state != self.last_state: + logger.info(_("State changed: %s → %s"), self.last_state, current_state) + self.last_state = current_state + self._update_theme() + + except Exception as e: + logger.error(_("Error in state monitor: %s"), e) + + sleep(60) # Check every minute + + def _auto_change_interval(self): + """Periodically trigger theme changes at user-defined intervals.""" + logger.info(_("Auto-change daemon started.")) + while not self._stop_event.is_set(): + try: + self._update_theme() + except Exception as e: + logger.error(_("Error in auto-change daemon: %s"), e) + + sleep(self.manager.theme_interval_in_sec) + + +# ------------------------- Entry Point ------------------------- +if __name__ == "__main__": + daemon = TMStateMonitor() + daemon.start() + + try: + while True: + sleep(1) + except KeyboardInterrupt: + daemon.stop() + logger.info(_("Theme Manager Daemon exited cleanly."))