From c02ead2fff6675751de402275b880a9e21abd5a0 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 9 Dec 2023 16:37:55 +0200 Subject: [PATCH 01/17] Code changes and improvements Split install.py into several files Part of code for creating a directory from copy_files was removed due to the availability of such a function in standard Python libraries Tried to make theme installation process less dependent on the project root folder --- install.py | 272 +++++----------------------------------------- scripts/config.py | 12 ++ scripts/theme.py | 148 +++++++++++++++++++++++++ scripts/utils.py | 141 ++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 247 deletions(-) create mode 100644 scripts/config.py create mode 100644 scripts/theme.py create mode 100644 scripts/utils.py diff --git a/install.py b/install.py index 5203b4b..10eff20 100644 --- a/install.py +++ b/install.py @@ -15,241 +15,18 @@ # along with this program. If not, see . -import colorsys # convert hsl to rgv +import json # working with json files import os # system commands, working with files -import json # colors.json import argparse # command-line options import textwrap # example text in argparse +from scripts import config # folder and files definitions -# folder definitions -temp_folder = "./.temp" -gnome_folder = "gnome-shell" -temp_gnome_folder = f"{temp_folder}/{gnome_folder}" -tweaks_folder = "./tweaks" -themes_folder = "~/.themes" +from scripts.utils import ( + remove_files, # delete already installed Marble theme + hex_to_rgba) # convert HEX to RGBA -# files definitions -gnome_shell_css = f"{temp_gnome_folder}/gnome-shell.css" - - -def generate_file(folder, final_file): - """ - Combines all files in a folder into a single file - :param folder: source folder - :param final_file: location where file will be created - """ - - opened_file = open(final_file, "w") - - for file in os.listdir(folder): - opened_file.write(open(folder + file).read() + '\n') - - opened_file.close() - - -def concatenate_files(file, edit_file): - """ - Merge two files - :param file: file you want to append - :param edit_file: where it will be appended - """ - - open(edit_file, 'a').write('\n' + open(file).read()) - - -def remove_files(): - """ - Delete already installed Marble theme - """ - - paths = (themes_folder, "~/.local/share/themes") - - print("💡 You do not need to delete files if you want to update theme.\n") - - confirmation = input(f"Do you want to delete all \"Marble\" folders in {' and in '.join(paths)}? (y/N) ").lower() - - if confirmation == "y": - for path in paths: - - # Check if the path exists - if os.path.exists(os.path.expanduser(path)): - - # Get the list of folders in the path - folders = os.listdir(os.path.expanduser(path)) - - # toggle if folder has no marble theme - found_folder = False - - for folder in folders: - if folder.startswith("Marble"): - folder_path = os.path.join(os.path.expanduser(path), folder) - print(f"Deleting folder {folder_path}...", end='') - - try: - os.system(f"rm -r {folder_path}") - - except Exception as e: - print(f"Error deleting folder {folder_path}: {e}") - - else: - found_folder = True - print("Done.") - - if not found_folder: - print(f"No folders starting with \"Marble\" found in {path}.") - - else: - print(f"The path {path} does not exist.") - - -def destination_return(path_name, theme_mode): - """ - Copied/modified theme location - :param path_name: color name - :param theme_mode: theme name (light or dark) - :return: copied files' folder location - """ - - return f"{themes_folder}/Marble-{path_name}-{theme_mode}/" - - -def copy_files(source, destination): - """ - Copy files from the source to another directory - :param source: where files will be copied - :param destination: where files will be pasted - """ - - destination_dirs = destination.split("/") # list of folders - loop_create_dirs = f"{destination_dirs[0]}/" - - # traverse through folders and create them - for i in range(1, len(destination_dirs)): - loop_create_dirs += f"{destination_dirs[i]}/" - os.system(f"mkdir -p {loop_create_dirs}") - - os.system(f"cp -aT {source} {destination}") - - -def replace_keywords(file, *args): - """ - Replace file with several keywords - :param file: file name where keywords must be replaced - :param args: (keyword, replacement), (...), ... - """ - - # skip binary files in project - if not file.lower().endswith(('.css', '.scss', '.svg')): - return - - with open(file, "r") as read_file: - content = read_file.read() - - for keyword, replacement in args: - content = content.replace(keyword, replacement) - - with open(file, "w") as write_file: - write_file.write(content) - - -def apply_colors(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 colors["elements"]: - # if color is has default color and hasn't been replaced - if theme_mode not in colors["elements"][element] and colors["elements"][element]["default"]: - default_element = colors["elements"][element]["default"] - default_color = colors["elements"][default_element][theme_mode] - colors["elements"][element][theme_mode] = default_color - - # convert sla to range(0, 1) - lightness = int(colors["elements"][element][theme_mode]["l"]) / 100 - saturation = int(colors["elements"][element][theme_mode]["s"]) / 100 if sat is None else \ - int(colors["elements"][element][theme_mode]["s"]) * (sat / 100) / 100 - alpha = colors["elements"][element][theme_mode]["a"] - - # convert hsl to rgb and multiply every item - red, green, blue = [int(item * 256) 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) - - -def apply_theme(hue, destination, theme_mode, sat=None): - """ - Apply theme to all files listed in "apply-theme-files" (colors.json) - :param hue - :param destination: file directory - :param theme_mode: theme name (light or dark) - :param sat: color saturation (optional) - """ - - for apply_file in os.listdir(f"{temp_gnome_folder}/"): - apply_colors(hue, destination, theme_mode, apply_file, sat=sat) - - -def install_color(hue, name, theme_mode, sat=None): - """ - Copy files and generate theme with different accent color - :param hue - :param name: theme name - :param theme_mode: light or dark mode - :param sat: color saturation (optional) - """ - - print(f"Creating {name} {', '.join(theme_mode)} theme...", end=" ") - - try: - for mode in theme_mode: - destination = destination_return(name, mode) - - copy_files(temp_folder, destination) - apply_theme(hue, f"{destination}/{gnome_folder}", mode, sat=sat) - - except Exception as err: - print("\nError: " + str(err)) - - else: - print("Done.") - - -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 +from scripts.theme import Theme def main(): @@ -303,12 +80,11 @@ def main(): args = parser.parse_args() - # is used as list because of install_color - mode = [args.mode] if args.mode else ['light', 'dark'] + colors = json.load(open(config.colors_json)) - # move files to temp folder - copy_files(f"./theme/{gnome_folder}/", f"{temp_gnome_folder}") - generate_file(f"./theme/{gnome_folder}_css/", gnome_shell_css) + gnome_shell_theme = Theme("gnome-shell", colors, f"{config.raw_theme_folder}/{config.gnome_folder}", + config.themes_folder, config.temp_folder, + mode=args.mode, is_filled=args.filled) # remove marble theme if args.remove: @@ -316,23 +92,26 @@ def main(): # panel tweaks if args.panel_default_size: - concatenate_files(f"{tweaks_folder}/panel/def-size.css", gnome_shell_css) + with open(f"{config.tweaks_folder}/panel/def-size.css", "r") as f: + gnome_shell_theme += f.read() if args.panel_no_pill: - concatenate_files(f"{tweaks_folder}/panel/no-pill.css", gnome_shell_css) + with open(f"{config.tweaks_folder}/panel/no-pill.css", "r") as f: + gnome_shell_theme += f.read() if args.panel_text_color: - open(f"{temp_gnome_folder}/{gnome_folder}.css", "a") \ - .write(".panel-button,\ + gnome_shell_theme += ".panel-button,\ .clock,\ .clock-display StIcon {\ color: rgba(" + ', '.join(map(str, hex_to_rgba(args.panel_text_color))) + ");\ - }") + }" # dock tweaks if args.launchpad: - concatenate_files(f"{tweaks_folder}/launchpad/launchpad.css", gnome_shell_css) - os.system(f"cp {tweaks_folder}/launchpad/launchpad.png {temp_gnome_folder}/") + with open(f"{config.tweaks_folder}/launchpad/launchpad.css", "r") as f: + gnome_shell_theme += f.read() + + gnome_shell_theme *= f"{config.tweaks_folder}/launchpad/launchpad.png" # color tweaks if args.filled: @@ -352,31 +131,30 @@ def main(): # if saturation already defined in color (gray) sat = colors["colors"][color]["s"] if colors["colors"][color]["s"] is not None else args.sat - install_color(hue, color, mode, sat) + gnome_shell_theme.install(hue, color, sat) elif args.red or args.pink or args.purple or args.blue or args.green or args.yellow or args.gray: + # install selected colors for color in colors["colors"]: if getattr(args, color): # if argument name is in defined colors hue = colors["colors"][color]["h"] # if saturation already defined in color (gray) sat = colors["colors"][color]["s"] if colors["colors"][color]["s"] is not None else args.sat - install_color(hue, color, mode, sat) + gnome_shell_theme.install(hue, color, sat) # custom color elif args.hue: hue = args.hue theme_name = args.name if args.name else f'hue{hue}' # if defined name - install_color(hue, theme_name, mode, args.sat) + gnome_shell_theme.install(hue, theme_name, args.sat) else: print('No arguments or no color arguments specified. Use -h or --help to see the available options.') if __name__ == "__main__": - colors = json.load(open("colors.json")) # used as database for replacing colors - main() - os.system(f"rm -r {temp_folder}") + os.system(f"rm -r {config.temp_folder}") diff --git a/scripts/config.py b/scripts/config.py new file mode 100644 index 0000000..e4603ca --- /dev/null +++ b/scripts/config.py @@ -0,0 +1,12 @@ +# folder definitions +temp_folder = ".temp" +gnome_folder = "gnome-shell" +temp_gnome_folder = f"{temp_folder}/{gnome_folder}" +tweaks_folder = "tweaks" +themes_folder = "~/.themes" +raw_theme_folder = "theme" +scripts_folder = "scripts" + +# files definitions +gnome_shell_css = f"{temp_gnome_folder}/gnome-shell.css" +colors_json = "colors.json" diff --git a/scripts/theme.py b/scripts/theme.py new file mode 100644 index 0000000..f165648 --- /dev/null +++ b/scripts/theme.py @@ -0,0 +1,148 @@ +import os +import shutil +import colorsys # colorsys.hls_to_rgb(h, l, s) + +from . import config # name of folders and files +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 + + +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 = colors_json + self.temp_folder = f"{temp_folder}/{theme_type}" + self.theme_folder = theme_folder + self.theme_type = theme_type + self.mode = [mode] if mode else ['light', 'dark'] + self.destination_folder = destination_folder + self.main_styles = f"{self.temp_folder}/{theme_type}.css" + + # move files to temp folder + copy_files(self.theme_folder, self.temp_folder) + generate_file(f"{self.theme_folder}_css/", self.main_styles) + + # if theme is filled + if 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_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"), + ("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"), + ("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY")) + + 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 __del__(self): + # delete temp folder + os.system(f"rm -r {self.temp_folder}") + + 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["elements"]: + # if color has default color and hasn't been replaced + if theme_mode not in self.colors["elements"][element] and self.colors["elements"][element]["default"]: + default_element = self.colors["elements"][element]["default"] + default_color = self.colors["elements"][default_element][theme_mode] + self.colors["elements"][element][theme_mode] = default_color + + # convert sla to range(0, 1) + lightness = int(self.colors["elements"][element][theme_mode]["l"]) / 100 + saturation = int(self.colors["elements"][element][theme_mode]["s"]) / 100 if sat is None else \ + int(self.colors["elements"][element][theme_mode]["s"]) * (sat / 100) / 100 + alpha = self.colors["elements"][element][theme_mode]["a"] + + # convert hsl to rgb and multiply every item + red, green, blue = [int(item * 256) 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) + + 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 install(self, hue, name, sat=None): + """ + Copy files and generate theme with different accent color + :param hue + :param name: theme name + :param sat: color saturation (optional) + """ + + print(f"Creating {name} {', '.join(self.mode)} theme...", end=" ") + + try: + for mode in self.mode: + 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) + + except Exception as err: + print("\nError: " + str(err)) + + else: + print("Done.") diff --git a/scripts/utils.py b/scripts/utils.py new file mode 100644 index 0000000..87f604d --- /dev/null +++ b/scripts/utils.py @@ -0,0 +1,141 @@ +import os +from . import config # name of folders and files + + +def generate_file(folder, final_file): + """ + Combines all files in a folder into a single file + :param folder: source folder + :param final_file: location where file will be created + """ + + opened_file = open(final_file, "w") + + for file in os.listdir(folder): + opened_file.write(open(folder + file).read() + '\n') + + opened_file.close() + + +def concatenate_files(edit_file, file): + """ + Merge two files + :param edit_file: where it will be appended + :param file: file you want to append + """ + + open(edit_file, 'a').write('\n' + open(file).read()) + + +def remove_files(): + """ + Delete already installed Marble theme + """ + + paths = (config.themes_folder, "~/.local/share/themes") + + print("💡 You do not need to delete files if you want to update theme.\n") + + confirmation = input(f"Do you want to delete all \"Marble\" folders in {' and in '.join(paths)}? (y/N) ").lower() + + if confirmation == "y": + for path in paths: + + # Check if the path exists + if os.path.exists(os.path.expanduser(path)): + + # Get the list of folders in the path + folders = os.listdir(os.path.expanduser(path)) + + # toggle if folder has no marble theme + found_folder = False + + for folder in folders: + if folder.startswith("Marble"): + folder_path = os.path.join(os.path.expanduser(path), folder) + print(f"Deleting folder {folder_path}...", end='') + + try: + os.system(f"rm -r {folder_path}") + + except Exception as e: + print(f"Error deleting folder {folder_path}: {e}") + + else: + found_folder = True + print("Done.") + + if not found_folder: + print(f"No folders starting with \"Marble\" found in {path}.") + + else: + print(f"The path {path} does not exist.") + + +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) + :return: copied files' folder location + """ + + return f"{themes_folder}/Marble-{path_name}-{theme_mode}/{theme_type}/" + + +def copy_files(source, destination): + """ + Copy files from the source to another directory + :param source: where files will be copied + :param destination: where files will be pasted + """ + + destination = os.path.expanduser(destination) # expand ~ to /home/user + os.makedirs(destination, exist_ok=True) + os.system(f"cp -aT {source} {destination}") + + +def replace_keywords(file, *args): + """ + Replace file with several keywords + :param file: file name where keywords must be replaced + :param args: (keyword, replacement), (...), ... + """ + + # skip binary files in project + if not file.lower().endswith(('.css', '.scss', '.svg')): + return + + with open(file, "r") as read_file: + content = read_file.read() + + for keyword, replacement in args: + content = content.replace(keyword, replacement) + + with open(file, "w") as write_file: + write_file.write(content) + + +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 From a58d55c53addbe3d414539c5d71eec72191c5d78 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 9 Dec 2023 17:03:32 +0200 Subject: [PATCH 02/17] Add some missing changes from c02ead2 --- install.py | 10 ---------- scripts/utils.py | 1 + 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/install.py b/install.py index 10eff20..16ca3a0 100644 --- a/install.py +++ b/install.py @@ -113,16 +113,6 @@ def main(): gnome_shell_theme *= f"{config.tweaks_folder}/launchpad/launchpad.png" - # color tweaks - if args.filled: - for apply_file in os.listdir(f"{temp_gnome_folder}/"): - replace_keywords(f"{temp_gnome_folder}/{apply_file}", - ("BUTTON-COLOR", "ACCENT-FILLED-COLOR"), - ("BUTTON_HOVER", "ACCENT-FILLED_HOVER"), - ("BUTTON_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"), - ("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"), - ("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY")) - # what argument colors defined if args.all: # install hue colors listed in colors.json diff --git a/scripts/utils.py b/scripts/utils.py index 87f604d..fe82235 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -78,6 +78,7 @@ def destination_return(themes_folder, path_name, theme_mode, theme_type): :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 """ From 992d3122df025a6021ddc4c8ebeedc7a22728f27 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 9 Dec 2023 19:17:35 +0200 Subject: [PATCH 03/17] Fix unclosed file warning --- scripts/utils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/utils.py b/scripts/utils.py index fe82235..b57d380 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -12,7 +12,8 @@ def generate_file(folder, final_file): opened_file = open(final_file, "w") for file in os.listdir(folder): - opened_file.write(open(folder + file).read() + '\n') + with open(folder + file) as f: + opened_file.write(f.read() + '\n') opened_file.close() @@ -24,7 +25,11 @@ def concatenate_files(edit_file, file): :param file: file you want to append """ - open(edit_file, 'a').write('\n' + open(file).read()) + 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) def remove_files(): From 8525dc0a8dc8a05f01a6a2e067381be15a61dfe6 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 9 Dec 2023 19:23:34 +0200 Subject: [PATCH 04/17] Remove config from theme.py because of it no longer depends on project folder --- scripts/theme.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/theme.py b/scripts/theme.py index f165648..6475bf8 100644 --- a/scripts/theme.py +++ b/scripts/theme.py @@ -2,7 +2,6 @@ import os import shutil import colorsys # colorsys.hls_to_rgb(h, l, s) -from . import config # name of folders and files from .utils import ( replace_keywords, # replace keywords in file copy_files, # copy files from source to destination From 99d4f2e425383a0b00b95fb2c1bf850083e2b4cf Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 9 Dec 2023 19:23:53 +0200 Subject: [PATCH 05/17] Initial test --- scripts/tests.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 scripts/tests.py diff --git a/scripts/tests.py b/scripts/tests.py new file mode 100644 index 0000000..b8ff790 --- /dev/null +++ b/scripts/tests.py @@ -0,0 +1,56 @@ +import unittest +import os +import json +import shutil + +from . import config +from .theme import Theme + +# folders +tests_folder = '.tests' +project_folder = '.' + + +class TestInstall(unittest.TestCase): + + def test_install_theme(self): + """ + Test if theme is installed correctly (colors are replaced) + """ + + # 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() From d50634138b1fa94ebfacdeab04a01f7c774606b8 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev <81307050+imarkoff@users.noreply.github.com> Date: Sat, 9 Dec 2023 20:02:01 +0200 Subject: [PATCH 06/17] Create python-app.yml --- .github/workflows/python-app.yml | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..d7087ab --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,43 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Marble installation script + +on: + push: + branches: + - "main" + - "gdm" + pull_request: + branches: + - "main" + - "gdm" + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest tests.py From 27b6574829465c1120857ab915887dfc5ba2f96a Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 9 Dec 2023 20:08:33 +0200 Subject: [PATCH 07/17] Update .gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d8da396..b4d6a80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__ +.pytest_cache .idea -.temp \ No newline at end of file +.temp +.tests \ No newline at end of file From bdd74f94bf476af80f812551ee8413baacf62af3 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 9 Dec 2023 20:10:10 +0200 Subject: [PATCH 08/17] Fix "ERROR: file or directory not found: tests.py" --- .github/workflows/python-app.yml | 2 +- scripts/__init__.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 scripts/__init__.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index d7087ab..915509e 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -40,4 +40,4 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | - pytest tests.py + pytest scripts/tests.py diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 From 253913ae40f825a65aa9faf8228daf6252870916 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sun, 10 Dec 2023 22:47:34 +0200 Subject: [PATCH 09/17] Add ability to set theme globally --- install.py | 131 +++++++++++++++++++++------------ scripts/config.py | 5 ++ scripts/gdm.py | 179 ++++++++++++++++++++++++++++++++++++++++++++++ scripts/theme.py | 17 ++++- scripts/utils.py | 36 ++++++++++ 5 files changed, 322 insertions(+), 46 deletions(-) create mode 100644 scripts/gdm.py diff --git a/install.py b/install.py index 16ca3a0..f9cd0e4 100644 --- a/install.py +++ b/install.py @@ -18,6 +18,7 @@ import json # working with json files import os # system commands, working with files import argparse # command-line options +import shutil import textwrap # example text in argparse from scripts import config # folder and files definitions @@ -27,6 +28,7 @@ from scripts.utils import ( hex_to_rgba) # convert HEX to RGBA from scripts.theme import Theme +from scripts.gdm import GlobalTheme def main(): @@ -70,6 +72,10 @@ def main(): color_tweaks.add_argument('--sat', type=int, choices=range(0, 251), help='custom color saturation (<100%% - reduce, >100%% - increase)', metavar='(0 - 250)%') + gdm_theming = parser.add_argument_group('GDM theming') + gdm_theming.add_argument('--gdm', action='store_true', help='install GDM theme. \ + Requires root privileges. You must specify a specific color.') + panel_args = parser.add_argument_group('Panel tweaks') panel_args.add_argument('-Pds', '--panel_default_size', action='store_true', help='set default panel size') panel_args.add_argument('-Pnp', '--panel_no_pill', action='store_true', help='remove panel button background') @@ -82,69 +88,106 @@ def main(): colors = json.load(open(config.colors_json)) - gnome_shell_theme = Theme("gnome-shell", colors, f"{config.raw_theme_folder}/{config.gnome_folder}", - config.themes_folder, config.temp_folder, - mode=args.mode, is_filled=args.filled) + if args.gdm: + gdm_status = 1 + if os.geteuid() != 0: + print("You must run this script as root to install GDM theme.") + return 1 - # remove marble theme - if args.remove: - remove_files() + if args.all: + print("Error: You can't install all colors for GDM theme. Use specific color.") + return 1 - # panel tweaks - if args.panel_default_size: - with open(f"{config.tweaks_folder}/panel/def-size.css", "r") as f: - gnome_shell_theme += f.read() + gdm_theme = GlobalTheme(colors, f"{config.raw_theme_folder}/{config.gnome_folder}", + config.global_gnome_shell_theme, config.gnome_shell_gresource, + config.temp_folder, is_filled=args.filled) - if args.panel_no_pill: - with open(f"{config.tweaks_folder}/panel/no-pill.css", "r") as f: - gnome_shell_theme += f.read() + if args.red or args.pink or args.purple or args.blue or args.green or args.yellow or args.gray: + for color in colors["colors"]: + if getattr(args, color): + hue = colors["colors"][color]["h"] + sat = colors["colors"][color]["s"] if colors["colors"][color]["s"] is not None else args.sat - if args.panel_text_color: - gnome_shell_theme += ".panel-button,\ - .clock,\ - .clock-display StIcon {\ - color: rgba(" + ', '.join(map(str, hex_to_rgba(args.panel_text_color))) + ");\ - }" + gdm_status = gdm_theme.install(hue, sat) - # dock tweaks - if args.launchpad: - with open(f"{config.tweaks_folder}/launchpad/launchpad.css", "r") as f: - gnome_shell_theme += f.read() + elif args.hue: + hue = args.hue - gnome_shell_theme *= f"{config.tweaks_folder}/launchpad/launchpad.png" + gdm_status = gdm_theme.install(hue, args.sat) - # what argument colors defined - if args.all: - # install hue colors listed in colors.json - for color in colors["colors"]: - hue = colors["colors"][color]["h"] - # if saturation already defined in color (gray) - sat = colors["colors"][color]["s"] if colors["colors"][color]["s"] is not None else args.sat + else: + print('No color arguments specified. Use -h or --help to see the available options.') - gnome_shell_theme.install(hue, color, sat) + if gdm_status == 0: + print("\nGDM theme installed successfully.") + print("You need to restart gdm.service to apply changes.") + print("Run \"systemctl restart gdm.service\" to restart GDM.") - elif args.red or args.pink or args.purple or args.blue or args.green or args.yellow or args.gray: - # install selected colors - for color in colors["colors"]: - if getattr(args, color): # if argument name is in defined colors + # if not GDM theme + else: + # remove marble theme + if args.remove: + remove_files() + + gnome_shell_theme = Theme("gnome-shell", colors, f"{config.raw_theme_folder}/{config.gnome_folder}", + config.themes_folder, config.temp_folder, + mode=args.mode, is_filled=args.filled) + + # panel tweaks + if args.panel_default_size: + with open(f"{config.tweaks_folder}/panel/def-size.css", "r") as f: + gnome_shell_theme += f.read() + + if args.panel_no_pill: + with open(f"{config.tweaks_folder}/panel/no-pill.css", "r") as f: + gnome_shell_theme += f.read() + + if args.panel_text_color: + gnome_shell_theme += ".panel-button,\ + .clock,\ + .clock-display StIcon {\ + color: rgba(" + ', '.join(map(str, hex_to_rgba(args.panel_text_color))) + ");\ + }" + + # dock tweaks + if args.launchpad: + with open(f"{config.tweaks_folder}/launchpad/launchpad.css", "r") as f: + gnome_shell_theme += f.read() + + gnome_shell_theme *= f"{config.tweaks_folder}/launchpad/launchpad.png" + + # what argument colors defined + if args.all: + # install hue colors listed in colors.json + for color in colors["colors"]: hue = colors["colors"][color]["h"] # if saturation already defined in color (gray) sat = colors["colors"][color]["s"] if colors["colors"][color]["s"] is not None else args.sat gnome_shell_theme.install(hue, color, sat) - # custom color - elif args.hue: - hue = args.hue - theme_name = args.name if args.name else f'hue{hue}' # if defined name + elif args.red or args.pink or args.purple or args.blue or args.green or args.yellow or args.gray: + # install selected colors + for color in colors["colors"]: + if getattr(args, color): # if argument name is in defined colors + hue = colors["colors"][color]["h"] + # if saturation already defined in color (gray) + sat = colors["colors"][color]["s"] if colors["colors"][color]["s"] is not None else args.sat - gnome_shell_theme.install(hue, theme_name, args.sat) + gnome_shell_theme.install(hue, color, sat) - else: - print('No arguments or no color arguments specified. Use -h or --help to see the available options.') + # custom color + elif args.hue: + hue = args.hue + theme_name = args.name if args.name else f'hue{hue}' # if defined name + + gnome_shell_theme.install(hue, theme_name, args.sat) + + else: + print('No arguments or no color arguments specified. Use -h or --help to see the available options.') if __name__ == "__main__": main() - os.system(f"rm -r {config.temp_folder}") + shutil.rmtree(config.temp_folder, ignore_errors=True) diff --git a/scripts/config.py b/scripts/config.py index e4603ca..0833347 100644 --- a/scripts/config.py +++ b/scripts/config.py @@ -7,6 +7,11 @@ themes_folder = "~/.themes" raw_theme_folder = "theme" scripts_folder = "scripts" +# GDM definitions +global_gnome_shell_theme = "/usr/share/gnome-shell" +gnome_shell_gresource = "gnome-shell-theme.gresource" +extracted_gdm_folder = "theme" + # files definitions gnome_shell_css = f"{temp_gnome_folder}/gnome-shell.css" colors_json = "colors.json" diff --git a/scripts/gdm.py b/scripts/gdm.py new file mode 100644 index 0000000..81ea67a --- /dev/null +++ b/scripts/gdm.py @@ -0,0 +1,179 @@ +import os +import subprocess +import shutil + +from .theme import Theme +from .utils import label_files +from . import config + + +class GlobalTheme: + def __init__(self, colors_json, theme_folder, destination_folder, destination_file, temp_folder, + is_filled=False): + """ + Initialize GlobalTheme class + :param colors_json: location of a json file with colors + :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 is_filled: if True, 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 = f"{temp_folder}/gdm" + + self.backup_file = f"{self.destination_file}.backup" + self.backup_trigger = "\n/* Marble theme */\n" # trigger to check if theme is installed + self.extracted_theme = f"{self.temp_folder}/{config.extracted_gdm_folder}" + + os.makedirs(self.temp_folder, exist_ok=True) # create temp folder + + # create light and dark themes + self.light_theme = Theme("gnome-shell-light", self.colors_json, self.theme_folder, + self.extracted_theme, self.temp_folder, mode='light', is_filled=is_filled) + self.dark_theme = Theme("gnome-shell-dark", self.colors_json, self.theme_folder, + self.extracted_theme, self.temp_folder, mode='dark', is_filled=is_filled) + + def __del__(self): + """ + Delete temp folder + """ + + del self.light_theme + del self.dark_theme + + shutil.rmtree(self.temp_folder) + + def __is_installed(self): + """ + Check if theme is installed + :return: True if theme is installed, False otherwise + """ + + with open(f"{self.destination_folder}/{self.destination_file}", "rb") as f: + content = f.read() + return self.backup_trigger.encode() in content + + def __extract(self): + """ + Extract gresource files to temp folder + """ + + print("Extracting gresource files...") + + gst = self.gst + workdir = self.temp_folder + + # Get the list of resources + resources = subprocess.getoutput(f"gresource list {gst}").split("\n") + + # Create directories + for r in resources: + r = r.replace("/org/gnome/shell/", "") + directory = os.path.join(workdir, os.path.dirname(r)) + os.makedirs(directory, exist_ok=True) + + # Extract resources + for r in resources: + output_path = os.path.join(workdir, r.replace("/org/gnome/shell/", "")) + subprocess.run(f"gresource extract {gst} {r} > {output_path}", shell=True) + + def __add_gnome_styles(self, theme): + """ + Add gnome styles to the start of the file + :param theme: Theme object + """ + + with open(f"{self.extracted_theme}/{theme.theme_type}.css", 'r') as gnome_theme: + gnome_styles = gnome_theme.read() + self.backup_trigger + theme.add_to_start(gnome_styles) + + def __prepare(self, hue, color, sat=None): + """ + Generate theme files for gnome-shell-theme.gresource.xml + :param hue: color hue + :param color: color name + :param sat: color saturation + """ + + # add -light label to light theme files because they are installed to the same folder + label_files(self.light_theme.temp_folder, "light", self.light_theme.main_styles) + + # add gnome styles to the start of the file + self.__add_gnome_styles(self.light_theme) + self.__add_gnome_styles(self.dark_theme) + + # build code for gnome-shell-theme.gresource.xml + self.light_theme.install(hue, color, sat, destination=self.extracted_theme) + self.dark_theme.install(hue, color, sat, destination=self.extracted_theme) + + def __backup(self): + """ + Backup installed theme + """ + + if self.__is_installed(): + return + + # backup installed theme + print("Backing up default theme...") + os.system(f"cp -aT {self.gst} {self.gst}.backup") + + def __generte_gresource_xml(self): + """ + Generates.gresource.xml + """ + + # list of files to add to gnome-shell-theme.gresource.xml + files = list(f"{file}" for file in os.listdir(self.extracted_theme)) + nl = "\n" # fstring doesn't support newline character + + ready_xml = f""" + + + {nl.join(files)} + +""" + + return ready_xml + + def install(self, hue, sat=None): + """ + Install theme globally + :param hue: color hue + :param sat: color saturation + """ + + # use backup file if theme is installed + self.gst = f"{self.destination_folder}/{self.destination_file}" + if self.__is_installed(): + print("Theme is installed. Reinstalling...") + self.gst += ".backup" + + self.__extract() + + # generate theme files for global theme + self.__prepare(hue, 'Marble', sat) + + # generate gnome-shell-theme.gresource.xml + with open(f"{self.extracted_theme}/{self.destination_file}.xml", 'w') as gresource_xml: + generated_xml = self.__generte_gresource_xml() + gresource_xml.write(generated_xml) + + # compile gnome-shell-theme.gresource.xml + print("Compiling theme...") + subprocess.run(f"glib-compile-resources {self.destination_file}.xml", + shell=True, cwd=self.extracted_theme) + + # backup installed theme + self.__backup() + + # install theme + print("Installing theme...") + os.system(f"sudo mv {self.extracted_theme}/{self.destination_file} " + f"{self.destination_folder}/{self.destination_file}") + + return 0 diff --git a/scripts/theme.py b/scripts/theme.py index 6475bf8..6d3d2fa 100644 --- a/scripts/theme.py +++ b/scripts/theme.py @@ -123,19 +123,23 @@ class Theme: for apply_file in os.listdir(f"{source}/"): self.__apply_colors(hue, destination, theme_mode, apply_file, sat=sat) - def install(self, hue, name, sat=None): + def install(self, hue, name, sat=None, destination=None): """ Copy files and generate theme with different accent color :param hue :param name: theme name :param sat: color saturation (optional) + :param destination: folder where theme will be installed """ + is_dest = bool(destination) + print(f"Creating {name} {', '.join(self.mode)} theme...", end=" ") try: for mode in self.mode: - destination = destination_return(self.destination_folder, name, mode, self.theme_type) + 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) @@ -145,3 +149,12 @@ class Theme: else: print("Done.") + + 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_styles.write(content + '\n' + main_styles.read()) diff --git a/scripts/utils.py b/scripts/utils.py index b57d380..8dca4f1 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -145,3 +145,39 @@ def hex_to_rgba(hex_color): int(hex_color[2:4], 16), \ int(hex_color[4:6], 16), \ int(hex_color[6:8], 16) / 255 + + +def label_files(directory, label, *args): + """ + Add a label to all files in a directory + :param directory: folder where files are located + :param label: label to add + :param args: files to change links to labeled files + :return: + """ + + # Open all files + files = [open(file, 'r+') for file in args] + read_files = [file.read() for file in files] + + for filename in os.listdir(directory): + # Skip if the file is already labeled + if label in filename: + continue + + # Split the filename into name and extension + name, extension = os.path.splitext(filename) + + # Form the new filename and rename the file + new_filename = f"{name}-{label}{extension}" + os.rename(os.path.join(directory, filename), os.path.join(directory, new_filename)) + + # Replace the filename in all files + for file in read_files: + file.replace(filename, new_filename) + + # Write the changes to the files and close them + for i, file in enumerate(files): + file.seek(0) + file.write(read_files[i]) + file.close() From 068207c949d246a4a1b8a2bd8408ca99247032c3 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sun, 10 Dec 2023 23:09:34 +0200 Subject: [PATCH 10/17] Add ability to remove GDM theme --- install.py | 6 ++++++ scripts/gdm.py | 27 +++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/install.py b/install.py index f9cd0e4..42a9ba4 100644 --- a/install.py +++ b/install.py @@ -102,6 +102,12 @@ def main(): config.global_gnome_shell_theme, config.gnome_shell_gresource, config.temp_folder, is_filled=args.filled) + if args.remove: + gdm_rm_status = gdm_theme.remove() + if gdm_rm_status == 0: + print("GDM theme removed successfully.") + return 0 + if args.red or args.pink or args.purple or args.blue or args.green or args.yellow or args.gray: for color in colors["colors"]: if getattr(args, color): diff --git a/scripts/gdm.py b/scripts/gdm.py index 81ea67a..50ccf3f 100644 --- a/scripts/gdm.py +++ b/scripts/gdm.py @@ -28,6 +28,7 @@ class GlobalTheme: self.backup_file = f"{self.destination_file}.backup" self.backup_trigger = "\n/* Marble theme */\n" # trigger to check if theme is installed self.extracted_theme = f"{self.temp_folder}/{config.extracted_gdm_folder}" + self.gst = f"{self.destination_folder}/{self.destination_file}" # use backup file if theme is installed os.makedirs(self.temp_folder, exist_ok=True) # create temp folder @@ -147,8 +148,6 @@ class GlobalTheme: :param sat: color saturation """ - # use backup file if theme is installed - self.gst = f"{self.destination_folder}/{self.destination_file}" if self.__is_installed(): print("Theme is installed. Reinstalling...") self.gst += ".backup" @@ -177,3 +176,27 @@ class GlobalTheme: f"{self.destination_folder}/{self.destination_file}") return 0 + + def remove(self): + """ + Remove installed theme + """ + + # use backup file if theme is installed + if self.__is_installed(): + print("Theme is installed. Removing...") + + if os.path.isfile(f"{self.destination_folder}/{self.backup_file}"): + subprocess.run(f"sudo mv {self.backup_file} {self.destination_file}", + shell=True, cwd=self.destination_folder) + + else: + print("Backup file not found. Try reinstalling gnome-shell package.") + return 1 + + else: + print("Theme is not installed. Nothing to remove.") + print("If theme is still installed globally, try reinstalling gnome-shell package.") + return 1 + + return 0 From 6cd2090af1098d44adcabf5be587e7ff46fc5594 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Fri, 15 Dec 2023 13:37:34 +0200 Subject: [PATCH 11/17] Fix death screen on Fedora after installing GDM --- scripts/gdm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gdm.py b/scripts/gdm.py index 50ccf3f..78120f0 100644 --- a/scripts/gdm.py +++ b/scripts/gdm.py @@ -172,7 +172,7 @@ class GlobalTheme: # install theme print("Installing theme...") - os.system(f"sudo mv {self.extracted_theme}/{self.destination_file} " + os.system(f"sudo cp -f {self.extracted_theme}/{self.destination_file} " f"{self.destination_folder}/{self.destination_file}") return 0 From dc6c31041ff1c3385f1e733eece7bb2909f93d9b Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 16 Dec 2023 18:01:48 +0200 Subject: [PATCH 12/17] Make code more readable --- scripts/theme.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/theme.py b/scripts/theme.py index 6d3d2fa..abb4f22 100644 --- a/scripts/theme.py +++ b/scripts/theme.py @@ -157,4 +157,7 @@ class Theme: """ with open(self.main_styles, 'r+') as main_styles: - main_styles.write(content + '\n' + main_styles.read()) + main_content = main_styles.read() + + main_styles.seek(0) + main_styles.write(content + '\n' + main_content) From da440e7a1932e30e55b417f82b96bc12fec4b2e6 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 16 Dec 2023 18:02:21 +0200 Subject: [PATCH 13/17] Add some tweaks that helps to install theme globally --- scripts/gdm.py | 13 ++++++++++++- scripts/utils.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/scripts/gdm.py b/scripts/gdm.py index 78120f0..ea4fa3e 100644 --- a/scripts/gdm.py +++ b/scripts/gdm.py @@ -3,7 +3,7 @@ import subprocess import shutil from .theme import Theme -from .utils import label_files +from .utils import label_files, remove_properties, remove_keywords from . import config @@ -29,6 +29,8 @@ class GlobalTheme: self.backup_trigger = "\n/* Marble theme */\n" # trigger to check if theme is installed self.extracted_theme = f"{self.temp_folder}/{config.extracted_gdm_folder}" self.gst = f"{self.destination_folder}/{self.destination_file}" # use backup file if theme is installed + self.extracted_light_theme = f"{self.extracted_theme}/gnome-shell-light.css" + self.extracted_dark_theme = f"{self.extracted_theme}/gnome-shell-dark.css" os.makedirs(self.temp_folder, exist_ok=True) # create temp folder @@ -103,6 +105,15 @@ class GlobalTheme: # add -light label to light theme files because they are installed to the same folder label_files(self.light_theme.temp_folder, "light", self.light_theme.main_styles) + # remove !important from the gnome file + remove_keywords(self.extracted_light_theme, "!important") + remove_keywords(self.extracted_dark_theme, "!important") + + # remove properties from the gnome file + props_to_remove = ("background-color", "color", "box-shadow", "border-radius") + remove_properties(self.extracted_light_theme, *props_to_remove) + remove_properties(self.extracted_dark_theme, *props_to_remove) + # add gnome styles to the start of the file self.__add_gnome_styles(self.light_theme) self.__add_gnome_styles(self.dark_theme) diff --git a/scripts/utils.py b/scripts/utils.py index 8dca4f1..3f7fbf9 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -181,3 +181,43 @@ def label_files(directory, label, *args): file.seek(0) file.write(read_files[i]) file.close() + + +def remove_properties(file, *args): + """ + Remove properties from a file + :param file: file name + :param args: properties to remove + """ + + new_content = "" + + with open(file, "r+") as read_file: + content = read_file.read() + + for line in content.splitlines(): + if not any(prop in line for prop in args): + new_content += line + "\n" + elif "}" in line: + new_content += "}\n" + + read_file.seek(0) + read_file.write(new_content) + + +def remove_keywords(file, *args): + """ + Remove keywords from a file + :param file: file name + :param args: keywords to remove + """ + + with open(file, "r+") as read_file: + content = read_file.read() + + for arg in args: + content = content.replace(arg, "") + + read_file.seek(0) + read_file.write(content) + \ No newline at end of file From 9591a2cbbc5b83ea9b2831a59987323503ac5f57 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 16 Dec 2023 18:08:19 +0200 Subject: [PATCH 14/17] Make ws-switcher look like Activities button --- theme/gnome-shell_css/osd.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/theme/gnome-shell_css/osd.css b/theme/gnome-shell_css/osd.css index fadc413..0246a72 100644 --- a/theme/gnome-shell_css/osd.css +++ b/theme/gnome-shell_css/osd.css @@ -42,6 +42,15 @@ /* ctrl + alt + arr_left/arr_right */ .ws-switcher-indicator { background-color: TEXT-SECONDARY-COLOR; + border-radius: 99px; + padding: 2.5px; + margin: 7.5px 5px; +} + +.ws-switcher-indicator:active { + background-color: TEXT-PRIMARY-COLOR; + padding: 5px 20px; + margin: 5px; } From 407aa299e0e02bc8722c5dadc6174b157aa5122b Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 16 Dec 2023 18:09:48 +0200 Subject: [PATCH 15/17] Make navigation arrow in app list squared --- theme/gnome-shell_css/apps.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/theme/gnome-shell_css/apps.css b/theme/gnome-shell_css/apps.css index 259a213..3bdda4b 100644 --- a/theme/gnome-shell_css/apps.css +++ b/theme/gnome-shell_css/apps.css @@ -104,9 +104,9 @@ /* Page arrow */ - .page-navigation-arrow { - border-radius: 99px; + border-radius: 15px; + padding: 12px; transition-duration: 100ms; } From bd83e6ebf92b5bb61984d49588ba94f0dc87fd1a Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 16 Dec 2023 18:10:58 +0200 Subject: [PATCH 16/17] Login screen support and various fixes when theme is set globally --- colors.json | 31 ++++ theme/gnome-shell_css/controls.css | 5 +- theme/gnome-shell_css/entries.css | 13 +- theme/gnome-shell_css/loginlock.css | 179 +++++++++++++++++++++++ theme/gnome-shell_css/messages.css | 4 +- theme/gnome-shell_css/overview.css | 5 +- theme/gnome-shell_css/panel.css | 3 +- theme/gnome-shell_css/popovers.css | 2 + theme/gnome-shell_css/quick-settings.css | 8 +- theme/gnome-shell_css/screenshot.css | 36 +++++ 10 files changed, 264 insertions(+), 22 deletions(-) create mode 100644 theme/gnome-shell_css/loginlock.css diff --git a/colors.json b/colors.json index e22bf48..16735ab 100644 --- a/colors.json +++ b/colors.json @@ -147,6 +147,37 @@ } }, + "ACCENT-LOGIN-COLOR" : { + "_comment" : "Accent color for login screen user list", + + "light" : { + "s" : 65, + "l" : 65, + "a" : 1 + }, + + "dark" : { + "s" : 65, + "l" : 55, + "a" : 1 + } + }, + "ACCENT-LOGIN_HOVER" : { + "_comment" : "Hover color for login screen user list", + + "light" : { + "s" : 65, + "l" : 55, + "a" : 0.8 + }, + + "dark" : { + "s" : 65, + "l" : 55, + "a" : 0.8 + } + }, + "ACCENT-FILLED-COLOR" : { "_comment" : "Accent color but more vibrant", diff --git a/theme/gnome-shell_css/controls.css b/theme/gnome-shell_css/controls.css index 7edb00f..a4f58c7 100644 --- a/theme/gnome-shell_css/controls.css +++ b/theme/gnome-shell_css/controls.css @@ -81,6 +81,7 @@ .button:insensitive { box-shadow: none; color: TEXT-SECONDARY-COLOR; + box-shadow: inset 0 0 0 1px BORDER-SHADOW; } .button:hover, @@ -104,12 +105,10 @@ .slider, .level { - height: 16px; -barlevel-height: 16px; -barlevel-background-color: ACCENT-DISABLED-COLOR; - box-shadow: inset 0 0 0 1px BORDER-SHADOW; - /* fill */ -barlevel-active-background-color: BUTTON-COLOR; + -barlevel-border-width: 0px; /* overfill */ -barlevel-overdrive-color: #c01c28; -barlevel-overdrive-separator-width: 2px; diff --git a/theme/gnome-shell_css/entries.css b/theme/gnome-shell_css/entries.css index ceb6a31..b654227 100644 --- a/theme/gnome-shell_css/entries.css +++ b/theme/gnome-shell_css/entries.css @@ -1,6 +1,7 @@ /* Entries */ -StEntry { +StEntry, +.search-entry { border-radius: 13px; padding: 8px; transition-duration: 100ms; @@ -31,14 +32,4 @@ StEntry:active { StEntry:focus { color: TEXT-PRIMARY-COLOR; -} - - -/* Entry in lock screen */ -.unlock-dialog StEntry { - border: none !important; -} - -.unlock-dialog StEntry:focus { - box-shadow: inset 0 0 0 1px TEXT-DISABLED-COLOR; } \ No newline at end of file diff --git a/theme/gnome-shell_css/loginlock.css b/theme/gnome-shell_css/loginlock.css new file mode 100644 index 0000000..74da88d --- /dev/null +++ b/theme/gnome-shell_css/loginlock.css @@ -0,0 +1,179 @@ +/* Lock Screen / Login screen */ + +#lockDialogGroup { + background-color: #000; +} + +.login-dialog { + background-color: BACKGROUND-OPAQUE-COLOR; +} + + +.login-dialog .caps-lock-warning-label, +.login-dialog .login-dialog-message-warning, +.unlock-dialog .caps-lock-warning-label, +.unlock-dialog .login-dialog-message-warning { + color: TEXT-SECONDARY-COLOR; +} + + +/* not listed button */ +.login-dialog-not-listed-label { + color: TEXT-SECONDARY-COLOR; +} +.login-dialog-not-listed-button:focus .login-dialog-not-listed-label, +.login-dialog-not-listed-button:hover .login-dialog-not-listed-label { + color: TEXT-PRIMARY-COLOR; +} +.login-dialog-not-listed-button:focus .login-dialog-not-listed-label { + text-decoration: underline; +} + + +/* users button */ +.login-dialog-user-list .login-dialog-user-list-item { + background-color: SECTION-COLOR; + border-radius: 20px; + box-shadow: inset 0 0 0 1px BORDER-SHADOW; +} + +.login-dialog-user-list:expanded .login-dialog-user-list-item { + transition-duration: 200ms; +} + +.login-dialog-user-list:expanded .login-dialog-user-list-item:hover { + background-color: ACCENT-LOGIN-COLOR; +} + +.login-dialog-user-list:expanded .login-dialog-user-list-item:focus, +.login-dialog-user-list:expanded .login-dialog-user-list-item:selected { + background-color: ACCENT-LOGIN-COLOR; + /* color: BUTTON-TEXT-COLOR; don't work*/ + box-shadow: inset 0 0 0 1px BORDER-MENU-SHADOW; +} + +.login-dialog-user-list:expanded .login-dialog-user-list-item:focus:hover, +.login-dialog-user-list:expanded .login-dialog-user-list-item:selected:hover { + background-color: ACCENT-LOGIN_HOVER; +} + + +/* login buttons */ +.login-dialog-button.cancel-button, +.login-dialog-button.switch-user-button, +.login-dialog-button.login-dialog-session-list-button { + background-color: ACCENT-DISABLED-COLOR; + color: TEXT-PRIMARY-COLOR; + box-shadow: inset 0 0 0 1px BORDER-SHADOW; + border-radius: 99px; + transition-duration: 100ms; +} + +.login-dialog-button.cancel-button:hover, +.login-dialog-button.switch-user-button:hover, +.login-dialog-button.login-dialog-session-list-button:hover { + background-color: ACCENT-DISABLED_HOVER; +} + +.login-dialog-button.cancel-button:focus, +.login-dialog-button.switch-user-button:focus, +.login-dialog-button.login-dialog-session-list-button:focus { + background-color: ACCENT-DISABLED_HOVER; + box-shadow: inset 0 0 0 2px ACCENT-SECONDARY-COLOR !important; +} + +.login-dialog-button.cancel-button:focus:active, +.login-dialog-button.switch-user-button:focus:active, +.login-dialog-button.login-dialog-session-list-button:focus:active, +.login-dialog-button.cancel-button:focus:hover, +.login-dialog-button.switch-user-button:focus:hover, +.login-dialog-button.login-dialog-session-list-button:focus:hover { + background-color: ACCENT-DISABLED_HOVER; + box-shadow: inset 0 0 0 1px ACCENT-SECONDARY-COLOR !important; +} + +.login-dialog-button.cancel-button:active, +.login-dialog-button.switch-user-button:active, +.login-dialog-button.login-dialog-session-list-button:active { + background-color: ACCENT-DISABLED_HOVER; +} + + +/* password */ +.login-dialog-prompt-entry { + background-color: ACCENT-DISABLED-COLOR; + border: none; + box-shadow: inset 0 0 0 1px BORDER-SHADOW; +} + +.login-dialog-prompt-entry:focus { + background-color: ACCENT-DISABLED-COLOR !important; + border: none; + box-shadow: inset 0 0 0 1px TEXT-DISABLED-COLOR !important; + border-color: TEXT-DISABLED-COLOR; +} + +.login-dialog-prompt-entry:insensitive { + background-color: ACCENT-DISABLED-COLOR; + color: TEXT-DISABLED-COLOR; +} + + +/* Clock */ +.unlock-dialog-clock { + color: TEXT-PRIMARY-COLOR; + spacing: 1em; +} + +.unlock-dialog-clock .unlock-dialog-clock-time { + font-feature-settings: "normal"; + font-weight: 500; +} + +.unlock-dialog-clock .unlock-dialog-clock-date { + font-weight: 500; + font-size: 16pt; +} + +.unlock-dialog-clock .unlock-dialog-clock-hint { + margin-top: 1em; + font-weight: 600; + font-size: 10pt; + color: TEXT-SECONDARY-COLOR; +} + + +/* Notifications */ +.unlock-dialog-notifications-container .notification, +.unlock-dialog-notifications-container .unlock-dialog-notification-source { + padding: 12px 16px; + box-shadow: inset 0 0 0 1px BORDER-SHADOW; + background-color: ACCENT-DISABLED-COLOR; + color: TEXT-PRIMARY-COLOR; + border-radius: 16px; +} +.unlock-dialog-notifications-container .notification.critical, +.unlock-dialog-notifications-container .unlock-dialog-notification-source.critical { + background-color: ACCENT_HOVER; +} + +/* messages counter */ +.unlock-dialog-notification-count-text { + color: TEXT-SECONDARY-COLOR; + background-color: ACCENT-DISABLED-COLOR; + box-shadow: inset 0 0 0 1px BORDER-SHADOW; + border-radius: 99px; +} + + +/* User widget */ +.user-widget .user-widget-label { + color: TEXT-PRIMARY-COLOR; +} + +.user-widget.horizontal .user-icon StIcon, +.user-widget.vertical .user-icon StIcon { + color: TEXT-PRIMARY-COLOR; + background-color: ACCENT-DISABLED-COLOR; + box-shadow: inset 0 0 0 1px BORDER-SHADOW; +} \ No newline at end of file diff --git a/theme/gnome-shell_css/messages.css b/theme/gnome-shell_css/messages.css index c9faeaa..4b4b947 100644 --- a/theme/gnome-shell_css/messages.css +++ b/theme/gnome-shell_css/messages.css @@ -2,8 +2,8 @@ /* datemenu message-list */ .message-list { - border: none; /* remove border between messages and calendar section */ - padding: 0; + border-color: transparent; /* remove border between messages and calendar section */ + padding: 0 !important; } /* "No Notifications" text */ diff --git a/theme/gnome-shell_css/overview.css b/theme/gnome-shell_css/overview.css index bb8806c..704a059 100644 --- a/theme/gnome-shell_css/overview.css +++ b/theme/gnome-shell_css/overview.css @@ -35,9 +35,8 @@ /* thumbnails on overview (workspaces list under search) */ .workspace-thumbnail { - border: none; /* because of impossibility round fullscreen apps */ - background-color: ACCENT-DISABLED-COLOR; - border: 1px solid BORDER-SHADOW; + background-color: ACCENT-DISABLED-COLOR !important; + border: 1px solid BORDER-SHADOW !important; } /* drag and drop indicator */ diff --git a/theme/gnome-shell_css/panel.css b/theme/gnome-shell_css/panel.css index 44af478..00ed913 100644 --- a/theme/gnome-shell_css/panel.css +++ b/theme/gnome-shell_css/panel.css @@ -20,7 +20,7 @@ .clock-display StIcon { /* DND / new messages icon */ color: TEXT-PRIMARY-COLOR; border-radius: 14px; - border: 4px solid transparent; + border: 4px solid transparent !important; background-color: ACCENT-DISABLED-COLOR; box-shadow: inset 0 0 0 1px BORDER-SHADOW; } @@ -35,6 +35,7 @@ /* workspaces indicator in activities button (45+) */ #panel .workspace-dot { background-color: TEXT-PRIMARY-COLOR; + border-radius: 99px; } /* indicator for active */ diff --git a/theme/gnome-shell_css/popovers.css b/theme/gnome-shell_css/popovers.css index 360df9b..937dba2 100644 --- a/theme/gnome-shell_css/popovers.css +++ b/theme/gnome-shell_css/popovers.css @@ -13,6 +13,7 @@ .popup-menu-item, /* menu items */ .app-menu { /* right-click (and panel) app menu */ margin: 3px 0; + border-radius: 12px; transition-duration: 150ms; } @@ -64,6 +65,7 @@ margin: 0; color: TEXT-PRIMARY-COLOR; background-color: transparent; + border-radius: 0; } /* make rounded borders for last element */ diff --git a/theme/gnome-shell_css/quick-settings.css b/theme/gnome-shell_css/quick-settings.css index 881907b..3242ea8 100644 --- a/theme/gnome-shell_css/quick-settings.css +++ b/theme/gnome-shell_css/quick-settings.css @@ -12,6 +12,7 @@ background-color: ACCENT-DISABLED-COLOR; color: TEXT-PRIMARY-COLOR; transition-duration: 100ms; + border-radius: 99px; box-shadow: inset 0 0 0 1px BORDER-SHADOW; } @@ -67,6 +68,7 @@ .quick-toggle:hover, .quick-menu-toggle .quick-toggle:hover, .quick-menu-toggle .quick-toggle-arrow:hover { + color: TEXT-PRIMARY-COLOR; background-color: ACCENT-DISABLED_HOVER; } @@ -81,16 +83,18 @@ .quick-toggle:checked:hover, .quick-menu-toggle .quick-toggle:checked:hover, .quick-menu-toggle .quick-toggle-arrow:checked:hover { + color: BUTTON-TEXT-COLOR; background-color: BUTTON_HOVER; } /* quick slider section */ -/* fix margins in quick slider dection */ +/* fix margins in quick slider section */ .quick-slider .slider-bin { - padding: 2px 1px; + padding: 4px; margin: 0; color: BUTTON-TEXT-COLOR; + border-radius: 99px; } .quick-slider .slider-bin:focus { diff --git a/theme/gnome-shell_css/screenshot.css b/theme/gnome-shell_css/screenshot.css index 8e02201..b3e47c7 100644 --- a/theme/gnome-shell_css/screenshot.css +++ b/theme/gnome-shell_css/screenshot.css @@ -53,6 +53,10 @@ background-color: SECTION-COLOR; } +.screenshot-ui-window-selector-window-border { + border-radius: 16px; +} + /* window border when selecting */ .screenshot-ui-window-selector-window:hover .screenshot-ui-window-selector-window-border { border-color: BUTTON-COLOR; @@ -63,10 +67,13 @@ background-color: ACCENT-OPACITY-COLOR; } +.screenshot-ui-window-selector-check { color: transparent; } + /* check icon when selected window */ .screenshot-ui-window-selector-window:checked .screenshot-ui-window-selector-check { color: BUTTON-TEXT-COLOR; background-color: BUTTON-COLOR; + border-radius: 99px; } @@ -114,12 +121,14 @@ /* screenshot / screencast buttons section */ .screenshot-ui-shot-cast-container { background-color: ACCENT-DISABLED-COLOR; + border-radius: 99px; box-shadow: inset 0 0 0 1px BORDER-SHADOW; } /* screenshot / screencast buttons */ .screenshot-ui-shot-cast-button { background-color: transparent; + border-radius: 99px; transition-duration: 100ms; } @@ -135,6 +144,32 @@ color: TEXT-INVERTED-COLOR; } +.screenshot-ui-area-indicator-shade { + background-color: rgba(0, 0, 0, 0.5); +} + +/* screen selection borders */ +.screenshot-ui-area-selector-handle { + border-radius: 99px; + background-color: white; + box-shadow: 0 1px 3px 2px rgba(0, 0, 0, 0.05); +} + + +.screenshot-ui-screen-selector { + background-color: rgba(0, 0, 0, 0.5); +} +.screenshot-ui-screen-selector:hover { + background-color: rgba(0, 0, 0, 0.3); +} +.screenshot-ui-screen-selector:active { + background-color: rgba(0, 0, 0, 0.7); +} +.screenshot-ui-screen-selector:checked { + background-color: transparent; +} + + /* tooltip for elements */ .screenshot-ui-tooltip { @@ -152,6 +187,7 @@ background-color: BUTTON-CLOSE-COLOR; color: TEXT-PRIMARY-COLOR; border: 1px solid BORDER-SHADOW; + border-radius: 99px; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2); transition-duration: 100ms; } From b92016fd9663472f59a2e572e3dba2b17a384851 Mon Sep 17 00:00:00 2001 From: Vladyslav Hroshev Date: Sat, 16 Dec 2023 18:47:55 +0200 Subject: [PATCH 17/17] Add GDM section to README.md --- README.md | 26 ++++++++++++++++++++++++++ readme-images/gdm.png | Bin 0 -> 25229 bytes 2 files changed, 26 insertions(+) create mode 100644 readme-images/gdm.png diff --git a/README.md b/README.md index 9338a8a..f62c712 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,32 @@ Icon theme: https://github.com/vinceliuice/Colloid-icon-theme 5. Select the shell theme you want. +## 🖥️ GDM theme + +![GDM theme](./readme-images/gdm.png?raw=true) + +1. Open the terminal. +2. Go to the directory with the theme. +3. Run the program with the `--gdm` option + ```shell + sudo python install.py --gdm (--your color) (--is filled) + ``` + - Example: + ```shell + sudo python install.py --gdm --blue --filled + ``` +4. After successful file restart GDM service: + ```shell + sudo systemctl restart gdm + ``` + +- 🗑️ If you want to remove the theme, run the program with the `--remove` option: + ```shell + sudo python install.py --gdm -r + ``` +- ☠️ If you got a death screen, you can switch to the console with the `Ctrl + Alt + F3` key combination, log in, go to the `Marble-shell-theme` directory and run the command above. If it doesn't help, try reinstalling `gnome-shell` package. + + ## 🏮 Installation tweaks #### Install default color diff --git a/readme-images/gdm.png b/readme-images/gdm.png new file mode 100644 index 0000000000000000000000000000000000000000..1490c1373d28cd92cf070b18937f65539a97ab19 GIT binary patch literal 25229 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 Q?+)%w1JmmTdjCB6FFAuT6aWAK literal 0 HcmV?d00001