diff --git a/scripts/config.py b/scripts/config.py index 911e512..c9e3c71 100644 --- a/scripts/config.py +++ b/scripts/config.py @@ -1,16 +1,14 @@ -import os.path +import os from tempfile import gettempdir # folder definitions -temp_folder = f"{gettempdir()}/marble" -temp_tests_folder = f"{temp_folder}/tests" +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" @@ -19,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 index d08ab15..9670f96 100644 --- a/scripts/gdm.py +++ b/scripts/gdm.py @@ -2,7 +2,8 @@ import os from typing import Optional from .install.colors_definer import ColorsDefiner -from .theme import Theme +from scripts.utils.theme.theme import Theme +from .types.theme_base import ThemeBase from .utils import remove_properties, remove_keywords from . import config from .utils.alternatives_updater import AlternativesUpdater @@ -11,25 +12,26 @@ from .utils.command_runner.subprocess_command_runner import SubprocessCommandRun from .utils.files_labeler import FilesLabeler from .utils.gresource import GresourceBackupNotFoundError from .utils.gresource.gresource import Gresource +from .utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder -class GlobalTheme: +class GlobalTheme(ThemeBase): """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, + colors_json: ColorsDefiner, source_folder: str, + destination_folder: str, destination_file: str, temp_folder: str | bytes, 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 source_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.source_folder = source_folder self.destination_folder = destination_folder self.destination_file = destination_file self.temp_folder = temp_folder @@ -75,9 +77,14 @@ class GlobalTheme: 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_builder = GnomeShellThemeBuilder(self.colors_json) + theme_builder.with_temp_folder(self.temp_folder) + theme_builder.with_theme_name(theme_type) + theme_builder.destination_folder = self.__gresource_temp_folder + theme_builder.with_mode(mode) + theme_builder.filled(self.is_filled) + + theme = theme_builder.build() 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)) @@ -113,7 +120,7 @@ class GlobalTheme: 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: + with open(f"{theme.destination_folder}/{theme.theme_name}.css", 'r') as gnome_theme: gnome_styles = gnome_theme.read() + self.__is_installed_trigger theme.add_to_start(gnome_styles) diff --git a/scripts/install/global_theme_installer.py b/scripts/install/global_theme_installer.py index 5370a73..e6d92c7 100644 --- a/scripts/install/global_theme_installer.py +++ b/scripts/install/global_theme_installer.py @@ -4,6 +4,7 @@ from scripts import config from scripts.gdm import GlobalTheme from scripts.install.theme_installer import ThemeInstaller from scripts.utils.logger.console import Console, Color, Format +from theme import SourceFolder class GlobalThemeInstaller(ThemeInstaller): @@ -16,7 +17,8 @@ class GlobalThemeInstaller(ThemeInstaller): 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}", + source_folder = SourceFolder().gnome_shell + self.theme = GlobalTheme(self.colors, source_folder, config.global_gnome_shell_theme, config.gnome_shell_gresource, gdm_temp, mode=self.args.mode, is_filled=self.args.filled) diff --git a/scripts/install/local_theme_installer.py b/scripts/install/local_theme_installer.py index 266f356..d91265a 100644 --- a/scripts/install/local_theme_installer.py +++ b/scripts/install/local_theme_installer.py @@ -1,8 +1,6 @@ -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.logger.console import Console, Color, Format @@ -15,10 +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) + theme_builder = GnomeShellThemeBuilder(self.colors) + theme_builder.with_mode(self.args.mode) + theme_builder.filled(self.args.filled) + self.theme = theme_builder.build() def _install_theme(self, hue, theme_name, sat): self.theme.install(hue, theme_name, sat) diff --git a/scripts/install/theme_installer.py b/scripts/install/theme_installer.py index 4f53669..7546802 100644 --- a/scripts/install/theme_installer.py +++ b/scripts/install/theme_installer.py @@ -3,7 +3,7 @@ import concurrent.futures from abc import ABC, abstractmethod from scripts.install.colors_definer import ColorsDefiner -from scripts.theme import Theme +from scripts.utils.theme.theme import Theme from scripts.tweaks_manager import TweaksManager diff --git a/scripts/theme.py b/scripts/theme.py deleted file mode 100644 index 7c3241f..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 scripts.utils.logger.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..54d104f --- /dev/null +++ b/scripts/types/theme_base.py @@ -0,0 +1,12 @@ +from abc import ABC + + +class ThemeBase(ABC): + """Base class for theme installation and preparation.""" + @staticmethod + def prepare(self): + pass + + @staticmethod + def install(self, hue: int, 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/color_converter.py b/scripts/utils/color_converter.py new file mode 100644 index 0000000..12c7b33 --- /dev/null +++ b/scripts/utils/color_converter.py @@ -0,0 +1,42 @@ +import colorsys +from abc import ABC, abstractmethod + + +class ColorConverter(ABC): + @staticmethod + @abstractmethod + def hex_to_rgba(hex_color): + pass + + @staticmethod + @abstractmethod + def hsl_to_rgb(hue, saturation, lightness): + pass + + +class ColorConverterImpl(ColorConverter): + @staticmethod + def hex_to_rgba(hex_color): + 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 + + @staticmethod + def hsl_to_rgb(hue, saturation, lightness): + # colorsys works in range(0, 1) + h = hue / 360 + red, green, blue = [int(item * 255) for item in colorsys.hls_to_rgb(h, lightness, saturation)] + return red, green, blue \ 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/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/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..2c0aab4 --- /dev/null +++ b/scripts/utils/style_manager.py @@ -0,0 +1,19 @@ +from scripts.utils import generate_file + + +class StyleManager: + def __init__(self, output_file: str): + self.output_file = output_file + + def append_content(self, content: str): + with open(self.output_file, 'a') as output: + output.write(content + '\n') + + def prepend_content(self, content: str): + 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_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/gnome_shell_theme_builder.py b/scripts/utils/theme/gnome_shell_theme_builder.py new file mode 100644 index 0000000..c51b0eb --- /dev/null +++ b/scripts/utils/theme/gnome_shell_theme_builder.py @@ -0,0 +1,96 @@ +import os.path + +from scripts import config +from scripts.install.colors_definer import ColorsDefiner +from scripts.utils.color_converter import ColorConverterImpl +from scripts.utils.logger.console import Console +from scripts.utils.theme.theme import Theme +from scripts.utils.theme.theme_color_applier import ColorReplacementGenerator, ThemeColorApplier +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 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.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 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 is None: + self.preparation = ThemePreparation(self.source_folder, self.temp_folder, self.main_styles) + + def _resolve_installer(self): + if self.installer is not None: 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 = 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..e1a3b98 --- /dev/null +++ b/scripts/utils/theme/theme_color_applier.py @@ -0,0 +1,61 @@ +import copy +import os + +from scripts.install.colors_definer import ColorsDefiner +from scripts.types.installation_color import InstallationColor, InstallationMode +from scripts.utils import replace_keywords +from scripts.utils.color_converter import ColorConverter + + +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) + + +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] \ No newline at end of file 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..1dfcec2 --- /dev/null +++ b/scripts/utils/theme/theme_path_provider.py @@ -0,0 +1,4 @@ +class ThemePathProvider: + @staticmethod + def get_theme_path(themes_folder: str, path_name: str, theme_mode: str, theme_type: str) -> str: + return f"{themes_folder}/Marble-{path_name}-{theme_mode}/{theme_type}/" diff --git a/scripts/utils/theme/theme_preparation.py b/scripts/utils/theme/theme_preparation.py new file mode 100644 index 0000000..fb7dedd --- /dev/null +++ b/scripts/utils/theme/theme_preparation.py @@ -0,0 +1,47 @@ +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, temp_folder: str, combined_styles_location: str): + self.sources_location = sources_location + self.temp_folder = temp_folder + self.combined_styles_location = combined_styles_location + + self.file_manager = ThemeTempManager(temp_folder) + self.style_manager = StyleManager(combined_styles_location) + + def __add__(self, content: str) -> "ThemePreparation": + self.style_manager.append_content(content) + return self + + def __mul__(self, content: str) -> "ThemePreparation": + self.file_manager.copy_to_temp(content) + return self + + def add_to_start(self, content) -> "ThemePreparation": + self.style_manager.prepend_content(content) + return self + + def prepare(self): + self.file_manager.prepare_files(self.sources_location) + self.style_manager.generate_combined_styles(self.sources_location, self.temp_folder) + self.file_manager.cleanup() + + def replace_filled_keywords(self): + 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")) \ No newline at end of file diff --git a/scripts/utils/theme/theme_temp_manager.py b/scripts/utils/theme/theme_temp_manager.py new file mode 100644 index 0000000..1ac6e38 --- /dev/null +++ b/scripts/utils/theme/theme_temp_manager.py @@ -0,0 +1,28 @@ +import os +import shutil + +from scripts.utils import copy_files + + +class ThemeTempManager: + """ + Manages operations with temp folder for Theme class + """ + def __init__(self, temp_folder: str): + self.temp_folder = temp_folder + + def copy_to_temp(self, content: str): + if os.path.isfile(content): + shutil.copy(content, self.temp_folder) + else: + shutil.copytree(content, self.temp_folder) + return self + + def prepare_files(self, sources_location: str): + """Prepare files in temp folder""" + copy_files(sources_location, self.temp_folder) + + 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/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..bb941e9 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 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))) + ");\ }"