Covered Theme module with tests

- Extracted `ColorReplacementGenerator`;
- Extracted `ColorConverterImpl`;
- Added documentation to some classes;
- `hex_to_rgba` now supports shorthand hex colors (#fff).
This commit is contained in:
Vladyslav Hroshev
2025-04-11 22:53:30 +03:00
parent 29485ddf1e
commit abfe1f5962
25 changed files with 877 additions and 103 deletions

View File

@@ -1,42 +0,0 @@
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

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

View File

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

View File

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

View File

@@ -1,19 +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(content + '\n')
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)

View File

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

View File

@@ -2,11 +2,12 @@ import os.path
from scripts import config
from scripts.install.colors_definer import ColorsDefiner
from scripts.utils.color_converter import ColorConverterImpl
from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl
from scripts.utils.logger.console import Console
from scripts.utils.style_manager import StyleManager
from scripts.utils.theme.theme import Theme
from scripts.utils.theme.theme_color_applier import ColorReplacementGenerator, ThemeColorApplier
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

View File

@@ -1,16 +1,14 @@
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
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"):
def __init__(self, color_replacement_generator: ColorReplacementGenerator):
self.color_replacement_generator = color_replacement_generator
def apply(self, theme_color: InstallationColor, destination: str, mode: InstallationMode):
@@ -20,42 +18,3 @@ class ThemeColorApplier:
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

@@ -1,4 +1,20 @@
import os
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}/"
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

View File

@@ -1,5 +1,4 @@
import os
import warnings
from scripts.utils import replace_keywords
from scripts.utils.theme.theme_temp_manager import ThemeTempManager
@@ -45,11 +44,10 @@ class ThemePreparation:
"""
Extract theme from source folder and prepare it for installation.
"""
self.file_manager.prepare_files(self.sources_location)
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()
@warnings.deprecated
def replace_filled_keywords(self):
"""
Replace keywords in the theme files for filled mode.
@@ -62,4 +60,4 @@ class ThemePreparation:
("BUTTON_ACTIVE", "ACCENT-FILLED_ACTIVE"),
("BUTTON_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"),
("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"),
("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY"))
("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY"))

View File

@@ -1,8 +1,6 @@
import os
import shutil
from scripts.utils import copy_files
class ThemeTempManager:
"""
@@ -10,18 +8,21 @@ class ThemeTempManager:
"""
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):
shutil.copy(content, self.temp_folder)
final_path = os.path.join(self.temp_folder, os.path.basename(content))
shutil.copy(content, final_path)
else:
shutil.copytree(content, self.temp_folder)
shutil.copytree(content, self.temp_folder, dirs_exist_ok=True)
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)