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"))