Merge pull request #52 from imarkoff/unstable

Colored logs, object oriented refactoring.
This commit is contained in:
Vladyslav Hroshev
2025-04-01 22:00:11 +03:00
committed by GitHub
22 changed files with 679 additions and 431 deletions

View File

@@ -7,11 +7,9 @@ on:
push:
branches:
- "main"
- "gdm"
pull_request:
branches:
- "main"
- "gdm"
permissions:
contents: read
@@ -23,10 +21,10 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.10
- name: Set up Python 3.13
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

View File

@@ -43,7 +43,7 @@ Icon theme: https://github.com/vinceliuice/Colloid-icon-theme
## 🚧 Requirements
- GNOME 42-48. Correct functionality on other versions is not guaranteed.
- [User Themes](https://extensions.gnome.org/extension/19/user-themes/ "User Themes") extension.
- Python 3.2+.
- Python 3.9+.
## 💡 Installation

View File

@@ -14,206 +14,35 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import json # working with json files
import argparse # command-line options
import os.path
import shutil
import textwrap # example text in argparse
from scripts import config # folder and files definitions
from scripts.tweaks_manager import TweaksManager # load tweaks from files
from scripts.utils import remove_files # delete already installed Marble theme
from scripts.utils.gnome import apply_gnome_theme # apply theme to GNOME shell
from scripts.theme import Theme
from scripts.gdm import GlobalTheme
def parse_args(colors) -> argparse.Namespace:
"""
Parse command-line arguments
:return: parsed arguments
"""
# script description
parser = argparse.ArgumentParser(prog="python install.py",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent('''
Examples:
-a all accent colors, light & dark mode
--all --mode dark all accent colors, dark mode
--purple --mode=light purple accent color, light mode
--hue 150 --name coldgreen custom coldgreen accent color, light & dark mode
--red --green --sat=70 red, green accent colors, 70% of stock saturation
--hue=200 --name=grayblue --sat=50 --mode=dark
custom grayblue accent color, 50% of stock saturation, dark mode
'''))
# Default arguments
parser.add_argument('-r', '--remove', action='store_true', help='remove Marble themes')
parser.add_argument('-ri', '--reinstall', action='store_true', help='reinstall Marble themes')
default_args = parser.add_argument_group('Install default theme')
default_args.add_argument('-a', '--all', action='store_true', help='all available accent colors')
for color in colors["colors"]:
default_args.add_argument(f'--{color}', action='store_true', help=f'{color} theme only')
custom_args = parser.add_argument_group('Install custom color theme')
custom_args.add_argument('--hue', type=int, choices=range(0, 361), help='generate theme from Hue prompt',
metavar='(0 - 360)')
custom_args.add_argument('--name', nargs='?', help='theme name (optional)')
color_styles = parser.add_argument_group("Theme color tweaks")
color_styles.add_argument("--filled", action="store_true", help="make accent color more vibrant")
color_tweaks = parser.add_argument_group('Optional theme tweaks')
color_tweaks.add_argument('--mode', choices=['light', 'dark'], help='select a specific theme mode to install')
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.')
# Dynamically load arguments from each tweak script
tweaks_manager = TweaksManager()
tweaks_manager.define_arguments(parser)
return parser.parse_args()
def apply_tweaks(args, theme, colors):
"""
Apply theme tweaks
:param args: parsed arguments
:param theme: Theme object
:param colors: colors from colors.json
"""
tweaks_manager = TweaksManager()
tweaks_manager.apply_tweaks(args, theme, colors)
def install_theme(theme, hue, theme_name, sat, gdm=False):
"""
Check if GDM and install theme
:param theme: object to install
:param hue: color hue
:param theme_name: future theme name
:param sat: color saturation
:param gdm: if GDM theme
"""
if gdm:
theme.install(hue, sat)
else:
theme.install(hue, theme_name, sat)
def apply_colors(args, theme, colors, gdm=False):
"""
Apply accent colors to the theme
:param args: parsed arguments
:param theme: Theme object
:param colors: colors from colors.json
:param gdm: if GDM theme
"""
is_colors = False # check if any color arguments specified
# if custom color
if args.hue:
hue = args.hue
theme_name = args.name if args.name else f'hue{hue}'
install_theme(theme, hue, theme_name, args.sat, gdm)
return None
else:
for color in colors["colors"]:
if args.all or getattr(args, color):
is_colors = True
hue = colors["colors"][color]["h"]
# if saturation already defined in color (gray)
sat = colors["colors"][color].get("s", args.sat)
install_theme(theme, hue, color, sat, gdm)
if gdm:
return None
if not is_colors:
print('No color arguments specified. Use -h or --help to see the available options.')
return 1
return None
def global_theme(args, colors):
"""
Apply GDM theme
:param args: parsed arguments
:param colors: colors from colors.json
"""
gdm_temp = os.path.join(config.temp_folder, config.gdm_folder)
gdm_theme = GlobalTheme(colors, f"{config.raw_theme_folder}/{config.gnome_folder}",
config.global_gnome_shell_theme, config.gnome_shell_gresource,
gdm_temp, mode=args.mode, is_filled=args.filled)
if args.remove:
gdm_rm_status = gdm_theme.remove()
if gdm_rm_status == 0:
print("GDM theme removed successfully.")
return
for theme in gdm_theme.themes:
apply_tweaks(args, theme.theme, colors)
if apply_colors(args, gdm_theme, colors, gdm=True) is None:
print("\nGDM theme installed successfully.")
print("You need to restart gdm.service to apply changes.")
print("Run \"systemctl restart gdm.service\" to restart GDM.")
def local_theme(args, colors):
"""
Apply local theme
:param args: parsed arguments
:param colors: colors from colors.json
"""
if args.remove or args.reinstall:
remove_files(args, colors["colors"])
if not args.reinstall:
return
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)
apply_tweaks(args, gnome_shell_theme, colors)
apply_colors(args, gnome_shell_theme, colors)
from scripts import config
from scripts.install import ArgumentsDefiner
from scripts.install.colors_definer import ColorsDefiner
from scripts.install.global_theme_installer import GlobalThemeInstaller
from scripts.install.local_theme_installer import LocalThemeInstaller
from scripts.utils.gnome import apply_gnome_theme
def main():
colors = json.load(open(config.colors_json))
colors_definer = ColorsDefiner(config.colors_json)
args = ArgumentsDefiner(colors_definer.colors).parse()
args = parse_args(colors)
installer_class = GlobalThemeInstaller if args.gdm else LocalThemeInstaller
installer = installer_class(args, colors_definer)
if args.gdm:
global_theme(args, colors)
if args.remove or args.reinstall:
installer.remove()
else:
local_theme(args, colors)
if not args.remove:
installer.install()
if args.remove == args.reinstall:
apply_gnome_theme()
if not args.gdm and not args.remove:
apply_gnome_theme()
if __name__ == "__main__":
try:
main()
finally:
shutil.rmtree(config.temp_folder, ignore_errors=True)
shutil.rmtree(config.temp_folder, ignore_errors=True)

