Refactored Theme class to correspond SOLID patterns

This commit is contained in:
Vladyslav Hroshev
2025-04-10 18:29:19 +03:00
parent 31e1a3deb9
commit 40a88cb7f4
26 changed files with 534 additions and 252 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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}/"

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]

View File

@@ -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}")

View File

@@ -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}/"

View File

@@ -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"))

View File

@@ -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)