diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 52b784b..ebfc5ca 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -38,4 +38,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
- pytest scripts/tests.py
+ pytest tests/
diff --git a/install.py b/install.py
index 1c2f84c..5d3b6c0 100644
--- a/install.py
+++ b/install.py
@@ -1,6 +1,6 @@
# This file installs Marble shell theme for GNOME DE
# Copyright (C) 2023-2025 Vladyslav Hroshev
-
+import os
# This program 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
@@ -22,6 +22,7 @@ from scripts.install.colors_definer import ColorsDefiner
from scripts.install.global_theme_installer import GlobalThemeInstaller
from scripts.install.local_theme_installer import LocalThemeInstaller
from scripts.utils.gnome import apply_gnome_theme
+from scripts.utils.logger.console import Console
def main():
@@ -31,6 +32,12 @@ def main():
installer_class = GlobalThemeInstaller if args.gdm else LocalThemeInstaller
installer = installer_class(args, colors_definer)
+ if args.gdm:
+ if os.getuid() != 0:
+ Console().Line().error(
+ "Global installation requires root privileges. Please run the script as root.")
+ return
+
if args.remove or args.reinstall:
installer.remove()
diff --git a/scripts/config.py b/scripts/config.py
index 8a0e6f1..c9e3c71 100644
--- a/scripts/config.py
+++ b/scripts/config.py
@@ -1,15 +1,14 @@
-import os.path
+import os
from tempfile import gettempdir
# folder definitions
-temp_folder = f"{gettempdir()}/marble"
+marble_folder = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+temp_folder = os.path.join(gettempdir(), 'marble')
+temp_tests_folder = os.path.join(temp_folder, 'tests')
gdm_folder = "gdm"
-gnome_folder = "gnome-shell"
-temp_gnome_folder = f"{temp_folder}/{gnome_folder}"
tweaks_folder = "tweaks"
-themes_folder = "~/.themes"
+themes_folder = os.path.expanduser("~/.themes")
raw_theme_folder = "theme"
-scripts_folder = "scripts"
# GDM definitions
global_gnome_shell_theme = "/usr/share/gnome-shell"
@@ -18,8 +17,7 @@ ubuntu_gresource_link = "gtk-theme.gresource"
extracted_gdm_folder = "theme"
# files definitions
-gnome_shell_css = f"{temp_gnome_folder}/gnome-shell.css"
tweak_file = f"./{tweaks_folder}/*/tweak.py"
-colors_json = "colors.json"
+colors_json = os.path.join(marble_folder, "colors.json")
user_themes_extension = "/org/gnome/shell/extensions/user-theme/name"
diff --git a/scripts/gdm.py b/scripts/gdm.py
deleted file mode 100644
index 5a573a4..0000000
--- a/scripts/gdm.py
+++ /dev/null
@@ -1,149 +0,0 @@
-import os
-from typing import Optional
-
-from .install.colors_definer import ColorsDefiner
-from .theme import Theme
-from .utils import remove_properties, remove_keywords
-from . import config
-from .utils.alternatives_updater import AlternativesUpdater
-from .utils.console import Console, Color, Format
-from .utils.files_labeler import FilesLabeler
-from .utils.gresource import Gresource, GresourceBackupNotFoundError
-
-
-class GlobalTheme:
- """Class to install global theme for GDM"""
- def __init__(self,
- colors_json: ColorsDefiner, theme_folder: str,
- destination_folder: str, destination_file: str, temp_folder: str,
- mode: Optional[str] = None, is_filled = False
- ):
- """
- :param colors_json: location of a JSON file with color values
- :param theme_folder: raw theme location
- :param destination_folder: folder where themes will be installed
- :param temp_folder: folder where files will be collected
- :param mode: theme mode (light or dark)
- :param is_filled: if True, the theme will be filled
- """
- self.colors_json = colors_json
- self.theme_folder = theme_folder
- self.destination_folder = destination_folder
- self.destination_file = destination_file
- self.temp_folder = temp_folder
- self.is_filled = is_filled
- self.mode = mode
-
- self.themes: list[ThemePrepare] = []
-
- self.__is_installed_trigger = "\n/* Marble theme */\n"
- self.__gresource_file = os.path.join(self.destination_folder, self.destination_file)
-
- self.__gresource_temp_folder = os.path.join(self.temp_folder, config.extracted_gdm_folder)
- self.__gresource = Gresource(self.destination_file, self.__gresource_temp_folder, self.destination_folder)
-
- def prepare(self):
- if self.__is_installed():
- Console.Line().info("Theme is installed. Reinstalling...")
- self.__gresource.use_backup_gresource()
-
- self.__gresource.extract()
- self.__find_themes()
-
- def __is_installed(self) -> bool:
- if not hasattr(self, '__is_installed_cached'):
- with open(self.__gresource_file, "rb") as f:
- self.__is_installed_cached = self.__is_installed_trigger.encode() in f.read()
-
- return self.__is_installed_cached
-
- def __find_themes(self):
- extracted_theme_files = os.listdir(self.__gresource_temp_folder)
-
- allowed_modes = ("dark", "light")
- allowed_css = ("gnome-shell-dark", "gnome-shell-light", "gnome-shell")
-
- for style_name in allowed_css:
- style_file = style_name + ".css"
- if style_file in extracted_theme_files:
- last_mode = style_name.split("-")[-1]
- mode = last_mode if last_mode in allowed_modes else None
- self.__append_theme(style_name, mode=mode or self.mode, label=mode)
-
- def __append_theme(self, theme_type: str, mode = None, label: Optional[str] = None):
- """Helper to create theme objects"""
- theme = Theme(theme_type, self.colors_json, self.theme_folder,
- self.__gresource_temp_folder, self.temp_folder,
- mode=mode, is_filled=self.is_filled)
- theme.prepare()
- theme_file = os.path.join(self.__gresource_temp_folder, f"{theme_type}.css")
- self.themes.append(ThemePrepare(theme=theme, theme_file=theme_file, label=label))
-
- def install(self, hue, sat=None):
- """Install theme globally"""
- if os.geteuid() != 0:
- raise Exception("Root privileges required to install GDM theme")
-
- self.__generate_themes(hue, 'Marble', sat)
-
- self.__gresource.compile()
- if not self.__is_installed():
- self.__gresource.backup()
- self.__gresource.move()
-
- self.__update_alternatives()
-
- def __generate_themes(self, hue: int, color: str, sat: Optional[int] = None):
- """Generate theme files for gnome-shell-theme.gresource.xml"""
- for theme_prepare in self.themes:
- if theme_prepare.label is not None:
- temp_folder = theme_prepare.theme.temp_folder
- main_styles = theme_prepare.theme.main_styles
- FilesLabeler(temp_folder, main_styles).append_label(theme_prepare.label)
-
- remove_keywords(theme_prepare.theme_file, "!important")
- remove_properties(theme_prepare.theme_file, "background-color", "color", "box-shadow", "border-radius")
-
- self.__add_gnome_styles(theme_prepare.theme)
-
- theme_prepare.theme.install(hue, color, sat, destination=self.__gresource_temp_folder)
-
- def __add_gnome_styles(self, theme: Theme):
- """Add gnome styles to the start of the file"""
- with open(f"{theme.destination_folder}/{theme.theme_type}.css", 'r') as gnome_theme:
- gnome_styles = gnome_theme.read() + self.__is_installed_trigger
- theme.add_to_start(gnome_styles)
-
- def __update_alternatives(self):
- link = os.path.join(self.destination_folder, config.ubuntu_gresource_link)
- name = config.ubuntu_gresource_link
- path = os.path.join(self.destination_folder, self.destination_file)
- AlternativesUpdater.install_and_set(link, name, path)
-
- def remove(self):
- if self.__is_installed():
- removing_line = Console.Line()
- removing_line.update("Theme is installed. Removing...")
-
- try:
- self.__gresource.restore()
- removing_line.success("Global theme removed successfully. Restart GDM to apply changes.")
- except GresourceBackupNotFoundError:
- formatted_shell = Console.format("gnome-shell", color=Color.BLUE, format_type=Format.BOLD)
- removing_line.error(f"Backup file not found. Try reinstalling {formatted_shell} package.")
- else:
- Console.Line().error("Theme is not installed. Nothing to remove.")
- Console.Line().warn("If theme is still installed globally, try reinstalling gnome-shell package.")
-
- def __remove_alternatives(self):
- name = config.ubuntu_gresource_link
- path = os.path.join(self.destination_folder, self.destination_file)
- AlternativesUpdater.remove(name, path)
-
-
-class ThemePrepare:
- """Theme data class prepared for installation"""
- def __init__(self, theme: Theme, theme_file, label: Optional[str] = None):
- self.theme = theme
- self.theme_file = theme_file
- self.label = label
\ No newline at end of file
diff --git a/scripts/install/global_theme_installer.py b/scripts/install/global_theme_installer.py
index a45a341..77e3470 100644
--- a/scripts/install/global_theme_installer.py
+++ b/scripts/install/global_theme_installer.py
@@ -1,13 +1,11 @@
-import os
-
-from scripts import config
-from scripts.gdm import GlobalTheme
from scripts.install.theme_installer import ThemeInstaller
-from scripts.utils.console import Console, Color, Format
+from scripts.utils.global_theme.gdm import GDMTheme
+from scripts.utils.global_theme.gdm_builder import GDMThemeBuilder
+from scripts.utils.logger.console import Console, Color, Format
class GlobalThemeInstaller(ThemeInstaller):
- theme: GlobalTheme
+ theme: GDMTheme
def remove(self):
gdm_rm_status = self.theme.remove()
@@ -15,13 +13,10 @@ class GlobalThemeInstaller(ThemeInstaller):
print("GDM theme removed successfully.")
def _define_theme(self):
- gdm_temp = os.path.join(config.temp_folder, config.gdm_folder)
- self.theme = GlobalTheme(self.colors, f"{config.raw_theme_folder}/{config.gnome_folder}",
- config.global_gnome_shell_theme, config.gnome_shell_gresource,
- gdm_temp, mode=self.args.mode, is_filled=self.args.filled)
-
- def _install_theme(self, hue, theme_name, sat):
- self.theme.install(hue, sat)
+ gdm_builder = GDMThemeBuilder(self.colors)
+ gdm_builder.with_mode(self.args.mode)
+ gdm_builder.with_filled(self.args.filled)
+ self.theme = gdm_builder.build()
def _apply_tweaks_to_theme(self):
for theme in self.theme.themes:
diff --git a/scripts/install/local_theme_installer.py b/scripts/install/local_theme_installer.py
index 7648003..b181843 100644
--- a/scripts/install/local_theme_installer.py
+++ b/scripts/install/local_theme_installer.py
@@ -1,10 +1,8 @@
-import os.path
-
-from scripts import config
from scripts.install.theme_installer import ThemeInstaller
-from scripts.theme import Theme
+from scripts.utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder
+from scripts.utils.theme.theme import Theme
from scripts.utils import remove_files
-from scripts.utils.console import Console, Color, Format
+from scripts.utils.logger.console import Console, Color, Format
class LocalThemeInstaller(ThemeInstaller):
@@ -15,13 +13,10 @@ class LocalThemeInstaller(ThemeInstaller):
remove_files(self.args, colors)
def _define_theme(self):
- theme_folder = os.path.join(config.raw_theme_folder, config.gnome_folder)
- self.theme = Theme("gnome-shell", self.colors, theme_folder,
- config.themes_folder, config.temp_folder,
- mode=self.args.mode, is_filled=self.args.filled)
-
- def _install_theme(self, hue, theme_name, sat):
- self.theme.install(hue, theme_name, sat)
+ theme_builder = GnomeShellThemeBuilder(self.colors)
+ theme_builder.with_mode(self.args.mode)
+ theme_builder.filled(self.args.filled)
+ self.theme = theme_builder.build()
def _apply_tweaks_to_theme(self):
self._apply_tweaks(self.theme)
diff --git a/scripts/install/theme_installer.py b/scripts/install/theme_installer.py
index 4f53669..5ad3c06 100644
--- a/scripts/install/theme_installer.py
+++ b/scripts/install/theme_installer.py
@@ -3,13 +3,13 @@ import concurrent.futures
from abc import ABC, abstractmethod
from scripts.install.colors_definer import ColorsDefiner
-from scripts.theme import Theme
+from scripts.types.theme_base import ThemeBase
from scripts.tweaks_manager import TweaksManager
class ThemeInstaller(ABC):
"""Base class for theme installers"""
- theme: Theme
+ theme: ThemeBase
def __init__(self, args: argparse.Namespace, colors: ColorsDefiner):
self.args = args
@@ -37,11 +37,6 @@ class ThemeInstaller(ABC):
"""Should apply the tweaks for prepared theme"""
pass
- @abstractmethod
- def _install_theme(self, hue, theme_name, sat):
- """Should say how to install the defined theme"""
- pass
-
@abstractmethod
def _after_install(self):
"""Method to be called after the theme is installed. Can be used for logging or other actions"""
@@ -53,16 +48,14 @@ class ThemeInstaller(ABC):
tweaks_manager.apply_tweaks(self.args, theme, self.colors)
def _apply_colors(self):
- installed_any = False
-
if self.args.hue:
- installed_any = True
self._apply_custom_color()
- else:
- installed_any = self._apply_default_color()
+ return
- if not installed_any:
- raise Exception('No color arguments specified. Use -h or --help to see the available options.')
+ if self._apply_default_color():
+ return
+
+ raise Exception('No color arguments specified. Use -h or --help to see the available options.')
def _apply_custom_color(self):
name = self.args.name
@@ -70,7 +63,7 @@ class ThemeInstaller(ABC):
sat = self.args.sat
theme_name = name if name else f'hue{hue}'
- self._install_theme(hue, theme_name, sat)
+ self.theme.install(hue, theme_name, sat)
def _apply_default_color(self) -> bool:
colors = self.colors.colors
@@ -90,7 +83,7 @@ class ThemeInstaller(ABC):
def _run_concurrent_installation(self, colors_to_install):
with concurrent.futures.ThreadPoolExecutor() as executor:
- futures = [executor.submit(self._install_theme, hue, color, sat)
+ futures = [executor.submit(self.theme.install, hue, color, sat)
for hue, color, sat in colors_to_install]
for future in concurrent.futures.as_completed(futures):
diff --git a/scripts/tests.py b/scripts/tests.py
deleted file mode 100644
index 9130560..0000000
--- a/scripts/tests.py
+++ /dev/null
@@ -1,70 +0,0 @@
-# TODO: Add more tests
-
-import unittest
-import os
-import json
-import shutil
-from unittest.mock import patch
-
-from . import config
-from .theme import Theme
-
-# folders
-tests_folder = '.tests'
-project_folder = '.'
-
-
-class TestInstall(unittest.TestCase):
- def setUp(self):
- # Create necessary directories
- os.makedirs(f"{project_folder}/{config.raw_theme_folder}/{config.gnome_folder}", exist_ok=True)
- os.makedirs(f"{tests_folder}/.themes", exist_ok=True)
- os.makedirs(f"{tests_folder}/.temp", exist_ok=True)
-
- def tearDown(self):
- # Clean up after tests
- shutil.rmtree(tests_folder, ignore_errors=True)
-
- @patch('scripts.utils.gnome.subprocess.check_output')
- def test_install_theme(self, mock_check_output):
- """
- Test if theme is installed correctly (colors are replaced)
- """
- mock_check_output.return_value = 'GNOME Shell 47.0\n'
-
- # folders
- themes_folder = f"{tests_folder}/.themes"
- temp_folder = f"{tests_folder}/.temp"
-
- # colors from colors.json
- colors_json = open(f"{project_folder}/{config.colors_json}")
- colors = json.load(colors_json)
- colors_json.close()
-
- # create test theme
- test_theme = Theme("gnome-shell", colors,
- f"{project_folder}/{config.raw_theme_folder}/{config.gnome_folder}",
- themes_folder, temp_folder,
- mode='light', is_filled=True)
-
- # install test theme
- test_theme.install(120, 'test', 70)
-
- # folder with installed theme (.tests/.themes/Marble-test-light/gnome-shell)
- installed_theme = f"{themes_folder}/{os.listdir(themes_folder)[0]}/{config.gnome_folder}"
-
- # check if files are installed
- for file in os.listdir(installed_theme):
- with open(f"{installed_theme}/{file}") as f:
- read_file = f.read()
-
- for color in colors["elements"]:
- self.assertNotIn(color, read_file, msg=f"Color {color} is not replaced in {file}")
-
- # delete test theme
- del test_theme
- shutil.rmtree(tests_folder)
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/scripts/theme.py b/scripts/theme.py
deleted file mode 100644
index 05573de..0000000
--- a/scripts/theme.py
+++ /dev/null
@@ -1,172 +0,0 @@
-import os
-import shutil
-import colorsys # colorsys.hls_to_rgb(h, l, s)
-
-from .install.colors_definer import ColorsDefiner
-from .utils import (
- replace_keywords, # replace keywords in file
- copy_files, # copy files from source to destination
- destination_return, # copied/modified theme location
- generate_file) # combine files from folder to one file
-from .utils.console import Console, Color, Format
-
-
-class Theme:
- def __init__(self, theme_type, colors_json, theme_folder, destination_folder, temp_folder,
- mode=None, is_filled=False):
- """
- Initialize Theme class
- :param colors_json: location of a json file with colors
- :param theme_type: theme type (gnome-shell, gtk, etc.)
- :param theme_folder: raw theme location
- :param destination_folder: folder where themes will be installed
- :param temp_folder: folder where files will be collected
- :param mode: theme mode (light or dark)
- :param is_filled: if True, theme will be filled
- """
-
- self.colors: ColorsDefiner = colors_json
- self.temp_folder = f"{temp_folder}/{theme_type}"
- self.theme_folder = theme_folder
- self.theme_type = theme_type
- self.modes = [mode] if mode else ['light', 'dark']
- self.destination_folder = destination_folder
- self.main_styles = f"{self.temp_folder}/{theme_type}.css"
- self.is_filled = is_filled
-
- def __add__(self, other):
- """
- Add to main styles another styles
- :param other: styles to add
- :return: new Theme object
- """
-
- with open(self.main_styles, 'a') as main_styles:
- main_styles.write('\n' + other)
- return self
-
- def __mul__(self, other):
- """
- Copy files to temp folder
- :param other: file or folder
- :return: new Theme object
- """
-
- if os.path.isfile(other):
- shutil.copy(other, self.temp_folder)
- else:
- shutil.copytree(other, self.temp_folder)
-
- return self
-
- def prepare(self):
- # move files to temp folder
- copy_files(self.theme_folder, self.temp_folder)
- generate_file(f"{self.theme_folder}", self.temp_folder, self.main_styles)
- # after generating main styles, remove .css and .versions folders
- shutil.rmtree(f"{self.temp_folder}/.css/", ignore_errors=True)
- shutil.rmtree(f"{self.temp_folder}/.versions/", ignore_errors=True)
-
- # if theme is filled
- if self.is_filled:
- for apply_file in os.listdir(f"{self.temp_folder}/"):
- replace_keywords(f"{self.temp_folder}/{apply_file}",
- ("BUTTON-COLOR", "ACCENT-FILLED-COLOR"),
- ("BUTTON_HOVER", "ACCENT-FILLED_HOVER"),
- ("BUTTON_ACTIVE", "ACCENT-FILLED_ACTIVE"),
- ("BUTTON_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"),
- ("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"),
- ("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY"))
-
- def add_to_start(self, content):
- """
- Add content to the start of main styles
- :param content: content to add
- """
-
- with open(self.main_styles, 'r') as main_styles:
- main_content = main_styles.read()
-
- with open(self.main_styles, 'w') as main_styles:
- main_styles.write(content + '\n' + main_content)
-
- def install(self, hue, name: str, sat=None, destination=None):
- """
- Copy files and generate theme with specified accent color
- :param hue
- :param name: theme name
- :param sat
- :param destination: folder where theme will be installed
- """
-
- joint_modes = f"({', '.join(self.modes)})"
-
- line = Console.Line(name)
- formatted_name = Console.format(name.capitalize(), color=Color.get(name), format_type=Format.BOLD)
- formatted_mode = Console.format(joint_modes, color=Color.GRAY)
- line.update(f"Creating {formatted_name} {formatted_mode} theme...")
-
- try:
- self._install_and_apply_theme(hue, name, sat=sat, destination=destination)
- line.success(f"{formatted_name} {formatted_mode} theme created successfully.")
-
- except Exception as err:
- line.error(f"Error installing {formatted_name} theme: {str(err)}")
-
- def _install_and_apply_theme(self, hue, name, sat=None, destination=None):
- is_dest = bool(destination)
- for mode in self.modes:
- if not is_dest:
- destination = destination_return(self.destination_folder, name, mode, self.theme_type)
-
- copy_files(self.temp_folder + '/', destination)
- self.__apply_theme(hue, self.temp_folder, destination, mode, sat=sat)
-
- def __apply_theme(self, hue, source, destination, theme_mode, sat=None):
- """
- Apply theme to all files in directory
- :param hue
- :param source
- :param destination: file directory
- :param theme_mode: theme name (light or dark)
- :param sat: color saturation (optional)
- """
-
- for apply_file in os.listdir(f"{source}/"):
- self.__apply_colors(hue, destination, theme_mode, apply_file, sat=sat)
-
- def __apply_colors(self, hue, destination, theme_mode, apply_file, sat=None):
- """
- Install accent colors from colors.json to different file
- :param hue
- :param destination: file directory
- :param theme_mode: theme name (light or dark)
- :param apply_file: file name
- :param sat: color saturation (optional)
- """
-
- # list of (keyword, replaced value)
- replaced_colors = list()
-
- # colorsys works in range(0, 1)
- h = hue / 360
- for element in self.colors.replacers:
- # if color has default color and hasn't been replaced
- if theme_mode not in self.colors.replacers[element] and self.colors.replacers[element]["default"]:
- default_element = self.colors.replacers[element]["default"]
- default_color = self.colors.replacers[default_element][theme_mode]
- self.colors.replacers[element][theme_mode] = default_color
-
- # convert sla to range(0, 1)
- lightness = int(self.colors.replacers[element][theme_mode]["l"]) / 100
- saturation = int(self.colors.replacers[element][theme_mode]["s"]) / 100 if sat is None else \
- int(self.colors.replacers[element][theme_mode]["s"]) * (sat / 100) / 100
- alpha = self.colors.replacers[element][theme_mode]["a"]
-
- # convert hsl to rgb and multiply every item
- red, green, blue = [int(item * 255) for item in colorsys.hls_to_rgb(h, lightness, saturation)]
-
- replaced_colors.append((element, f"rgba({red}, {green}, {blue}, {alpha})"))
-
- # replace colors
- replace_keywords(os.path.expanduser(f"{destination}/{apply_file}"), *replaced_colors)
\ No newline at end of file
diff --git a/scripts/types/__init__.py b/scripts/types/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/types/installation_color.py b/scripts/types/installation_color.py
new file mode 100644
index 0000000..ded5b40
--- /dev/null
+++ b/scripts/types/installation_color.py
@@ -0,0 +1,11 @@
+from dataclasses import dataclass
+from typing import Literal
+
+InstallationMode = Literal["light", "dark"]
+
+
+@dataclass
+class InstallationColor:
+ hue: int
+ saturation: int | None
+ modes: list[InstallationMode]
\ No newline at end of file
diff --git a/scripts/types/theme_base.py b/scripts/types/theme_base.py
new file mode 100644
index 0000000..a9c18c9
--- /dev/null
+++ b/scripts/types/theme_base.py
@@ -0,0 +1,12 @@
+from abc import ABC, abstractmethod
+
+
+class ThemeBase(ABC):
+ """Base class for theme installation and preparation."""
+ @abstractmethod
+ def prepare(self):
+ pass
+
+ @abstractmethod
+ def install(self, hue: int, name: str, sat: float | None = None):
+ pass
\ No newline at end of file
diff --git a/scripts/utils/__init__.py b/scripts/utils/__init__.py
index 786b174..2b91836 100644
--- a/scripts/utils/__init__.py
+++ b/scripts/utils/__init__.py
@@ -1,8 +1,5 @@
-from .concatenate_files import concatenate_files
from .copy_files import copy_files
-from .destinaiton_return import destination_return
from .generate_file import generate_file
-from .hex_to_rgba import hex_to_rgba
from .remove_files import remove_files
from .remove_keywords import remove_keywords
from .remove_properties import remove_properties
diff --git a/scripts/utils/alternatives_updater.py b/scripts/utils/alternatives_updater.py
index f5e4ff8..1761dce 100644
--- a/scripts/utils/alternatives_updater.py
+++ b/scripts/utils/alternatives_updater.py
@@ -2,7 +2,7 @@ import functools
import subprocess
from typing import TypeAlias
-from scripts.utils.console import Console
+from scripts.utils.logger.console import Console
PathString: TypeAlias = str | bytes
@@ -28,7 +28,7 @@ class AlternativesUpdater:
@staticmethod
@ubuntu_specific
- def install_and_set(link: str, name: str, path: PathString, priority: int = 0):
+ def install_and_set(link: PathString, name: str, path: PathString, priority: int = 0):
AlternativesUpdater.install(link, name, path, priority)
AlternativesUpdater.set(name, path)
diff --git a/scripts/utils/color_converter/__init__.py b/scripts/utils/color_converter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/utils/color_converter/color_converter.py b/scripts/utils/color_converter/color_converter.py
new file mode 100644
index 0000000..6683659
--- /dev/null
+++ b/scripts/utils/color_converter/color_converter.py
@@ -0,0 +1,26 @@
+from abc import ABC, abstractmethod
+
+
+class ColorConverter(ABC):
+ @staticmethod
+ @abstractmethod
+ def hex_to_rgba(hex_color):
+ """
+ Converts a HEX color code to RGBA format.
+ :param hex_color: HEX color code (e.g., '#ff5733' or 'ff5733').
+ :return: Tuple of RGBA values (red, green, blue, alpha).
+ :raises ValueError: If the HEX color code is invalid.
+ """
+ pass
+
+ @staticmethod
+ @abstractmethod
+ def hsl_to_rgb(hue, saturation, lightness):
+ """
+ Converts HSL color values to RGB format.
+ :param hue: Hue value (0-360).
+ :param saturation: Saturation value (0-1).
+ :param lightness: Lightness value (0-1).
+ :return: Tuple of RGB values (red, green, blue) in range(0-255).
+ """
+ pass
diff --git a/scripts/utils/color_converter/color_converter_impl.py b/scripts/utils/color_converter/color_converter_impl.py
new file mode 100644
index 0000000..aff09ad
--- /dev/null
+++ b/scripts/utils/color_converter/color_converter_impl.py
@@ -0,0 +1,43 @@
+import colorsys
+
+from scripts.utils.color_converter.color_converter import ColorConverter
+
+
+class ColorConverterImpl(ColorConverter):
+ @staticmethod
+ def hex_to_rgba(hex_color):
+ try:
+ hex_color = hex_color.lstrip('#')
+
+ # Handle shorthand hex colors (e.g., #fff)
+ if len(hex_color) == 3:
+ hex_color = ''.join([char * 2 for char in hex_color])
+
+ # Add alpha channel if missing
+ if len(hex_color) == 6:
+ hex_color += "ff"
+
+ # Validate the hex color
+ int(hex_color, 16)
+
+ except ValueError:
+ raise ValueError(f'Error: Invalid HEX color code: #{hex_color}')
+
+ else:
+ return int(hex_color[0:2], 16), \
+ int(hex_color[2:4], 16), \
+ int(hex_color[4:6], 16), \
+ int(hex_color[6:8], 16) / 255
+
+ @staticmethod
+ def hsl_to_rgb(hue, saturation, lightness):
+ if hue > 360 or hue < 0:
+ raise ValueError(f'Hue must be between 0 and 360, not {hue}')
+ if saturation > 1 or saturation < 0:
+ raise ValueError(f'Saturation must be between 0 and 1, not {saturation}')
+ if lightness > 1 or lightness < 0:
+ raise ValueError(f'Lightness must be between 0 and 1, not {lightness}')
+
+ h = hue / 360
+ red, green, blue = [round(item * 255) for item in colorsys.hls_to_rgb(h, lightness, saturation)]
+ return red, green, blue
diff --git a/scripts/utils/command_runner/__init__.py b/scripts/utils/command_runner/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/utils/command_runner/command_runner.py b/scripts/utils/command_runner/command_runner.py
new file mode 100644
index 0000000..abbbca0
--- /dev/null
+++ b/scripts/utils/command_runner/command_runner.py
@@ -0,0 +1,14 @@
+import subprocess
+from abc import ABC, abstractmethod
+
+
+class CommandRunner(ABC):
+ @abstractmethod
+ def run(self, command: list[str], **kwargs) -> subprocess.CompletedProcess:
+ """
+ Run a command in the shell and return the output.
+ :param command: Command to run.
+ :param kwargs: Additional arguments for the command.
+ :return: Output of the command.
+ """
+ pass
\ No newline at end of file
diff --git a/scripts/utils/command_runner/subprocess_command_runner.py b/scripts/utils/command_runner/subprocess_command_runner.py
new file mode 100644
index 0000000..0f9732e
--- /dev/null
+++ b/scripts/utils/command_runner/subprocess_command_runner.py
@@ -0,0 +1,8 @@
+import subprocess
+
+from scripts.utils.command_runner.command_runner import CommandRunner
+
+
+class SubprocessCommandRunner(CommandRunner):
+ def run(self, command: list[str], **kwargs) -> subprocess.CompletedProcess:
+ return subprocess.run(command, **kwargs)
\ No newline at end of file
diff --git a/scripts/utils/concatenate_files.py b/scripts/utils/concatenate_files.py
deleted file mode 100644
index 6ede247..0000000
--- a/scripts/utils/concatenate_files.py
+++ /dev/null
@@ -1,12 +0,0 @@
-def concatenate_files(edit_file, file):
- """
- Merge two files
- :param edit_file: where it will be appended
- :param file: file you want to append
- """
-
- with open(file, 'r') as read_file:
- file_content = read_file.read()
-
- with open(edit_file, 'a') as write_file:
- write_file.write('\n' + file_content)
\ No newline at end of file
diff --git a/scripts/utils/destinaiton_return.py b/scripts/utils/destinaiton_return.py
deleted file mode 100644
index c43cf75..0000000
--- a/scripts/utils/destinaiton_return.py
+++ /dev/null
@@ -1,11 +0,0 @@
-def destination_return(themes_folder, path_name, theme_mode, theme_type):
- """
- Copied/modified theme location
- :param themes_folder: themes folder location
- :param path_name: color name
- :param theme_mode: theme name (light or dark)
- :param theme_type: theme type (gnome-shell, gtk-4.0, ...)
- :return: copied files' folder location
- """
-
- return f"{themes_folder}/Marble-{path_name}-{theme_mode}/{theme_type}/"
diff --git a/scripts/utils/files_labeler.py b/scripts/utils/files_labeler.py
index 6243f7a..1107f45 100644
--- a/scripts/utils/files_labeler.py
+++ b/scripts/utils/files_labeler.py
@@ -1,15 +1,28 @@
import os
+from abc import ABC, abstractmethod
from typing import Tuple, TypeAlias
LabeledFileGroup: TypeAlias = Tuple[str, str]
+
+class FilesLabelerFactory(ABC):
+ @abstractmethod
+ def create(self, temp_folder: str, *files_to_update_references: str) -> 'FilesLabeler':
+ pass
+
+
+class FilesLabelerFactoryImpl(FilesLabelerFactory):
+ def create(self, temp_folder: str, *files_to_update_references: str) -> 'FilesLabeler':
+ return FilesLabeler(temp_folder, *files_to_update_references)
+
+
class FilesLabeler:
- def __init__(self, directory: str, *args: str):
+ def __init__(self, directory: str, *files_to_update_references: str):
"""
Initialize the working directory and files to change
"""
self.directory = directory
- self.files = args
+ self.files = files_to_update_references
def append_label(self, label: str):
"""
diff --git a/scripts/utils/get_version_folder.py b/scripts/utils/get_version_folder.py
index 92848fc..4ba2418 100644
--- a/scripts/utils/get_version_folder.py
+++ b/scripts/utils/get_version_folder.py
@@ -7,6 +7,9 @@ def get_version_folders(version, base_path):
:param base_path: base path to version folders
:return: list of matching version folders
"""
+ if not os.path.exists(base_path):
+ return []
+
version_folders = os.listdir(base_path)
version = int(version.split('.')[0]) # Use only the major version for comparison
matching_folders = []
diff --git a/scripts/utils/global_theme/__init__.py b/scripts/utils/global_theme/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/utils/global_theme/gdm.py b/scripts/utils/global_theme/gdm.py
new file mode 100644
index 0000000..6af2608
--- /dev/null
+++ b/scripts/utils/global_theme/gdm.py
@@ -0,0 +1,88 @@
+from scripts.types.theme_base import ThemeBase
+from scripts.utils.global_theme.gdm_installer import GDMThemeInstaller
+from scripts.utils.global_theme.gdm_preparer import GDMThemePreparer
+from scripts.utils.global_theme.gdm_remover import GDMThemeRemover
+from scripts.utils.global_theme.gdm_theme_prepare import GDMThemePrepare
+
+
+class GDMTheme(ThemeBase):
+ """
+ GDM theming module.
+
+ This module provides functionality to prepare, install, and remove GNOME Display Manager themes.
+ It follows a workflow of:
+ 1. Preparing themes from existing GDM resources
+ 2. Installing themes with custom colors/styles
+ 3. Providing ability to restore original GDM themes
+
+ The main entry point is the GDMTheme class, which orchestrates the entire theme management process.
+ """
+ def __init__(self, preparer: GDMThemePreparer, installer: GDMThemeInstaller, remover: GDMThemeRemover):
+ """
+ :param preparer: GDMThemePreparer instance for preparing themes
+ :param installer: GDMThemeInstaller instance for installing themes
+ :param remover: GDMThemeRemover instance for removing themes
+ """
+ self.preparer = preparer
+ self.installer = installer
+ self.remover = remover
+
+ self.themes: list[GDMThemePrepare] = []
+
+ def prepare(self):
+ """
+ Prepare the theme for installation.
+
+ This method:
+ 1. Checks if a theme is already installed and uses backup if needed
+ 2. Extracts relevant theme files
+ 3. Processes them into ready-to-compile GDMThemePrepare objects
+
+ The processed themes are stored in the themes attribute for later use.
+ """
+ if self._is_installed():
+ self.preparer.use_backup_as_source()
+ self.themes = self.preparer.prepare()
+
+ def _is_installed(self) -> bool:
+ """
+ Check if a GDM theme is currently installed.
+
+ This looks for specific markers in the system gresource files
+ that indicate our theme has been installed.
+
+ :return: True if a custom theme is installed, False otherwise
+ """
+ return self.installer.is_installed()
+
+ def install(self, hue: int, name: str, sat: float | None = None):
+ """
+ Install the prepared theme with specified color adjustments.
+
+ This method:
+ 1. Compiles theme files with the specified hue and saturation
+ 2. Creates a backup of the original GDM theme if one doesn't exist
+ 3. Installs the compiled theme to the system
+
+ :param hue: The hue adjustment (0-360) to apply to the theme
+ :param name: The name of the theme to be installed. In GDM will only be shown in logger
+ :param sat: Optional saturation adjustment (0-100) to apply
+ """
+ self.installer.compile(self.themes, hue, name, sat)
+
+ if not self._is_installed():
+ self.installer.backup()
+
+ self.installer.install()
+
+ def remove(self):
+ """
+ Remove the installed theme and restore the original GDM theme.
+
+ If no theme is installed, displays a warning message to the user.
+ This will restore from backup and update GDM alternatives if needed.
+ """
+ if self._is_installed():
+ self.remover.remove()
+ else:
+ self.remover.warn_not_installed()
diff --git a/scripts/utils/global_theme/gdm_builder.py b/scripts/utils/global_theme/gdm_builder.py
new file mode 100644
index 0000000..31ea366
--- /dev/null
+++ b/scripts/utils/global_theme/gdm_builder.py
@@ -0,0 +1,171 @@
+import os.path
+from typing import Optional
+
+from scripts import config
+from scripts.install.colors_definer import ColorsDefiner
+from scripts.types.installation_color import InstallationMode
+from scripts.utils.alternatives_updater import AlternativesUpdater, PathString
+from scripts.utils.command_runner.subprocess_command_runner import SubprocessCommandRunner
+from scripts.utils.files_labeler import FilesLabelerFactoryImpl
+from scripts.utils.global_theme.gdm import GDMTheme
+from scripts.utils.global_theme.gdm_installer import GDMThemeInstaller
+from scripts.utils.global_theme.gdm_preparer import GDMThemePreparer
+from scripts.utils.global_theme.gdm_remover import GDMThemeRemover
+from scripts.utils.global_theme.ubuntu_alternatives_updater import UbuntuGDMAlternativesUpdater
+from scripts.utils.gresource.gresource import Gresource
+from scripts.utils.logger.console import Console
+from scripts.utils.logger.logger import LoggerFactory
+from scripts.utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder
+
+
+class GDMThemeBuilder:
+ """
+ Builder class for creating GDMTheme instances with configurable components.
+
+ This class follows the Builder pattern to create a GDMTheme with all necessary
+ dependencies. Dependencies can be injected via the with_* methods or will be
+ automatically resolved during build() if not provided.
+
+ Example usage:
+ builder = GMDThemeBuilder(colors_provider)
+ theme = builder.with_mode("dark").with_filled(True).build()
+ """
+ def __init__(self, colors_provider: ColorsDefiner):
+ """
+ :param colors_provider: A provider for color definitions.
+ """
+ self.colors_provider: ColorsDefiner = colors_provider
+ self._temp_folder: PathString = os.path.join(config.temp_folder, config.gdm_folder)
+ self._mode: Optional[InstallationMode] = None
+ self._is_filled: bool = False
+
+ self._logger_factory: Optional[LoggerFactory] = None
+ self._gresource: Optional[Gresource] = None
+ self._ubuntu_gdm_alternatives_updater: Optional[UbuntuGDMAlternativesUpdater] = None
+
+ self._preparer: Optional[GDMThemePreparer] = None
+ self._installer: Optional[GDMThemeInstaller] = None
+ self._remover: Optional[GDMThemeRemover] = None
+
+ def with_mode(self, mode: InstallationMode | None) -> 'GDMThemeBuilder':
+ """Set the mode for the theme."""
+ self._mode = mode
+ return self
+
+ def with_filled(self, is_filled=True) -> 'GDMThemeBuilder':
+ """Set the filled state for the theme."""
+ self._is_filled = is_filled
+ return self
+
+ def with_logger_factory(self, logger_factory: LoggerFactory) -> 'GDMThemeBuilder':
+ """Inject a logger factory for logging purposes."""
+ self._logger_factory = logger_factory
+ return self
+
+ def with_gresource(self, gresource: Gresource) -> 'GDMThemeBuilder':
+ """Inject a gresource instance for managing gresource files."""
+ self._gresource = gresource
+ return self
+
+ def with_ubuntu_gdm_alternatives_updater(self, alternatives_updater: UbuntuGDMAlternativesUpdater) -> 'GDMThemeBuilder':
+ """Inject an alternatives updater for managing GDM alternatives."""
+ self._ubuntu_gdm_alternatives_updater = alternatives_updater
+ return self
+
+ def with_preparer(self, preparer: GDMThemePreparer) -> 'GDMThemeBuilder':
+ """Inject a preparer for preparing the theme."""
+ self._preparer = preparer
+ return self
+
+ def with_installer(self, installer: GDMThemeInstaller) -> 'GDMThemeBuilder':
+ """Inject an installer for installing the theme."""
+ self._installer = installer
+ return self
+
+ def with_remover(self, remover: GDMThemeRemover) -> 'GDMThemeBuilder':
+ """Inject a remover for removing the theme."""
+ self._remover = remover
+ return self
+
+ def build(self) -> GDMTheme:
+ """
+ Build the GDMTheme object with the configured components.
+
+ Automatically resolves any dependencies that haven't been explicitly
+ provided through with_* methods. The order of resolution ensures
+ that dependencies are created before they're needed.
+
+ :return: A fully configured GDMTheme instance ready for use
+ """
+ self._resolve_logger_factory()
+ self._resolve_gresource()
+ self._resolve_ubuntu_gdm_alternatives_updater()
+
+ self._resolve_preparer()
+ self._resolve_installer()
+ self._resolve_remover()
+
+ return GDMTheme(self._preparer, self._installer, self._remover)
+
+ def _resolve_logger_factory(self):
+ """Instantiate a default Console logger if not explicitly provided."""
+ if self._logger_factory: return
+ self._logger_factory = Console()
+
+ def _resolve_gresource(self):
+ """
+ Create a Gresource handler if not explicitly provided.
+ Uses configuration values for file paths and destinations.
+ """
+ if self._gresource: return
+
+ gresource_file = config.gnome_shell_gresource
+ temp_folder = os.path.join(self._temp_folder, config.extracted_gdm_folder)
+ destination = config.global_gnome_shell_theme
+ runner = SubprocessCommandRunner()
+
+ self._gresource = Gresource(
+ gresource_file=gresource_file,
+ temp_folder=temp_folder,
+ destination=destination,
+ logger_factory=self._logger_factory,
+ runner=runner
+ )
+
+ def _resolve_ubuntu_gdm_alternatives_updater(self):
+ """Create an UbuntuGDMAlternativesUpdater if not explicitly provided."""
+ if self._ubuntu_gdm_alternatives_updater: return
+ alternatives_updater = AlternativesUpdater()
+ self._ubuntu_gdm_alternatives_updater = UbuntuGDMAlternativesUpdater(alternatives_updater)
+
+ def _resolve_preparer(self):
+ """Create a GDMThemePreparer if not explicitly provided."""
+ if self._preparer: return
+ theme_builder = GnomeShellThemeBuilder(self.colors_provider)
+ files_labeler_factory = FilesLabelerFactoryImpl()
+ self._preparer = GDMThemePreparer(
+ temp_folder=self._temp_folder,
+ default_mode=self._mode,
+ is_filled=self._is_filled,
+ gresource=self._gresource,
+ theme_builder=theme_builder,
+ logger_factory=self._logger_factory,
+ files_labeler_factory=files_labeler_factory,
+ )
+
+ def _resolve_installer(self):
+ """Create a GDMThemeInstaller if not explicitly provided."""
+ if self._installer: return
+ self._installer = GDMThemeInstaller(
+ gresource=self._gresource,
+ alternatives_updater=self._ubuntu_gdm_alternatives_updater,
+ )
+
+ def _resolve_remover(self):
+ """Create a GDMThemeRemover if not explicitly provided."""
+ if self._remover: return
+ self._remover = GDMThemeRemover(
+ gresource=self._gresource,
+ alternatives_updater=self._ubuntu_gdm_alternatives_updater,
+ logger_factory=self._logger_factory
+ )
\ No newline at end of file
diff --git a/scripts/utils/global_theme/gdm_installer.py b/scripts/utils/global_theme/gdm_installer.py
new file mode 100644
index 0000000..29f5350
--- /dev/null
+++ b/scripts/utils/global_theme/gdm_installer.py
@@ -0,0 +1,66 @@
+from scripts.utils.global_theme.gdm_theme_prepare import GDMThemePrepare
+from scripts.utils.global_theme.ubuntu_alternatives_updater import UbuntuGDMAlternativesUpdater
+from scripts.utils.gresource.gresource import Gresource
+
+
+class GDMThemeInstaller:
+ """
+ Handles the installation of GDM themes system-wide.
+
+ This class manages:
+ - Compiling prepared theme resources into a gresource file
+ - Creating backups of original system files
+ - Installing compiled themes via the alternatives system
+ - Detecting if a theme is already installed
+ """
+ def __init__(self, gresource: Gresource, alternatives_updater: UbuntuGDMAlternativesUpdater):
+ """
+ :param gresource: Handler for gresource operations
+ :param alternatives_updater: Handler for update-alternatives operations
+ """
+ self.gresource = gresource
+ self.alternatives_updater = alternatives_updater
+
+ self._is_installed_trigger = "\n/* Marble theme */\n"
+
+ def is_installed(self) -> bool:
+ """
+ Check if the theme is installed
+ by looking for the trigger in the gresource file.
+ """
+ return self.gresource.has_trigger(self._is_installed_trigger)
+
+ def compile(self, themes: list[GDMThemePrepare], hue: int, color: str, sat: int = None):
+ """
+ Prepares themes for gresource and compiles them.
+ :param themes: themes to be compiled
+ :param hue: hue value for the theme
+ :param color: the color name. in GDM will only be shown in logger
+ :param sat: saturation value for the theme
+ """
+ self._generate_themes(themes, hue, color, sat)
+ self.gresource.compile()
+
+ def _generate_themes(self, themes: list[GDMThemePrepare], hue: int, color: str, sat: int = None):
+ """Generate theme files for further compiling by gresource"""
+ for theme_prepare in themes:
+ if theme_prepare.label is not None:
+ theme_prepare.label_theme()
+
+ theme_prepare.remove_keywords("!important")
+ theme_prepare.remove_properties("background-color", "color", "box-shadow", "border-radius")
+ theme_prepare.prepend_source_styles(self._is_installed_trigger)
+
+ theme_prepare.install(hue, color, sat, destination=self.gresource.temp_folder)
+
+ def backup(self):
+ """Backup the current gresource file."""
+ self.gresource.backup()
+
+ def install(self):
+ """
+ Install the theme globally by moving the compiled gresource file to the destination.
+ Also updates the alternatives for the gdm theme.
+ """
+ self.gresource.move()
+ self.alternatives_updater.install_and_set()
\ No newline at end of file
diff --git a/scripts/utils/global_theme/gdm_preparer.py b/scripts/utils/global_theme/gdm_preparer.py
new file mode 100644
index 0000000..3af77e6
--- /dev/null
+++ b/scripts/utils/global_theme/gdm_preparer.py
@@ -0,0 +1,94 @@
+import os
+
+from scripts.utils.files_labeler import FilesLabelerFactory
+from scripts.utils.global_theme.gdm_theme_prepare import GDMThemePrepare
+from scripts.utils.gresource.gresource import Gresource
+from scripts.utils.logger.logger import LoggerFactory
+from scripts.utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder
+
+
+class GDMThemePreparer:
+ """
+ GDM theme preparation module.
+
+ This module contains classes responsible for extracting and processing
+ GDM theme resources for later compilation and installation.
+
+ The main class, GDMThemePreparer, orchestrates the extraction process
+ and creates GDMThemePrepare objects representing processable themes.
+ """
+ def __init__(self, temp_folder: str, default_mode: str | None, is_filled: bool,
+ gresource: Gresource,
+ theme_builder: GnomeShellThemeBuilder,
+ logger_factory: LoggerFactory,
+ files_labeler_factory: FilesLabelerFactory):
+ """
+ :param temp_folder: Temporary folder for extracted theme files
+ :param default_mode: Default theme mode to use if not specified in CSS filename
+ :param is_filled: Whether to generate filled (True) or dimmed (False) styles
+ :param gresource: Gresource instance for managing gresource files
+ :param theme_builder: Theme builder instance for creating themes
+ :param logger_factory: Logger factory for logging messages
+ :param files_labeler_factory: Factory for creating FilesLabeler instances
+ """
+ self.temp_folder = temp_folder
+ self.gresource_temp_folder = gresource.temp_folder
+
+ self.default_mode = default_mode
+ self.is_filled = is_filled
+
+ self.gresource = gresource
+ self.theme_builder = theme_builder
+ self.logger_factory = logger_factory
+ self.files_labeler_factory = files_labeler_factory
+
+ def use_backup_as_source(self):
+ """Use backup gresource file for extraction"""
+ self.gresource.use_backup_gresource()
+ self.logger_factory.create_logger().info("Using backup gresource file for extraction...")
+
+ def prepare(self) -> list[GDMThemePrepare]:
+ """
+ Extract and prepare GDM themes for processing.
+ :return: List of prepared theme objects ready for compilation
+ """
+ self.gresource.extract()
+ return self._find_themes()
+
+ def _find_themes(self) -> list[GDMThemePrepare]:
+ extracted_files = os.listdir(self.gresource_temp_folder)
+ allowed_css = {"gnome-shell-dark.css", "gnome-shell-light.css", "gnome-shell.css"}
+
+ themes = [
+ self._create_theme(file_name)
+ for file_name in extracted_files
+ if file_name in allowed_css
+ ]
+ return themes
+
+ def _create_theme(self, file_name: str) -> GDMThemePrepare:
+ """Helper to create and prepare a theme"""
+ mode = file_name.split("-")[-1].replace(".css", "")
+ mode = mode if mode in {"dark", "light"} else self.default_mode
+
+ self._setup_theme_builder(file_name, mode)
+
+ theme = self.theme_builder.build()
+ theme.prepare()
+
+ theme_file = os.path.join(self.gresource_temp_folder, file_name)
+ files_labeler = self.files_labeler_factory.create(
+ theme.temp_folder, theme.main_styles)
+ return GDMThemePrepare(
+ theme=theme, theme_file=theme_file, label=mode, files_labeler=files_labeler)
+
+ def _setup_theme_builder(self, file_name: str, mode: str):
+ theme_name = file_name.replace(".css", "")
+
+ (self.theme_builder
+ .with_temp_folder(self.temp_folder)
+ .with_theme_name(theme_name)
+ .with_mode(mode)
+ .filled(self.is_filled)
+ .with_logger_factory(self.logger_factory)
+ .with_reset_dependencies())
\ No newline at end of file
diff --git a/scripts/utils/global_theme/gdm_remover.py b/scripts/utils/global_theme/gdm_remover.py
new file mode 100644
index 0000000..75f4c62
--- /dev/null
+++ b/scripts/utils/global_theme/gdm_remover.py
@@ -0,0 +1,65 @@
+from scripts.utils.global_theme.ubuntu_alternatives_updater import UbuntuGDMAlternativesUpdater
+from scripts.utils.gresource import GresourceBackupNotFoundError
+from scripts.utils.gresource.gresource import Gresource
+from scripts.utils.logger.console import Console, Color, Format
+from scripts.utils.logger.logger import LoggerFactory
+
+
+class GDMThemeRemover:
+ """
+ Responsible for safely removing installed GDM themes.
+
+ This class handles:
+ - Restoring original gresource files from backups
+ - Removing theme alternatives from the system
+ - Providing feedback about removal status
+ """
+ def __init__(self,
+ gresource: Gresource,
+ alternatives_updater: UbuntuGDMAlternativesUpdater,
+ logger_factory: LoggerFactory):
+ """
+ :param gresource: Handler for gresource operations
+ :param alternatives_updater: Handler for update-alternatives operations
+ :param logger_factory: Factory for creating loggers
+ """
+ self.gresource = gresource
+ self.alternatives_updater = alternatives_updater
+ self.remover_logger = GDMRemoverLogger(logger_factory)
+
+ def remove(self):
+ """Restores the gresource backup and removes the alternatives."""
+ self.remover_logger.start_removing()
+
+ try:
+ self.gresource.restore()
+ self.alternatives_updater.remove()
+ self.remover_logger.success_removing()
+ except GresourceBackupNotFoundError:
+ self.remover_logger.error_removing()
+
+ def warn_not_installed(self):
+ self.remover_logger.not_installed_warning()
+
+
+class GDMRemoverLogger:
+ def __init__(self, logger_factory: LoggerFactory):
+ self.logger_factory = logger_factory
+ self.removing_line = None
+
+ def start_removing(self):
+ self.removing_line = self.logger_factory.create_logger()
+ self.removing_line.update("Theme is installed. Removing...")
+
+ def success_removing(self):
+ self.removing_line.success("Global theme removed successfully. Restart GDM to apply changes.")
+
+ def error_removing(self):
+ formatted_shell = Console.format("gnome-shell", color=Color.BLUE, format_type=Format.BOLD)
+ self.removing_line.error(f"Backup file not found. Try reinstalling {formatted_shell} package.")
+
+ def not_installed_warning(self):
+ self.logger_factory.create_logger().error(
+ "Theme is not installed. Nothing to remove.")
+ self.logger_factory.create_logger().warn(
+ "If theme is still installed globally, try reinstalling gnome-shell package.")
\ No newline at end of file
diff --git a/scripts/utils/global_theme/gdm_theme_prepare.py b/scripts/utils/global_theme/gdm_theme_prepare.py
new file mode 100644
index 0000000..66db0d7
--- /dev/null
+++ b/scripts/utils/global_theme/gdm_theme_prepare.py
@@ -0,0 +1,68 @@
+from scripts.utils import remove_keywords, remove_properties
+from scripts.utils.files_labeler import FilesLabeler
+from scripts.utils.theme.theme import Theme
+
+
+class GDMThemePrepare:
+ """
+ Prepares theme files for installation into the GDM system.
+
+ This class handles:
+ - Theme file labeling for dark/light variants
+ - CSS property and keyword removal for customization
+ - Theme installation with color adjustments
+ """
+ def __init__(self, theme: Theme, theme_file: str, label: str | None,
+ files_labeler: FilesLabeler):
+ """
+ :param theme: The theme object to prepare
+ :param theme_file: Path to the original decompiled CSS file
+ :param label: Optional label for the theme (e.g. "dark", "light")
+ :param files_labeler: FilesLabeler instance for labeling files
+ """
+ self.theme = theme
+ self.theme_file = theme_file
+ self.label = label
+ self.files_labeler = files_labeler
+
+ def label_theme(self):
+ """
+ Label the theme files if the label is set.
+ Also updates references in the theme files.
+ :raises ValueError: if the label is not set
+ """
+ if self.label is None:
+ raise ValueError("Label is not set for the theme.")
+
+ self.files_labeler.append_label(self.label)
+
+ def remove_keywords(self, *args: str):
+ """Remove specific keywords from the theme file"""
+ remove_keywords(self.theme_file, *args)
+
+ def remove_properties(self, *args: str):
+ """Remove specific properties from the theme file"""
+ remove_properties(self.theme_file, *args)
+
+ def prepend_source_styles(self, trigger: str):
+ """
+ Add source styles and installation trigger to the theme file.
+
+ This adds original theme styles and a marker that identifies
+ the theme as installed by this application.
+
+ :param trigger: String marker used to identify installed themes
+ """
+ with open(self.theme_file, 'r') as gnome_theme:
+ gnome_styles = gnome_theme.read() + '\n' + trigger + '\n'
+ self.theme.add_to_start(gnome_styles)
+
+ def install(self, hue: int, color: str, sat: int | None, destination: str):
+ """
+ Install the theme to the specified destination
+ :param hue: Hue value for the theme
+ :param color: Color name for the theme
+ :param sat: Saturation value for the theme
+ :param destination: Destination folder for the theme
+ """
+ self.theme.install(hue, color, sat, destination=destination)
\ No newline at end of file
diff --git a/scripts/utils/global_theme/ubuntu_alternatives_updater.py b/scripts/utils/global_theme/ubuntu_alternatives_updater.py
new file mode 100644
index 0000000..351cb5d
--- /dev/null
+++ b/scripts/utils/global_theme/ubuntu_alternatives_updater.py
@@ -0,0 +1,65 @@
+import os.path
+
+from scripts import config
+from scripts.utils.alternatives_updater import AlternativesUpdater
+
+
+class UbuntuGDMAlternativesUpdater:
+ """
+ Manages update-alternatives for Ubuntu GDM themes.
+
+ This class handles:
+ - Creating alternatives for GDM theme files
+ - Setting installed theme as the active alternative
+ - Removing theme alternatives during uninstallation
+ """
+ def __init__(self, alternatives_updater: AlternativesUpdater):
+ """
+ :param alternatives_updater: Handler for update-alternatives operations
+ """
+ self.ubuntu_gresource_link_name = config.ubuntu_gresource_link
+ self.destination_dir = config.global_gnome_shell_theme
+ self.destination_file = config.gnome_shell_gresource
+
+ self.alternatives_updater = alternatives_updater
+
+ self._update_gresource_paths()
+
+ def _update_gresource_paths(self):
+ self.ubuntu_gresource_path = os.path.join(self.destination_dir, self.ubuntu_gresource_link_name)
+ self.gnome_gresource_path = os.path.join(self.destination_dir, self.destination_file)
+
+ def with_custom_destination(self, destination_dir: str, destination_file: str):
+ """Set custom destination directory and file for the theme."""
+ self.destination_dir = destination_dir
+ self.destination_file = destination_file
+ self._update_gresource_paths()
+ return self
+
+ def install_and_set(self, priority: int = 0):
+ """
+ Add theme as an alternative and set it as active.
+
+ This creates a system alternative for the GDM theme and
+ makes it the active selection with the specified priority.
+
+ :param priority: Priority level for the alternative (higher wins in conflicts)
+ """
+ self.alternatives_updater.install_and_set(
+ link=self.ubuntu_gresource_path,
+ name=self.ubuntu_gresource_link_name,
+ path=self.gnome_gresource_path,
+ priority=priority
+ )
+
+ def remove(self):
+ """
+ Remove the theme alternative from the system.
+
+ This removes the previously installed alternative, allowing
+ the system to fall back to the default GDM theme.
+ """
+ self.alternatives_updater.remove(
+ name=self.ubuntu_gresource_link_name,
+ path=self.gnome_gresource_path
+ )
diff --git a/scripts/utils/gnome.py b/scripts/utils/gnome.py
index 9448766..83e65cc 100644
--- a/scripts/utils/gnome.py
+++ b/scripts/utils/gnome.py
@@ -2,7 +2,7 @@ import subprocess
import time
from scripts import config
-from scripts.utils.console import Console, Format, Color
+from scripts.utils.logger.console import Console, Format, Color
from scripts.utils.parse_folder import parse_folder
diff --git a/scripts/utils/gresource.py b/scripts/utils/gresource.py
deleted file mode 100644
index 8d15657..0000000
--- a/scripts/utils/gresource.py
+++ /dev/null
@@ -1,164 +0,0 @@
-import os
-import subprocess
-import textwrap
-from pathlib import Path
-
-from scripts.utils.console import Console
-
-
-class GresourceBackupNotFoundError(FileNotFoundError):
- def __init__(self, location: str = None):
- if location:
- super().__init__(f"Gresource backup file not found: {location}")
- else:
- super().__init__("Gresource backup file not found.")
-
-
-class Gresource:
- """Handles the extraction and compilation of gresource files for GNOME Shell themes."""
-
- def __init__(self, gresource_file: str, temp_folder: str, destination: str):
- """
- :param gresource_file: The name of the gresource file to be processed.
- :param temp_folder: The temporary folder where resources will be extracted.
- :param destination: The destination folder where the compiled gresource file will be saved.
- """
- self.gresource_file = gresource_file
- self.temp_folder = temp_folder
- self.destination = destination
-
- self.__temp_gresource = os.path.join(temp_folder, gresource_file)
- self.__destination_gresource = os.path.join(destination, gresource_file)
- self.__active_source_gresource = self.__destination_gresource
- self.__backup_gresource = os.path.join(destination, f"{gresource_file}.backup")
- self.__gresource_xml = os.path.join(temp_folder, f"{gresource_file}.xml")
-
- def use_backup_gresource(self):
- if not os.path.exists(self.__backup_gresource):
- raise GresourceBackupNotFoundError(self.__backup_gresource)
- self.__active_source_gresource = self.__backup_gresource
-
- def extract(self):
- extract_line = Console.Line()
- extract_line.update("Extracting gresource files...")
-
- resources = self.__get_resources_list()
- self.__extract_resources(resources)
-
- extract_line.success("Extracted gresource files.")
-
- def __get_resources_list(self):
- resources_list_response = subprocess.run(
- ["gresource", "list", self.__active_source_gresource],
- capture_output=True, text=True, check=False
- )
-
- if resources_list_response.stderr:
- raise Exception(f"gresource could not process the theme file: {self.__active_source_gresource}")
-
- return resources_list_response.stdout.strip().split("\n")
-
- def __extract_resources(self, resources: list[str]):
- prefix = "/org/gnome/shell/theme/"
- try:
- for resource in resources:
- resource_path = resource.replace(prefix, "")
- output_path = os.path.join(self.temp_folder, resource_path)
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
-
- with open(output_path, 'wb') as f:
- subprocess.run(
- ["gresource", "extract", self.__active_source_gresource, resource],
- stdout=f, check=True
- )
- except FileNotFoundError as e:
- if "gresource" in str(e):
- self.__raise_gresource_error(e)
- raise
-
- @staticmethod
- def __raise_gresource_error(e: Exception):
- print("Error: 'gresource' command not found.")
- print("Please install the glib2-devel package:")
- print(" - For Fedora/RHEL: sudo dnf install glib2-devel")
- print(" - For Ubuntu/Debian: sudo apt install libglib2.0-dev")
- print(" - For Arch: sudo pacman -S glib2-devel")
- raise Exception("Missing required dependency: glib2-devel") from e
-
- def compile(self):
- compile_line = Console.Line()
- compile_line.update("Compiling gnome-shell theme...")
-
- self.__create_gresource_xml()
- self.__compile_resources()
-
- compile_line.success("Theme compiled.")
-
- def __create_gresource_xml(self):
- with open(self.__gresource_xml, 'w') as gresource_xml:
- gresource_xml.write(self.__generate_gresource_xml())
-
- def __generate_gresource_xml(self):
- files_to_include = self.__get_files_to_include()
- nl = "\n" # fstring doesn't support newline character
- return textwrap.dedent(f"""
-
-
-
- {nl.join(files_to_include)}
-
-
- """)
-
- def __get_files_to_include(self):
- temp_path = Path(self.temp_folder)
- return [
- f"{file.relative_to(temp_path)}"
- for file in temp_path.glob('**/*')
- if file.is_file()
- ]
-
- def __compile_resources(self):
- try:
- subprocess.run(["glib-compile-resources",
- "--sourcedir", self.temp_folder,
- "--target", self.__temp_gresource,
- self.__gresource_xml
- ],
- cwd=self.temp_folder, check=True)
- except FileNotFoundError as e:
- if "glib-compile-resources" in str(e):
- self.__raise_gresource_error(e)
- raise
-
- def backup(self):
- backup_line = Console.Line()
- backup_line.update("Backing up gresource files...")
-
- subprocess.run(["cp", "-aT",
- self.__destination_gresource,
- self.__backup_gresource],
- check=True)
-
- backup_line.success("Backed up gresource files.")
-
- def restore(self):
- if not os.path.exists(self.__backup_gresource):
- raise GresourceBackupNotFoundError(self.__backup_gresource)
-
- subprocess.run(["sudo", "mv", "-f",
- self.__backup_gresource,
- self.__destination_gresource],
- check=True)
-
-
- def move(self):
- move_line = Console.Line()
- move_line.update("Moving gresource files...")
-
- subprocess.run(["sudo", "cp", "-f",
- self.__temp_gresource,
- self.__destination_gresource],
- check=True)
-
- move_line.success("Moved gresource files.")
\ No newline at end of file
diff --git a/scripts/utils/gresource/__init__.py b/scripts/utils/gresource/__init__.py
new file mode 100644
index 0000000..b69e6ab
--- /dev/null
+++ b/scripts/utils/gresource/__init__.py
@@ -0,0 +1,21 @@
+class GresourceBackupNotFoundError(FileNotFoundError):
+ def __init__(self, location: str = None):
+ if location:
+ super().__init__(f"Gresource backup file not found: {location}")
+ else:
+ super().__init__("Gresource backup file not found.")
+
+
+class MissingDependencyError(Exception):
+ def __init__(self, dependency: str):
+ super().__init__(f"Missing required dependency: {dependency}")
+ self.dependency = dependency
+
+
+def raise_gresource_error(tool: str, e: Exception):
+ print(f"Error: '{tool}' command not found.")
+ print("Please install the glib2-devel package:")
+ print(" - For Fedora/RHEL: sudo dnf install glib2-devel")
+ print(" - For Ubuntu/Debian: sudo apt install libglib2.0-dev")
+ print(" - For Arch: sudo pacman -S glib2-devel")
+ raise MissingDependencyError("glib2-devel") from e
diff --git a/scripts/utils/gresource/gresource.py b/scripts/utils/gresource/gresource.py
new file mode 100644
index 0000000..c9963c4
--- /dev/null
+++ b/scripts/utils/gresource/gresource.py
@@ -0,0 +1,71 @@
+import os
+
+from scripts.utils.alternatives_updater import PathString
+from scripts.utils.command_runner.command_runner import CommandRunner
+from scripts.utils.gresource.gresource_backuper import GresourceBackuperManager
+from scripts.utils.gresource.gresource_compiler import GresourceCompiler
+from scripts.utils.gresource.gresource_extractor import GresourceExtractor
+from scripts.utils.gresource.gresource_mover import GresourceMover
+from scripts.utils.logger.logger import LoggerFactory
+
+
+class Gresource:
+ """Orchestrator for gresource files."""
+
+ def __init__(
+ self, gresource_file: str, temp_folder: PathString, destination: PathString,
+ logger_factory: LoggerFactory, runner: CommandRunner
+ ):
+ """
+ :param gresource_file: The name of the gresource file to be processed.
+ :param temp_folder: The temporary folder where resources will be extracted.
+ :param destination: The destination folder where the compiled gresource file will be saved.
+ """
+ self.gresource_file = gresource_file
+ self.temp_folder = temp_folder
+ self.destination = destination
+
+ self.logger_factory = logger_factory
+ self.runner = runner
+
+ self._temp_gresource = os.path.join(temp_folder, gresource_file)
+ self._destination_gresource = os.path.join(destination, gresource_file)
+ self._active_source_gresource = self._destination_gresource
+
+ self._backuper = GresourceBackuperManager(self._destination_gresource,
+ logger_factory=self.logger_factory)
+
+ def has_trigger(self, trigger: str) -> bool:
+ """
+ Check if the trigger is present in the gresource file.
+ Used to detect if the theme is already installed.
+ :param trigger: The trigger to check for.
+ :return: True if the trigger is found, False otherwise.
+ """
+ return self._backuper.has_trigger(trigger)
+
+ def use_backup_gresource(self):
+ self._active_source_gresource = self._backuper.get_backup()
+ return self._active_source_gresource
+
+ def extract(self):
+ extractor = GresourceExtractor(self._active_source_gresource, self.temp_folder,
+ logger_factory=self.logger_factory, runner=self.runner)
+ extractor.extract()
+
+ def compile(self):
+ compiler = GresourceCompiler(self.temp_folder, self._temp_gresource,
+ logger_factory=self.logger_factory, runner=self.runner)
+ compiler.compile()
+
+ def backup(self):
+ self._backuper.backup()
+
+ def restore(self):
+ self._backuper.restore()
+ self._active_source_gresource = self._destination_gresource
+
+ def move(self):
+ mover = GresourceMover(self._temp_gresource, self._destination_gresource,
+ logger_factory=self.logger_factory)
+ mover.move()
diff --git a/scripts/utils/gresource/gresource_backuper.py b/scripts/utils/gresource/gresource_backuper.py
new file mode 100644
index 0000000..04245ec
--- /dev/null
+++ b/scripts/utils/gresource/gresource_backuper.py
@@ -0,0 +1,59 @@
+import os
+import shutil
+
+from scripts.utils.gresource import GresourceBackupNotFoundError
+from scripts.utils.logger.logger import LoggerFactory
+
+
+class GresourceBackuperManager:
+ def __init__(self, destination_file: str, logger_factory: LoggerFactory):
+ self.destination_file = destination_file
+ self._backup_file = f"{destination_file}.backup"
+ self._backuper = GresourceBackuper(destination_file, self._backup_file, logger_factory)
+
+ def has_trigger(self, trigger: str) -> bool:
+ return self._backuper.has_trigger(trigger)
+
+ def backup(self):
+ self._backuper.backup()
+
+ def restore(self):
+ self._backuper.restore()
+
+ def get_backup(self) -> str:
+ return self._backuper.get_backup()
+
+
+class GresourceBackuper:
+ def __init__(self, destination_file: str, backup_file: str, logger_factory: LoggerFactory):
+ self.destination_file = destination_file
+ self.backup_file = backup_file
+ self.logger_factory = logger_factory
+
+ def has_trigger(self, trigger: str) -> bool:
+ with open(self.destination_file, "rb") as f:
+ return trigger.encode() in f.read()
+
+ def get_backup(self) -> str:
+ if not os.path.exists(self.backup_file):
+ raise GresourceBackupNotFoundError(self.backup_file)
+ return self.backup_file
+
+ def backup(self):
+ backup_line = self.logger_factory.create_logger()
+ backup_line.update("Backing up gresource files...")
+
+ if os.path.exists(self.backup_file):
+ os.remove(self.backup_file)
+
+ shutil.copy2(self.destination_file, self.backup_file)
+
+ backup_line.success("Backed up gresource files.")
+
+ def restore(self):
+ if not os.path.exists(self.backup_file):
+ raise GresourceBackupNotFoundError(self.backup_file)
+
+ shutil.move(self.backup_file, self.destination_file)
+
+ self.logger_factory.create_logger().success("Restored gresource files.")
diff --git a/scripts/utils/gresource/gresource_compiler.py b/scripts/utils/gresource/gresource_compiler.py
new file mode 100644
index 0000000..350afde
--- /dev/null
+++ b/scripts/utils/gresource/gresource_compiler.py
@@ -0,0 +1,68 @@
+import textwrap
+from pathlib import Path
+
+from scripts.utils.command_runner.command_runner import CommandRunner
+from scripts.utils.gresource import raise_gresource_error
+from scripts.utils.logger.logger import LoggerFactory
+
+
+class GresourceCompiler:
+ def __init__(
+ self, source_folder: str, target_file: str,
+ logger_factory: LoggerFactory, runner: CommandRunner
+ ):
+ self.source_folder = source_folder
+ self.target_file = target_file
+ self.gresource_xml = target_file + ".xml"
+
+ self.logger_factory = logger_factory
+ self.runner = runner
+
+ def compile(self):
+ compile_line = self.logger_factory.create_logger()
+ compile_line.update("Compiling gnome-shell theme...")
+
+ self._create_gresource_xml()
+ self._compile_resources()
+
+ compile_line.success("Compiled gnome-shell theme.")
+
+ def _create_gresource_xml(self):
+ with open(self.gresource_xml, 'w') as gresource_xml:
+ gresource_xml.write(self._generate_gresource_xml())
+
+ def _generate_gresource_xml(self):
+ files_to_include = self._get_files_to_include()
+ nl = "\n" # fstring doesn't support newline character
+ return textwrap.dedent(f"""
+
+
+
+ {nl.join(files_to_include)}
+
+
+ """)
+
+ def _get_files_to_include(self):
+ source_path = Path(self.source_folder)
+ return [
+ f"{file.relative_to(source_path)}"
+ for file in source_path.glob('**/*')
+ if file.is_file()
+ ]
+
+ def _compile_resources(self):
+ try:
+ self._try_compile_resources()
+ except FileNotFoundError as e:
+ if "glib-compile-resources" in str(e):
+ raise_gresource_error("glib-compile-resources", e)
+ raise
+
+ def _try_compile_resources(self):
+ self.runner.run(["glib-compile-resources",
+ "--sourcedir", self.source_folder,
+ "--target", self.target_file,
+ self.gresource_xml
+ ],
+ cwd=self.source_folder, check=True)
diff --git a/scripts/utils/gresource/gresource_extractor.py b/scripts/utils/gresource/gresource_extractor.py
new file mode 100644
index 0000000..136c109
--- /dev/null
+++ b/scripts/utils/gresource/gresource_extractor.py
@@ -0,0 +1,56 @@
+import os
+
+from scripts.utils.command_runner.command_runner import CommandRunner
+from scripts.utils.gresource import raise_gresource_error
+from scripts.utils.logger.logger import LoggerFactory
+
+
+class GresourceExtractor:
+ def __init__(
+ self, gresource_path: str, extract_folder: str,
+ logger_factory: LoggerFactory, runner: CommandRunner
+ ):
+ self.gresource_path = gresource_path
+ self.extract_folder = extract_folder
+ self.logger_factory = logger_factory
+ self.runner = runner
+
+ def extract(self):
+ extract_line = self.logger_factory.create_logger()
+ extract_line.update("Extracting gresource files...")
+
+ self._try_extract_resources()
+
+ extract_line.success("Extracted gresource files.")
+
+ def _try_extract_resources(self):
+ try:
+ resources = self._get_resources_list()
+ self._extract_resources(resources)
+ except FileNotFoundError as e:
+ print(e)
+ if "gresource" in str(e):
+ raise_gresource_error("gresource", e)
+ raise
+ except Exception as e:
+ raise Exception(f"gresource could not process the theme file: {self.gresource_path}") from e
+
+ def _get_resources_list(self):
+ resources_list_response = self.runner.run(
+ ["gresource", "list", self.gresource_path],
+ capture_output=True, text=True, check=True
+ )
+ return resources_list_response.stdout.strip().split("\n")
+
+ def _extract_resources(self, resources: list[str]):
+ prefix = "/org/gnome/shell/theme/"
+ for resource in resources:
+ resource_path = resource.replace(prefix, "")
+ output_path = os.path.join(self.extract_folder, resource_path)
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+
+ with open(output_path, 'wb') as f:
+ self.runner.run(
+ ["gresource", "extract", self.gresource_path, resource],
+ stdout=f, check=True
+ )
diff --git a/scripts/utils/gresource/gresource_mover.py b/scripts/utils/gresource/gresource_mover.py
new file mode 100644
index 0000000..314c057
--- /dev/null
+++ b/scripts/utils/gresource/gresource_mover.py
@@ -0,0 +1,21 @@
+import os
+import shutil
+
+from scripts.utils.logger.logger import LoggerFactory
+
+
+class GresourceMover:
+ def __init__(self, source_file: str, destination_file: str, logger_factory: LoggerFactory):
+ self.source_file = source_file
+ self.destination_file = destination_file
+ self.logger_factory = logger_factory
+
+ def move(self):
+ move_line = self.logger_factory.create_logger()
+ move_line.update("Moving gresource files...")
+
+ os.makedirs(os.path.dirname(self.destination_file), exist_ok=True)
+ shutil.copyfile(self.source_file, self.destination_file)
+ os.chmod(self.destination_file, 0o644)
+
+ move_line.success("Moved gresource files.")
diff --git a/scripts/utils/hex_to_rgba.py b/scripts/utils/hex_to_rgba.py
deleted file mode 100644
index eff3daf..0000000
--- a/scripts/utils/hex_to_rgba.py
+++ /dev/null
@@ -1,22 +0,0 @@
-def hex_to_rgba(hex_color):
- """
- Convert hex(a) to rgba
- :param hex_color: input value
- """
-
- try:
- if len(hex_color) in range(6, 10):
- hex_color = hex_color.lstrip('#') + "ff"
- # if is convertable
- int(hex_color[:], 16)
- else:
- raise ValueError
-
- except ValueError:
- raise ValueError(f'Error: Invalid HEX color code: {hex_color}')
-
- else:
- return int(hex_color[0:2], 16), \
- int(hex_color[2:4], 16), \
- int(hex_color[4:6], 16), \
- int(hex_color[6:8], 16) / 255
\ No newline at end of file
diff --git a/scripts/utils/logger/__init__.py b/scripts/utils/logger/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/utils/console.py b/scripts/utils/logger/console.py
similarity index 88%
rename from scripts/utils/console.py
rename to scripts/utils/logger/console.py
index 53d2738..b04552f 100644
--- a/scripts/utils/console.py
+++ b/scripts/utils/logger/console.py
@@ -3,14 +3,24 @@ import threading
from enum import Enum
from typing import Optional
+from scripts.utils.logger.logger import LoggerFactory, Logger
-class Console:
+
+class Console(LoggerFactory):
"""Manages console output for concurrent processes with line tracking"""
_print_lock = threading.Lock()
_line_mapping = {}
_next_line = 0
- class Line:
+ def create_logger(self, name: Optional[str]=None) -> 'Console.Line':
+ """
+ Create a logger instance with the given name.
+ :param name: Name of the logger.
+ :return: Logger instance.
+ """
+ return Console.Line(name)
+
+ class Line(Logger):
def __init__(self, name: Optional[str]=None):
"""Initialize a new managed line"""
self.name = name or f"line_{Console._next_line}"
diff --git a/scripts/utils/logger/logger.py b/scripts/utils/logger/logger.py
new file mode 100644
index 0000000..e3a00e3
--- /dev/null
+++ b/scripts/utils/logger/logger.py
@@ -0,0 +1,36 @@
+from abc import ABC, abstractmethod
+from typing import Optional
+
+
+class LoggerFactory(ABC):
+ @staticmethod
+ @abstractmethod
+ def create_logger(name: Optional[str] = None) -> 'Logger':
+ """
+ Create a logger instance with the given name.
+ :param name: Name of the logger.
+ :return: Logger instance.
+ """
+ pass
+
+
+class Logger(ABC):
+ @abstractmethod
+ def update(self, message: str):
+ pass
+
+ @abstractmethod
+ def success(self, message):
+ pass
+
+ @abstractmethod
+ def error(self, message):
+ pass
+
+ @abstractmethod
+ def warn(self, message):
+ pass
+
+ @abstractmethod
+ def info(self, message):
+ pass
\ No newline at end of file
diff --git a/scripts/utils/remove_files.py b/scripts/utils/remove_files.py
index ffb9173..7a1d9af 100644
--- a/scripts/utils/remove_files.py
+++ b/scripts/utils/remove_files.py
@@ -7,7 +7,7 @@ import shutil
from collections import defaultdict
from typing import Any
-from .console import Console, Color, Format
+from scripts.utils.logger.console import Console, Color, Format
from .parse_folder import parse_folder
from .. import config
import os
diff --git a/scripts/utils/remove_properties.py b/scripts/utils/remove_properties.py
index eed79ce..f20ef5e 100644
--- a/scripts/utils/remove_properties.py
+++ b/scripts/utils/remove_properties.py
@@ -10,10 +10,10 @@ def remove_properties(file, *args):
with open(file, "r") as read_file:
content = read_file.read()
- for line in content.splitlines():
+ for i, line in enumerate(content.splitlines()):
if not any(prop in line for prop in args):
new_content += line + "\n"
- elif "}" in line:
+ elif "}" in line and not "{" in line:
new_content += "}\n"
with open(file, "w") as write_file:
diff --git a/scripts/utils/replace_keywords.py b/scripts/utils/replace_keywords.py
index b90445e..f280602 100644
--- a/scripts/utils/replace_keywords.py
+++ b/scripts/utils/replace_keywords.py
@@ -1,4 +1,4 @@
-def replace_keywords(file, *args):
+def replace_keywords(file, *args: tuple[str, str]):
"""
Replace file with several keywords
:param file: file name where keywords must be replaced
diff --git a/scripts/utils/style_manager.py b/scripts/utils/style_manager.py
new file mode 100644
index 0000000..13e978c
--- /dev/null
+++ b/scripts/utils/style_manager.py
@@ -0,0 +1,40 @@
+import os
+
+from scripts.utils import generate_file
+
+
+class StyleManager:
+ """Manages the style files for the theme."""
+
+ def __init__(self, output_file: str):
+ """
+ :param output_file: The path to the output file where styles will be combined.
+ """
+ self.output_file = output_file
+
+ def append_content(self, content: str):
+ """
+ Append content to the output file.
+ :raises FileNotFoundError: if the file does not exist.
+ """
+ if not os.path.exists(self.output_file):
+ raise FileNotFoundError(f"The file {self.output_file} does not exist.")
+ with open(self.output_file, 'a') as output:
+ output.write('\n' + content)
+
+ def prepend_content(self, content: str):
+ """
+ Prepend content to the output file.
+ :raises FileNotFoundError: if the file does not exist.
+ """
+ with open(self.output_file, 'r') as output:
+ main_content = output.read()
+ with open(self.output_file, 'w') as output:
+ output.write(content + '\n' + main_content)
+
+ def generate_combined_styles(self, sources_location: str, temp_folder: str):
+ """
+ Generate the combined styles file
+ by merging all styles from the source location.
+ """
+ generate_file(sources_location, temp_folder, self.output_file)
\ No newline at end of file
diff --git a/scripts/utils/theme/__init__.py b/scripts/utils/theme/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/utils/theme/color_replacement_generator.py b/scripts/utils/theme/color_replacement_generator.py
new file mode 100644
index 0000000..904467b
--- /dev/null
+++ b/scripts/utils/theme/color_replacement_generator.py
@@ -0,0 +1,44 @@
+import copy
+
+from scripts.install.colors_definer import ColorsDefiner
+from scripts.types.installation_color import InstallationMode, InstallationColor
+from scripts.utils.color_converter.color_converter import ColorConverter
+
+
+class ColorReplacementGenerator:
+ def __init__(self, colors_provider: ColorsDefiner, color_converter: ColorConverter):
+ self.colors = copy.deepcopy(colors_provider)
+ self.color_converter = color_converter
+
+ def convert(self, mode: InstallationMode, theme_color: InstallationColor) -> list[tuple[str, str]]:
+ """Generate a list of color replacements for the given theme color and mode"""
+ return [
+ (element, self._create_rgba_value(element, mode, theme_color))
+ for element in self.colors.replacers
+ ]
+
+ def _create_rgba_value(self, element: str, mode: str, theme_color: InstallationColor) -> str:
+ """Create RGBA value for the specified element"""
+ color_def = self._get_color_definition(element, mode)
+
+ lightness = int(color_def["l"]) / 100
+ saturation = int(color_def["s"]) / 100
+ if theme_color.saturation is not None:
+ saturation *= theme_color.saturation / 100
+ alpha = color_def["a"]
+
+ red, green, blue = self.color_converter.hsl_to_rgb(
+ theme_color.hue, saturation, lightness
+ )
+
+ return f"rgba({red}, {green}, {blue}, {alpha})"
+
+ def _get_color_definition(self, element: str, mode: str) -> dict:
+ """Get color definition for element, handling defaults if needed"""
+ replacer = self.colors.replacers[element]
+
+ if mode not in replacer and replacer["default"]:
+ default_element = replacer["default"]
+ return self.colors.replacers[default_element][mode]
+
+ return replacer[mode]
diff --git a/scripts/utils/theme/gnome_shell_theme_builder.py b/scripts/utils/theme/gnome_shell_theme_builder.py
new file mode 100644
index 0000000..25bfbac
--- /dev/null
+++ b/scripts/utils/theme/gnome_shell_theme_builder.py
@@ -0,0 +1,128 @@
+import os.path
+
+from scripts import config
+from scripts.install.colors_definer import ColorsDefiner
+from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl
+from scripts.utils.logger.console import Console
+from scripts.utils.logger.logger import LoggerFactory
+from scripts.utils.style_manager import StyleManager
+from scripts.utils.theme.theme import Theme
+from scripts.utils.theme.theme_color_applier import ThemeColorApplier
+from scripts.utils.theme.color_replacement_generator import ColorReplacementGenerator
+from scripts.utils.theme.theme_installer import ThemeInstaller
+from scripts.utils.theme.theme_path_provider import ThemePathProvider
+from scripts.utils.theme.theme_preparation import ThemePreparation
+from scripts.utils.theme.theme_temp_manager import ThemeTempManager
+from theme import SourceFolder
+
+
+class GnomeShellThemeBuilder:
+ """
+ Builder for creating a Gnome Shell theme.
+
+ Example:
+ builder = GnomeShellThemeBuilder(colors_provider)
+ theme = builder.with_mode("dark").filled().build()
+ theme.prepare()
+ theme.install(hue=200, name="MyTheme")
+ """
+
+ def __init__(self, colors_provider: ColorsDefiner):
+ self.theme_name = "gnome-shell"
+ self.colors_provider = colors_provider
+ self.source_folder = SourceFolder().gnome_shell
+ self._base_temp_folder = config.temp_folder
+ self.destination_folder = config.themes_folder
+ self.mode = None
+ self.is_filled = False
+
+ self.temp_folder = os.path.join(self._base_temp_folder, self.theme_name)
+ self.main_styles = os.path.join(self.temp_folder, f"{self.theme_name}.css")
+
+ self.logger_factory: LoggerFactory | None = None
+ self.preparation: ThemePreparation | None = None
+ self.installer: ThemeInstaller | None = None
+
+ def __update_paths(self):
+ """Update derived paths when base folder or theme name changes"""
+ self.temp_folder = os.path.join(self._base_temp_folder, self.theme_name)
+ self.main_styles = os.path.join(self.temp_folder, f"{self.theme_name}.css")
+
+ def with_temp_folder(self, temp_folder: str):
+ """Set the base temporary folder"""
+ self._base_temp_folder = temp_folder
+ self.__update_paths()
+ return self
+
+ def with_theme_name(self, theme_name: str):
+ """Set the theme name"""
+ self.theme_name = theme_name
+ self.__update_paths()
+ return self
+
+ def with_mode(self, mode):
+ self.mode = mode
+ return self
+
+ def filled(self, filled = True):
+ self.is_filled = filled
+ return self
+
+ def with_logger_factory(self, logger_factory: LoggerFactory | None):
+ """Inject a logger factory for logging purposes."""
+ self.logger_factory = logger_factory
+ return self
+
+ def with_preparation(self, preparation: ThemePreparation | None):
+ """Inject a preparation instance for preparing the theme."""
+ self.preparation = preparation
+ return self
+
+ def with_installer(self, installer: ThemeInstaller | None):
+ """Inject an installer for installing the theme."""
+ self.installer = installer
+ return self
+
+
+ def with_reset_dependencies(self):
+ """Reset the dependencies for the theme preparation and installation."""
+ self.preparation = None
+ self.installer = None
+ return self
+
+ def build(self) -> "Theme":
+ """
+ Constructs and returns a Theme instance using the configured properties.
+
+ This method resolves all necessary dependencies for the theme's preparation
+ and installation. The returned Theme will have the mode and filled options set
+ according to the builder's configuration.
+
+ :return: Theme instance ready for preparation and installation
+ """
+ self._resolve_preparation()
+ self._resolve_installer()
+ return Theme(self.preparation, self.installer, self.mode, self.is_filled)
+
+ def _resolve_preparation(self):
+ if self.preparation: return
+
+ file_manager = ThemeTempManager(self.temp_folder)
+ style_manager = StyleManager(self.main_styles)
+ self.preparation = ThemePreparation(self.source_folder,
+ file_manager=file_manager, style_manager=style_manager)
+
+ def _resolve_installer(self):
+ if self.installer: return
+
+ color_converter = ColorConverterImpl()
+ color_replacement_generator = ColorReplacementGenerator(
+ colors_provider=self.colors_provider,
+ color_converter=color_converter)
+ color_applier = ThemeColorApplier(color_replacement_generator=color_replacement_generator)
+ logger_factory = self.logger_factory or Console()
+ path_provider = ThemePathProvider()
+ self.installer = ThemeInstaller(self.theme_name, self.temp_folder, self.destination_folder,
+ logger_factory=logger_factory,
+ color_applier=color_applier,
+ path_provider=path_provider)
\ No newline at end of file
diff --git a/scripts/utils/theme/theme.py b/scripts/utils/theme/theme.py
new file mode 100644
index 0000000..8cac48a
--- /dev/null
+++ b/scripts/utils/theme/theme.py
@@ -0,0 +1,93 @@
+from scripts.types.installation_color import InstallationColor
+from scripts.types.theme_base import ThemeBase
+from scripts.utils.theme.theme_installer import ThemeInstaller
+from scripts.utils.theme.theme_preparation import ThemePreparation
+
+
+class Theme(ThemeBase):
+ """
+ Manages theme preparation and installation.
+
+ The Theme class orchestrates the process of preparing a theme by combining files,
+ applying color schemes, and installing the final theme into a destination folder.
+ """
+
+ def __init__(self, preparation: ThemePreparation, installer: ThemeInstaller, mode=None, is_filled=False):
+ """
+ :param preparation: Object responsible for theme extraction and preparation.
+ :param installer: Object responsible for installing the theme.
+ :param mode: Theme mode (e.g., 'light' or 'dark'). If not provided, both modes are used.
+ :param is_filled: if True, theme will be filled
+ """
+ self.modes = [mode] if mode else ['light', 'dark']
+ self.is_filled = is_filled
+
+ self._preparation = preparation
+ self._installer = installer
+
+ @property
+ def temp_folder(self):
+ """The temporary folder path where the theme is prepared."""
+ return self._preparation.temp_folder
+
+ @property
+ def destination_folder(self):
+ """The destination folder path where the theme will be installed."""
+ return self._installer.destination_folder
+
+ @property
+ def main_styles(self):
+ """The path to the combined styles file generated during preparation."""
+ return self._preparation.combined_styles_location
+
+ @property
+ def theme_name(self):
+ return self._installer.theme_type
+
+ def __add__(self, other: str) -> "Theme":
+ """
+ Appends additional styles to the main styles file.
+ :param other: The additional styles to append.
+ """
+ self._preparation += other
+ return self
+
+ def __mul__(self, other: str) -> "Theme":
+ """
+ Adds a file to the theme, copying it to the temporary folder.
+ :param other: The path of the file or folder to add.
+ """
+ self._preparation *= other
+ return self
+
+ def add_to_start(self, content) -> "Theme":
+ """
+ Inserts content at the beginning of the main styles file.
+ :param content: The content to insert.
+ """
+ self._preparation.add_to_start(content)
+ return self
+
+ def prepare(self):
+ """Extract theme from source folder and prepare it for installation."""
+ self._preparation.prepare()
+ if self.is_filled:
+ self._preparation.replace_filled_keywords()
+
+ def install(self, hue, name: str, sat: float | None = None, destination: str | None = None):
+ """
+ Installs the theme by applying the specified accent color and copying the finalized files
+ to the designated destination.
+
+ Args:
+ hue: The hue value for the accent color (0-360 degrees).
+ name: The name of the theme.
+ sat: The saturation value for the accent color.
+ destination: The custom folder where the theme will be installed.
+ """
+ theme_color = InstallationColor(
+ hue=hue,
+ saturation=sat,
+ modes=self.modes
+ )
+ self._installer.install(theme_color, name, destination)
\ No newline at end of file
diff --git a/scripts/utils/theme/theme_color_applier.py b/scripts/utils/theme/theme_color_applier.py
new file mode 100644
index 0000000..fc88aef
--- /dev/null
+++ b/scripts/utils/theme/theme_color_applier.py
@@ -0,0 +1,20 @@
+import os
+
+from scripts.types.installation_color import InstallationColor, InstallationMode
+from scripts.utils import replace_keywords
+from scripts.utils.theme.color_replacement_generator import ColorReplacementGenerator
+
+
+class ThemeColorApplier:
+ """Class to apply theme colors to files in a directory."""
+
+ def __init__(self, color_replacement_generator: ColorReplacementGenerator):
+ self.color_replacement_generator = color_replacement_generator
+
+ def apply(self, theme_color: InstallationColor, destination: str, mode: InstallationMode):
+ """Apply theme colors to all files in the directory"""
+ replacements = self.color_replacement_generator.convert(mode, theme_color)
+
+ for filename in os.listdir(destination):
+ file_path = os.path.join(destination, filename)
+ replace_keywords(file_path, *replacements)
diff --git a/scripts/utils/theme/theme_installer.py b/scripts/utils/theme/theme_installer.py
new file mode 100644
index 0000000..b9873eb
--- /dev/null
+++ b/scripts/utils/theme/theme_installer.py
@@ -0,0 +1,75 @@
+from scripts.types.installation_color import InstallationColor, InstallationMode
+from scripts.utils import copy_files
+from scripts.utils.logger.console import Console, Color, Format
+from scripts.utils.logger.logger import LoggerFactory
+from scripts.utils.theme.theme_color_applier import ThemeColorApplier
+from scripts.utils.theme.theme_path_provider import ThemePathProvider
+
+
+class ThemeInstaller:
+ """
+ Handles the installation of themes by copying files and applying color schemes.
+ """
+
+ def __init__(self, theme_type: str, source_folder: str, destination_folder: str,
+ logger_factory: LoggerFactory, color_applier: ThemeColorApplier, path_provider: ThemePathProvider):
+ """
+ :param theme_type: type of the theme (e.g., gnome-shell, gtk)
+ :param source_folder: folder containing the theme files (e.g. temp folder)
+ :param destination_folder: folder where the theme will be installed
+ """
+ self.theme_type = theme_type
+ self.source_folder = source_folder
+ self.destination_folder = destination_folder
+
+ self.logger_factory = logger_factory
+ self.color_applier = color_applier
+ self.path_provider = path_provider
+
+ def install(self, theme_color: InstallationColor, name: str, custom_destination: str = None):
+ """
+ Install theme and generate theme with specified accent color
+ :param theme_color: object containing color and modes
+ :param name: theme name
+ :param custom_destination: optional custom destination folder
+ """
+ logger = InstallationLogger(name, theme_color.modes, self.logger_factory)
+
+ try:
+ self._perform_installation(theme_color, name, custom_destination=custom_destination)
+ logger.success()
+ except Exception as err:
+ logger.error(str(err))
+ raise
+
+ def _perform_installation(self, theme_color, name, custom_destination=None):
+ for mode in theme_color.modes:
+ destination = (custom_destination or
+ self.path_provider.get_theme_path(
+ self.destination_folder, name, mode, self.theme_type))
+
+ copy_files(self.source_folder, destination)
+ self.color_applier.apply(theme_color, destination, mode)
+
+
+class InstallationLogger:
+ def __init__(self, name: str, modes: list[InstallationMode], logger_factory: LoggerFactory):
+ self.name = name
+ self.modes = modes
+
+ self.logger = logger_factory.create_logger(self.name)
+ self._setup_logger()
+
+ def _setup_logger(self):
+ self.formatted_name = Console.format(self.name.capitalize(),
+ color=Color.get(self.name),
+ format_type=Format.BOLD)
+ joint_modes = f"({', '.join(self.modes)})"
+ self.formatted_modes = Console.format(joint_modes, color=Color.GRAY)
+ self.logger.update(f"Creating {self.formatted_name} {self.formatted_modes} theme...")
+
+ def success(self):
+ self.logger.success(f"{self.formatted_name} {self.formatted_modes} theme created successfully.")
+
+ def error(self, error_message: str):
+ self.logger.error(f"Error installing {self.formatted_name} theme: {error_message}")
\ No newline at end of file
diff --git a/scripts/utils/theme/theme_path_provider.py b/scripts/utils/theme/theme_path_provider.py
new file mode 100644
index 0000000..bfa1c3e
--- /dev/null
+++ b/scripts/utils/theme/theme_path_provider.py
@@ -0,0 +1,20 @@
+import os
+
+
+class ThemePathProvider:
+ @staticmethod
+ def get_theme_path(themes_folder: str, color_name: str, theme_mode: str, theme_type: str) -> str:
+ """
+ Generates the path for the theme based on the provided parameters.
+ :param themes_folder: The base folder where themes are stored.
+ :param color_name: The name of the color scheme.
+ :param theme_mode: The mode of the theme (e.g., 'light' or 'dark').
+ :param theme_type: The type of the theme (e.g., 'gnome-shell', 'gtk').
+ """
+ if not themes_folder or not color_name or not theme_mode or not theme_type:
+ raise ValueError("All parameters must be non-empty strings.")
+
+ marble_name = '-'.join(["Marble", color_name, theme_mode])
+ final_path = os.path.join(themes_folder, marble_name, theme_type, "")
+
+ return final_path
diff --git a/scripts/utils/theme/theme_preparation.py b/scripts/utils/theme/theme_preparation.py
new file mode 100644
index 0000000..c36a12a
--- /dev/null
+++ b/scripts/utils/theme/theme_preparation.py
@@ -0,0 +1,63 @@
+import os
+
+from scripts.utils import replace_keywords
+from scripts.utils.theme.theme_temp_manager import ThemeTempManager
+from scripts.utils.style_manager import StyleManager
+
+
+class ThemePreparation:
+ """
+ Class for extracting themes from the source folder
+ and preparing them for installation.
+ """
+
+ def __init__(self, sources_location: str, file_manager: ThemeTempManager, style_manager: StyleManager):
+ self.sources_location = sources_location
+
+ self.file_manager = file_manager
+ self.style_manager = style_manager
+
+ @property
+ def temp_folder(self):
+ return self.file_manager.temp_folder
+
+ @property
+ def combined_styles_location(self):
+ return self.style_manager.output_file
+
+ def __add__(self, content: str) -> "ThemePreparation":
+ """Append additional styles to the main styles file."""
+ self.style_manager.append_content(content)
+ return self
+
+ def __mul__(self, content: str) -> "ThemePreparation":
+ """Adds a file to the theme, copying it to the temporary folder."""
+ self.file_manager.copy_to_temp(content)
+ return self
+
+ def add_to_start(self, content) -> "ThemePreparation":
+ """Inserts content at the beginning of the main styles file."""
+ self.style_manager.prepend_content(content)
+ return self
+
+ def prepare(self):
+ """
+ Extract theme from source folder and prepare it for installation.
+ """
+ self.file_manager.copy_to_temp(self.sources_location)
+ self.style_manager.generate_combined_styles(self.sources_location, self.temp_folder)
+ self.file_manager.cleanup()
+
+ def replace_filled_keywords(self):
+ """
+ Replace keywords in the theme files for filled mode.
+ This method is deprecated and will be removed in future versions.
+ """
+ for apply_file in os.listdir(f"{self.temp_folder}/"):
+ replace_keywords(f"{self.temp_folder}/{apply_file}",
+ ("BUTTON-COLOR", "ACCENT-FILLED-COLOR"),
+ ("BUTTON_HOVER", "ACCENT-FILLED_HOVER"),
+ ("BUTTON_ACTIVE", "ACCENT-FILLED_ACTIVE"),
+ ("BUTTON_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"),
+ ("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"),
+ ("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY"))
diff --git a/scripts/utils/theme/theme_temp_manager.py b/scripts/utils/theme/theme_temp_manager.py
new file mode 100644
index 0000000..2294a57
--- /dev/null
+++ b/scripts/utils/theme/theme_temp_manager.py
@@ -0,0 +1,29 @@
+import os
+import shutil
+
+
+class ThemeTempManager:
+ """
+ Manages operations with temp folder for Theme class
+ """
+ def __init__(self, temp_folder: str):
+ self.temp_folder = temp_folder
+ os.makedirs(self.temp_folder, exist_ok=True)
+
+ def copy_to_temp(self, content: str):
+ """
+ Copy a file or directory to the temporary folder.
+ If the content is a file, it will be copied directly.
+ If the content is a directory, all its contents will be copied to the temp folder.
+ """
+ if os.path.isfile(content):
+ final_path = os.path.join(self.temp_folder, os.path.basename(content))
+ shutil.copy(content, final_path)
+ else:
+ shutil.copytree(content, self.temp_folder, dirs_exist_ok=True)
+ return self
+
+ def cleanup(self):
+ """Remove temporary folders"""
+ shutil.rmtree(f"{self.temp_folder}/.css/", ignore_errors=True)
+ shutil.rmtree(f"{self.temp_folder}/.versions/", ignore_errors=True)
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/_helpers/__init__.py b/tests/_helpers/__init__.py
new file mode 100644
index 0000000..ea9510b
--- /dev/null
+++ b/tests/_helpers/__init__.py
@@ -0,0 +1,14 @@
+import os
+
+
+def create_dummy_file(file_path: str, content: str = "dummy content"):
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
+ with open(file_path, 'w') as f:
+ f.write(content)
+
+
+def try_remove_file(file_path: str):
+ try:
+ os.remove(file_path)
+ except FileNotFoundError:
+ pass
\ No newline at end of file
diff --git a/tests/_helpers/dummy_logger_factory.py b/tests/_helpers/dummy_logger_factory.py
new file mode 100644
index 0000000..1b2f373
--- /dev/null
+++ b/tests/_helpers/dummy_logger_factory.py
@@ -0,0 +1,25 @@
+from typing import Optional
+
+from scripts.utils.logger.logger import LoggerFactory, Logger
+
+
+class DummyLoggerFactory(LoggerFactory):
+ def create_logger(self, name: Optional[str] = None) -> 'DummyLogger':
+ return DummyLogger()
+
+
+class DummyLogger(Logger):
+ def update(self, msg):
+ pass
+
+ def success(self, msg):
+ pass
+
+ def error(self, msg):
+ pass
+
+ def warn(self, msg):
+ pass
+
+ def info(self, msg):
+ pass
\ No newline at end of file
diff --git a/tests/_helpers/dummy_runner.py b/tests/_helpers/dummy_runner.py
new file mode 100644
index 0000000..fcff239
--- /dev/null
+++ b/tests/_helpers/dummy_runner.py
@@ -0,0 +1,6 @@
+from scripts.utils.command_runner.command_runner import CommandRunner
+
+
+class DummyRunner(CommandRunner):
+ def run(self, command: list[str], **kwargs) -> str:
+ return "Dummy output"
\ No newline at end of file
diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/utils/color_converter/__init__.py b/tests/utils/color_converter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/utils/color_converter/test_color_converter_impl.py b/tests/utils/color_converter/test_color_converter_impl.py
new file mode 100644
index 0000000..e90a8de
--- /dev/null
+++ b/tests/utils/color_converter/test_color_converter_impl.py
@@ -0,0 +1,64 @@
+import unittest
+
+from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl
+
+
+class ColorConverterImplTestCase(unittest.TestCase):
+ def setUp(self):
+ self.converter = ColorConverterImpl()
+
+ def test_hex_to_rgba_is_valid(self):
+ hex_color = "#ff5733"
+ expected_rgba = (255, 87, 51, 1.0)
+
+ result = self.converter.hex_to_rgba(hex_color)
+
+ self.assertEqual(result, expected_rgba)
+
+ def test_hex_to_rgba_is_invalid(self):
+ hex_color = "#invalid"
+
+ with self.assertRaises(ValueError):
+ self.converter.hex_to_rgba(hex_color)
+
+ def test_hex_to_rgba_with_alpha_is_valid(self):
+ hex_color = "#ff5733ff"
+ expected_rgba = (255, 87, 51, 1.0)
+
+ result = self.converter.hex_to_rgba(hex_color)
+
+ self.assertEqual(result, expected_rgba)
+
+ def test_hex_to_rgba_with_shorthand_is_valid(self):
+ hex_color = "#fff"
+ expected_rgba = (255, 255, 255, 1.0)
+
+ result = self.converter.hex_to_rgba(hex_color)
+
+ self.assertEqual(result, expected_rgba)
+
+ def test_hsl_to_rgb_is_valid(self):
+ hue = 360
+ saturation = 1
+ lightness = 0.5
+ expected_rgb = (255, 0, 0)
+
+ result = self.converter.hsl_to_rgb(hue, saturation, lightness)
+
+ self.assertEqual(result, expected_rgb)
+
+ def test_hsl_to_rgb_with_overflow_hue_is_invalid(self):
+ hue = 400
+ saturation = 1
+ lightness = 0.5
+
+ with self.assertRaises(ValueError):
+ self.converter.hsl_to_rgb(hue, saturation, lightness)
+
+ def test_hsl_to_rgb_with_invalid_saturation_and_lightness_is_invalid(self):
+ hue = 360
+ saturation = 1.5
+ lightness = -2
+
+ with self.assertRaises(ValueError):
+ self.converter.hsl_to_rgb(hue, saturation, lightness)
\ No newline at end of file
diff --git a/tests/utils/global_theme/__init__.py b/tests/utils/global_theme/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/utils/global_theme/test_gdm.py b/tests/utils/global_theme/test_gdm.py
new file mode 100644
index 0000000..4906086
--- /dev/null
+++ b/tests/utils/global_theme/test_gdm.py
@@ -0,0 +1,81 @@
+from unittest import TestCase
+from unittest.mock import Mock
+
+from scripts.utils.global_theme.gdm import GDMTheme
+from scripts.utils.global_theme.gdm_installer import GDMThemeInstaller
+from scripts.utils.global_theme.gdm_preparer import GDMThemePreparer
+from scripts.utils.global_theme.gdm_remover import GDMThemeRemover
+
+
+class GDMTestCase(TestCase):
+ def setUp(self):
+ self.preparer = Mock(spec=GDMThemePreparer)
+ self.installer = Mock(spec=GDMThemeInstaller)
+ self.remover = Mock(spec=GDMThemeRemover)
+
+ self.gdm = GDMTheme(self.preparer, self.installer, self.remover)
+
+ def test_prepare_uses_backup_if_installed(self):
+ self.installer.is_installed.return_value = True
+
+ self.gdm.prepare()
+
+ self.preparer.use_backup_as_source.assert_called_once()
+
+ def test_prepare_does_not_use_backup_if_not_installed(self):
+ self.installer.is_installed.return_value = False
+
+ self.gdm.prepare()
+
+ self.preparer.use_backup_as_source.assert_not_called()
+
+ def test_prepare_calls_preparer_prepare_and_sets_themes(self):
+ mock_theme = Mock()
+ self.preparer.prepare.return_value = [mock_theme]
+
+ self.gdm.prepare()
+
+ self.preparer.prepare.assert_called_once()
+ self.assertEqual(self.gdm.themes, [mock_theme])
+
+ def test_install_correctly_passes_arguments_to_installer_compile(self):
+ hue = 100
+ name = "test_theme"
+ sat = 0.5
+
+ self.gdm.install(hue, name, sat)
+
+ self.installer.compile.assert_called_once_with(self.gdm.themes, hue, name, sat)
+
+ def test_install_calls_installer_backup_if_not_installed(self):
+ self.installer.is_installed.return_value = False
+
+ self.gdm.install(100, "test_theme")
+
+ self.installer.backup.assert_called_once()
+
+ def test_install_does_not_call_installer_backup_if_installed(self):
+ self.installer.is_installed.return_value = True
+
+ self.gdm.install(100, "test_theme")
+
+ self.installer.backup.assert_not_called()
+
+ def test_install_calls_installer_install(self):
+ self.gdm.install(100, "test_theme")
+
+ self.installer.install.assert_called_once()
+
+ def test_remove_calls_installer_remove_if_installed(self):
+ self.installer.is_installed.return_value = True
+
+ self.gdm.remove()
+
+ self.remover.remove.assert_called_once()
+
+ def test_remove_calls_installer_warn_if_not_installed(self):
+ self.installer.is_installed.return_value = False
+
+ self.gdm.remove()
+
+ self.remover.remove.assert_not_called()
\ No newline at end of file
diff --git a/tests/utils/global_theme/test_gdm_builder.py b/tests/utils/global_theme/test_gdm_builder.py
new file mode 100644
index 0000000..e01012a
--- /dev/null
+++ b/tests/utils/global_theme/test_gdm_builder.py
@@ -0,0 +1,155 @@
+from unittest import TestCase
+from unittest.mock import Mock
+
+from scripts.types.installation_color import InstallationMode
+from scripts.utils.global_theme.gdm_builder import GDMThemeBuilder
+
+
+class GDMBuilderTestCase(TestCase):
+ def setUp(self):
+ self.colors_provider = Mock()
+ self.builder = GDMThemeBuilder(colors_provider=self.colors_provider)
+
+ def test_with_mode_sets_correct_mode(self):
+ self.builder._mode = None
+ mode: InstallationMode = "dark"
+
+ builder = self.builder.with_mode(mode)
+
+ self.assertEqual(builder._mode, mode)
+
+ def test_with_filled_sets_correct_filled_state(self):
+ self.builder._is_filled = False
+ is_filled = True
+
+ builder = self.builder.with_filled(is_filled)
+
+ self.assertEqual(builder._is_filled, is_filled)
+
+ def test_with_logger_factory_sets_specified_logger_factory(self):
+ logger_factory = Mock()
+ builder = self.builder.with_logger_factory(logger_factory)
+ self.assertEqual(builder._logger_factory, logger_factory)
+
+ def test_with_gresource_sets_specified_gresource(self):
+ gresource = Mock()
+ builder = self.builder.with_gresource(gresource)
+ self.assertEqual(builder._gresource, gresource)
+
+ def test_with_ubuntu_gdm_alternatives_updater_sets_specified_updater(self):
+ alternatives_updater = Mock()
+ builder = self.builder.with_ubuntu_gdm_alternatives_updater(alternatives_updater)
+ self.assertEqual(builder._ubuntu_gdm_alternatives_updater, alternatives_updater)
+
+ def test_with_preparer_sets_specified_preparer(self):
+ preparer = Mock()
+ builder = self.builder.with_preparer(preparer)
+ self.assertEqual(builder._preparer, preparer)
+
+ def test_with_installer_sets_specified_installer(self):
+ installer = Mock()
+ builder = self.builder.with_installer(installer)
+ self.assertEqual(builder._installer, installer)
+
+ def test_with_remover_sets_specified_remover(self):
+ remover = Mock()
+ builder = self.builder.with_remover(remover)
+ self.assertEqual(builder._remover, remover)
+
+ def test_resolve_logger_factory_initializes_logger_factory(self):
+ self.builder._logger_factory = None
+
+ self.builder._resolve_logger_factory()
+
+ self.assertIsNotNone(self.builder._logger_factory)
+
+ def test_resolve_gresource_initializes_gresource(self):
+ self.builder._logger_factory = Mock()
+ self.builder._gresource = None
+
+ self.builder._resolve_gresource()
+
+ self.assertIsNotNone(self.builder._gresource)
+
+ def test_builder_supports_chaining(self):
+ theme = self.builder.with_mode("dark").with_filled(True).build()
+
+ self.assertIsNotNone(theme)
+
+ def test_resolve_ubuntu_gdm_alternatives_updater_initializes_gresource(self):
+ self.builder._logger_factory = Mock()
+ self.builder._gresource = Mock()
+ self.builder._ubuntu_gdm_alternatives_updater = None
+ self.builder._resolve_ubuntu_gdm_alternatives_updater()
+ self.assertIsNotNone(self.builder._ubuntu_gdm_alternatives_updater)
+
+ def test_resolve_preparer_initializes_preparer(self):
+ self.builder._logger_factory = Mock()
+ self.builder._gresource = Mock()
+ self.builder._ubuntu_gdm_alternatives_updater = Mock()
+ self.builder._preparer = None
+
+ self.builder._resolve_preparer()
+
+ self.assertIsNotNone(self.builder._preparer)
+
+ def test_resolve_installer_initializes_installer(self):
+ self.builder._gresource = Mock()
+ self.builder._ubuntu_gdm_alternatives_updater = Mock()
+ self.builder._installer = None
+
+ self.builder._resolve_installer()
+
+ self.assertIsNotNone(self.builder._installer)
+
+ def test_resolve_remover_initializes_remover(self):
+ self.builder._gresource = Mock()
+ self.builder._ubuntu_gdm_alternatives_updater = Mock()
+ self.builder._remover = None
+
+ self.builder._resolve_remover()
+
+ self.assertIsNotNone(self.builder._remover)
+
+ def test_build_resolves_dependencies(self):
+ self.builder._resolve_logger_factory = Mock()
+ self.builder._resolve_gresource = Mock()
+ self.builder._resolve_ubuntu_gdm_alternatives_updater = Mock()
+ self.builder._resolve_preparer = Mock()
+ self.builder._resolve_installer = Mock()
+ self.builder._resolve_remover = Mock()
+
+ self.builder.build()
+
+ self.builder._resolve_logger_factory.assert_called_once()
+ self.builder._resolve_gresource.assert_called_once()
+ self.builder._resolve_ubuntu_gdm_alternatives_updater.assert_called_once()
+ self.builder._resolve_preparer.assert_called_once()
+ self.builder._resolve_installer.assert_called_once()
+ self.builder._resolve_remover.assert_called_once()
+
+ def test_build_correctly_builds_gdm_theme(self):
+ self.builder._preparer = Mock()
+ self.builder._installer = Mock()
+ self.builder._remover = Mock()
+
+ result = self.builder.build()
+
+ self.assertEqual(result.preparer, self.builder._preparer)
+ self.assertEqual(result.installer, self.builder._installer)
+ self.assertEqual(result.remover, self.builder._remover)
+
+ def test_build_with_explicit_dependencies_works_correctly(self):
+ preparer = Mock()
+ installer = Mock()
+ remover = Mock()
+ builder = (self.builder
+ .with_preparer(preparer)
+ .with_installer(installer)
+ .with_remover(remover))
+
+ result = builder.build()
+
+ self.assertEqual(result.preparer, preparer)
+ self.assertEqual(result.installer, installer)
+ self.assertEqual(result.remover, remover)
\ No newline at end of file
diff --git a/tests/utils/global_theme/test_gdm_installer.py b/tests/utils/global_theme/test_gdm_installer.py
new file mode 100644
index 0000000..79bc558
--- /dev/null
+++ b/tests/utils/global_theme/test_gdm_installer.py
@@ -0,0 +1,87 @@
+import os.path
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from scripts import config
+from scripts.utils.global_theme.gdm_installer import GDMThemeInstaller
+
+
+class GDMInstallerTestCase(TestCase):
+ def setUp(self):
+ self.temp_folder = os.path.join(config.temp_tests_folder, "gdm_installer")
+ self.gresource = MagicMock()
+ self.gresource.temp_folder = self.temp_folder
+
+ self.alternatives_updater = MagicMock()
+
+ self.gdm_installer = GDMThemeInstaller(
+ gresource=self.gresource,
+ alternatives_updater=self.alternatives_updater
+ )
+
+ def test_is_installed_return_the_same_value_as_gresource(self):
+ self.gresource.has_trigger.return_value = True
+
+ result = self.gdm_installer.is_installed()
+
+ self.assertTrue(result)
+ self.gresource.has_trigger.assert_called_once()
+
+ def test_compile_does_not_call_label_theme_if_label_is_none(self):
+ theme_prepare = MagicMock()
+ theme_prepare.label = None
+ theme_prepare.label_theme = MagicMock()
+
+ self.gdm_installer.compile(themes=[theme_prepare], hue=0, color="red", sat=None)
+
+ theme_prepare.label_theme.assert_not_called()
+
+ def test_compile_calls_label_theme_if_label_is_set(self):
+ theme_prepare = MagicMock()
+ theme_prepare.label = "dark"
+ theme_prepare.label_theme = MagicMock()
+
+ self.gdm_installer.compile(themes=[theme_prepare], hue=0, color="red", sat=None)
+
+ theme_prepare.label_theme.assert_called_once()
+
+ def test_compile_calls_removes_keywords_and_properties_and_prepends_source_styles(self):
+ theme_prepare = MagicMock()
+ theme_prepare.remove_keywords = MagicMock()
+ theme_prepare.remove_properties = MagicMock()
+ theme_prepare.prepend_source_styles = MagicMock()
+
+ self.gdm_installer.compile(themes=[theme_prepare], hue=0, color="red", sat=None)
+
+ theme_prepare.remove_keywords.assert_called_once()
+ theme_prepare.remove_properties.assert_called_once()
+ theme_prepare.prepend_source_styles.assert_called_once()
+
+ def test_compile_installs_themes_with_correct_parameters(self):
+ theme_prepare = MagicMock()
+ theme_prepare.install = MagicMock()
+ themes = [theme_prepare]
+ hue = 0
+ color = "red"
+ sat = None
+
+ self.gdm_installer.compile(themes, hue, color, sat)
+
+ theme_prepare.install.assert_called_once()
+ theme_prepare.install.assert_called_with(hue, color, sat, destination=self.temp_folder)
+
+ def test_compile_calls_gresource_compile(self):
+ self.gdm_installer.compile([], 0, "red", None)
+
+ self.gresource.compile.assert_called_once()
+
+ def test_backup_calls_gresource_backup(self):
+ self.gdm_installer.backup()
+
+ self.gresource.backup.assert_called_once()
+
+ def test_install_calls_gresource_move_and_alternatives_updater_install_and_set(self):
+ self.gdm_installer.install()
+
+ self.gresource.move.assert_called_once()
+ self.alternatives_updater.install_and_set.assert_called_once()
\ No newline at end of file
diff --git a/tests/utils/global_theme/test_gdm_preparer.py b/tests/utils/global_theme/test_gdm_preparer.py
new file mode 100644
index 0000000..99748ce
--- /dev/null
+++ b/tests/utils/global_theme/test_gdm_preparer.py
@@ -0,0 +1,124 @@
+import os
+import shutil
+import unittest
+from unittest.mock import MagicMock, patch
+
+from scripts import config
+from scripts.types.theme_base import ThemeBase
+from scripts.utils.global_theme.gdm_preparer import GDMThemePreparer
+
+
+class DummyTheme(ThemeBase):
+ def __init__(self):
+ super().__init__()
+ self.temp_folder = None
+ self.main_styles = None
+
+ def prepare(self):
+ pass
+
+ def install(self, hue: int, name: str, sat: float | None = None):
+ pass
+
+
+class TestGDMThemePreparer(unittest.TestCase):
+ def setUp(self):
+ self.temp_folder = os.path.join(config.temp_tests_folder, "gdm_preparer")
+
+ self.gresource = self._mock_gresource(self.temp_folder)
+ self.theme_builder = self._mock_builder()
+
+ self.mock_logger = MagicMock()
+ self.logger_factory = MagicMock()
+ self.logger_factory.create_logger.return_value = self.mock_logger
+
+ self.preparer = GDMThemePreparer(
+ temp_folder=self.temp_folder,
+ default_mode="light",
+ is_filled=True,
+ gresource=self.gresource,
+ theme_builder=self.theme_builder,
+ logger_factory=self.logger_factory,
+ files_labeler_factory=MagicMock(),
+ )
+
+ @staticmethod
+ def _mock_gresource(temp_folder):
+ gresource = MagicMock()
+ gresource.temp_folder = temp_folder
+ gresource.extract = MagicMock()
+ gresource.use_backup_gresource = MagicMock()
+ return gresource
+
+ @staticmethod
+ def _mock_builder():
+ theme_builder = MagicMock()
+ theme_builder.with_temp_folder.return_value = theme_builder
+ theme_builder.with_theme_name.return_value = theme_builder
+ theme_builder.with_mode.return_value = theme_builder
+ theme_builder.filled.return_value = theme_builder
+ theme_builder.with_logger_factory.return_value = theme_builder
+ theme_builder.with_reset_dependencies.return_value = theme_builder
+ theme_builder.build.return_value = DummyTheme()
+ return theme_builder
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+
+ def test_use_backup_as_source(self):
+ self.preparer.use_backup_as_source()
+
+ self.gresource.use_backup_gresource.assert_called_once()
+
+ @patch("os.listdir")
+ def test_preparer_extracts_gresource(self, mock_listdir):
+ mock_listdir.return_value = ["gnome-shell.css"]
+
+ self.preparer.prepare()
+
+ self.gresource.extract.assert_called_once()
+
+ @patch("os.listdir")
+ def test_preparer_scans_correct_directory(self, mock_listdir):
+ mock_listdir.return_value = ["gnome-shell.css"]
+
+ self.preparer.prepare()
+
+ mock_listdir.assert_called_once_with(self.gresource.temp_folder)
+
+ @patch("os.listdir")
+ def test_preparer_filters_valid_css_files(self, mock_listdir):
+ valid_files = ["gnome-shell-dark.css", "gnome-shell-light.css", "gnome-shell.css"]
+ invalid_files = ["other.css", "readme.txt"]
+ mock_listdir.return_value = valid_files + invalid_files
+
+ themes = self.preparer.prepare()
+
+ self.assertEqual(len(themes), len(valid_files))
+
+ @patch("os.listdir")
+ def test_preparer_assigns_correct_labels(self, mock_listdir):
+ test_files = ["gnome-shell-dark.css", "gnome-shell-light.css", "gnome-shell.css"]
+ mock_listdir.return_value = test_files
+
+ themes = self.preparer.prepare()
+
+ expected_labels = {
+ "gnome-shell-dark.css": "dark",
+ "gnome-shell-light.css": "light",
+ "gnome-shell.css": "light" # Uses default_mode
+ }
+
+ for theme_obj in themes:
+ file_name = os.path.basename(theme_obj.theme_file)
+ self.assertEqual(theme_obj.label, expected_labels[file_name])
+
+ @patch("os.listdir")
+ def test_preparer_configures_theme_builder_correctly(self, mock_listdir):
+ mock_listdir.return_value = ["gnome-shell-dark.css", "gnome-shell.css"]
+
+ self.preparer.prepare()
+
+ self.theme_builder.with_theme_name.assert_any_call("gnome-shell-dark")
+ self.theme_builder.with_theme_name.assert_any_call("gnome-shell")
+
diff --git a/tests/utils/global_theme/test_gdm_remover.py b/tests/utils/global_theme/test_gdm_remover.py
new file mode 100644
index 0000000..04b2d07
--- /dev/null
+++ b/tests/utils/global_theme/test_gdm_remover.py
@@ -0,0 +1,44 @@
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from scripts.utils.global_theme.gdm_remover import GDMThemeRemover
+from scripts.utils.gresource import GresourceBackupNotFoundError
+
+
+class GDMRemoverTestCase(TestCase):
+ def setUp(self):
+ self.gresource = MagicMock()
+ self.alternatives_updater = MagicMock()
+ self.logger = MagicMock()
+ self.logger_factory = MagicMock(return_value=self.logger)
+
+ self.remover = GDMThemeRemover(
+ gresource=self.gresource,
+ alternatives_updater=self.alternatives_updater,
+ logger_factory=self.logger_factory
+ )
+
+ self.remover.remover_logger = MagicMock()
+
+ def test_remove_logs_start_message(self):
+ self.remover.remove()
+
+ self.remover.remover_logger.start_removing.assert_called_once()
+
+ def test_remove_calls_gresource_restore_and_alternatives_remove(self):
+ self.remover.remove()
+
+ self.gresource.restore.assert_called_once()
+ self.alternatives_updater.remove.assert_called_once()
+
+ def test_remove_logs_success_message(self):
+ self.remover.remove()
+
+ self.remover.remover_logger.success_removing.assert_called_once()
+
+ def test_remove_logs_error_message_when_backup_not_found(self):
+ self.gresource.restore.side_effect = GresourceBackupNotFoundError()
+
+ self.remover.remove()
+
+ self.remover.remover_logger.error_removing.assert_called_once()
\ No newline at end of file
diff --git a/tests/utils/global_theme/test_gdm_theme_prepare.py b/tests/utils/global_theme/test_gdm_theme_prepare.py
new file mode 100644
index 0000000..cc0fac1
--- /dev/null
+++ b/tests/utils/global_theme/test_gdm_theme_prepare.py
@@ -0,0 +1,118 @@
+import os.path
+import shutil
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from scripts import config
+from scripts.utils.global_theme.gdm_theme_prepare import GDMThemePrepare
+from ..._helpers import create_dummy_file, try_remove_file
+
+
+class GDMThemePrepareTestCase(TestCase):
+ def setUp(self):
+ self.temp_folder = os.path.join(config.temp_tests_folder, "gdm_theme_prepare")
+ self.main_styles = os.path.join(self.temp_folder, "gnome-shell.css")
+ self.theme = MagicMock()
+ self.theme.add_to_start.return_value = None
+ self.theme.temp_folder = self.temp_folder
+ self.theme.main_styles = self.main_styles
+
+ self.main_styles_destination = os.path.join(self.temp_folder, "gnome-shell-result.css")
+ create_dummy_file(self.main_styles_destination, "body { background-color: #000; }")
+
+ self.files_labeler = MagicMock()
+
+ self.theme_prepare = GDMThemePrepare(
+ theme=self.theme,
+ theme_file=self.main_styles_destination,
+ label=None,
+ files_labeler=self.files_labeler,
+ )
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+
+ def test_label_files_calls_labeler(self):
+ self.theme_prepare.label = "dark"
+
+ self.theme_prepare.label_theme()
+
+ self.files_labeler.append_label.assert_called_once_with("dark")
+
+ def test_label_files_raises_value_error_if_label_none(self):
+ self.theme_prepare.label = None
+
+ with self.assertRaises(ValueError):
+ self.theme_prepare.label_theme()
+
+ def test_remove_keywords_removes_destination_keywords(self):
+ try_remove_file(self.main_styles_destination)
+ expected_content = "body { background-color: #000; }"
+ create_dummy_file(self.main_styles_destination, "body {keyword1 background-color: #000 !important; }")
+ keywords = ["keyword1", " !important"]
+
+ self.theme_prepare.remove_keywords(*keywords)
+
+ with open(self.main_styles_destination, 'r') as file:
+ content = file.read()
+ self.assertEqual(content, expected_content)
+ try_remove_file(self.main_styles_destination)
+
+ def test_remove_properties_removes_destination_properties(self):
+ try_remove_file(self.main_styles_destination)
+ expected_content = "body {\n}\n"
+ create_dummy_file(self.main_styles_destination, "body {\nbackground-color: #000;\n}")
+ properties = ["background-color"]
+
+ self.theme_prepare.remove_properties(*properties)
+
+ with open(self.main_styles_destination, 'r') as file:
+ actual_content = file.read()
+ self.assertEqual(expected_content, actual_content)
+ try_remove_file(self.main_styles_destination)
+
+ def test_remove_properties_removes_one_line_properties(self):
+ try_remove_file(self.main_styles_destination)
+ expected_content = ""
+ create_dummy_file(self.main_styles_destination, "body { background-color: #000; }")
+ properties = ["background-color"]
+
+ self.theme_prepare.remove_properties(*properties)
+
+ with open(self.main_styles_destination, 'r') as file:
+ actual_content = file.read()
+ self.assertEqual(expected_content, actual_content)
+ try_remove_file(self.main_styles_destination)
+
+ def test_prepend_source_styles_prepends_destination_styles(self):
+ try_remove_file(self.main_styles_destination)
+ expected_content = "body { background-color: #000; }\n"
+ create_dummy_file(self.main_styles_destination, "body { background-color: #000; }")
+
+ self.theme_prepare.prepend_source_styles("")
+
+ called_content: str = self.theme.add_to_start.call_args[0][0]
+ self.assertTrue(called_content.startswith(expected_content))
+ try_remove_file(self.main_styles_destination)
+
+ def test_prepend_source_styles_adds_trigger(self):
+ try_remove_file(self.main_styles_destination)
+ expected_content = "\ntrigger\n"
+ create_dummy_file(self.main_styles_destination)
+ trigger = "trigger"
+
+ self.theme_prepare.prepend_source_styles(trigger)
+
+ called_content: str = self.theme.add_to_start.call_args[0][0]
+ self.assertTrue(expected_content in called_content)
+ try_remove_file(self.main_styles_destination)
+
+ def test_install_passes_arguments_to_theme(self):
+ hue = 0
+ color = "#000000"
+ sat = 100
+ destination = os.path.join(self.temp_folder, "destination")
+
+ self.theme_prepare.install(hue, color, sat, destination)
+
+ self.theme.install.assert_called_once_with(hue, color, sat, destination=destination)
\ No newline at end of file
diff --git a/tests/utils/global_theme/test_ubuntu_gdm_update_alternatives.py b/tests/utils/global_theme/test_ubuntu_gdm_update_alternatives.py
new file mode 100644
index 0000000..98b5f3e
--- /dev/null
+++ b/tests/utils/global_theme/test_ubuntu_gdm_update_alternatives.py
@@ -0,0 +1,47 @@
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from scripts.utils.alternatives_updater import AlternativesUpdater
+from scripts.utils.global_theme.ubuntu_alternatives_updater import UbuntuGDMAlternativesUpdater
+
+
+class UbuntuGDMUpdateAlternativesTestCase(TestCase):
+ def setUp(self):
+ self.updater = MagicMock(spec=AlternativesUpdater)
+ self.ubuntu_updater = UbuntuGDMAlternativesUpdater(
+ alternatives_updater=self.updater
+ )
+
+ def test_custom_destination_updates_correctly(self):
+ custom_destination_dir = "/custom/path"
+ custom_destination_file = "custom_file.gresource"
+
+ self.ubuntu_updater.with_custom_destination(
+ custom_destination_dir, custom_destination_file
+ )
+
+ self.assertEqual(
+ self.ubuntu_updater.destination_dir, custom_destination_dir
+ )
+ self.assertEqual(
+ self.ubuntu_updater.destination_file, custom_destination_file
+ )
+
+ def test_install_and_set_calls_updater_correctly(self):
+ priority = 100
+ self.ubuntu_updater.install_and_set(priority)
+
+ self.updater.install_and_set.assert_called_once_with(
+ link=self.ubuntu_updater.ubuntu_gresource_path,
+ name=self.ubuntu_updater.ubuntu_gresource_link_name,
+ path=self.ubuntu_updater.gnome_gresource_path,
+ priority=priority
+ )
+
+ def test_remove_calls_updater_correctly(self):
+ self.ubuntu_updater.remove()
+
+ self.updater.remove.assert_called_once_with(
+ name=self.ubuntu_updater.ubuntu_gresource_link_name,
+ path=self.ubuntu_updater.gnome_gresource_path
+ )
\ No newline at end of file
diff --git a/tests/utils/gresource/__init__.py b/tests/utils/gresource/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/utils/gresource/test_gresource.py b/tests/utils/gresource/test_gresource.py
new file mode 100644
index 0000000..08ba95b
--- /dev/null
+++ b/tests/utils/gresource/test_gresource.py
@@ -0,0 +1,130 @@
+import os.path
+import shutil
+import unittest
+from unittest.mock import patch
+
+from scripts import config
+from scripts.utils.gresource.gresource import Gresource
+from ..._helpers import create_dummy_file, try_remove_file
+from ..._helpers.dummy_logger_factory import DummyLoggerFactory
+from ..._helpers.dummy_runner import DummyRunner
+
+
+class GresourceTestCase(unittest.TestCase):
+ def setUp(self):
+ self.gresource_file = "test.gresource"
+ self.temp_folder = os.path.join(config.temp_tests_folder, "gresource_temp")
+ self.destination = os.path.join(config.temp_tests_folder, "gresource_dest")
+
+ self.temp_file = os.path.join(self.temp_folder, self.gresource_file)
+ self.destination_file = os.path.join(self.destination, self.gresource_file)
+
+ self.logger = DummyLoggerFactory()
+ self.runner = DummyRunner()
+
+ self.gresource = Gresource(
+ self.gresource_file, self.temp_folder, self.destination,
+ logger_factory=self.logger, runner=self.runner
+ )
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+ shutil.rmtree(self.destination, ignore_errors=True)
+
+ def test_use_backup_gresource(self):
+ destination_file = os.path.join(self.destination, self.gresource_file)
+ create_dummy_file(destination_file)
+ self.gresource.backup()
+
+ self.gresource.use_backup_gresource()
+
+ assert self.gresource._active_source_gresource != self.gresource._destination_gresource
+ assert os.path.exists(self.gresource._active_source_gresource)
+
+ try_remove_file(self.gresource._active_source_gresource)
+ try_remove_file(destination_file)
+
+ def test_use_backup_gresource_not_found(self):
+ destination_file = os.path.join(self.destination, self.gresource_file)
+ try_remove_file(destination_file)
+
+ with self.assertRaises(FileNotFoundError):
+ self.gresource.use_backup_gresource()
+
+ def test_extract(self):
+ """Test that extract creates and calls GresourceExtractor correctly."""
+ with patch('scripts.utils.gresource.gresource.GresourceExtractor') as mock_extractor_class:
+ mock_extractor_instance = mock_extractor_class.return_value
+
+ self.gresource.extract()
+
+ mock_extractor_class.assert_called_once_with(
+ self.gresource._active_source_gresource,
+ self.temp_folder,
+ logger_factory=self.logger,
+ runner=self.runner
+ )
+ mock_extractor_instance.extract.assert_called_once()
+
+ def test_compile(self):
+ """Test that compile creates and calls GresourceCompiler correctly."""
+ with (patch('scripts.utils.gresource.gresource.GresourceCompiler') as mock_compiler_class):
+ mock_compiler_instance = mock_compiler_class.return_value
+
+ self.gresource.compile()
+
+ mock_compiler_class.assert_called_once_with(
+ self.temp_folder,
+ self.gresource._temp_gresource,
+ logger_factory=self.logger,
+ runner=self.runner
+ )
+ mock_compiler_instance.compile.assert_called_once()
+
+ def test_backup(self):
+ create_dummy_file(self.destination_file)
+
+ self.gresource.backup()
+ backup = self.gresource.use_backup_gresource()
+
+ assert os.path.exists(backup)
+ self.gresource.restore()
+
+ def test_backup_not_found(self):
+ try_remove_file(self.destination_file)
+
+ with self.assertRaises(FileNotFoundError):
+ self.gresource.backup()
+
+ def test_restore(self):
+ destination_file = os.path.join(self.destination, self.gresource_file)
+ create_dummy_file(destination_file, content="dummy content")
+ self.gresource.backup()
+ create_dummy_file(destination_file, content="new content")
+
+ self.gresource.restore()
+
+ assert os.path.exists(destination_file)
+ with open(destination_file) as f:
+ content = f.read()
+ assert content == "dummy content"
+
+ def test_restore_not_found(self):
+ destination_file = os.path.join(self.destination, self.gresource_file)
+ try_remove_file(destination_file)
+
+ with self.assertRaises(FileNotFoundError):
+ self.gresource.restore()
+
+ def test_move(self):
+ create_dummy_file(self.temp_file)
+
+ self.gresource.move()
+
+ assert os.path.exists(self.destination_file)
+
+ def test_move_not_found(self):
+ try_remove_file(self.temp_file)
+
+ with self.assertRaises(FileNotFoundError):
+ self.gresource.move()
\ No newline at end of file
diff --git a/tests/utils/gresource/test_gresource_backuper.py b/tests/utils/gresource/test_gresource_backuper.py
new file mode 100644
index 0000000..69b0145
--- /dev/null
+++ b/tests/utils/gresource/test_gresource_backuper.py
@@ -0,0 +1,100 @@
+import os
+import shutil
+import unittest
+
+import pytest
+
+from scripts import config
+from scripts.utils.gresource import GresourceBackupNotFoundError
+from scripts.utils.gresource.gresource_backuper import GresourceBackuperManager, GresourceBackuper
+from ..._helpers import create_dummy_file, try_remove_file
+from ..._helpers.dummy_logger_factory import DummyLoggerFactory
+
+
+class GresourceBackuperManagerTestCase(unittest.TestCase):
+ def setUp(self):
+ self.gresource_file = "test.gresource"
+ self.temp_folder = os.path.join(config.temp_tests_folder, "backup_temp")
+ self.destination = os.path.join(config.temp_tests_folder, "backup_dest")
+ self.destination_file = os.path.join(self.temp_folder, self.gresource_file)
+
+ self.logger = DummyLoggerFactory()
+
+ self.backuper_manager = GresourceBackuperManager(self.destination_file,
+ logger_factory=self.logger)
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+ shutil.rmtree(self.destination, ignore_errors=True)
+
+ def test_get_backup(self):
+ create_dummy_file(self.destination_file)
+
+ self.backuper_manager.backup()
+ backup = self.backuper_manager.get_backup()
+
+ assert os.path.exists(backup)
+
+ def test_backup_overwrites_existing_backup(self):
+ """Test that backup properly overwrites an existing backup file."""
+ create_dummy_file(self.destination_file, content="original")
+ create_dummy_file(self.backuper_manager._backup_file, content="old backup")
+
+ self.backuper_manager.backup()
+
+ with open(self.backuper_manager._backup_file, 'r') as f:
+ content = f.read()
+ assert content == "original"
+
+
+class GresourceBackuperTestCase(unittest.TestCase):
+ def setUp(self):
+ self.temp_folder = os.path.join(config.temp_tests_folder, "backup_temp")
+ self.destination_file = os.path.join(self.temp_folder, "test.gresource")
+ self.backup_file = f"{self.destination_file}.backup"
+
+ self.logger = DummyLoggerFactory()
+
+ self.backuper = GresourceBackuper(self.destination_file, self.backup_file,
+ logger_factory=self.logger)
+
+ os.makedirs(self.temp_folder, exist_ok=True)
+
+ def test_get_backup(self):
+ create_dummy_file(self.backup_file)
+
+ backup = self.backuper.get_backup()
+
+ assert os.path.exists(backup)
+ assert backup == self.backup_file
+
+ def test_use_backup_gresource_not_found(self):
+ try_remove_file(self.backup_file)
+
+ with pytest.raises(GresourceBackupNotFoundError):
+ self.backuper.get_backup()
+
+ def test_backup_creates_backup_file(self):
+ """Test direct backup functionality."""
+ create_dummy_file(self.destination_file)
+
+ self.backuper.backup()
+
+ assert os.path.exists(self.backup_file)
+
+ def test_backup_handles_missing_destination(self):
+ """Test backup behavior when destination file doesn't exist."""
+ try_remove_file(self.destination_file)
+
+ with pytest.raises(FileNotFoundError):
+ self.backuper.backup()
+
+ def test_restore_implementation(self):
+ """Test direct restore implementation."""
+ create_dummy_file(self.backup_file)
+ try_remove_file(self.destination_file)
+
+ self.backuper.restore()
+
+ assert os.path.exists(self.destination_file)
+ assert not os.path.exists(self.backup_file)
\ No newline at end of file
diff --git a/tests/utils/gresource/test_gresource_compiler.py b/tests/utils/gresource/test_gresource_compiler.py
new file mode 100644
index 0000000..2973867
--- /dev/null
+++ b/tests/utils/gresource/test_gresource_compiler.py
@@ -0,0 +1,125 @@
+import os
+import shutil
+import subprocess
+import unittest
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from scripts import config
+from scripts.utils.gresource import MissingDependencyError
+from scripts.utils.gresource.gresource_compiler import GresourceCompiler
+from ..._helpers.dummy_logger_factory import DummyLoggerFactory
+from ..._helpers.dummy_runner import DummyRunner
+
+
+class GresourceCompilerTestCase(unittest.TestCase):
+ def setUp(self):
+ self.temp_folder = os.path.join(config.temp_tests_folder, "gresource_compiler_temp")
+ self.target_file = os.path.join(self.temp_folder, "test.gresource")
+
+ self.logger = DummyLoggerFactory()
+ self.runner = DummyRunner()
+
+ self.compiler = GresourceCompiler(self.temp_folder, self.target_file,
+ logger_factory=self.logger, runner=self.runner)
+
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+
+ def test_compile_calls_correct_methods(self):
+ """Test that compile calls the right methods in sequence."""
+ with (
+ patch.object(self.compiler, '_create_gresource_xml') as mock_create_xml,
+ patch.object(self.compiler, '_compile_resources') as mock_compile
+ ):
+ self.compiler.compile()
+
+ # Verify methods were called correctly in order
+ mock_create_xml.assert_called_once()
+ mock_compile.assert_called_once()
+
+ def test_create_gresource_xml(self):
+ """Test that _create_gresource_xml creates the XML file with correct content."""
+ with (
+ patch("builtins.open", create=True) as mock_open,
+ patch.object(self.compiler, '_generate_gresource_xml') as mock_generate
+ ):
+ mock_generate.return_value = "test content"
+ mock_file = MagicMock()
+ mock_open.return_value.__enter__.return_value = mock_file
+
+ self.compiler._create_gresource_xml()
+
+ mock_open.assert_called_once_with(self.compiler.gresource_xml, 'w')
+ mock_file.write.assert_called_once_with("test content")
+
+ def test_generate_gresource_xml(self):
+ """Test that _generate_gresource_xml creates correct XML structure."""
+ with patch.object(self.compiler, '_get_files_to_include') as mock_get_files:
+ mock_get_files.return_value = ["file1.css", "subdir/file2.css"]
+
+ result = self.compiler._generate_gresource_xml()
+
+ assert "" in result
+ assert "" in result
+ assert "file1.css" in result
+ assert "subdir/file2.css" in result
+
+ def test_get_files_to_include(self):
+ """Test that _get_files_to_include finds and formats files correctly."""
+ self.__create_dummy_files_in_temp()
+
+ result = self.compiler._get_files_to_include()
+
+ assert len(result) == 2
+ assert "file1.css" in result
+ assert "subdir/file2.css" in result
+
+ def __create_dummy_files_in_temp(self):
+ os.makedirs(self.temp_folder, exist_ok=True)
+ test_file1 = os.path.join(self.temp_folder, "file1.css")
+ test_subdir = os.path.join(self.temp_folder, "subdir")
+ os.makedirs(test_subdir, exist_ok=True)
+ test_file2 = os.path.join(test_subdir, "file2.css")
+
+ with open(test_file1, 'w') as f:
+ f.write("test content")
+ with open(test_file2, 'w') as f:
+ f.write("test content")
+
+ def test_compile_resources(self):
+ """Test that _compile_resources runs the correct subprocess command."""
+ with patch.object(self.runner, "run") as mock_run:
+ self.compiler._compile_resources()
+
+ mock_run.assert_called_once()
+ args = mock_run.call_args[0][0]
+ assert args[0] == "glib-compile-resources"
+ assert args[2] == self.temp_folder
+ assert args[4] == self.compiler.target_file
+ assert args[5] == self.compiler.gresource_xml
+
+ def test_compile_resources_file_not_found(self):
+ """Test that _compile_resources raises appropriate error when command not found."""
+ with (
+ patch.object(self.runner, "run", side_effect=FileNotFoundError("glib-compile-resources not found")),
+ patch("builtins.print")
+ ):
+ with pytest.raises(MissingDependencyError):
+ self.compiler._compile_resources()
+
+ def test_try_compile_resources_called_process_error(self):
+ """Test handling of subprocess execution failures."""
+ process_error = subprocess.CalledProcessError(1, "glib-compile-resources", output="Failed to compile")
+ with patch.object(self.runner, "run", side_effect=process_error):
+ with pytest.raises(subprocess.CalledProcessError):
+ self.compiler._try_compile_resources()
+
+ def test_compile_resources_other_file_not_found_error(self):
+ """Test that other FileNotFoundError exceptions are propagated."""
+ with patch.object(self.runner, "run", side_effect=FileNotFoundError("Some other file not found")):
+ with pytest.raises(FileNotFoundError):
+ self.compiler._compile_resources()
\ No newline at end of file
diff --git a/tests/utils/gresource/test_gresource_extractor.py b/tests/utils/gresource/test_gresource_extractor.py
new file mode 100644
index 0000000..88cd613
--- /dev/null
+++ b/tests/utils/gresource/test_gresource_extractor.py
@@ -0,0 +1,123 @@
+import os
+import shutil
+import unittest
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from scripts import config
+from scripts.utils.gresource import MissingDependencyError
+from scripts.utils.gresource.gresource_extractor import GresourceExtractor
+from ..._helpers.dummy_logger_factory import DummyLoggerFactory
+from ..._helpers.dummy_runner import DummyRunner
+
+
+class GresourceExtractorTestCase(unittest.TestCase):
+ def setUp(self):
+ self.gresource_file = "test.gresource"
+ self.temp_folder = os.path.join(config.temp_tests_folder, "gresource_extractor_temp")
+ self.destination = os.path.join(config.temp_tests_folder, "gresource_extractor_dest")
+
+ self.logger = DummyLoggerFactory()
+ self.runner = DummyRunner()
+
+ self.extractor = GresourceExtractor(
+ self.gresource_file, self.temp_folder,
+ logger_factory=self.logger, runner=self.runner
+ )
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+ shutil.rmtree(self.destination, ignore_errors=True)
+
+ def test_extract_calls_correct_methods(self):
+ with (
+ patch.object(self.extractor, '_get_resources_list') as mock_get_list,
+ patch.object(self.extractor, '_extract_resources') as mock_extract
+ ):
+ resources = ["resource1", "resource2"]
+ mock_get_list.return_value = resources
+
+ self.extractor.extract()
+
+ mock_get_list.assert_called_once()
+ mock_extract.assert_called_once_with(resources)
+
+ def test_get_resources_list(self):
+ """Test that resources are correctly listed from the gresource file."""
+ test_resources = ["/org/gnome/shell/theme/file1.css", "/org/gnome/shell/theme/file2.css"]
+
+ with patch.object(self.runner, "run") as mock_run:
+ mock_run.return_value = self.__mock_gresources_list(
+ stdout="\n".join(test_resources),
+ stderr=""
+ )
+
+ result = self.extractor._get_resources_list()
+
+ assert result == test_resources
+ mock_run.assert_called_once()
+ assert mock_run.call_args[0][0][1] == "list"
+
+ @staticmethod
+ def __mock_gresources_list(stdout: str, stderr: str):
+ mock_result = MagicMock()
+ mock_result.stdout = stdout
+ mock_result.stderr = stderr
+ return mock_result
+
+ def test_get_resources_list_error(self):
+ """Test that an exception is raised when gresource fails to list resources."""
+ with patch.object(self.runner, "run",
+ side_effect=Exception("Error: gresource failed")):
+
+ with pytest.raises(Exception):
+ self.extractor._get_resources_list()
+
+ def test_extract_resources(self):
+ """Test that resources are correctly extracted."""
+ test_resources = [
+ "/org/gnome/shell/theme/file1.css",
+ "/org/gnome/shell/theme/subdir/file2.css"
+ ]
+
+ with (
+ patch.object(self.runner, "run") as mock_run,
+ patch("os.makedirs") as mock_makedirs,
+ patch("builtins.open", create=True)
+ ):
+ self.extractor._extract_resources(test_resources)
+
+ assert mock_makedirs.call_count == 2
+ assert mock_run.call_count == 2
+ for i, resource in enumerate(test_resources):
+ args_list = mock_run.call_args_list[i][0][0]
+ assert args_list[1] == "extract"
+ assert args_list[3] == resource
+
+ def test_extract_resources_file_not_found(self):
+ with (
+ patch.object(self.runner, "run",
+ side_effect=FileNotFoundError("gresource not found")),
+ patch("builtins.print")
+ ):
+ with pytest.raises(MissingDependencyError):
+ self.extractor.extract()
+
+ def test_try_extract_resources(self):
+ resources = ["/org/gnome/shell/theme/file.css"]
+
+ with (
+ patch("os.makedirs"),
+ patch("builtins.open", create=True) as mock_open
+ ):
+ mock_file = MagicMock()
+ mock_open.return_value.__enter__.return_value = mock_file
+
+ self.extractor._extract_resources(resources)
+
+ expected_path = os.path.join(self.temp_folder, "file.css")
+ mock_open.assert_called_once_with(expected_path, 'wb')
+
+ def test_empty_resource_list(self):
+ self.extractor._extract_resources([])
\ No newline at end of file
diff --git a/tests/utils/gresource/test_gresource_mover.py b/tests/utils/gresource/test_gresource_mover.py
new file mode 100644
index 0000000..9e95f8e
--- /dev/null
+++ b/tests/utils/gresource/test_gresource_mover.py
@@ -0,0 +1,51 @@
+import os.path
+import unittest
+from unittest.mock import patch
+
+from scripts import config
+from scripts.utils.gresource.gresource_mover import GresourceMover
+from ..._helpers import create_dummy_file, try_remove_file
+from ..._helpers.dummy_logger_factory import DummyLoggerFactory
+from ..._helpers.dummy_runner import DummyRunner
+
+
+class GresourceMoverTestCase(unittest.TestCase):
+ def setUp(self):
+ self.gresource_file = "test.gresource"
+ self.source_file = os.path.join(config.temp_tests_folder, "gresource_mover_source", self.gresource_file)
+ self.destination_file = os.path.join(config.temp_tests_folder, "gresource_mover_destination", self.gresource_file)
+
+ self.logger = DummyLoggerFactory()
+ self.runner = DummyRunner()
+
+ self.mover = GresourceMover(self.source_file, self.destination_file,
+ logger_factory=self.logger)
+
+
+ def tearDown(self):
+ try_remove_file(self.source_file)
+ try_remove_file(self.destination_file)
+
+ def test_move_with_correct_permissions(self):
+ """Test that move changes permissions correctly."""
+ create_dummy_file(self.source_file)
+
+ self.mover.move()
+
+ assert os.path.exists(self.mover.destination_file)
+ permissions = oct(os.stat(self.mover.destination_file).st_mode)[-3:]
+ assert permissions == "644"
+
+ def test_move_handles_cp_error(self):
+ """Test that errors during copy are properly handled."""
+ with patch('shutil.copyfile', side_effect=OSError):
+ with self.assertRaises(OSError):
+ self.mover.move()
+
+ def test_move_handles_chmod_error(self):
+ """Test that errors during chmod are properly handled."""
+ create_dummy_file(self.source_file)
+
+ with patch('os.chmod', side_effect=PermissionError):
+ with self.assertRaises(PermissionError):
+ self.mover.move()
\ No newline at end of file
diff --git a/tests/utils/test_files_labeler.py b/tests/utils/test_files_labeler.py
new file mode 100644
index 0000000..8c024a1
--- /dev/null
+++ b/tests/utils/test_files_labeler.py
@@ -0,0 +1,57 @@
+import os.path
+import shutil
+from unittest import TestCase
+
+from scripts import config
+from scripts.utils.files_labeler import FilesLabelerFactoryImpl
+from .._helpers import create_dummy_file
+
+
+class FilesLabelerTestCase(TestCase):
+ def setUp(self):
+ self.temp_folder = os.path.join(config.temp_tests_folder, "labeler")
+
+ self.files = ["file1.svg", "file2.png", "file3.svg"]
+ self.styles_file = os.path.join(self.temp_folder, "styles-test.css") # styles files are already labeled
+ self.original_styles_content = f"body {{ background: url('./{self.files[0]}'); }}"
+
+ self.factory = FilesLabelerFactoryImpl()
+
+ def _generate_test_files(self):
+ self.tearDown()
+
+ for filename in self.files:
+ create_dummy_file(os.path.join(self.temp_folder, filename))
+
+ create_dummy_file(self.styles_file, self.original_styles_content)
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+
+ def test_append_label_correctly_labels_files(self):
+ self._generate_test_files()
+ label = "test"
+ labeled_files = [(f, f.replace(".", f"-{label}.")) for f in self.files]
+ labeler = self.factory.create(self.temp_folder)
+
+ labeler.append_label(label)
+
+ for original, labeled in labeled_files:
+ labeled_path = os.path.join(self.temp_folder, labeled)
+ original_path = os.path.join(self.temp_folder, original)
+ self.assertTrue(os.path.exists(labeled_path))
+ self.assertFalse(os.path.exists(original_path))
+
+ def test_append_label_correctly_updates_references(self):
+ self._generate_test_files()
+ label = "test"
+ replaced_file = self.files[0].replace('.', f'-{label}.')
+ expected_content = f"body {{ background: url('./{replaced_file}'); }}"
+ labeler = self.factory.create(self.temp_folder, self.styles_file)
+
+ labeler.append_label(label)
+
+ with open(self.styles_file, 'r') as file:
+ actual_content = file.read()
+ self.assertNotEqual(actual_content, self.original_styles_content)
+ self.assertEqual(actual_content, expected_content)
\ No newline at end of file
diff --git a/tests/utils/test_style_manager.py b/tests/utils/test_style_manager.py
new file mode 100644
index 0000000..ab07f06
--- /dev/null
+++ b/tests/utils/test_style_manager.py
@@ -0,0 +1,80 @@
+import os
+import shutil
+import unittest
+from unittest.mock import patch
+
+from scripts import config
+from scripts.utils.style_manager import StyleManager
+from .._helpers import create_dummy_file
+
+
+class StyleManagerTestCase(unittest.TestCase):
+ def setUp(self):
+ self.temp_folder = os.path.join(config.temp_tests_folder, "style_manager")
+ os.makedirs(self.temp_folder, exist_ok=True)
+ self.output_file = os.path.join(self.temp_folder, "output.css")
+ self.manager = StyleManager(output_file=self.output_file)
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+
+ def test_append_content(self):
+ start_css = "body { background-color: blue; }"
+ create_dummy_file(self.output_file, start_css)
+ end_css = "h1 { color: red; }"
+
+ self.manager.append_content(end_css)
+
+ with open(self.output_file, 'r') as f:
+ content = f.read()
+ split_content = content.splitlines()
+ assert split_content[0] == start_css
+ assert split_content[1] == end_css
+ os.remove(self.output_file)
+
+ def test_append_does_not_create_file(self):
+ end_css = "h1 { color: red; }"
+
+ with self.assertRaises(FileNotFoundError):
+ self.manager.append_content(end_css)
+
+ def test_prepend_content(self):
+ start_css = "body { background-color: blue; }"
+ create_dummy_file(self.output_file, start_css)
+ prepend_css = "h1 { color: red; }"
+
+ self.manager.prepend_content(prepend_css)
+
+ with open(self.output_file, 'r') as f:
+ content = f.read()
+ split_content = content.splitlines()
+ assert split_content[0] == prepend_css
+ assert split_content[1] == start_css
+ os.remove(self.output_file)
+
+ def test_prepend_does_not_create_file(self):
+ prepend_css = "h1 { color: red; }"
+
+ with self.assertRaises(FileNotFoundError):
+ self.manager.prepend_content(prepend_css)
+
+ def test_generate_combined_styles(self):
+ source_folder = os.path.join(config.temp_tests_folder, "style_manager_source")
+ source_css_folder = os.path.join(source_folder, ".css")
+ first_file = os.path.join(source_css_folder, "file1.css")
+ second_file = os.path.join(source_css_folder, "file2.css")
+ first_css = "body { background-color: blue; }"
+ second_css = "h1 { color: red; }"
+ create_dummy_file(first_file, first_css)
+ create_dummy_file(second_file, second_css)
+
+ with patch("subprocess.check_output", return_value="GNOME Shell 47.0"):
+ self.manager.generate_combined_styles(source_folder, self.temp_folder)
+
+ with open(self.output_file, 'r') as f:
+ content = f.read()
+ split_content = content.splitlines()
+ assert first_css in split_content
+ assert second_css in split_content
+ os.remove(self.output_file)
+ shutil.rmtree(source_folder, ignore_errors=True)
\ No newline at end of file
diff --git a/tests/utils/theme/__init__.py b/tests/utils/theme/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/utils/theme/assets/colors.json b/tests/utils/theme/assets/colors.json
new file mode 100644
index 0000000..42fbe85
--- /dev/null
+++ b/tests/utils/theme/assets/colors.json
@@ -0,0 +1,72 @@
+{
+ "elements": {
+ "BUTTON-COLOR": {
+ "default": "ACCENT-COLOR"
+ },
+ "ACCENT-COLOR": {
+ "light": {
+ "s": 52,
+ "l": 67,
+ "a": 1
+ },
+ "dark": {
+ "s": 42,
+ "l": 26,
+ "a": 1
+ }
+ },
+ "ACCENT_HOVER": {
+ "light": {
+ "s": 50,
+ "l": 60,
+ "a": 0.8
+ },
+ "dark": {
+ "s": 66,
+ "l": 22,
+ "a": 0.4
+ }
+ }
+ },
+ "colors": {
+ "red": {
+ "h": 0
+ },
+ "gray": {
+ "h": 0,
+ "s": 0
+ },
+ "expected": {
+ "BUTTON-COLOR": {
+ "0,100": {
+ "light": "rgba(215, 127, 127, 1)",
+ "dark": "rgba(94, 38, 38, 1)"
+ },
+ "0,0": {
+ "light": "rgba(171, 171, 171, 1)",
+ "dark": "rgba(66, 66, 66, 1)"
+ }
+ },
+ "ACCENT-COLOR": {
+ "0,100": {
+ "light": "rgba(215, 127, 127, 1)",
+ "dark": "rgba(94, 38, 38, 1)"
+ },
+ "0,0": {
+ "light": "rgba(171, 171, 171, 1)",
+ "dark": "rgba(66, 66, 66, 1)"
+ }
+ },
+ "ACCENT_HOVER": {
+ "0,100": {
+ "light": "rgba(204, 102, 102, 0.8)",
+ "dark": "rgba(93, 19, 19, 0.4)"
+ },
+ "0,0": {
+ "light": "rgba(153, 153, 153, 0.8)",
+ "dark": "rgba(56, 56, 56, 0.4)"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/utils/theme/test_color_replacement_generator.py b/tests/utils/theme/test_color_replacement_generator.py
new file mode 100644
index 0000000..96c0764
--- /dev/null
+++ b/tests/utils/theme/test_color_replacement_generator.py
@@ -0,0 +1,64 @@
+import os.path
+import unittest
+
+from scripts.install.colors_definer import ColorsDefiner
+from scripts.types.installation_color import InstallationColor, InstallationMode
+from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl
+from scripts.utils.theme.color_replacement_generator import ColorReplacementGenerator
+
+class ColorReplacementGeneratorTestCase(unittest.TestCase):
+ def setUp(self):
+ colors_location = os.path.join(os.path.dirname(__file__), "assets", "colors.json")
+ self.colors_provider = ColorsDefiner(colors_location)
+ self.color_converter = ColorConverterImpl()
+ self.generator = ColorReplacementGenerator(self.colors_provider, self.color_converter)
+
+ def test_convert_red_color_in_dark_mode_generates_correct_rgba(self):
+ theme_color = InstallationColor(hue=0, saturation=None, modes=[])
+ mode: InstallationMode = "dark"
+ expected_output = self._get_expected_output(theme_color, mode)
+
+ actual_output = self.generator.convert(mode, theme_color)
+
+ self._assert_expected_and_actual_replacers_match(expected_output, actual_output)
+
+ def test_convert_gray_color_in_light_mode_generates_correct_rgba(self):
+ theme_color = InstallationColor(hue=0, saturation=0, modes=[])
+ mode: InstallationMode = "light"
+ expected_output = self._get_expected_output(theme_color, mode)
+
+ actual_output = self.generator.convert(mode, theme_color)
+
+ self._assert_expected_and_actual_replacers_match(expected_output, actual_output)
+
+ def test_convert_not_existent_mode_raises_key_error(self):
+ theme_color = InstallationColor(hue=0, saturation=0, modes=[])
+ mode = "not_existent_mode"
+
+ with self.assertRaises(KeyError):
+ # noinspection PyTypeChecker
+ self.generator.convert(mode, theme_color)
+
+ def _get_expected_output(self, theme_color: InstallationColor, mode: str):
+ return [
+ ("ACCENT-COLOR", self._get_rgba("ACCENT-COLOR", theme_color, mode)),
+ ("ACCENT_HOVER", self._get_rgba("ACCENT_HOVER", theme_color, mode)),
+ ("BUTTON-COLOR", self._get_rgba("BUTTON-COLOR", theme_color, mode)),
+ ]
+
+ def _get_rgba(self, replacer_name: str, theme_color: InstallationColor, mode: str):
+ expected_colors: dict = self.colors_provider.colors.get("expected")
+ replacer_colors = expected_colors.get(replacer_name)
+ saturation = theme_color.saturation if theme_color.saturation is not None else 100
+ variant_colors = replacer_colors.get(f"{theme_color.hue},{saturation}")
+ expected_rgba = variant_colors.get(mode)
+ return expected_rgba
+
+ @staticmethod
+ def _assert_expected_and_actual_replacers_match(expected: list, actual: list):
+ for expected_element, expected_rgba in expected:
+ actual_rgba = next(
+ (rgba for element, rgba in actual if element == expected_element), None
+ )
+ assert actual_rgba is not None
+ assert expected_rgba == actual_rgba
\ No newline at end of file
diff --git a/tests/utils/theme/test_gnome_shell_theme_builder.py b/tests/utils/theme/test_gnome_shell_theme_builder.py
new file mode 100644
index 0000000..a6c6a37
--- /dev/null
+++ b/tests/utils/theme/test_gnome_shell_theme_builder.py
@@ -0,0 +1,76 @@
+import unittest
+from unittest.mock import Mock
+import os
+
+from scripts.utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder
+
+
+class GnomeShellThemeBuilderTestCase(unittest.TestCase):
+ def setUp(self):
+ self.colors_provider = Mock()
+ self.builder = GnomeShellThemeBuilder(self.colors_provider)
+
+ def test_builder_method_chaining_works_correctly(self):
+ result = (self.builder.with_theme_name("test-theme")
+ .with_mode("dark")
+ .filled()
+ .with_temp_folder("/tmp/test"))
+
+ self.assertIs(result, self.builder)
+ self.assertEqual("test-theme", self.builder.theme_name)
+ self.assertEqual("dark", self.builder.mode)
+ self.assertTrue(self.builder.is_filled)
+ self.assertEqual("/tmp/test", self.builder._base_temp_folder)
+
+ def test_paths_update_when_base_folder_changes(self):
+ self.builder.with_temp_folder("/custom/temp")
+
+ expected_temp_folder = os.path.join("/custom/temp", self.builder.theme_name)
+ expected_main_styles = os.path.join(expected_temp_folder, f"{self.builder.theme_name}.css")
+
+ self.assertEqual(expected_temp_folder, self.builder.temp_folder)
+ self.assertEqual(expected_main_styles, self.builder.main_styles)
+
+ def test_paths_update_when_theme_name_changes(self):
+ original_temp_folder = self.builder.temp_folder
+ original_main_styles = self.builder.main_styles
+
+ self.builder.with_theme_name("custom-theme")
+
+ self.assertNotEqual(original_temp_folder, self.builder.temp_folder)
+ self.assertNotEqual(original_main_styles, self.builder.main_styles)
+ self.assertEqual(os.path.join(self.builder._base_temp_folder, "custom-theme"), self.builder.temp_folder)
+ self.assertEqual(os.path.join(self.builder.temp_folder, "custom-theme.css"), self.builder.main_styles)
+
+ def test_default_values_are_set_properly(self):
+ builder = GnomeShellThemeBuilder(self.colors_provider)
+
+ self.assertEqual("gnome-shell", builder.theme_name)
+ self.assertIsNone(builder.mode)
+ self.assertFalse(builder.is_filled)
+ self.assertIsNone(builder.preparation)
+ self.assertIsNone(builder.installer)
+
+ def test_build_correctly_resolves_dependencies(self):
+ self.builder.build()
+
+ self.assertIsNotNone(self.builder.preparation)
+ self.assertIsNotNone(self.builder.installer)
+
+ def test_build_correctly_creates_theme(self):
+ self.builder.with_mode("light").filled()
+
+ theme = self.builder.build()
+
+ self.assertEqual(theme._preparation, self.builder.preparation)
+ self.assertEqual(theme._installer, self.builder.installer)
+ self.assertTrue(theme.is_filled)
+ self.assertTrue(len(theme.modes) == 1)
+ self.assertEqual(theme.modes[0], "light")
+
+ def test_filled_method_with_parameter(self):
+ self.builder.filled(False)
+ self.assertFalse(self.builder.is_filled)
+
+ self.builder.filled(True)
+ self.assertTrue(self.builder.is_filled)
\ No newline at end of file
diff --git a/tests/utils/theme/test_theme.py b/tests/utils/theme/test_theme.py
new file mode 100644
index 0000000..5ed64fd
--- /dev/null
+++ b/tests/utils/theme/test_theme.py
@@ -0,0 +1,90 @@
+import os.path
+import unittest
+from unittest.mock import MagicMock
+
+from scripts import config
+from scripts.types.installation_color import InstallationColor
+from scripts.utils.theme.theme import Theme
+
+
+class ThemeTestCase(unittest.TestCase):
+ def setUp(self):
+ self.mock_preparation = MagicMock()
+ self.mock_installer = MagicMock()
+
+ temp_folder = os.path.join(config.temp_tests_folder, "theme_temp")
+ destination_folder = os.path.join(temp_folder, "theme_destination")
+
+ self.mock_preparation.temp_folder = temp_folder
+ self.mock_preparation.combined_styles_location = os.path.join(temp_folder, "styles.css")
+ self.mock_installer.destination_folder = destination_folder
+ self.mock_installer.theme_type = "gtk"
+
+ self.theme = Theme(self.mock_preparation, self.mock_installer)
+
+ def test_default_initialization_works_correctly(self):
+ self.assertEqual(self.theme.modes, ['light', 'dark'])
+ self.assertFalse(self.theme.is_filled)
+
+ def test_initialization_with_specific_mode_works_correctly(self):
+ theme_light = Theme(self.mock_preparation, self.mock_installer, mode='light')
+ self.assertEqual(theme_light.modes, ['light'])
+
+ def test_initialization_with_is_filled_works_correctly(self):
+ theme_filled = Theme(self.mock_preparation, self.mock_installer, is_filled=True)
+ self.assertTrue(theme_filled.is_filled)
+
+ def test_properties_fetch_values_correctly(self):
+ temp_folder = os.path.join(config.temp_tests_folder, "theme_temp")
+ destination_folder = os.path.join(temp_folder, "theme_destination")
+
+ self.assertEqual(self.theme.temp_folder, temp_folder)
+ self.assertEqual(self.theme.destination_folder, destination_folder)
+ self.assertEqual(self.theme.main_styles, os.path.join(temp_folder, "styles.css"))
+ self.assertEqual(self.theme.theme_name, "gtk")
+
+ def test_add_operator_called_once_and_return_value(self):
+ result = self.theme + "additional styles"
+
+ self.mock_preparation.__iadd__.assert_called_once_with("additional styles")
+ self.assertEqual(result, self.theme)
+
+ def test_mul_operator_called_once_and_return_value(self):
+ result = self.theme * "/path/to/file"
+
+ self.mock_preparation.__imul__.assert_called_once_with("/path/to/file")
+ self.assertEqual(result, self.theme)
+
+ def test_add_to_start_called_once_and_return_value(self):
+ result = self.theme.add_to_start("starting content")
+
+ self.mock_preparation.add_to_start.assert_called_once_with("starting content")
+ self.assertEqual(result, self.theme)
+
+ def test_prepare_called_once(self):
+ self.theme.prepare()
+
+ self.mock_preparation.prepare.assert_called_once()
+
+ def test_install_without_optional_params_called_correctly(self):
+ self.theme.install(200, "Green")
+
+ args = self.mock_installer.install.call_args[0]
+ self.assertEqual(args[0].hue, 200)
+ self.assertIsNone(args[0].saturation)
+ self.assertEqual(args[1], "Green")
+ self.assertIsNone(args[2])
+
+ def test_install_with_optional_params_called_correctly(self):
+ self.theme.install(hue=180, name="Blue", sat=0.5, destination="/custom/dest")
+
+ self.mock_installer.install.assert_called_once()
+ args = self.mock_installer.install.call_args[0]
+
+ theme_color = args[0]
+ self.assertIsInstance(theme_color, InstallationColor)
+ self.assertEqual(theme_color.hue, 180)
+ self.assertEqual(theme_color.saturation, 0.5)
+ self.assertEqual(theme_color.modes, ['light', 'dark'])
+ self.assertEqual(args[1], "Blue")
+ self.assertEqual(args[2], "/custom/dest")
diff --git a/tests/utils/theme/test_theme_color_applier.py b/tests/utils/theme/test_theme_color_applier.py
new file mode 100644
index 0000000..5c442da
--- /dev/null
+++ b/tests/utils/theme/test_theme_color_applier.py
@@ -0,0 +1,52 @@
+import os.path
+import shutil
+import unittest
+from unittest.mock import Mock
+
+from scripts import config
+from scripts.utils.theme.theme_color_applier import ThemeColorApplier
+from ..._helpers import create_dummy_file
+
+
+class ThemeColorApplierTestCase(unittest.TestCase):
+ def setUp(self):
+ color_replacement_generator = Mock()
+ color_replacement_generator.convert.return_value = [
+ ("ACCENT-COLOR", "rgba(255, 0, 0, 1)"),
+ ("ACCENT_HOVER", "rgba(255, 0, 0, 0.8)"),
+ ("BACKGROUND-COLOR", "rgba(0, 0, 0, 1)")
+ ]
+
+ self.temp_folder = os.path.join(config.temp_tests_folder, "color_applier")
+ self._setup_temp_folder()
+
+ self.color_applier = ThemeColorApplier(color_replacement_generator)
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+
+ def _setup_temp_folder(self):
+ self.first_file = os.path.join(self.temp_folder, "file1.css")
+ self.second_file = os.path.join(self.temp_folder, "file2.css")
+ self.first_css = "body { background-color: ACCENT-COLOR; color: ACCENT_HOVER; }"
+ self.second_css = "body { background-color: BACKGROUND-COLOR; }"
+ create_dummy_file(self.first_file, self.first_css)
+ create_dummy_file(self.second_file, self.second_css)
+
+ def test_colors_in_files_are_replaced_correctly(self):
+ theme_color = Mock()
+
+ self.color_applier.apply(theme_color, self.temp_folder, "dark")
+
+ with open(self.first_file, "r") as file:
+ content = file.read()
+ replaced = self.first_css
+ replaced = replaced.replace("ACCENT-COLOR", "rgba(255, 0, 0, 1)")
+ replaced = replaced.replace("ACCENT_HOVER", "rgba(255, 0, 0, 0.8)")
+ assert content == replaced
+
+ with open(self.second_file, "r") as file:
+ content = file.read()
+ replaced = self.second_css
+ replaced = replaced.replace("BACKGROUND-COLOR", "rgba(0, 0, 0, 1)")
+ assert content == replaced
\ No newline at end of file
diff --git a/tests/utils/theme/test_theme_installer.py b/tests/utils/theme/test_theme_installer.py
new file mode 100644
index 0000000..f6f623a
--- /dev/null
+++ b/tests/utils/theme/test_theme_installer.py
@@ -0,0 +1,109 @@
+import os
+import shutil
+import unittest
+from unittest.mock import Mock
+
+from scripts import config
+from scripts.utils.theme.theme_installer import ThemeInstaller
+from scripts.utils.theme.theme_path_provider import ThemePathProvider
+from ..._helpers import create_dummy_file
+
+
+class ThemeInstallerTestCase(unittest.TestCase):
+ def setUp(self):
+ self.theme_type = "gnome-shell"
+ self.source_folder = os.path.join(config.temp_tests_folder, "theme_installer_source")
+ self.destination_folder = os.path.join(config.temp_tests_folder, "theme_installer_destination")
+ self.custom_destination_folder = os.path.join(config.temp_tests_folder, "theme_installer_custom_destination")
+
+ self.logger_factory = Mock()
+ self.color_applier = Mock()
+ self.path_provider = ThemePathProvider()
+ self.path_provider.get_theme_path = Mock(return_value=self.destination_folder)
+
+ self.theme_installer = ThemeInstaller(
+ theme_type=self.theme_type,
+ source_folder=self.source_folder,
+ destination_folder=self.destination_folder,
+ logger_factory=self.logger_factory,
+ color_applier=self.color_applier,
+ path_provider=self.path_provider,
+ )
+
+ self._setup_source_folder()
+
+ def tearDown(self):
+ shutil.rmtree(self.source_folder, ignore_errors=True)
+ shutil.rmtree(self.destination_folder, ignore_errors=True)
+ shutil.rmtree(self.custom_destination_folder, ignore_errors=True)
+
+ def _setup_source_folder(self):
+ os.makedirs(self.source_folder, exist_ok=True)
+ first_file = os.path.join(self.source_folder, "file1.css")
+ second_file = os.path.join(self.source_folder, "file2.css")
+ first_css = "body { background-color: ACCENT-COLOR; color: ACCENT_HOVER; }"
+ second_css = "body { background-color: BACKGROUND-COLOR; }"
+ create_dummy_file(first_file, first_css)
+ create_dummy_file(second_file, second_css)
+
+ def test_install_calls_get_theme_path_and_apply_methods_with_correct_parameters(self):
+ theme_color = Mock()
+ theme_color.modes = ["light"]
+ name = "test-theme"
+
+ self.theme_installer.install(theme_color, name)
+
+ # noinspection PyUnresolvedReferences
+ self.path_provider.get_theme_path.assert_called_once_with(
+ self.destination_folder, name, "light", self.theme_type
+ )
+ self.color_applier.apply.assert_called_once_with(theme_color, self.destination_folder, "light")
+
+ def test_install_with_custom_destination_calls_get_theme_path_and_apply_methods_with_correct_parameters(self):
+ theme_color = Mock()
+ theme_color.modes = ["light"]
+ name = "test-theme"
+ os.makedirs(self.custom_destination_folder, exist_ok=True)
+
+ self.theme_installer.install(theme_color, name, self.custom_destination_folder)
+
+ # noinspection PyUnresolvedReferences
+ self.path_provider.get_theme_path.assert_not_called()
+ self.color_applier.apply.assert_called_once_with(theme_color, self.custom_destination_folder, "light")
+
+ def test_install_with_multiple_modes_calls_get_theme_path_and_apply_methods_for_each_mode(self):
+ theme_color = Mock()
+ theme_color.modes = ["light", "dark"]
+ name = "test-theme"
+
+ self.theme_installer.install(theme_color, name)
+
+ # noinspection PyUnresolvedReferences
+ self.assertEqual(self.path_provider.get_theme_path.call_count, 2)
+ self.assertEqual(self.color_applier.apply.call_count, 2)
+
+ def test_install_raises_exception_and_logs_error(self):
+ theme_color = Mock()
+ theme_color.modes = ["light"]
+ name = "test-theme"
+ self.color_applier.apply.side_effect = Exception("Test error")
+
+ with self.assertRaises(Exception):
+ self.theme_installer.install(theme_color, name)
+
+ logger_mock = self.logger_factory.create_logger.return_value
+ self.assertTrue(logger_mock.error.called)
+
+ def test_install_copies_files_to_destination(self):
+ theme_color = Mock()
+ theme_color.modes = ["light"]
+ name = "test-theme"
+ destination = os.path.join(self.destination_folder, "actual_destination")
+ self.path_provider.get_theme_path.return_value = destination
+
+ self.theme_installer.install(theme_color, name)
+
+ first_file_exists = os.path.exists(os.path.join(destination, "file1.css"))
+ second_file_exists = os.path.exists(os.path.join(destination, "file2.css"))
+ self.assertTrue(first_file_exists)
+ self.assertTrue(second_file_exists)
\ No newline at end of file
diff --git a/tests/utils/theme/test_theme_path_provider.py b/tests/utils/theme/test_theme_path_provider.py
new file mode 100644
index 0000000..b1e7a1e
--- /dev/null
+++ b/tests/utils/theme/test_theme_path_provider.py
@@ -0,0 +1,28 @@
+import unittest
+
+from scripts.utils.theme.theme_path_provider import ThemePathProvider
+
+
+class ThemePathProviderTestCase(unittest.TestCase):
+ def setUp(self):
+ self.theme_path_provider = ThemePathProvider()
+
+ def test_get_theme_path_with_valid_values_returns_correct_path(self):
+ themes_folder = "/usr/share/themes"
+ color_name = "Marble"
+ theme_mode = "dark"
+ theme_type = "gnome-shell"
+
+ expected_path = f"{themes_folder}/Marble-{color_name}-{theme_mode}/{theme_type}/"
+ actual_path = self.theme_path_provider.get_theme_path(themes_folder, color_name, theme_mode, theme_type)
+
+ assert expected_path == actual_path
+
+ def test_get_theme_path_with_empty_values_raises_exception(self):
+ themes_folder = ""
+ color_name = ""
+ theme_mode = ""
+ theme_type = ""
+
+ with self.assertRaises(ValueError):
+ self.theme_path_provider.get_theme_path(themes_folder, color_name, theme_mode, theme_type)
\ No newline at end of file
diff --git a/tests/utils/theme/test_theme_temp_manager.py b/tests/utils/theme/test_theme_temp_manager.py
new file mode 100644
index 0000000..3e9a084
--- /dev/null
+++ b/tests/utils/theme/test_theme_temp_manager.py
@@ -0,0 +1,71 @@
+import os.path
+import shutil
+import unittest
+
+from scripts import config
+from scripts.utils.theme.theme_temp_manager import ThemeTempManager
+from ..._helpers import create_dummy_file
+
+
+class ThemeTempManagerTestCase(unittest.TestCase):
+ def setUp(self):
+ self.source_folder = os.path.join(config.temp_tests_folder, "theme_temp_manager_source")
+ self.temp_folder = os.path.join(config.temp_tests_folder, "theme_temp_manager")
+ self.manager = ThemeTempManager(temp_folder=self.temp_folder)
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_folder, ignore_errors=True)
+ shutil.rmtree(self.source_folder, ignore_errors=True)
+
+ @staticmethod
+ def _verify_file_copied(source, destination):
+ assert os.path.exists(destination)
+ assert os.path.getsize(destination) == os.path.getsize(source)
+ assert open(destination).read() == open(source).read()
+
+ def test_file_copies_correctly_to_temp(self):
+ test_file_name = "test_file.txt"
+ test_file_location = os.path.join(self.source_folder, test_file_name)
+ create_dummy_file(test_file_location)
+
+ self.manager.copy_to_temp(test_file_location)
+
+ final_file_location = os.path.join(self.temp_folder, test_file_name)
+ self._verify_file_copied(test_file_location, final_file_location)
+ os.remove(final_file_location)
+
+ def test_directory_content_copies_correctly_to_temp(self):
+ test_dir_name = "test_dir"
+ test_dir_location = os.path.join(self.source_folder, test_dir_name)
+ os.makedirs(test_dir_location, exist_ok=True)
+
+ test_file_name = "test_file.txt"
+ test_file_location = os.path.join(test_dir_location, test_file_name)
+ create_dummy_file(test_file_location)
+
+ self.manager.copy_to_temp(test_dir_location)
+
+ final_file_location = os.path.join(self.temp_folder, test_file_name)
+ self._verify_file_copied(test_file_location, final_file_location)
+ os.remove(final_file_location)
+
+ def test_cleanup_removes_temp_folders(self):
+ css_folder = os.path.join(self.temp_folder, ".css")
+ versions_folder = os.path.join(self.temp_folder, ".versions")
+ os.makedirs(css_folder, exist_ok=True)
+ os.makedirs(versions_folder, exist_ok=True)
+
+ self.manager.cleanup()
+
+ assert not os.path.exists(css_folder)
+ assert not os.path.exists(versions_folder)
+
+ def test_cleanup_non_existent_folders_do_not_raise_error(self):
+ css_folder = os.path.join(self.temp_folder, ".css")
+ versions_folder = os.path.join(self.temp_folder, ".versions")
+
+ self.manager.cleanup()
+
+ # Check that no error is raised and the method completes successfully
+ assert not os.path.exists(css_folder)
+ assert not os.path.exists(versions_folder)
\ No newline at end of file
diff --git a/theme/__init__.py b/theme/__init__.py
new file mode 100644
index 0000000..8bb3151
--- /dev/null
+++ b/theme/__init__.py
@@ -0,0 +1,9 @@
+import os
+
+
+class SourceFolder:
+ themes_folder = os.path.dirname(__file__)
+
+ @property
+ def gnome_shell(self):
+ return os.path.join(self.themes_folder, "gnome-shell")
\ No newline at end of file
diff --git a/tweaks/panel/tweak.py b/tweaks/panel/tweak.py
index 0defa53..e58e89f 100755
--- a/tweaks/panel/tweak.py
+++ b/tweaks/panel/tweak.py
@@ -1,5 +1,6 @@
from scripts import config
-from scripts.utils import hex_to_rgba
+from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl
+
panel_folder = f"{config.tweaks_folder}/panel"
@@ -28,5 +29,5 @@ def apply_tweak(args, theme, colors):
theme += ".panel-button,\
.clock,\
.clock-display StIcon {\
- color: rgba(" + ', '.join(map(str, hex_to_rgba(args.panel_text_color))) + ");\
+ color: rgba(" + ', '.join(map(str, ColorConverterImpl.hex_to_rgba(args.panel_text_color))) + ");\
}"