View File

@@ -19,3 +19,5 @@ extracted_gdm_folder = "theme"
gnome_shell_css = f"{temp_gnome_folder}/gnome-shell.css"
tweak_file = f"./{tweaks_folder}/*/tweak.py"
colors_json = "colors.json"
user_themes_extension = "/org/gnome/shell/extensions/user-theme/name"

View File

@@ -2,8 +2,10 @@ import os
import subprocess
from .theme import Theme
from .utils import label_files, remove_properties, remove_keywords, gnome
from .utils import remove_properties, remove_keywords, gnome
from . import config
from .utils.console import Console, Color, Format
from .utils.files_labeler import FilesLabeler
class ThemePrepare:
@@ -11,7 +13,7 @@ class ThemePrepare:
Theme object prepared for installation
"""
def __init__(self, theme, theme_file, should_label=False):
def __init__(self, theme: Theme, theme_file, should_label=False):
self.theme = theme
self.theme_file = theme_file
self.should_label = should_label
@@ -38,34 +40,20 @@ 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 = os.path.join(self.temp_folder, config.extracted_gdm_folder)
self.extracted_theme: str = os.path.join(self.temp_folder, config.extracted_gdm_folder)
self.gst = os.path.join(self.destination_folder, self.destination_file) # use backup file if theme is installed
self.themes: list[ThemePrepare] = []
try:
gnome_version = gnome.gnome_version()
gnome_major = gnome_version.split(".")[0]
if int(gnome_major) >= 44:
self.themes += [
self.__create_theme("gnome-shell-light", mode='light', should_label=True, is_filled=is_filled),
self.__create_theme("gnome-shell-dark", mode='dark', is_filled=is_filled)
]
except Exception as e:
print(f"Error: {e}")
print("Using single theme.")
if not self.themes:
self.themes.append(
self.__create_theme(
"gnome-shell", mode=mode if mode else 'dark', is_filled=is_filled))
self.is_filled = is_filled
self.mode = mode
def __create_theme(self, theme_type, mode=None, should_label=False, is_filled=False):
def __create_theme(self, theme_type: str, mode=None, should_label=False, is_filled=False):
"""Helper to create theme objects"""
theme = Theme(theme_type, self.colors_json, self.theme_folder,
self.extracted_theme, self.temp_folder,
mode=mode, is_filled=is_filled)
theme.prepare()
theme_file = os.path.join(self.extracted_theme, f"{theme_type}.css")
return ThemePrepare(theme=theme, theme_file=theme_file, should_label=should_label)
@@ -82,7 +70,8 @@ class GlobalTheme:
"""
Extract gresource files to temp folder
"""
print("Extracting gresource files...")
extract_line = Console.Line()
extract_line.update("Extracting gresource files...")
resources = subprocess.getoutput(f"gresource list {self.gst}").split("\n")
prefix = "/org/gnome/shell/"
@@ -97,6 +86,8 @@ class GlobalTheme:
with open(output_path, 'wb') as f:
subprocess.run(["gresource", "extract", self.gst, resource], stdout=f, check=True)
extract_line.success("Extracted gresource files.")
except FileNotFoundError as e:
if "gresource" in str(e):
print("Error: 'gresource' command not found.")
@@ -117,7 +108,7 @@ class GlobalTheme:
gnome_styles = gnome_theme.read() + self.backup_trigger
theme.add_to_start(gnome_styles)
def __prepare(self, hue, color, sat=None):
def __generate_themes(self, hue, color, sat=None):
"""
Generate theme files for gnome-shell-theme.gresource.xml
:param hue: color hue
@@ -125,35 +116,31 @@ class GlobalTheme:
:param sat: color saturation
"""
for theme in self.themes:
if theme.should_label:
label_files(theme.theme.temp_folder, "light", theme.theme.main_styles)
for theme_prepare in self.themes:
if theme_prepare.should_label:
temp_folder = theme_prepare.theme.temp_folder
main_styles = theme_prepare.theme.main_styles
FilesLabeler(temp_folder, main_styles).append_label("light")
remove_keywords(theme.theme_file, "!important")
remove_properties(theme.theme_file, "background-color", "color", "box-shadow", "border-radius")
remove_keywords(theme_prepare.theme_file, "!important")
remove_properties(theme_prepare.theme_file, "background-color", "color", "box-shadow", "border-radius")
self.__add_gnome_styles(theme.theme)
self.__add_gnome_styles(theme_prepare.theme)
theme.theme.install(hue, color, sat, destination=self.extracted_theme)
theme_prepare.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...")
backup_line = Console.Line()
backup_line.update("Backing up default theme...")
subprocess.run(["cp", "-aT", self.gst, f"{self.gst}.backup"], cwd=self.destination_folder, check=True)
backup_line.success("Backed up default theme.")
def __generate_gresource_xml(self):
"""
Generates.gresource.xml
"""
# list of files to add to gnome-shell-theme.gresource.xml
files = [f"<file>{file}</file>" for file in os.listdir(self.extracted_theme)]
nl = "\n" # fstring doesn't support newline character
@@ -165,6 +152,24 @@ class GlobalTheme:
</gresource>
</gresources>"""
def prepare(self):
try:
gnome_version = gnome.gnome_version()
gnome_major = gnome_version.split(".")[0]
if int(gnome_major) >= 44:
self.themes += [
self.__create_theme("gnome-shell-light", mode='light', should_label=True, is_filled=self.is_filled),
self.__create_theme("gnome-shell-dark", mode='dark', is_filled=self.is_filled)
]
except Exception as e:
print(f"Error: {e}")
print("Using single theme.")
if not self.themes:
self.themes.append(
self.__create_theme(
"gnome-shell", mode=self.mode if self.mode else 'dark', is_filled=self.is_filled))
def install(self, hue, sat=None):
"""
Install theme globally
@@ -182,7 +187,7 @@ class GlobalTheme:
self.__extract()
# generate theme files for global theme
self.__prepare(hue, 'Marble', sat)
self.__generate_themes(hue, 'Marble', sat)
# generate gnome-shell-theme.gresource.xml
with open(f"{self.extracted_theme}/{self.destination_file}.xml", 'w') as gresource_xml:
@@ -190,21 +195,23 @@ class GlobalTheme:
gresource_xml.write(generated_xml)
# compile gnome-shell-theme.gresource.xml
print("Compiling theme...")
compile_line = Console.Line()
compile_line.update("Compiling gnome-shell theme...")
subprocess.run(["glib-compile-resources" , f"{self.destination_file}.xml"],
cwd=self.extracted_theme, check=True)
compile_line.success("Theme compiled.")
# backup installed theme
self.__backup()
# install theme
print("Installing theme...")
install_line = Console.Line()
install_line.update("Moving compiled theme to system folder...")
subprocess.run(["sudo", "cp", "-f",
f"{self.extracted_theme}/{self.destination_file}",
f"{self.destination_folder}/{self.destination_file}"],
check=True)
print("Theme installed successfully.")
install_line.success("Theme moved to system folder.")
def remove(self):
@@ -214,16 +221,19 @@ class GlobalTheme:
# use backup file if theme is installed
if self.__is_installed():
print("Theme is installed. Removing...")
removing_line = Console.Line()
removing_line.update("Theme is installed. Removing...")
backup_path = os.path.join(self.destination_folder, self.backup_file)
dest_path = os.path.join(self.destination_folder, self.destination_file)
if os.path.isfile(backup_path):
subprocess.run(["sudo", "mv", backup_path, dest_path], check=True)
removing_line.success("Global theme removed successfully. Restart GDM to apply changes.")
else:
print("Backup file not found. Try reinstalling gnome-shell package.")
formatted_shell = Console.format("gnome-shell", color=Color.BLUE, format_type=Format.BOLD)
removing_line.error(f"Backup file not found. Try reinstalling {formatted_shell} package.")
else:
print("Theme is not installed. Nothing to remove.")
print("If theme is still installed globally, try reinstalling gnome-shell package.")
Console.Line().error("Theme is not installed. Nothing to remove.")
Console.Line().update("If theme is still installed globally, try reinstalling gnome-shell package.", icon="⚠️")

View File

@@ -0,0 +1 @@
from scripts.install.arguments_definer import ArgumentsDefiner

View File

@@ -0,0 +1,73 @@
import argparse
import textwrap
from typing import Any
from scripts.tweaks_manager import TweaksManager
class ArgumentsDefiner:
def __init__(self, colors: dict[str, Any]):
self._parser = argparse.ArgumentParser(prog="python install.py",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=self._get_epilog())
self._define_default_arguments()
self._define_color_arguments(colors)
self._define_custom_color_arguments()
self._define_theme_styles_arguments()
self._define_color_tweaks_arguments()
self._define_gdm_arguments()
self._define_tweaks_arguments()
def parse(self) -> argparse.Namespace:
return self._parser.parse_args()
@staticmethod
def _get_epilog():
return textwrap.dedent('''
Examples:
-a all accent colors, light & dark mode
--all --mode dark all accent colors, dark mode
--purple --mode=light purple accent color, light mode
--hue 150 --name coldgreen custom coldgreen accent color, light & dark mode
--red --green --sat=70 red, green accent colors, 70% of stock saturation
--hue=200 --name=grayblue --sat=50 --mode=dark
custom grayblue accent color, 50% of stock saturation, dark mode
''')
def _define_default_arguments(self):
self._parser.add_argument('-r', '--remove', action='store_true', help='remove Marble themes')
self._parser.add_argument('-ri', '--reinstall', action='store_true', help='reinstall Marble themes')
def _define_color_arguments(self, colors: dict[str, Any]):
default_args = self._parser.add_argument_group('Install default theme')
default_args.add_argument('-a', '--all', action='store_true', help='all available accent colors')
for color in colors:
default_args.add_argument(f'--{color}', action='store_true', help=f'{color} theme only')
def _define_custom_color_arguments(self):
custom_args = self._parser.add_argument_group('Install custom color theme')
custom_args.add_argument('--hue', type=int, choices=range(0, 361), help='generate theme from Hue prompt',
metavar='(0 - 360)')
custom_args.add_argument('--name', nargs='?', help='theme name (optional)')
def _define_theme_styles_arguments(self):
color_styles = self._parser.add_argument_group("Theme color styles")
color_styles.add_argument("--filled", action="store_true", help="make accent color more vibrant")
def _define_color_tweaks_arguments(self):
color_tweaks = self._parser.add_argument_group('Optional theme tweaks')
color_tweaks.add_argument('--mode', choices=['light', 'dark'], help='select a specific theme mode to install')
color_tweaks.add_argument('--sat', type=int, choices=range(0, 251),
help='custom color saturation (<100%% - reduce, >100%% - increase)',
metavar='(0 - 250)')
def _define_gdm_arguments(self):
gdm_theming = self._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.')
def _define_tweaks_arguments(self):
tweaks_manager = TweaksManager()
tweaks_manager.define_arguments(self._parser)

View File

@@ -0,0 +1,17 @@
import json
class ColorsDefiner:
# TODO: Create a class for each replacer
replacers: dict[ str, # ACCENT-COLOR
dict[ str, # default, light/dark
str | dict[str, int] # random string, s/l/a
]
]
# TODO: Create a class for each color
colors: dict[str, dict[str, int]]
def __init__(self, filename):
colors_dict = json.load(open(filename))
self.replacers = colors_dict["elements"]
self.colors = colors_dict["colors"]

View File

@@ -0,0 +1,39 @@
import os
from scripts import config
from scripts.gdm import GlobalTheme
from scripts.install.theme_installer import ThemeInstaller
from scripts.utils.console import Console, Color, Format
class GlobalThemeInstaller(ThemeInstaller):
theme: GlobalTheme
def remove(self):
gdm_rm_status = self.theme.remove()
if gdm_rm_status == 0:
print("GDM theme removed successfully.")
def _define_theme(self):
gdm_temp = os.path.join(config.temp_folder, config.gdm_folder)
self.theme = GlobalTheme(self.colors, f"{config.raw_theme_folder}/{config.gnome_folder}",
config.global_gnome_shell_theme, config.gnome_shell_gresource,
gdm_temp, mode=self.args.mode, is_filled=self.args.filled)
def _install_theme(self, hue, theme_name, sat):
self.theme.install(hue, sat)
def _apply_tweaks_to_theme(self):
for theme in self.theme.themes:
self._apply_tweaks(theme.theme)
def _after_install(self):
print()
Console.Line().update(
Console.format("GDM theme installed successfully.", color=Color.GREEN, format_type=Format.BOLD),
icon="🥳"
)
Console.Line().update("You need to restart GDM to apply changes.", icon=" ")
formatted_command = Console.format("systemctl restart gdm.service", color=Color.YELLOW, format_type=Format.BOLD)
Console.Line().update(f"Run {formatted_command} to restart GDM.", icon="🔄")

View File

@@ -0,0 +1,32 @@
import os.path
from scripts import config
from scripts.install.theme_installer import ThemeInstaller
from scripts.theme import Theme
from scripts.utils import remove_files
from scripts.utils.console import Console, Color, Format
class LocalThemeInstaller(ThemeInstaller):
theme: Theme
def remove(self):
colors = self.colors.colors
remove_files(self.args, colors)
def _define_theme(self):
theme_folder = os.path.join(config.raw_theme_folder, config.gnome_folder)
self.theme = Theme("gnome-shell", self.colors, theme_folder,
config.themes_folder, config.temp_folder,
mode=self.args.mode, is_filled=self.args.filled)
def _install_theme(self, hue, theme_name, sat):
self.theme.install(hue, theme_name, sat)
def _apply_tweaks_to_theme(self):
self._apply_tweaks(self.theme)
def _after_install(self):
print()
formatted_output = Console.format("Theme installed successfully.", color=Color.GREEN, format_type=Format.BOLD)
Console.Line().update(formatted_output, icon="🥳")

View File

@@ -0,0 +1,99 @@
import argparse
import concurrent.futures
from abc import ABC, abstractmethod
from scripts.install.colors_definer import ColorsDefiner
from scripts.theme import Theme
from scripts.tweaks_manager import TweaksManager
class ThemeInstaller(ABC):
"""Base class for theme installers"""
theme: Theme
def __init__(self, args: argparse.Namespace, colors: ColorsDefiner):
self.args = args
self.colors = colors
self.stop_after_first_installed_color = False
self._define_theme()
@abstractmethod
def remove(self):
"""Method for removing already installed themes"""
pass
def install(self):
self.theme.prepare()
self._apply_tweaks_to_theme()
self._apply_colors()
self._after_install()
@abstractmethod
def _define_theme(self):
"""Here is the place to define the theme object"""
pass
@abstractmethod
def _apply_tweaks_to_theme(self):
"""Should apply the tweaks for prepared theme"""
pass
@abstractmethod
def _install_theme(self, hue, theme_name, sat):
"""Should say how to install the defined theme"""
pass
@abstractmethod
def _after_install(self):
"""Method to be called after the theme is installed. Can be used for logging or other actions"""
pass
def _apply_tweaks(self, theme):
"""This method should be called in the _apply_tweaks_to_theme method"""
tweaks_manager = TweaksManager()
tweaks_manager.apply_tweaks(self.args, theme, self.colors)
def _apply_colors(self):
installed_any = False
if self.args.hue:
installed_any = True
self._apply_custom_color()
else:
installed_any = self._apply_default_color()
if not installed_any:
raise Exception('No color arguments specified. Use -h or --help to see the available options.')
def _apply_custom_color(self):
name = self.args.name
hue = self.args.hue
sat = self.args.sat
theme_name = name if name else f'hue{hue}'
self._install_theme(hue, theme_name, sat)
def _apply_default_color(self) -> bool:
colors = self.colors.colors
args = self.args
colors_to_install = []
for color, values in colors.items():
if args.all or getattr(args, color, False):
hue = values.get('h')
sat = values.get('s', args.sat)
colors_to_install.append((hue, color, sat))
if self.stop_after_first_installed_color:
break
if not colors_to_install:
return False
self._run_concurrent_installation(colors_to_install)
return True
def _run_concurrent_installation(self, colors_to_install):
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = [executor.submit(self._install_theme, hue, color, sat)
for hue, color, sat in colors_to_install]
concurrent.futures.wait(futures)

View File

@@ -2,11 +2,13 @@ import os
import shutil
import colorsys # colorsys.hls_to_rgb(h, l, s)
from .install.colors_definer import ColorsDefiner
from .utils import (
replace_keywords, # replace keywords in file
copy_files, # copy files from source to destination
destination_return, # copied/modified theme location
generate_file) # combine files from folder to one file
from .utils.console import Console, Color, Format
class Theme:
@@ -23,31 +25,14 @@ class Theme:
:param is_filled: if True, theme will be filled
"""
self.colors = colors_json
self.colors: ColorsDefiner = 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.modes = [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}", self.temp_folder, self.main_styles)
# after generating main styles, remove .css and .versions folders
shutil.rmtree(f"{self.temp_folder}/.css/", ignore_errors=True)
shutil.rmtree(f"{self.temp_folder}/.versions/", ignore_errors=True)
# if theme is filled
if is_filled:
for apply_file in os.listdir(f"{self.temp_folder}/"):
replace_keywords(f"{self.temp_folder}/{apply_file}",
("BUTTON-COLOR", "ACCENT-FILLED-COLOR"),
("BUTTON_HOVER", "ACCENT-FILLED_HOVER"),
("BUTTON_ACTIVE", "ACCENT-FILLED_ACTIVE"),
("BUTTON_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"),
("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"),
("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY"))
self.is_filled = is_filled
def __add__(self, other):
"""
@@ -74,9 +59,81 @@ class Theme:
return self
def __del__(self):
# delete temp folder
shutil.rmtree(self.temp_folder, ignore_errors=True)
def prepare(self):
# move files to temp folder
copy_files(self.theme_folder, self.temp_folder)
generate_file(f"{self.theme_folder}", self.temp_folder, self.main_styles)
# after generating main styles, remove .css and .versions folders
shutil.rmtree(f"{self.temp_folder}/.css/", ignore_errors=True)
shutil.rmtree(f"{self.temp_folder}/.versions/", ignore_errors=True)
# if theme is filled
if self.is_filled:
for apply_file in os.listdir(f"{self.temp_folder}/"):
replace_keywords(f"{self.temp_folder}/{apply_file}",
("BUTTON-COLOR", "ACCENT-FILLED-COLOR"),
("BUTTON_HOVER", "ACCENT-FILLED_HOVER"),
("BUTTON_ACTIVE", "ACCENT-FILLED_ACTIVE"),
("BUTTON_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"),
("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"),
("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY"))
def add_to_start(self, content):
"""
Add content to the start of main styles
:param content: content to add
"""
with open(self.main_styles, 'r') as main_styles:
main_content = main_styles.read()
with open(self.main_styles, 'w') as main_styles:
main_styles.write(content + '\n' + main_content)
def install(self, hue, name: str, sat=None, destination=None):
"""
Copy files and generate theme with specified accent color
:param hue
:param name: theme name
:param sat
:param destination: folder where theme will be installed
"""
joint_modes = f"({', '.join(self.modes)})"
line = Console.Line(name)
formatted_name = Console.format(name.capitalize(), color=Color.get(name), format_type=Format.BOLD)
formatted_mode = Console.format(joint_modes, color=Color.GRAY)
line.update(f"Creating {formatted_name} {formatted_mode} theme...")
try:
self._install_and_apply_theme(hue, name, sat=sat, destination=destination)
line.success(f"{formatted_name} {formatted_mode} theme created successfully.")
except Exception as err:
line.error(f"Error installing {formatted_name} theme: {str(err)}")
def _install_and_apply_theme(self, hue, name, sat=None, destination=None):
is_dest = bool(destination)
for mode in self.modes:
if not is_dest:
destination = destination_return(self.destination_folder, name, mode, self.theme_type)
copy_files(self.temp_folder + '/', destination)
self.__apply_theme(hue, self.temp_folder, destination, mode, sat=sat)
def __apply_theme(self, hue, source, destination, theme_mode, sat=None):
"""
Apply theme to all files in directory
:param hue
:param source
:param destination: file directory
:param theme_mode: theme name (light or dark)
:param sat: color saturation (optional)
"""
for apply_file in os.listdir(f"{source}/"):
self.__apply_colors(hue, destination, theme_mode, apply_file, sat=sat)
def __apply_colors(self, hue, destination, theme_mode, apply_file, sat=None):
"""
@@ -93,18 +150,18 @@ class Theme:
# colorsys works in range(0, 1)
h = hue / 360
for element in self.colors["elements"]:
for element in self.colors.replacers:
# 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
if theme_mode not in self.colors.replacers[element] and self.colors.replacers[element]["default"]:
default_element = self.colors.replacers[element]["default"]
default_color = self.colors.replacers[default_element][theme_mode]
self.colors.replacers[element][theme_mode] = default_color
# convert sla to range(0, 1)
lightness = int(self.colors["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"]
lightness = int(self.colors.replacers[element][theme_mode]["l"]) / 100
saturation = int(self.colors.replacers[element][theme_mode]["s"]) / 100 if sat is None else \
int(self.colors.replacers[element][theme_mode]["s"]) * (sat / 100) / 100
alpha = self.colors.replacers[element][theme_mode]["a"]
# convert hsl to rgb and multiply every item
red, green, blue = [int(item * 255) for item in colorsys.hls_to_rgb(h, lightness, saturation)]
@@ -112,56 +169,4 @@ class Theme:
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, 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:
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)
except Exception as err:
print("\nError: " + str(err))
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_content = main_styles.read()
with open(self.main_styles, 'w') as main_styles:
main_styles.write(content + '\n' + main_content)
replace_keywords(os.path.expanduser(f"{destination}/{apply_file}"), *replaced_colors)

