diff --git a/README.md b/README.md index 9f1200a..2e5a65e 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,21 @@ unidesk ``` src/unidesk/ main.py entry point - home.py main window and all navigation logic - text_data.py loads the text below from the bundled JSON - academic_config.py reads/writes the shared academic profile + helpers/ + text_data.py loads the text below from the bundled JSON + academic_config.py reads/writes the shared academic profile + autostart.py manages launching UniDesk at login + ui/ + main_window.py main window and all navigation logic + widgets.py shared widget helpers + pages/ builders for each page (text, links, credits, academic config) + styles/ + loader.py loads the bundled .qss stylesheets and assets + check.svg checkmark icon for checkboxes + widgets/ + controls.qss buttons, checkbox and combo box roles + labels.qss label text roles + layout.qss backgrounds for windows, bars, cards and dividers assets/text_data/ pages.json body text for each informational page faq.json FAQ entries as question/answer pairs diff --git a/setup.py b/setup.py index def382b..92c1e07 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ packages=find_packages(where="src"), package_data={ "unidesk.assets.text_data": ["*.json"], + "unidesk.styles": ["*/*.qss", "*.svg"], }, install_requires=["PyQt6"], entry_points={ diff --git a/src/unidesk/helpers/__init__.py b/src/unidesk/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unidesk/academic_config.py b/src/unidesk/helpers/academic_config.py similarity index 100% rename from src/unidesk/academic_config.py rename to src/unidesk/helpers/academic_config.py diff --git a/src/unidesk/helpers/autostart.py b/src/unidesk/helpers/autostart.py new file mode 100644 index 0000000..02ad0c3 --- /dev/null +++ b/src/unidesk/helpers/autostart.py @@ -0,0 +1,29 @@ +import os + +# Disabling autostart drops a hidden override of the system-wide entry that +# the package installs in /etc/xdg/autostart. +AUTOSTART_PATH = os.path.expanduser("~/.config/autostart/unidesk.desktop") + +_DISABLED_ENTRY = ( + "[Desktop Entry]\nType=Application\nName=UniDesk\n" + "Exec=unidesk\nIcon=unios\nTerminal=false\n" + "X-KDE-autostart-condition=false\nHidden=true\n" +) + + +def is_autostart_disabled(): + if not os.path.exists(AUTOSTART_PATH): + return False + with open(AUTOSTART_PATH) as f: + return "Hidden=true" in f.read() + + +def set_autostart(enabled): + """Enable autostart by removing the user override, or disable it by writing one.""" + if enabled: + if os.path.exists(AUTOSTART_PATH): + os.remove(AUTOSTART_PATH) + else: + os.makedirs(os.path.dirname(AUTOSTART_PATH), exist_ok=True) + with open(AUTOSTART_PATH, "w") as f: + f.write(_DISABLED_ENTRY) diff --git a/src/unidesk/text_data.py b/src/unidesk/helpers/text_data.py similarity index 100% rename from src/unidesk/text_data.py rename to src/unidesk/helpers/text_data.py diff --git a/src/unidesk/home.py b/src/unidesk/home.py deleted file mode 100644 index f63d155..0000000 --- a/src/unidesk/home.py +++ /dev/null @@ -1,555 +0,0 @@ -import os -import sys -import tempfile - -sys.path.insert(0, os.path.dirname(__file__)) - -from academic_config import load_academic_config, save_academic_config -from PyQt6.QtCore import Qt, QUrl -from PyQt6.QtGui import QDesktopServices -from PyQt6.QtWidgets import ( - QApplication, - QCheckBox, - QComboBox, - QFrame, - QHBoxLayout, - QLabel, - QMainWindow, - QPushButton, - QScrollArea, - QStackedWidget, - QVBoxLayout, - QWidget, -) -from text_data import load, pages - -PAGES = pages() -CREDITS = load("credits")["people"] -LINKS = load("links")["links"] -UI = load("ui_strings") -UNIVERSITIES = load("academic_institutions")["universities"] -_NAV = load("navigation") - -AUTOSTART_PATH = os.path.expanduser("~/.config/autostart/unidesk.desktop") - - -def _is_autostart_disabled(): - if not os.path.exists(AUTOSTART_PATH): - return False - with open(AUTOSTART_PATH) as f: - return "Hidden=true" in f.read() - - -FOOTER_LINKS = _NAV["footer_links"] - -NAV_LEFT = _NAV["nav_left"] -NAV_RIGHT = _NAV["nav_right"] - - -# Helpers - - -def _qlabel(text, size=12, color="#cdd6f4", bold=False, wrap=False): - lbl = QLabel(text) - lbl.setStyleSheet( - f"background: transparent; border: none; color: {color}; " - f"font-size: {size}px; font-weight: {'bold' if bold else 'normal'};" - ) - if wrap: - lbl.setWordWrap(True) - return lbl - - -def _divider(): - line = QFrame() - line.setFrameShape(QFrame.Shape.HLine) - line.setFixedHeight(1) - line.setStyleSheet("background-color: #2d1f3d; border: none;") - return line - - -def _back_bar(title, on_back): - bar = QWidget() - bar.setFixedHeight(40) - bar.setStyleSheet("background: #110d1a;") - layout = QHBoxLayout(bar) - layout.setContentsMargins(12, 0, 12, 0) - - btn = QPushButton(UI["back_button"]) - btn.setFixedWidth(70) - btn.setStyleSheet( - "background: transparent; border: none; color: #a6adc8; " - "font-size: 12px; text-align: left;" - ) - btn.setCursor(Qt.CursorShape.PointingHandCursor) - btn.clicked.connect(on_back) - layout.addWidget(btn) - - lbl = QLabel(title) - lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - lbl.setStyleSheet( - "background: transparent; border: none; color: #cdd6f4; " - "font-size: 13px; font-weight: bold;" - ) - layout.addWidget(lbl, stretch=1) - layout.addSpacing(70) - return bar - - -def _scroll_page(on_back, title): - """Returns (outer_widget, content_layout) with back bar already added.""" - widget = QWidget() - outer = QVBoxLayout(widget) - outer.setContentsMargins(0, 0, 0, 0) - outer.setSpacing(0) - - outer.addWidget(_back_bar(title, on_back)) - outer.addWidget(_divider()) - - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.NoFrame) - scroll.setStyleSheet("background: #1a1226;") - outer.addWidget(scroll) - - content = QWidget() - content.setStyleSheet("background: transparent;") - scroll.setWidget(content) - - cl = QVBoxLayout(content) - cl.setContentsMargins(24, 20, 24, 20) - cl.setSpacing(10) - - outer.addWidget(_divider()) - outer.addWidget(_footer()) - - return widget, cl - - -def _footer(on_configure=None): - footer = QWidget() - footer.setFixedHeight(40) - footer.setStyleSheet("background: #110d1a;") - ft = QHBoxLayout(footer) - ft.setContentsMargins(14, 0, 14, 0) - ft.addWidget(_qlabel(UI["footer_copyright"], size=11, color="#585b70")) - ft.addStretch() - - if on_configure is not None: - cfg = QPushButton(UI["configure_button"]) - cfg.setStyleSheet( - "background: transparent; border: none; color: #8b5897; font-size: 11px;" - ) - cfg.setCursor(Qt.CursorShape.PointingHandCursor) - cfg.clicked.connect(lambda _: on_configure()) - ft.addWidget(cfg) - - for link in FOOTER_LINKS: - b = QPushButton(link["label"]) - b.setStyleSheet( - "background: transparent; border: none; color: #8b5897; font-size: 11px;" - ) - b.setCursor(Qt.CursorShape.PointingHandCursor) - b.clicked.connect(lambda _, u=link["url"]: QDesktopServices.openUrl(QUrl(u))) - ft.addWidget(b) - return footer - - -# Page Builders - - -def build_text_page(key, on_back): - data = PAGES[key] - widget, cl = _scroll_page(on_back, key) - - body = _qlabel(data["body"], size=12, color="#a6adc8", wrap=True) - body.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) - cl.addWidget(body) - cl.addStretch() - - return widget - - -def build_credits_page(on_back): - widget, cl = _scroll_page(on_back, UI["credits_page_title"]) - - for person in CREDITS: - frame = QFrame() - frame.setFrameShape(QFrame.Shape.StyledPanel) - frame.setStyleSheet( - "QFrame { background-color: #2d1f3d; border: 1px solid #8b5897; border-radius: 5px; }" - ) - fl = QVBoxLayout(frame) - fl.setContentsMargins(14, 10, 14, 10) - fl.setSpacing(2) - - name = _qlabel(person["name"], size=13, color="#cdd6f4", bold=True) - name.setStyleSheet( - name.styleSheet() + " background: transparent; border: none;" - ) - fl.addWidget(name) - - role = _qlabel(person["role"], size=11, color="#a6adc8") - role.setStyleSheet( - role.styleSheet() + " background: transparent; border: none;" - ) - fl.addWidget(role) - - if person.get("projects"): - proj = _qlabel( - UI["projects_prefix"] + ", ".join(person["projects"]), - size=11, - color="#8b5897", - ) - proj.setStyleSheet( - proj.styleSheet() + " background: transparent; border: none;" - ) - fl.addWidget(proj) - - cl.addWidget(frame) - - cl.addStretch() - return widget - - -def build_links_page(on_back): - widget, cl = _scroll_page(on_back, UI["links_page_title"]) - - for link in LINKS: - frame = QFrame() - frame.setFrameShape(QFrame.Shape.StyledPanel) - frame.setStyleSheet( - "QFrame { background-color: #2d1f3d; border: 1px solid #8b5897; border-radius: 5px; }" - ) - fl = QVBoxLayout(frame) - fl.setContentsMargins(14, 10, 14, 10) - fl.setSpacing(6) - - name = _qlabel(link["label"], size=13, color="#cdd6f4", bold=True) - name.setStyleSheet( - name.styleSheet() + " background: transparent; border: none;" - ) - fl.addWidget(name) - - btn = QPushButton(UI["open_link_button"]) - btn.setCursor(Qt.CursorShape.PointingHandCursor) - btn.setStyleSheet( - "QPushButton { background-color: #89b4fa; color: #1e1e2e; font-weight: bold; " - "border: none; border-radius: 4px; padding: 5px 10px; font-size: 11px; }" - "QPushButton:hover { background-color: #b4befe; }" - ) - btn.clicked.connect(lambda _, u=link["url"]: QDesktopServices.openUrl(QUrl(u))) - fl.addWidget(btn) - - cl.addWidget(frame) - - cl.addStretch() - return widget - - -def build_academic_config_page(on_back): - widget, cl = _scroll_page(on_back, UI["academic_config_page_title"]) - - intro = _qlabel( - UI["academic_config_intro"], - size=12, - color="#a6adc8", - wrap=True, - ) - cl.addWidget(intro) - - combo_style = ( - "QComboBox { background-color: #2d1f3d; color: #cdd6f4; border: 1px solid #8b5897; " - "border-radius: 4px; padding: 6px 8px; font-size: 12px; }" - "QComboBox QAbstractItemView { background-color: #2d1f3d; color: #cdd6f4; " - "selection-background-color: #3d2a52; }" - ) - - cl.addWidget( - _qlabel(UI["academic_university_label"], size=12, color="#cdd6f4", bold=True) - ) - university_combo = QComboBox() - university_combo.setStyleSheet(combo_style) - university_combo.setPlaceholderText(UI["academic_university_placeholder"]) - university_combo.addItems(list(UNIVERSITIES.keys())) - university_combo.setCurrentIndex(-1) - cl.addWidget(university_combo) - - cl.addWidget( - _qlabel(UI["academic_department_label"], size=12, color="#cdd6f4", bold=True) - ) - department_combo = QComboBox() - department_combo.setStyleSheet(combo_style) - department_combo.setPlaceholderText(UI["academic_department_placeholder"]) - department_combo.setCurrentIndex(-1) - cl.addWidget(department_combo) - - status = _qlabel("", size=11, color="#a6adc8", wrap=True) - - def refresh_departments(): - university = university_combo.currentText() - department_combo.clear() - if university in UNIVERSITIES: - department_combo.addItems(UNIVERSITIES[university]) - department_combo.setCurrentIndex(-1) - - university_combo.currentIndexChanged.connect(lambda _: refresh_departments()) - - # Pre-fill from any existing config, but only if the saved values are still - # known to us (UniBackpack may have entries we haven't mirrored yet). - saved = load_academic_config() - saved_university = saved["universityName"] - saved_department = saved["departmentName"] - if saved_university in UNIVERSITIES: - university_combo.setCurrentText(saved_university) - if saved_department in UNIVERSITIES[saved_university]: - department_combo.setCurrentText(saved_department) - - save_btn = QPushButton(UI["academic_save_button"]) - save_btn.setCursor(Qt.CursorShape.PointingHandCursor) - save_btn.setStyleSheet( - "QPushButton { background-color: #89b4fa; color: #1e1e2e; font-weight: bold; " - "border: none; border-radius: 4px; padding: 6px 12px; font-size: 12px; }" - "QPushButton:hover { background-color: #b4befe; }" - ) - - def on_save(): - university = university_combo.currentText() - department = department_combo.currentText() - if not university or not department: - status.setText(UI["academic_error_incomplete"]) - status.setStyleSheet(status.styleSheet().replace("#a6adc8", "#f38ba8")) - return - save_academic_config(university, department) - status.setText( - UI["academic_saved_template"].format( - university=university, department=department - ) - ) - status.setStyleSheet(status.styleSheet().replace("#f38ba8", "#a6adc8")) - - save_btn.clicked.connect(lambda _: on_save()) - - cl.addSpacing(6) - cl.addWidget(save_btn) - cl.addWidget(status) - cl.addStretch() - return widget - - -# Nav button - - -class NavButton(QPushButton): - def __init__(self, label, align_right=False): - super().__init__(label) - self.setFixedHeight(38) - self.setCursor(Qt.CursorShape.PointingHandCursor) - - self.setStyleSheet(""" - QPushButton {{ - background-color: #2d1f3d; - border: 1px solid #8b5897; - border-radius: 5px; - color: #cdd6f4; - font-size: 12px; - font-weight: bold; - padding: 0 14px; - text-align: center; - }} - QPushButton:hover {{ - background-color: #3d2a52; - border-color: #cba6f7; - color: #cba6f7; - }} - QPushButton:pressed {{ - background-color: #211a2c; - }} - """) - - -# Main Window - - -class UniOSWelcome(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle(UI["window_title"]) - self.setMinimumSize(580, 500) - self.setStyleSheet("background-color: #1a1226;") - - self._stack = QStackedWidget() - self.setCentralWidget(self._stack) - self._page_indices = {} - - self._build_main() - self._build_subpages() - - def _build_main(self): - main_page = QWidget() - main_page.setStyleSheet("background: transparent;") - mp = QVBoxLayout(main_page) - mp.setContentsMargins(0, 0, 0, 0) - mp.setSpacing(0) - - # Hero - hero = QWidget() - hero.setStyleSheet("background: #211a2c;") - hl = QVBoxLayout(hero) - hl.setContentsMargins(20, 20, 20, 16) - hl.setSpacing(4) - - hero_title = _qlabel(UI["hero_title"], size=26, color="#cba6f7", bold=True) - hero_title.setAlignment(Qt.AlignmentFlag.AlignCenter) - hl.addWidget(hero_title) - - hero_sub = _qlabel( - UI["hero_subtitle"], - size=12, - color="#a6adc8", - ) - hero_sub.setAlignment(Qt.AlignmentFlag.AlignCenter) - hl.addWidget(hero_sub) - - mp.addWidget(hero) - mp.addWidget(_divider()) - - # Nav columns - nav_widget = QWidget() - nav_widget.setStyleSheet("background: transparent;") - nav_layout = QHBoxLayout(nav_widget) - nav_layout.setContentsMargins(28, 24, 28, 24) - nav_layout.setSpacing(20) - - left_col = QVBoxLayout() - left_col.setSpacing(10) - for key in NAV_LEFT: - btn = NavButton(key, align_right=False) - btn.clicked.connect(lambda _, k=key: self._show_page(k)) - left_col.addWidget(btn) - left_col.addStretch() - - bottom_row = QHBoxLayout() - bottom_row.setContentsMargins(28, 0, 28, 14) - self._autostart_cb = QCheckBox(UI["autostart_checkbox"]) - self._autostart_cb.setChecked(True) - _svg = b"" - _f = tempfile.NamedTemporaryFile(suffix=".svg", delete=False) - _f.write(_svg) - _f.flush() - _check_path = _f.name - - self._autostart_cb.setStyleSheet( - """ - QCheckBox { - color: #a6adc8; - font-size: 12px; - background: transparent; - spacing: 8px; - } - QCheckBox::indicator { - width: 16px; - height: 16px; - border: 1px solid #585b70; - border-radius: 4px; - background: transparent; - } - QCheckBox::indicator:hover { - border-color: #a6adc8; - } - QCheckBox::indicator:checked { - background-color: #8b5897; - border-color: #cba6f7; - image: url(%s); - } - QCheckBox::indicator:checked:hover { - background-color: #9b68a7; - } - """ - % _check_path - ) - - self._autostart_cb.stateChanged.connect(self._toggle_autostart) - cfg_btn = QPushButton(UI["configure_button"]) - cfg_btn = QPushButton(UI["configure_button"]) - cfg_btn.setFixedHeight(28) - cfg_btn.setStyleSheet(""" - QPushButton { - background-color: #2d1f3d; - border: 1px solid #8b5897; - border-radius: 5px; - color: #cdd6f4; - font-size: 11px; - font-weight: bold; - padding: 0 12px; - } - """) - cfg_btn.setCursor(Qt.CursorShape.PointingHandCursor) - cfg_btn.clicked.connect(self._show_academic_config) - - bottom_row.addWidget(self._autostart_cb) - bottom_row.addStretch() - bottom_row.addWidget(cfg_btn) - - right_col = QVBoxLayout() - right_col.setSpacing(10) - for key in NAV_RIGHT: - btn = NavButton(key, align_right=True) - btn.clicked.connect(lambda _, k=key: self._show_page(k)) - right_col.addWidget(btn) - right_col.addStretch() - - nav_layout.addLayout(left_col) - nav_layout.addLayout(right_col) - mp.addWidget(nav_widget, stretch=1) - mp.addLayout(bottom_row) - - mp.addWidget(_divider()) - mp.addWidget(_footer()) - self._stack.addWidget(main_page) # index 0 - - def _build_subpages(self): - subpages = { - **{key: build_text_page(key, self._show_main) for key in PAGES}, - UI["credits_page_title"]: build_credits_page(self._show_main), - UI["links_page_title"]: build_links_page(self._show_main), - UI["academic_config_page_title"]: build_academic_config_page( - self._show_main - ), - } - for key, widget in subpages.items(): - self._page_indices[key] = self._stack.addWidget(widget) - - def _toggle_autostart(self, state): - if self._autostart_cb.isChecked(): - if os.path.exists(AUTOSTART_PATH): - os.remove(AUTOSTART_PATH) - else: - os.makedirs(os.path.dirname(AUTOSTART_PATH), exist_ok=True) - with open(AUTOSTART_PATH, "w") as f: - f.write( - "[Desktop Entry]\nType=Application\nName=UniDesk\n" - "Exec=unidesk\nIcon=unios\nTerminal=false\n" - "X-KDE-autostart-condition=false\nHidden=true\n" - ) - - def _show_page(self, key): - self._stack.setCurrentIndex(self._page_indices[key]) - - def _show_main(self): - self._stack.setCurrentIndex(0) - - def _show_academic_config(self): - self._stack.setCurrentIndex(self._page_indices[UI["academic_config_page_title"]]) - - -def main(): - app = QApplication(sys.argv) - app.setApplicationName("UniDesk") - window = UniOSWelcome() - window.show() - sys.exit(app.exec()) - - -if __name__ == "__main__": - main() diff --git a/src/unidesk/main.py b/src/unidesk/main.py index 001bd9f..243b4c5 100644 --- a/src/unidesk/main.py +++ b/src/unidesk/main.py @@ -1,8 +1,10 @@ import os import sys + from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QApplication -from .home import UniOSWelcome + +from .ui.main_window import UniOSWelcome def main(): diff --git a/src/unidesk/styles/__init__.py b/src/unidesk/styles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unidesk/styles/check.svg b/src/unidesk/styles/check.svg new file mode 100644 index 0000000..e7af846 --- /dev/null +++ b/src/unidesk/styles/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/unidesk/styles/loader.py b/src/unidesk/styles/loader.py new file mode 100644 index 0000000..4b6b287 --- /dev/null +++ b/src/unidesk/styles/loader.py @@ -0,0 +1,28 @@ +"""Loads UniDesk's Qt stylesheets and assets from the styles package.""" + +import atexit +from contextlib import ExitStack +from functools import cache +from importlib.resources import as_file, files + +# Keeps extracted asset files alive for the whole process (needed when the +# package is imported from a zipped wheel); cleaned up on interpreter exit. +_asset_stack = ExitStack() +atexit.register(_asset_stack.close) + + +@cache +def load_qss(filename): + """Return from the styles package, with asset tokens resolved.""" + qss = files("unidesk.styles").joinpath(filename).read_text(encoding="utf-8") + if "__CHECK_SVG__" in qss: + qss = qss.replace("__CHECK_SVG__", str(resolve_asset_path("check.svg"))) + return qss + + +@cache +def resolve_asset_path(filename): + """Return a real filesystem path for from the styles package.""" + return _asset_stack.enter_context( + as_file(files("unidesk.styles").joinpath(filename)) + ) diff --git a/src/unidesk/styles/widgets/controls.qss b/src/unidesk/styles/widgets/controls.qss new file mode 100644 index 0000000..76fa4e9 --- /dev/null +++ b/src/unidesk/styles/widgets/controls.qss @@ -0,0 +1,17 @@ +QPushButton[button="back"] { background: transparent; border: none; color: #a6adc8; font-size: 12px; text-align: left; } +QPushButton[button="footer-link"] { background: transparent; border: none; color: #8b5897; font-size: 11px; } +QPushButton[button="link"] { background-color: #89b4fa; color: #1e1e2e; font-weight: bold; border: none; border-radius: 4px; padding: 5px 10px; font-size: 11px; } +QPushButton[button="link"]:hover { background-color: #b4befe; } +QPushButton[button="save"] { background-color: #89b4fa; color: #1e1e2e; font-weight: bold; border: none; border-radius: 4px; padding: 6px 12px; font-size: 12px; } +QPushButton[button="save"]:hover { background-color: #b4befe; } +QPushButton[button="configure"] { background-color: #2d1f3d; border: 1px solid #8b5897; border-radius: 5px; color: #cdd6f4; font-size: 11px; font-weight: bold; padding: 0 12px; } +QPushButton[button="nav"] { background-color: #2d1f3d; border: 1px solid #8b5897; border-radius: 5px; color: #cdd6f4; font-size: 12px; font-weight: bold; padding: 0 14px; text-align: center; } +QPushButton[button="nav"]:hover { background-color: #3d2a52; border-color: #cba6f7; color: #cba6f7; } +QPushButton[button="nav"]:pressed { background-color: #211a2c; } +QCheckBox[control="autostart"] { color: #a6adc8; font-size: 12px; background: transparent; spacing: 8px; } +QCheckBox[control="autostart"]::indicator { width: 16px; height: 16px; border: 1px solid #585b70; border-radius: 4px; background: transparent; } +QCheckBox[control="autostart"]::indicator:hover { border-color: #a6adc8; } +QCheckBox[control="autostart"]::indicator:checked { background-color: #8b5897; border-color: #cba6f7; image: url("__CHECK_SVG__"); } +QCheckBox[control="autostart"]::indicator:checked:hover { background-color: #9b68a7; } +QComboBox[control="academic"] { background-color: #2d1f3d; color: #cdd6f4; border: 1px solid #8b5897; border-radius: 4px; padding: 6px 8px; font-size: 12px; } +QComboBox[control="academic"] QAbstractItemView { background-color: #2d1f3d; color: #cdd6f4; selection-background-color: #3d2a52; } diff --git a/src/unidesk/styles/widgets/labels.qss b/src/unidesk/styles/widgets/labels.qss new file mode 100644 index 0000000..8c31d61 --- /dev/null +++ b/src/unidesk/styles/widgets/labels.qss @@ -0,0 +1,9 @@ +QLabel { background: transparent; border: none; } +QLabel[role="title"] { color: #cba6f7; font-size: 26px; font-weight: bold; } +QLabel[role="body"] { color: #a6adc8; font-size: 12px; font-weight: normal; } +QLabel[role="field-label"] { color: #cdd6f4; font-size: 12px; font-weight: bold; } +QLabel[role="card-title"] { color: #cdd6f4; font-size: 13px; font-weight: bold; } +QLabel[role="muted"] { color: #a6adc8; font-size: 11px; font-weight: normal; } +QLabel[state="error"] { color: #f38ba8; } +QLabel[role="accent"] { color: #8b5897; font-size: 11px; font-weight: normal; } +QLabel[role="footer"] { color: #585b70; font-size: 11px; font-weight: normal; } diff --git a/src/unidesk/styles/widgets/layout.qss b/src/unidesk/styles/widgets/layout.qss new file mode 100644 index 0000000..d634b58 --- /dev/null +++ b/src/unidesk/styles/widgets/layout.qss @@ -0,0 +1,14 @@ +/* Η σειρά έχει σημασία: ο κανόνας "descendant" του scroll (με *) πρέπει + να μένει πρώτος. Έχει ίδια ειδικότητα με τους κανόνες ρόλων, και + αν μπει μετά, κερδίζει αυτός και βάφει λάθος παιδιά (hero, + divider, footer, scroll content). Ο κανόνας "card" πρέπει να μένει + τελευταίος, μετά τον descendant κανόνα: το card frame είναι μέσα σε + scroll γονέα, οπότε το * το πιάνει με ίδια ειδικότητα. */ +QScrollArea[layout="scroll"] * { background: #1a1226; } +QMainWindow[layout="window"] { background-color: #1a1226; } +QWidget[layout="hero"] { background: #211a2c; } +QWidget[layout="bar"] { background: #110d1a; } +QWidget[layout="transparent"] { background: transparent; } +QScrollArea[layout="scroll"] { background: #1a1226; } +QFrame[layout="divider"] { background-color: #2d1f3d; border: none; } +QFrame[layout="card"] { background-color: #2d1f3d; border: 1px solid #8b5897; border-radius: 5px; } diff --git a/src/unidesk/ui/__init__.py b/src/unidesk/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unidesk/ui/main_window.py b/src/unidesk/ui/main_window.py new file mode 100644 index 0000000..34254cb --- /dev/null +++ b/src/unidesk/ui/main_window.py @@ -0,0 +1,149 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QCheckBox, + QHBoxLayout, + QMainWindow, + QPushButton, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from ..helpers.autostart import set_autostart +from ..helpers.text_data import load, pages +from ..styles.loader import load_qss +from .pages.academic_config_page import build_academic_config_page +from .pages.credits_page import build_credits_page +from .pages.links_page import build_links_page +from .pages.text_page import build_text_page +from .widgets import NavButton, divider, footer, qlabel + +PAGES = pages() +UI = load("ui_strings") +_NAV = load("navigation") + + +NAV_LEFT = _NAV["nav_left"] +NAV_RIGHT = _NAV["nav_right"] + + +# Main Window + + +class UniOSWelcome(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle(UI["window_title"]) + self.setMinimumSize(580, 500) + self.setProperty("layout", "window") + self.setStyleSheet(load_qss("widgets/layout.qss")) + + self._stack = QStackedWidget() + self.setCentralWidget(self._stack) + self._page_indices = {} + + self._build_main() + self._build_subpages() + + def _build_main(self): + main_page = QWidget() + main_page.setProperty("layout", "transparent") + main_page.setStyleSheet(load_qss("widgets/layout.qss")) + mp = QVBoxLayout(main_page) + mp.setContentsMargins(0, 0, 0, 0) + mp.setSpacing(0) + + # Hero + hero = QWidget() + hero.setProperty("layout", "hero") + hero.setStyleSheet(load_qss("widgets/layout.qss")) + hl = QVBoxLayout(hero) + hl.setContentsMargins(20, 20, 20, 16) + hl.setSpacing(4) + + hero_title = qlabel(UI["hero_title"], role="title") + hero_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + hl.addWidget(hero_title) + + hero_sub = qlabel(UI["hero_subtitle"], role="body") + hero_sub.setAlignment(Qt.AlignmentFlag.AlignCenter) + hl.addWidget(hero_sub) + + mp.addWidget(hero) + mp.addWidget(divider()) + + # Nav columns + nav_widget = QWidget() + nav_widget.setProperty("layout", "transparent") + nav_widget.setStyleSheet(load_qss("widgets/layout.qss")) + nav_layout = QHBoxLayout(nav_widget) + nav_layout.setContentsMargins(28, 24, 28, 24) + nav_layout.setSpacing(20) + + left_col = QVBoxLayout() + left_col.setSpacing(10) + for key in NAV_LEFT: + btn = NavButton(key) + btn.clicked.connect(lambda _, k=key: self._show_page(k)) + left_col.addWidget(btn) + left_col.addStretch() + + bottom_row = QHBoxLayout() + bottom_row.setContentsMargins(28, 0, 28, 14) + self._autostart_cb = QCheckBox(UI["autostart_checkbox"]) + self._autostart_cb.setChecked(True) + self._autostart_cb.setProperty("control", "autostart") + self._autostart_cb.setStyleSheet(load_qss("widgets/controls.qss")) + + self._autostart_cb.stateChanged.connect(self._toggle_autostart) + cfg_btn = QPushButton(UI["configure_button"]) + cfg_btn.setFixedHeight(28) + cfg_btn.setProperty("button", "configure") + cfg_btn.setStyleSheet(load_qss("widgets/controls.qss")) + cfg_btn.setCursor(Qt.CursorShape.PointingHandCursor) + cfg_btn.clicked.connect(self._show_academic_config) + + bottom_row.addWidget(self._autostart_cb) + bottom_row.addStretch() + bottom_row.addWidget(cfg_btn) + + right_col = QVBoxLayout() + right_col.setSpacing(10) + for key in NAV_RIGHT: + btn = NavButton(key) + btn.clicked.connect(lambda _, k=key: self._show_page(k)) + right_col.addWidget(btn) + right_col.addStretch() + + nav_layout.addLayout(left_col) + nav_layout.addLayout(right_col) + mp.addWidget(nav_widget, stretch=1) + mp.addLayout(bottom_row) + + mp.addWidget(divider()) + mp.addWidget(footer()) + self._stack.addWidget(main_page) # index 0 + + def _build_subpages(self): + subpages = { + **{key: build_text_page(key, self._show_main) for key in PAGES}, + UI["credits_page_title"]: build_credits_page(self._show_main), + UI["links_page_title"]: build_links_page(self._show_main), + UI["academic_config_page_title"]: build_academic_config_page( + self._show_main + ), + } + for key, widget in subpages.items(): + self._page_indices[key] = self._stack.addWidget(widget) + + def _toggle_autostart(self, state): + set_autostart(self._autostart_cb.isChecked()) + + def _show_page(self, key): + self._stack.setCurrentIndex(self._page_indices[key]) + + def _show_main(self): + self._stack.setCurrentIndex(0) + + def _show_academic_config(self): + self._stack.setCurrentIndex(self._page_indices[UI["academic_config_page_title"]]) diff --git a/src/unidesk/ui/pages/__init__.py b/src/unidesk/ui/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unidesk/ui/pages/academic_config_page.py b/src/unidesk/ui/pages/academic_config_page.py new file mode 100644 index 0000000..babe530 --- /dev/null +++ b/src/unidesk/ui/pages/academic_config_page.py @@ -0,0 +1,89 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QComboBox, QPushButton + +from ...helpers.academic_config import load_academic_config, save_academic_config +from ...helpers.text_data import load +from ...styles.loader import load_qss +from ..widgets import qlabel, scroll_page + +UI = load("ui_strings") +UNIVERSITIES = load("academic_institutions")["universities"] + + +def build_academic_config_page(on_back): + widget, cl = scroll_page(on_back, UI["academic_config_page_title"]) + + intro = qlabel(UI["academic_config_intro"], role="body", wrap=True) + cl.addWidget(intro) + + cl.addWidget(qlabel(UI["academic_university_label"], role="field-label")) + university_combo = QComboBox() + university_combo.setProperty("control", "academic") + university_combo.setStyleSheet(load_qss("widgets/controls.qss")) + university_combo.setPlaceholderText(UI["academic_university_placeholder"]) + university_combo.addItems(list(UNIVERSITIES.keys())) + university_combo.setCurrentIndex(-1) + cl.addWidget(university_combo) + + cl.addWidget(qlabel(UI["academic_department_label"], role="field-label")) + department_combo = QComboBox() + department_combo.setProperty("control", "academic") + department_combo.setStyleSheet(load_qss("widgets/controls.qss")) + department_combo.setPlaceholderText(UI["academic_department_placeholder"]) + department_combo.setCurrentIndex(-1) + cl.addWidget(department_combo) + + status = qlabel("", role="muted", wrap=True) + + def set_status_error(error): + status.setProperty("state", "error" if error else "") + # Qt won't re-evaluate property selectors on a shown widget unless repolished. + status.style().unpolish(status) + status.style().polish(status) + + def refresh_departments(): + university = university_combo.currentText() + department_combo.clear() + if university in UNIVERSITIES: + department_combo.addItems(UNIVERSITIES[university]) + department_combo.setCurrentIndex(-1) + + university_combo.currentIndexChanged.connect(lambda _: refresh_departments()) + + # Pre-fill from any existing config, but only if the saved values are still + # known to us (UniBackpack may have entries we haven't mirrored yet). + saved = load_academic_config() + saved_university = saved["universityName"] + saved_department = saved["departmentName"] + if saved_university in UNIVERSITIES: + university_combo.setCurrentText(saved_university) + if saved_department in UNIVERSITIES[saved_university]: + department_combo.setCurrentText(saved_department) + + save_btn = QPushButton(UI["academic_save_button"]) + save_btn.setCursor(Qt.CursorShape.PointingHandCursor) + save_btn.setProperty("button", "save") + save_btn.setStyleSheet(load_qss("widgets/controls.qss")) + + def on_save(): + university = university_combo.currentText() + department = department_combo.currentText() + if not university or not department: + status.setText(UI["academic_error_incomplete"]) + set_status_error(True) + return + save_academic_config(university, department) + status.setText( + UI["academic_saved_template"].format( + university=university, department=department + ) + ) + set_status_error(False) + + save_btn.clicked.connect(lambda _: on_save()) + + cl.addSpacing(6) + cl.addWidget(save_btn) + cl.addWidget(status) + cl.addStretch() + return widget diff --git a/src/unidesk/ui/pages/credits_page.py b/src/unidesk/ui/pages/credits_page.py new file mode 100644 index 0000000..37d7da8 --- /dev/null +++ b/src/unidesk/ui/pages/credits_page.py @@ -0,0 +1,39 @@ +from PyQt6.QtWidgets import QFrame, QVBoxLayout + +from ...helpers.text_data import load +from ...styles.loader import load_qss +from ..widgets import qlabel, scroll_page + +CREDITS = load("credits")["people"] +UI = load("ui_strings") + + +def build_credits_page(on_back): + widget, cl = scroll_page(on_back, UI["credits_page_title"]) + + for person in CREDITS: + frame = QFrame() + frame.setFrameShape(QFrame.Shape.StyledPanel) + frame.setProperty("layout", "card") + frame.setStyleSheet(load_qss("widgets/layout.qss")) + fl = QVBoxLayout(frame) + fl.setContentsMargins(14, 10, 14, 10) + fl.setSpacing(2) + + name = qlabel(person["name"], role="card-title") + fl.addWidget(name) + + role = qlabel(person["role"], role="muted") + fl.addWidget(role) + + if person.get("projects"): + proj = qlabel( + UI["projects_prefix"] + ", ".join(person["projects"]), + role="accent", + ) + fl.addWidget(proj) + + cl.addWidget(frame) + + cl.addStretch() + return widget diff --git a/src/unidesk/ui/pages/links_page.py b/src/unidesk/ui/pages/links_page.py new file mode 100644 index 0000000..7026352 --- /dev/null +++ b/src/unidesk/ui/pages/links_page.py @@ -0,0 +1,38 @@ +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtWidgets import QFrame, QPushButton, QVBoxLayout + +from ...helpers.text_data import load +from ...styles.loader import load_qss +from ..widgets import qlabel, scroll_page + +LINKS = load("links")["links"] +UI = load("ui_strings") + + +def build_links_page(on_back): + widget, cl = scroll_page(on_back, UI["links_page_title"]) + + for link in LINKS: + frame = QFrame() + frame.setFrameShape(QFrame.Shape.StyledPanel) + frame.setProperty("layout", "card") + frame.setStyleSheet(load_qss("widgets/layout.qss")) + fl = QVBoxLayout(frame) + fl.setContentsMargins(14, 10, 14, 10) + fl.setSpacing(6) + + name = qlabel(link["label"], role="card-title") + fl.addWidget(name) + + btn = QPushButton(UI["open_link_button"]) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setProperty("button", "link") + btn.setStyleSheet(load_qss("widgets/controls.qss")) + btn.clicked.connect(lambda _, u=link["url"]: QDesktopServices.openUrl(QUrl(u))) + fl.addWidget(btn) + + cl.addWidget(frame) + + cl.addStretch() + return widget diff --git a/src/unidesk/ui/pages/text_page.py b/src/unidesk/ui/pages/text_page.py new file mode 100644 index 0000000..647a453 --- /dev/null +++ b/src/unidesk/ui/pages/text_page.py @@ -0,0 +1,18 @@ +from PyQt6.QtCore import Qt + +from ...helpers.text_data import pages +from ..widgets import qlabel, scroll_page + +PAGES = pages() + + +def build_text_page(key, on_back): + data = PAGES[key] + widget, cl = scroll_page(on_back, key) + + body = qlabel(data["body"], role="body", wrap=True) + body.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) + cl.addWidget(body) + cl.addStretch() + + return widget diff --git a/src/unidesk/ui/widgets.py b/src/unidesk/ui/widgets.py new file mode 100644 index 0000000..c5af140 --- /dev/null +++ b/src/unidesk/ui/widgets.py @@ -0,0 +1,128 @@ +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from ..helpers.text_data import load +from ..styles.loader import load_qss + +UI = load("ui_strings") +FOOTER_LINKS = load("navigation")["footer_links"] + + +def qlabel(text, role="body", wrap=False): + lbl = QLabel(text) + lbl.setProperty("role", role) + lbl.setStyleSheet(load_qss("widgets/labels.qss")) + if wrap: + lbl.setWordWrap(True) + return lbl + + +def divider(): + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setFixedHeight(1) + line.setProperty("layout", "divider") + line.setStyleSheet(load_qss("widgets/layout.qss")) + return line + + +def back_bar(title, on_back): + bar = QWidget() + bar.setFixedHeight(40) + bar.setProperty("layout", "bar") + bar.setStyleSheet(load_qss("widgets/layout.qss")) + layout = QHBoxLayout(bar) + layout.setContentsMargins(12, 0, 12, 0) + + btn = QPushButton(UI["back_button"]) + btn.setFixedWidth(70) + btn.setProperty("button", "back") + btn.setStyleSheet(load_qss("widgets/controls.qss")) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(on_back) + layout.addWidget(btn) + + lbl = qlabel(title, role="card-title") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(lbl, stretch=1) + layout.addSpacing(70) + return bar + + +def scroll_page(on_back, title): + """Returns (outer_widget, content_layout) with back bar already added.""" + widget = QWidget() + outer = QVBoxLayout(widget) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + outer.addWidget(back_bar(title, on_back)) + outer.addWidget(divider()) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setProperty("layout", "scroll") + scroll.setStyleSheet(load_qss("widgets/layout.qss")) + outer.addWidget(scroll) + + content = QWidget() + content.setProperty("layout", "transparent") + content.setStyleSheet(load_qss("widgets/layout.qss")) + scroll.setWidget(content) + + cl = QVBoxLayout(content) + cl.setContentsMargins(24, 20, 24, 20) + cl.setSpacing(10) + + outer.addWidget(divider()) + outer.addWidget(footer()) + + return widget, cl + + +def footer(on_configure=None): + footer = QWidget() + footer.setFixedHeight(40) + footer.setProperty("layout", "bar") + footer.setStyleSheet(load_qss("widgets/layout.qss")) + ft = QHBoxLayout(footer) + ft.setContentsMargins(14, 0, 14, 0) + ft.addWidget(qlabel(UI["footer_copyright"], role="footer")) + ft.addStretch() + + if on_configure is not None: + cfg = QPushButton(UI["configure_button"]) + cfg.setProperty("button", "footer-link") + cfg.setStyleSheet(load_qss("widgets/controls.qss")) + cfg.setCursor(Qt.CursorShape.PointingHandCursor) + cfg.clicked.connect(lambda _: on_configure()) + ft.addWidget(cfg) + + for link in FOOTER_LINKS: + b = QPushButton(link["label"]) + b.setProperty("button", "footer-link") + b.setStyleSheet(load_qss("widgets/controls.qss")) + b.setCursor(Qt.CursorShape.PointingHandCursor) + b.clicked.connect(lambda _, u=link["url"]: QDesktopServices.openUrl(QUrl(u))) + ft.addWidget(b) + return footer + + +class NavButton(QPushButton): + def __init__(self, label): + super().__init__(label) + self.setFixedHeight(38) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self.setProperty("button", "nav") + self.setStyleSheet(load_qss("widgets/controls.qss"))