Skip to content

Commit

Permalink
Refactor the theme classes
Browse files Browse the repository at this point in the history
  • Loading branch information
gentlegiantJGC committed May 17, 2024
1 parent f40c6eb commit 64f2997
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 169 deletions.
2 changes: 1 addition & 1 deletion src/amulet_editor/application/appearance/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from amulet_editor.application.appearance._theme import Color, Theme
from amulet_editor.application.appearance._theme import Color
from amulet_editor.application.appearance._theme_manager import (
changed,
list_themes,
Expand Down
261 changes: 106 additions & 155 deletions src/amulet_editor/application/appearance/_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,29 @@
import os
import re
from pathlib import Path
from typing import Any
from typing import Any, TypeVar
from abc import ABC, abstractmethod

from amulet_editor.resources import get_resource
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QApplication

T = TypeVar("T")


def dynamic_cast(obj: Any, cls: type[T]) -> T:
if isinstance(obj, cls):
return obj
raise TypeError(f"{obj} is not an instance of {cls}")


class Color:
def __init__(self, colour_hex: str):
self._qcolor = QColor(colour_hex)

def get_qcolor(self) -> QColor:
return self._qcolor

def get_hex(self) -> str:
return self._qcolor.name()

def get_rgb(self) -> str:
return "rgb({}, {}, {})".format(*self._qcolor.getRgb())

def get_rgba(self) -> str:
return "rgba({}, {}, {}, {})".format(*self._qcolor.getRgb())

def darker(self, percent: int = 100) -> Color:
if percent < 0:
raise ValueError("'percent' must be a positive integer")
Expand All @@ -38,43 +38,60 @@ def lighter(self, percent: int = 50) -> Color:
return Color(self._qcolor.lighter(100 + percent).name())


class Theme:
def __init__(self, theme_dir: str):
with open(os.path.join(theme_dir, "theme.json"), "r") as fh:
self._theme: dict[str, Any] = json.load(fh)
class AbstractBaseTheme(ABC):
@abstractmethod
def apply(self, application: QApplication) -> None:
raise NotImplementedError

self._style_sheets: dict[str, str] = {}
@property
@abstractmethod
def name(self) -> str:
raise NotImplementedError

path_style_sheets = get_resource(
os.path.join("themes", "_default", "style_sheets")
)
for style_name in os.listdir(path_style_sheets):
self._style_sheets[style_name] = self.load_style_sheet(
os.path.join(path_style_sheets, style_name)
)

path_style_sheets = os.path.join(theme_dir, "style_sheets")
if os.path.isdir(path_style_sheets):
for style_name in os.listdir(path_style_sheets):
self._style_sheets[style_name] = self.load_style_sheet(
os.path.join(path_style_sheets, style_name)
)
class LegacyTheme(AbstractBaseTheme):
def __init__(self, theme_dir: str):
with open(os.path.join(theme_dir, "theme.json"), "r") as fh:
theme_json: dict = dynamic_cast(json.load(fh), dict)

def apply(self, application: QApplication):
"""Apply theme to a `QtWidgets.QApplication`."""
application.setStyle(self._theme["style"])
application.setStyleSheet(self._style_sheets["application.qss"])
self._name: str = dynamic_cast(theme_json["theme_name"], str)

def get_style_sheet(self, file_name: str) -> str:
"""Returns Qt style sheet from current theme with matching file name."""
return self._style_sheets.get(file_name, "")
# Get the style theme
self._style: str = dynamic_cast(theme_json["style"], str)

def load_style_sheet(self, file_path: str) -> str:
"""Returns Qt style sheet at the provided file path. Substitutes placeholder colors with theme colors.
\nGenerally `get_style_sheet()` should be used instead of this method.
"""
with open(file_path, "r") as fh:
style_sheet = fh.read().replace('"', "")
style_sheet_path = os.path.join(theme_dir, "style_sheets", "application.qss")
if not os.path.isfile(style_sheet_path):
# If no override is defined fall back to the default.
style_sheet_path = get_resource(
os.path.join("themes", "_default", "style_sheets", "application.qss")
)

# Load the style sheet
with open(style_sheet_path) as f:
raw_style_sheet = f.read()

# Format the style sheet.
self._style_sheet = self._format_style_sheet(raw_style_sheet, theme_json)

@staticmethod
def _format_style_sheet(raw_style_sheet: str, theme_json: dict[str, Any]) -> str:
"""Returns Qt style sheet. Substitutes placeholder colors with theme colors."""
style_sheet = raw_style_sheet.replace('"', "")

font_family = theme_json["font"]["family"]
font_subfamilies = theme_json["font"].get("subfamilies", {})
primary = Color(theme_json["material_colors"]["primary"])
primary_variant = Color(theme_json["material_colors"]["primary_variant"])
on_primary = Color(theme_json["material_colors"]["on_primary"])
secondary = Color(theme_json["material_colors"]["secondary"])
secondary_variant = Color(theme_json["material_colors"]["secondary_variant"])
on_secondary = Color(theme_json["material_colors"]["on_secondary"])
background = Color(theme_json["material_colors"]["background"])
on_background = Color(theme_json["material_colors"]["on_background"])
surface = Color(theme_json["material_colors"]["surface"])
on_surface = Color(theme_json["material_colors"]["on_surface"])
error = Color(theme_json["material_colors"]["error"])
on_error = Color(theme_json["material_colors"]["on_error"])

icons: set[str] = set(
[icon for icon in re.findall(r"url\((.*?)\)", style_sheet)]
Expand All @@ -87,138 +104,72 @@ def load_style_sheet(self, file_path: str) -> str:
),
)