View File

@@ -3,7 +3,6 @@ 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 .label_files import label_files
from .remove_files import remove_files
from .remove_keywords import remove_keywords
from .remove_properties import remove_properties

95
scripts/utils/console.py Normal file
View File

@@ -0,0 +1,95 @@
import sys
import threading
from enum import Enum
from typing import Optional
class Console:
"""Manages console output for concurrent processes with line tracking"""
_print_lock = threading.Lock()
_line_mapping = {}
_next_line = 0
class Line:
def __init__(self, name: Optional[str]=None):
"""Initialize a new managed line"""
self.name = name or f"line_{Console._next_line}"
self._reserve_line()
def update(self, message, icon=""):
"""Update the status message for the line"""
with Console._print_lock:
# Calculate how many lines to move up
lines_up = Console._next_line - Console._line_mapping[self.name]
# Move the cursor to correct line
if lines_up > 0:
sys.stdout.write(f"\033[{lines_up}F")
# Clear line and write status
if icon.strip() == "":
sys.stdout.write(f"\r\033[K{message}")
else:
sys.stdout.write(f"\r\033[K{icon} {message}")
# Move the cursor back down
if lines_up > 0:
sys.stdout.write(f"\033[{lines_up}E")
sys.stdout.flush()
def success(self, message):
self.update(message, "")
def error(self, message):
self.update(message, "")
def warn(self, message):
self.update(message, "⚠️")
def _reserve_line(self):
"""Reserve a line for future updates"""
with Console._print_lock:
line_number = Console._next_line
Console._next_line += 1
Console._line_mapping[self.name] = line_number
sys.stdout.write(f"\n") # Ensure we have a new line
sys.stdout.flush()
return line_number
@staticmethod
def format(text: str, color: Optional['Color']=None, format_type: Optional['Format']=None):
"""Apply color and formatting to text"""
formatted_text = text
if color:
formatted_text = color.value + formatted_text + Color.NORMAL.value
if format_type:
formatted_text = format_type.value + formatted_text + Format.NORMAL.value
return formatted_text
class Color(Enum):
"""ANSI color codes for terminal output"""
NORMAL = '\033[0m' # Reset color
BLACK = '\033[30m'
RED = '\033[31m'
GREEN = '\033[32m'
YELLOW = '\033[33m'
BLUE = '\033[34m'
MAGENTA = '\033[35m'
PURPLE = '\033[35m'
CYAN = '\033[36m'
WHITE = '\033[37m'
GRAY = '\033[90m'
@classmethod
def get(cls, color: str, default: Optional['Color']=None) -> Optional['Color']:
return getattr(cls, color.upper(), default)
class Format(Enum):
"""ANSI formatting codes for terminal output"""
NORMAL = '\033[0m' # Reset formatting
BOLD = '\033[1m'
ITALIC = '\033[3m'
UNDERLINE = '\033[4m'
BLINK = '\033[5m'
REVERSE = '\033[7m'

