Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement custom theme support #112

Merged
merged 7 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
$(cat "${version_file}")
EOF

- name: Build Default Theme
run: python theme.py build --default

- name: Build
run: pyinstaller EonTimer.spec

Expand Down
5 changes: 3 additions & 2 deletions EonTimer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
from eon_timer import resources
from eon_timer.app_window import AppWindow
from eon_timer.util.injector.app_context import AppContext
from eon_timer.util.injector.provider import InstanceProvider


def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName('EonTimer')
app.setOrganizationName('DasAmpharos')
app.setOrganizationDomain('io.github.dasampharos')
icon_filepath = resources.get_filepath('eon_timer.resources.images', 'icon-512.png')
icon_filepath = resources.get_filepath('icon-512.png')
app.setWindowIcon(QIcon(icon_filepath))

context = AppContext(['eon_timer'])
context = AppContext(['eon_timer'], provided={QApplication: InstanceProvider(app)})
app_window = context.get_component(AppWindow)
app_window.show()
return app.exec()
Expand Down
10 changes: 5 additions & 5 deletions EonTimer.spec
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ a = Analysis(
pathex=[],
binaries=[],
datas=[
('eon_timer/resources/*.png', 'eon_timer/resources'),
('eon_timer/resources/*.zip', 'eon_timer/resources'),
('eon_timer/resources/fonts/*.ttf', 'eon_timer/resources/fonts'),
('eon_timer/resources/images/*.png', 'eon_timer/resources/images'),
('eon_timer/resources/sounds/*.wav', 'eon_timer/resources/sounds'),
('eon_timer/resources/*.scss', 'eon_timer/resources'),
],
hiddenimports=[
'eon_timer.action',
'eon_timer.theme_manager'
'eon_timer.theme.theme_engine'
],
hookspath=[],
hooksconfig={},
Expand Down Expand Up @@ -43,11 +43,11 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='eon_timer/resources/images/icon-512.png'
icon='eon_timer/resources/icon-512.png'
)
app = BUNDLE(
exe,
name='EonTimer.app',
icon='eon_timer/resources/images/icon-512.png',
icon='eon_timer/resources/icon-512.png',
bundle_identifier=None,
)
2 changes: 1 addition & 1 deletion eon_timer/action/sound_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class SoundManager:
def __init__(self):
def predefined_sound(filename: str) -> pygame.mixer.Sound:
filepath = resources.get_filepath('eon_timer.resources.sounds', filename)
filepath = resources.get_filepath(f'sounds/{filename}')
return pygame.mixer.Sound(filepath)

pygame.mixer.init()
Expand Down
4 changes: 2 additions & 2 deletions eon_timer/app_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class AppState(QObject):
phases_changed: Final[Signal] = Signal(list)
current_phase_changed: Final[Signal] = Signal(float)
current_phase_elapsed_changed: Final[Signal] = Signal(float)
minutes_before_target_changed: Final[Signal] = Signal(float)
minutes_before_target_changed: Final[Signal] = Signal(int)
next_phase_changed: Final[Signal] = Signal(float)