font_subfamilies: set[str] = set(
font_subfamilies_: set[str] = set(
[
subfamily
for subfamily in re.findall("{(.+?)}", style_sheet)
if "font_family." in subfamily
]
)
for subfamily in font_subfamilies:
try:
font_data = subfamily.split(".")
style_sheet = style_sheet.replace(
f"{{{subfamily}}}",
'"{}"'.format(
" ".join(
[
self.font_family,
self.font_subfamilies.get(font_data[1], ""),
]
).strip()
),
)
except:
raise ValueError(
f'unparsable font data: "{font_data}" in stylesheet "{file_path}"'
)
for subfamily in font_subfamilies_:
font_data = subfamily.split(".")
style_sheet = style_sheet.replace(
f"{{{subfamily}}}",
'"{}"'.format(
" ".join(
[
font_family,
font_subfamilies.get(font_data[1], ""),
]
).strip()
),
)

modified_colors: set[str] = set(
[
color
for color in re.findall("{(.+?)}", style_sheet)
if color not in self._theme["material_colors"]
and color.rsplit(".", 2)[0] in self._theme["material_colors"]
if color not in theme_json["material_colors"]
and color.rsplit(".", 2)[0] in theme_json["material_colors"]
]
)
for modified_color in modified_colors:
try:
color_data = modified_color.split(".")
if color_data[1] == "darker":
style_sheet = style_sheet.replace(
f"{{{modified_color}}}",
Color(self._theme["material_colors"][color_data[0]])
.darker(int(color_data[2]))
.get_hex(),
)
elif color_data[1] == "lighter":
style_sheet = style_sheet.replace(
f"{{{modified_color}}}",
Color(self._theme["material_colors"][color_data[0]])
.lighter(int(color_data[2]))
.get_hex(),
)
except:
raise ValueError(
f'unparsable color data: "{color_data}" in stylesheet "{file_path}"'
color_data = modified_color.split(".")
if color_data[1] == "darker":
style_sheet = style_sheet.replace(
f"{{{modified_color}}}",
Color(theme_json["material_colors"][color_data[0]])
.darker(int(color_data[2]))
.get_hex(),
)
elif color_data[1] == "lighter":
style_sheet = style_sheet.replace(
f"{{{modified_color}}}",
Color(theme_json["material_colors"][color_data[0]])
.lighter(int(color_data[2]))
.get_hex(),
)

return style_sheet.format(
font_family=self.font_family,
background=self.background.get_hex(),
error=self.error.get_hex(),
primary=self.primary.get_hex(),
primary_variant=self.primary_variant.get_hex(),
secondary=self.secondary.get_hex(),
secondary_variant=self.secondary_variant.get_hex(),
surface=self.surface.get_hex(),
on_background=self.on_background.get_hex(),
on_error=self.on_error.get_hex(),
on_primary=self.on_primary.get_hex(),
on_secondary=self.on_secondary.get_hex(),
on_surface=self.on_surface.get_hex(),
font_family=font_family,
background=background.get_hex(),
error=error.get_hex(),
primary=primary.get_hex(),
primary_variant=primary_variant.get_hex(),
secondary=secondary.get_hex(),
secondary_variant=secondary_variant.get_hex(),
surface=surface.get_hex(),
on_background=on_background.get_hex(),
on_error=on_error.get_hex(),
on_primary=on_primary.get_hex(),
on_secondary=on_secondary.get_hex(),
on_surface=on_surface.get_hex(),
)

@property
def name(self) -> str:
return self._theme["theme_name"]
def apply(self, application: QApplication) -> None:
application.setStyle(self._style)
application.setStyleSheet(self._style_sheet)

@property
def font_family(self) -> str:
return self._theme["font"]["family"]

@property
def font_subfamilies(self) -> dict[str, str]:
return self._theme["font"].get("subfamilies", {})

@property
def font_size(self) -> str:
return self._theme["font"]["size"]

@property
def primary(self) -> Color:
return Color(self._theme["material_colors"]["primary"])

@property
def primary_variant(self) -> Color:
return Color(self._theme["material_colors"]["primary_variant"])

@property
def on_primary(self) -> Color:
return Color(self._theme["material_colors"]["on_primary"])

@property
def secondary(self) -> Color:
return Color(self._theme["material_colors"]["secondary"])

@property
def secondary_variant(self) -> Color:
return Color(self._theme["material_colors"]["secondary_variant"])

@property
def on_secondary(self) -> Color:
return Color(self._theme["material_colors"]["on_secondary"])

@property
def background(self) -> Color:
return Color(self._theme["material_colors"]["background"])

@property
def on_background(self) -> Color:
return Color(self._theme["material_colors"]["on_background"])

@property
def surface(self) -> Color:
return Color(self._theme["material_colors"]["surface"])

@property
def on_surface(self) -> Color:
return Color(self._theme["material_colors"]["on_surface"])

@property
def error(self) -> Color:
return Color(self._theme["material_colors"]["error"])

@property
def on_error(self) -> Color:
return Color(self._theme["material_colors"]["on_error"])
def name(self) -> str:
return self._name
24 changes: 11 additions & 13 deletions src/amulet_editor/application/appearance/_theme_manager.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import os
from typing import Optional

from amulet_editor.application.appearance._theme import Theme
from amulet_editor.application.appearance._theme import AbstractBaseTheme, LegacyTheme
from amulet_editor.resources import get_resource
# from amulet_editor.data import project
from PySide6.QtCore import QObject, Signal


Expand All @@ -12,30 +11,29 @@ class ThemeManager(QObject):

def __init__(self) -> None:
super().__init__(parent=None)
self._theme: Optional[Theme] = None
self._theme: Optional[AbstractBaseTheme] = None

self._themes: list[Theme] = []
self._themes: list[AbstractBaseTheme] = []
theme_dir = get_resource("themes")
for theme_ in os.listdir(theme_dir):
if theme_ != "_default":
self._themes.append(Theme(os.path.join(theme_dir, theme_)))

# self.set_theme(project.settings()["theme"])
self._themes.append(LegacyTheme(os.path.join(theme_dir, theme_)))
self.set_theme("Amulet Dark")

@property
def theme(self) -> Theme:
def theme(self) -> AbstractBaseTheme:
assert self._theme is not None
return self._theme

def list_themes(self) -> list[str]:
return [theme_.name for theme_ in self._themes]

def set_theme(self, theme_name: str):
def set_theme(self, theme_name: str) -> None:
for theme_ in self._themes:
if theme_.name == theme_name:
self._theme = theme_
self.changed.emit(self.theme)
return
break


theme_manager = ThemeManager()
Expand All @@ -47,9 +45,9 @@ def list_themes() -> list[str]:
return theme_manager.list_themes()


def set_theme(theme_name: str):
return theme_manager.set_theme(theme_name)
def set_theme(theme_name: str) -> None:
theme_manager.set_theme(theme_name)


def theme() -> Theme:
def theme() -> AbstractBaseTheme:
return theme_manager.theme

0 comments on commit 64f2997

Please sign in to comment.