View File

@@ -1,4 +1,6 @@
import os
import shutil
def copy_files(source, destination):
"""
@@ -7,6 +9,7 @@ def copy_files(source, destination):
:param destination: where files will be pasted
"""
destination = os.path.expanduser(destination) # expand ~ to /home/user
destination = os.path.expanduser(destination)
os.makedirs(destination, exist_ok=True)
os.system(f"cp -aT {source} {destination}")
shutil.copytree(source, destination, dirs_exist_ok=True)

View File

@@ -0,0 +1,52 @@
import os
from typing import Tuple
type LabeledFileGroup = Tuple[str, str]
class FilesLabeler:
def __init__(self, directory: str, *args: str):
"""
Initialize the working directory and files to change
"""
self.directory = directory
self.files = args
def append_label(self, label: str):
"""
Append a label to all files in the directory
and update references in the files
"""
labeled_files = self._label_files(label)
self._update_references(labeled_files)
def _label_files(self, label: str) -> list[LabeledFileGroup]:
labeled_files = []
for filename in os.listdir(self.directory):
if label in filename: continue
name, extension = os.path.splitext(filename)
new_filename = f"{name}-{label}{extension}"
old_filepath = os.path.join(self.directory, filename)
new_filepath = os.path.join(self.directory, new_filename)
os.rename(old_filepath, new_filepath)
labeled_files.append((filename, new_filename))
return labeled_files
def _update_references(self, labeled_files: list[LabeledFileGroup]):
for file_path in self.files:
with open(file_path, 'r') as file:
file_content = file.read()
file_content = self._update_references_in_file(file_content, labeled_files)
with open(file_path, 'w') as file:
file.write(file_content)
@staticmethod
def _update_references_in_file(file_content: str, labeled_files: list[LabeledFileGroup]) -> str:
replaced_content = file_content
for old_name, new_name in labeled_files:
replaced_content = replaced_content.replace(old_name, new_name)
return replaced_content