running_changed: Final[Signal] = Signal(bool)
Expand Down Expand Up @@ -87,5 +87,5 @@ def reset(self):
self.current_phase_index = 0
self.current_phase_elapsed = 0.0
total_time = sum(self.__phases)
self.minutes_before_target_changed.emit(total_time // 60_000)
self.minutes_before_target_changed.emit(int(total_time // 60_000))
self.__resetting = False
8 changes: 5 additions & 3 deletions eon_timer/app_widget.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import functools
import importlib.resources
from typing import Final

from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from PySide6.QtWidgets import *

from eon_timer.app_state import AppState
Expand Down Expand Up @@ -74,19 +74,21 @@ def __init_components(self) -> None:
layout.addWidget(self.settings_btn, 2, 0)
self.settings_btn.clicked.connect(self.__on_settings_btn_clicked)
self.settings_btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
font_data = importlib.resources.read_binary('eon_timer.resources.fonts', 'FontAwesome.ttf')
self.settings_btn.setFont(pyside.get_font(font_data))
self.settings_btn.setFont(QFont('Font Awesome 5 Free'))
self.settings_btn.setObjectName('settingsBtn')
# ----- update_btn -----
self.update_btn.setText('Update')
layout.addWidget(self.update_btn, 2, 1)
self.update_btn.clicked.connect(self.__on_update_btn_clicked)
self.update_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.settings_btn.setObjectName('updateBtn')
# ----- timer_btn -----
self.timer_btn.setText('Start')
layout.addWidget(self.timer_btn, 2, 2)
self.timer_btn.clicked.connect(self.__on_timer_btn_clicked)
self.timer_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.timer_btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.timer_btn.setObjectName('timerBtn')
# ----- running_changed -----
disable_on_run = [self.tab_widget, self.settings_btn, self.update_btn]
self.state.running_changed.connect(
Expand Down
17 changes: 13 additions & 4 deletions eon_timer/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,25 @@
import sys


def get_filepath(package: str, resource: str, normalize_path: bool = False) -> str:
with importlib.resources.path(package, resource) as path:
def get_filepath(resource: str, normalize_path: bool = False) -> str:
package, resource_name = __get_resource_name(resource)
with importlib.resources.path(package, resource_name) as path:
filepath = str(path)
if normalize_path:
filepath = normalize_filepath(filepath)
return filepath


def get_bytes(package: str, resource: str) -> bytes:
return importlib.resources.read_binary(package, resource)
def get_bytes(resource: str) -> bytes:
package, resource_name = __get_resource_name(resource)
return importlib.resources.read_binary(package, resource_name)


def __get_resource_name(resource: str) -> tuple[str, str]:
components = resource.split('/')
resource_name = components.pop()
package = '.'.join(['eon_timer.resources', *components])
return package, resource_name


def normalize_filepath(filepath: str) -> str:
Expand Down
Binary file added eon_timer/resources/theme.zip
Binary file not shown.
11 changes: 6 additions & 5 deletions eon_timer/settings/action/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def __init__(self, model: ActionSettingsModel) -> None:
self.mode: Final = Property(model.mode.get())
self.sound: Final = Property(model.sound.get())
self.color: Final = Property(model.color.get())
self.custom_sound: Final = Property(model.custom_sound.get())
self.custom_sound: Final = Property(model.custom_sound.get(), str)
self.interval: Final = Property(model.interval.get())
self.count: Final = Property(model.count.get())
self.model: Final[ActionSettingsModel] = model
Expand All @@ -44,15 +44,16 @@ def __init_components(self) -> None:
self._layout.set_content_margins(10, 10, 10, 10)
# ----- mode -----
field = EnumComboBox(ActionMode)
bindings.bind_combobox(field, self.mode)
bindings.bind_enum_combobox(field, self.mode)
self.add_field(self.Field.MODE, field)
# ----- sound -----
field = EnumComboBox(ActionSound)
bindings.bind_combobox(field, self.sound)
bindings.bind_enum_combobox(field, self.sound)
self.add_field(self.Field.SOUND, field)
# ----- custom_sound -----
field = FileSelectorWidget()
field.file_validator = self.__is_valid_sound
field = FileSelectorWidget(title='Select Sound',
filter='Sound Files (*.wav *.mp3)',
validator=self.__is_valid_sound)
bindings.bind(field.file, self.custom_sound, True)
self.add_field(self.Field.CUSTOM_SOUND, field, visible=self.sound.get() == ActionSound.CUSTOM)
self.sound.on_change(self.__on_sound_changed)
Expand Down
11 changes: 2 additions & 9 deletions eon_timer/settings/theme/model.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
from enum import StrEnum

from eon_timer.util.enum import EnhancedEnum
from eon_timer.theme.theme_manager import ThemeManager
from eon_timer.util.injector import component
from eon_timer.util.properties.property import Property
from eon_timer.util.properties.settings import Settings


class Theme(EnhancedEnum, StrEnum):
DEFAULT = 'Default'
SYSTEM = 'System'


@component()
class ThemeSettingsModel(Settings):
theme = Property(Theme.DEFAULT, value_type=str)
theme = Property(ThemeManager.DEFAULT_THEME)

@property
def group(self) -> str:
Expand Down
85 changes: 70 additions & 15 deletions eon_timer/settings/theme/widget.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,89 @@
import os
import platform
import subprocess
from typing import Final

from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QComboBox, QPushButton, QWidget, QGridLayout, QLabel, QSizePolicy, QMessageBox

from eon_timer.theme.theme_manager import ThemeManager, ThemeError
from eon_timer.util.injector import component
from eon_timer.util.properties import bindings
from eon_timer.util.properties.property import Property
from eon_timer.util.pyside import EnumComboBox
from eon_timer.util.pyside.form import FormWidget
from .model import ThemeSettingsModel, Theme
from eon_timer.util.properties.property_change import PropertyChangeEvent
from eon_timer.util.pyside.file_selector_widget import FileSelectorWidget
from .model import ThemeSettingsModel


@component()
class ThemeSettingsWidget(FormWidget):
class Field(FormWidget.Field):
THEME = 'Theme'

def __init__(self, model: ThemeSettingsModel) -> None:
class ThemeSettingsWidget(QWidget):
def __init__(self,
model: ThemeSettingsModel,
theme_manager: ThemeManager) -> None:
super().__init__()
self.theme: Final = Property(model.theme.get())
self.model: Final[ThemeSettingsModel] = model
self.theme_manager: Final[ThemeManager] = theme_manager
self.theme: Final = Property(model.theme.get())

self.__theme_field: Final[QComboBox] = QComboBox()
self.__import_theme_field: Final[FileSelectorWidget] = FileSelectorWidget()
self.__import_btn: Final[QPushButton] = QPushButton()
self.__init_components()

def __init_components(self):
# ----- layout -----
self._layout.set_alignment(Qt.AlignmentFlag.AlignTop)
self._layout.set_content_margins(10, 10, 10, 10)
# ----- theme -----
field = EnumComboBox(Theme)
bindings.bind_combobox(field, self.theme)
self.add_field(self.Field.THEME, field)
layout = QGridLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
layout.setContentsMargins(10, 10, 10, 10)
# ----- theme_field -----
self.__on_themes_changed()
bindings.bind_str_combobox(self.__theme_field, self.theme)
self.theme_manager.themes_changed.connect(self.__on_themes_changed)
self.__theme_field.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout.addWidget(QLabel('Theme'), 0, 0)
layout.addWidget(self.__theme_field, 0, 1)
# ----- import_theme_field -----
self.__import_theme_field.title = 'Select Theme'
self.__import_theme_field.filter = 'Theme Files (*.zip)'
self.__import_theme_field.file.on_change(self.__on_import_theme_file_changed)
layout.addWidget(self.__import_theme_field, 1, 0, 1, 2)
# ----- import_btn -----
self.__import_btn.setText('Import Theme')
layout.addWidget(self.__import_btn, 2, 1)
self.__import_btn.clicked.connect(self.__on_import)
self.__import_btn.setDisabled(True)
# ----- open theme dir -----
button = QPushButton(chr(0xf07b))
button.setToolTip('Open Theme Directory')
button.setFont(QFont('Font Awesome 5 Free'))
button.clicked.connect(self.__open_theme_dir)
layout.addWidget(button, 2, 0)

def __on_themes_changed(self):
self.__theme_field.clear()
self.__theme_field.addItems(
self.theme_manager.list_theme_names()
)

def __on_import(self):
try:
file = self.__import_theme_field.file.get()
self.theme_manager.install_theme(file)
self.__import_theme_field.file.set('')
except Exception as e:
QMessageBox.critical(self, 'Import Theme', f'Failed to import theme: {e}')

def __on_import_theme_file_changed(self, event: PropertyChangeEvent[str]):
self.__import_btn.setDisabled(not event.new_value)

def __open_theme_dir(self):
if platform.system() == 'Windows':
os.startfile(self.theme_manager.theme_dir)
elif platform.system() == 'Darwin':
subprocess.Popen(['open', self.theme_manager.theme_dir])
else:
subprocess.Popen(['xdg-open', self.theme_manager.theme_dir])

def on_accepted(self):
self.model.theme.update(self.theme)
Expand Down
2 changes: 1 addition & 1 deletion eon_timer/settings/timer/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def __init_components(self) -> None:
self._layout.set_content_margins(10, 10, 10, 10)
# ----- console -----
field = EnumComboBox(Console)
bindings.bind_combobox(field, self.console)
bindings.bind_enum_combobox(field, self.console)
self.add_field(self.Field.CONSOLE, field)
# ----- custom framerate -----
field = QDoubleSpinBox()
Expand Down
File renamed without changes.
32 changes: 32 additions & 0 deletions eon_timer/theme/theme_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from typing import Final

from eon_timer.app_window import AppWindow
from eon_timer.settings.dialog import SettingsDialog
from eon_timer.settings.theme.model import ThemeSettingsModel
from eon_timer.theme.theme_manager import ThemeManager
from eon_timer.util.injector import component
from eon_timer.util.properties.property_change import PropertyChangeEvent


@component()
class ThemeEngine:
def __init__(self,
app_window: AppWindow,
settings_dialog: SettingsDialog,
theme_settings: ThemeSettingsModel,
theme_manager: ThemeManager):
self.app_window: Final[AppWindow] = app_window
self.settings_dialog: Final[SettingsDialog] = settings_dialog
self.theme_settings: Final[ThemeSettingsModel] = theme_settings
self.theme_manager: Final[ThemeManager] = theme_manager
theme_settings.theme.on_change(self.__apply_theme)
self.__apply_theme()

def __apply_theme(self, _: PropertyChangeEvent[str] | None = None):
theme_name = self.theme_settings.theme.get()
theme = self.theme_manager.get_theme(theme_name)
self.__apply_stylesheet(theme.stylesheet)

def __apply_stylesheet(self, stylesheet: str):
self.app_window.setStyleSheet(stylesheet)
self.settings_dialog.setStyleSheet(stylesheet)
Loading