View File

@@ -1,10 +1,15 @@
import subprocess
import time
from scripts import config
from scripts.utils.console import Console, Format, Color
from scripts.utils.parse_folder import parse_folder
def gnome_version() -> str | None:
"""
Get gnome-shell version
"""
try:
output = subprocess.check_output(['gnome-shell', '--version'], text=True).strip()
return output.split(' ')[2]
@@ -13,21 +18,40 @@ def gnome_version() -> str | None:
def apply_gnome_theme(theme=None) -> bool:
"""
Apply gnome-shell theme
:param theme: theme name
Applies the theme in user theme extension if it is Marble and extension installed.
"""
try:
if theme is None:
current_theme = subprocess.check_output(['dconf', 'read', '/org/gnome/shell/extensions/user-theme/name'], text=True).strip().strip("'")
if current_theme.startswith("Marble"):
theme = current_theme
else:
return False
theme = get_current_theme()
subprocess.run(['dconf', 'reset', '/org/gnome/shell/extensions/user-theme/name'], check=True)
subprocess.run(['dconf', 'write', '/org/gnome/shell/extensions/user-theme/name', f"'{theme}'"], check=True)
print(f"Theme '{theme}' applied.")
except subprocess.CalledProcessError:
line = Console.Line("apply_gnome_theme")
(color, _) = parse_folder(theme)
formatted_theme = Console.format(theme, color=Color.get(color, Color.GRAY), format_type=Format.BOLD)
line.update(f"Applying {formatted_theme} theme...")
time.sleep(0.025) # applying the theme may freeze, so we need to wait a bit
apply_user_theme(theme)
line.success(f"Theme {formatted_theme} applied.")
except Exception:
return False
return True
return True
def get_current_theme() -> str:
"""
Throws an error if theme is not Marble.
"""
try:
output = subprocess.check_output(['dconf', 'read', config.user_themes_extension], text=True)
output = output.strip().strip("'")
if not output.startswith("Marble"):
raise Exception(f"Theme {output} doesn't appear to be a Marble theme")
return output
except subprocess.CalledProcessError:
raise Exception("User theme extension not found.")
def apply_user_theme(theme_name: str):
subprocess.run(['dconf', 'reset', config.user_themes_extension], check=True)
subprocess.run(['dconf', 'write', config.user_themes_extension, f"'{theme_name}'"], check=True)

View File

@@ -1,44 +0,0 @@
import os
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 = []
filenames = []
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))
filenames.append((filename, new_filename))
# Replace the filename in all files
for i, file in enumerate(files):
read_file = file.read()
read_file.replace(filenames[i][0], filenames[i][1])
read_files.append(read_file)
file.close()
write_files = [open(file, 'w') for file in args]
# Write the changes to the files and close them
for i, file in enumerate(write_files):
file.write(read_files[i])
file.close()

View File

@@ -0,0 +1,11 @@
def parse_folder(folder: str) -> tuple[str, str] | None:
"""Parse a folder name into color and mode"""
folder_arr = folder.split("-")
if len(folder_arr) < 2 or folder_arr[0] != "Marble":
return None
color = "-".join(folder_arr[1:-1])
mode = folder_arr[-1]
return color, mode

View File

@@ -5,11 +5,14 @@
import argparse
import shutil
from collections import defaultdict
from typing import Any
from .console import Console, Color, Format
from .parse_folder import parse_folder
from .. import config
import os
def remove_files(args: argparse.Namespace, colors: dict[str, str]):
def remove_files(args: argparse.Namespace, formatted_colors: dict[str, Any]):
"""Delete already installed Marble theme"""
themes = detect_themes(config.themes_folder)
@@ -19,23 +22,26 @@ def remove_files(args: argparse.Namespace, colors: dict[str, str]):
filtered_themes = themes
if not args.all:
args_dict = vars(args)
arguments = [color for color in colors.keys() if args_dict.get(color)]
arguments = [color for color in formatted_colors.keys() if args_dict.get(color)]
filtered_themes = themes.filter(arguments)
if not filtered_themes:
print("No matching themes found.")
Console.Line().error("No matching themes found.")
return
colors = [color for (color, modes) in filtered_themes]
print(f"The following themes will be deleted: {', '.join(colors)}.")
formatted_colors = [
Console.format(color, color=Color.get(color), format_type=Format.BOLD)
for (color, modes) in filtered_themes
]
Console.Line().warn(f"The following themes will be deleted: {', '.join(formatted_colors)}.")
if args.mode:
print(f"Theme modes to be deleted: {args.mode}.")
Console.Line().warn(f"Theme modes to be deleted: {args.mode}.")
if input(f"Proceed? (y/N) ").lower() == "y":
if proceed_input().lower() == "y":
filtered_themes.remove(args.mode)
print("Themes deleted successfully.")
Console.Line().success("Themes deleted successfully.")
else:
print("Operation cancelled.")
Console.Line().error("Operation cancelled.")
def detect_themes(path: str) -> 'Themes':
@@ -58,41 +64,12 @@ def detect_themes(path: str) -> 'Themes':
return themes
def parse_folder(folder: str) -> tuple[str, str] | None:
"""Parse a folder name into color and mode"""
folder_arr = folder.split("-")
if len(folder_arr) < 2 or folder_arr[0] != "Marble":
return None
color = "-".join(folder_arr[1:-1])
mode = folder_arr[-1]
return color, mode
class ThemeMode:
"""Concrete theme with mode and path"""
mode: str
path: str
def __init__(self, mode: str, path: str):
self.mode = mode
self.path = path
def remove(self):
try:
shutil.rmtree(self.path)
except Exception as e:
print(f"Error deleting {self.path}: {e}")
class Themes:
"""Collection of themes grouped by color"""
def __init__(self):
self.by_color: dict[str, list[ThemeMode]] = defaultdict(list) # color: list[ThemeMode]
def add_theme(self, color: str, theme_mode: ThemeMode):
def add_theme(self, color: str, theme_mode: 'ThemeMode'):
self.by_color[color].append(theme_mode)
def filter(self, colors: list[str]):
@@ -120,3 +97,26 @@ class Themes:
def __iter__(self):
for color, modes in self.by_color.items():
yield color, modes
class ThemeMode:
"""Concrete theme with mode and path"""
mode: str
path: str
def __init__(self, mode: str, path: str):
self.mode = mode
self.path = path
def remove(self):
try:
shutil.rmtree(self.path)
except Exception as e:
print(f"Error deleting {self.path}: {e}")
def proceed_input():
formatted_agree = Console.format("y", color=Color.GREEN, format_type=Format.BOLD)
formatted_disagree = Console.format("N", color=Color.RED, format_type=Format.BOLD)
formatted_proceed = Console.format("Proceed?", format_type=Format.BOLD)
return input(f"{formatted_proceed} ({formatted_agree}/{formatted_disagree}) ")

View File

@@ -101,7 +101,7 @@
/* that's much better than adding "margin: $base_padding * 0.5;" to .message-close-button */
.message-close-button { margin: 0; }
.message-header:ltr > :last-child { margin-right: 2px; }
.messahe-header:rtl > :first-child { margin-left: 2px; }
.message-header:rtl > :first-child { margin-left: 2px; }
/* close button, expand button (46+) */
.message-close-button,

View File

@@ -1,9 +1,12 @@
from scripts.install.colors_definer import ColorsDefiner
def define_arguments(parser):
color_args = parser.add_argument_group('Color tweaks')
color_args.add_argument('-O', '--opaque', action='store_true', help='make the background in menus/popovers opaque')
def apply_tweak(args, theme, colors):
def apply_tweak(args, theme, colors: ColorsDefiner):
if args.opaque:
colors["elements"]["BACKGROUND-COLOR"]["light"]["a"] = 1
colors["elements"]["BACKGROUND-COLOR"]["dark"]["a"] = 1
colors.replacers["BACKGROUND-COLOR"]["light"]["a"] = 1
colors.replacers["BACKGROUND-COLOR"]["dark"]["a"] = 1