From a2d2e5de39c777d0e268033c7922092217a4d2f0 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Thu, 20 Aug 2026 20:02:09 +1000 Subject: [PATCH 1/4] feat: prepare cross-platform v1.0.0 release --- .github/workflows/python-app.yml | 57 +++-- .github/workflows/release.yml | 99 ++++++++ .gitignore | 1 + README.md | 175 +++++--------- glasshub-updater/updater-main.py | 15 +- glasshub-updater/updater.py | 1 - main.py | 16 +- pyproject.toml | 42 ++-- requirements.txt | 4 - scripts/__init__.py | 1 + scripts/release.py | 121 ++++++++++ src/__main__.py | 3 + src/app.py | 20 ++ src/core/sensors.py | 245 ++++++++++---------- src/core/settings_storage.py | 66 ++++++ src/core/stats.py | 70 +----- src/core/theme.py | 31 +++ src/themes.json | 20 ++ src/ui/hud.py | 384 ++++++++----------------------- src/ui/layout.py | 70 ++++++ src/ui/lin_hud.py | 9 +- src/ui/menu.py | 12 +- src/ui/settings.py | 64 ++++++ src/ui/tray.py | 153 ++++++------ src/ui/widgets/__init__.py | 13 ++ src/ui/widgets/base.py | 63 +++++ src/ui/widgets/metrics.py | 69 ++++++ tests/test_core.py | 55 +++++ tests/test_release.py | 9 + tests/test_ui.py | 17 ++ 30 files changed, 1135 insertions(+), 770 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 requirements.txt create mode 100644 scripts/__init__.py create mode 100644 scripts/release.py create mode 100644 src/__main__.py create mode 100644 src/app.py create mode 100644 src/core/settings_storage.py create mode 100644 src/core/theme.py create mode 100644 src/themes.json create mode 100644 src/ui/layout.py create mode 100644 src/ui/settings.py create mode 100644 src/ui/widgets/__init__.py create mode 100644 src/ui/widgets/base.py create mode 100644 src/ui/widgets/metrics.py create mode 100644 tests/test_core.py create mode 100644 tests/test_release.py create mode 100644 tests/test_ui.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index f4e7e17..8922694 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,41 +1,40 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python application +name: Python checks on: push: - branches: [ "main" ] + branches: [main] pull_request: - branches: [ "main" ] + branches: [main] permissions: contents: read jobs: build: - - runs-on: windows-latest + name: ${{ matrix.os }} / Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10"] + env: + QT_QPA_PLATFORM: offscreen steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if (Test-Path requirements.txt) { pip install -r requirements.txt } - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest - # no test modules found, exit with code 0 - if ($LASTEXITCODE -eq 5) { exit 0 } \ No newline at end of file + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install project and development tools + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Check formatting + run: ruff format --check . + - name: Test + run: pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8987c3e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: GitHub Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: Existing v-prefixed tag to build and publish + required: true + type: string + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + - name: Install and test + run: | + python -m pip install -e ".[dev]" + ruff format --check . + ruff check . + QT_QPA_PLATFORM=offscreen pytest + - name: Verify tag matches project version + run: python scripts/release.py verify-tag "${{ inputs.tag || github.ref_name }}" + + build: + needs: verify + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + asset: glint-windows-x86_64 + archive: zip + - os: macos-14 + asset: glint-macos-arm64 + archive: zip + - os: macos-15-intel + asset: glint-macos-x86_64 + archive: zip + - os: ubuntu-22.04 + asset: glint-linux-x86_64 + archive: gztar + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + - name: Install release tooling + run: python -m pip install -e ".[release]" + - name: Build native bundle + run: python scripts/release.py build --asset-name "${{ matrix.asset }}" --archive "${{ matrix.archive }}" + - name: Upload native bundle + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: dist-release/* + if-no-files-found: error + retention-days: 7 + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + - name: Download native bundles + uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + - name: Generate checksums + run: python scripts/release.py checksums release-assets + - name: Publish GitHub Release + run: gh release create "$RELEASE_TAG" release-assets/* --verify-tag --generate-notes --latest diff --git a/.gitignore b/.gitignore index f2bbc37..a459c9c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ # Distribution / packaging build/ dist/ +dist-release/ *.egg-info/ *.egg diff --git a/README.md b/README.md index abcd1ad..692d7cc 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,91 @@ # Glint -Glint is a lightweight, painter‑rendered system monitor designed for **my personal Windows PC**. -It sits on the desktop, stays out of the way, and gives a clean, at‑a‑glance view of system usage without relying on HTML, Electron, or heavy widget engines. +Glint is a lightweight, painter-rendered desktop system monitor for Windows, macOS, and Linux. It displays CPU, memory, disk, GPU, temperature, and live network metrics without a browser or Electron runtime. -If it works for you, great. -If it doesn’t, open an issue with your **system specs** and I’ll see if support can be added. +## Highlights -Ubuntu support exists in theory (PyQt + psutil), but **Glint has not been tested on Ubuntu yet**. +- Native PyQt 6 HUD and system tray +- Portable CPU, RAM, disk, temperature, and network sensors through `psutil` +- NVIDIA GPU usage and temperature through `nvidia-smi`, with graceful fallback on other hardware +- JSON-backed settings and user-defined widget layouts +- Built-in themes and serializable painter-rendered widgets +- Native autostart entries for Windows, macOS, and freedesktop Linux desktops ---- +Unavailable hardware metrics display as “Unavailable”; they do not prevent Glint from starting. -## **Features** +## Requirements -### **Minimal Desktop UI** -- Painter‑rendered interface (no web engine, no layout jitter) -- Frameless, draggable, unobtrusive -- Crisp usage bars and clean text +- Python 3.10 or later +- Windows 10+, a current macOS release, or a Linux desktop with a system tray +- A graphical desktop session -### **Live System Stats** -- CPU usage -- RAM usage -- Disk usage (first two drives) -- Modular backend for future sensors (GPU, temps, etc.) +Linux packages may require Qt system libraries supplied by the distribution. Wayland desktop-shell rules can prevent applications from forcing a window below every other window; the HUD remains frameless and behaves normally in that case. -### **Desktop Behavior** -- Always‑on‑desktop layer (below windows, above wallpaper) -- Right‑click context menu -- Lightweight footprint +## Install and run -### **Architecture** -- Modular Linux‑style layout -- Dedicated updater micro‑application (half working on might never make it) -- JSON configuration -- Clean packaging pipeline (PyInstaller + installer) - ---- - -## **Why Glint Exists** - -Most Windows system monitors rely on HTML widgets, Electron shells, or heavy UI frameworks. -Glint takes a different approach: - -- **Zero web stack** -- **Fully painter‑rendered** -- **Fast, stable, pixel‑perfect** -- **Always visible without getting in the way** -- **Modular internal design** - -It’s built to be simple, reliable, and personal. - ---- - -## **Installation (Development Mode)** - -### 1. Clone the repository -```bash -git clone https://github.com/ZFordDev/Glint.git -cd Glint -``` - -### 2. Install dependencies ```bash -pip install -r requirements.txt -# working on the pyproject.toml +python -m venv .venv +# Windows: .venv\Scripts\activate +# macOS/Linux: source .venv/bin/activate +python -m pip install . +glint ``` -Dependencies include: -- PyQt6 -- psutil -- wmi / pywin32 (optional Windows sensors) -- requests (updater) - ---- - -## **Usage** - -Run Glint: +For development: ```bash +python -m pip install -e ".[dev]" +python -m pytest +ruff check . python main.py ``` -### **Controls** -- **Left‑click + drag** — move the HUD -- **Right‑click** — context menu (Exit, Update, Settings) +## Controls ---- +- Left-click and drag moves the HUD. +- Right-click opens Settings or exits. +- Double-clicking the tray icon restores the HUD. +- The tray menu controls autostart. -## **Project Structure** +Settings and layouts use the operating system's application configuration directory. A generated `default_layout.json` can contain multiple disk widgets; set each widget's `disk` field to its device label. -``` -glint/ -├── main.py -├── pyproject.toml -├── requirements.txt -├── updater/ -│ ├── updater-main.py -│ └── updater.py -└── glint/ - ├── __init__.py - ├── core/ - │ ├── sensors.py - │ └── stats.py - └── ui/ - ├── hud.py - └── menu.py -``` +## Sensor support ---- +| Metric | Windows | macOS | Linux | +| --- | --- | --- | --- | +| CPU, RAM, disk, network | `psutil` | `psutil` | `psutil` | +| CPU temperature | WMI when exposed | `psutil` when exposed | hwmon through `psutil` | +| GPU usage | `nvidia-smi`, then WMI for AMD/Intel | `nvidia-smi` when supported | `nvidia-smi` | +| Other GPU temperature | unavailable fallback | platform sensor when exposed | hwmon when exposed | -## **Roadmap** +Hardware and driver vendors expose sensors inconsistently, so temperature and GPU metrics are optional by design. -### **Near‑Term** -- GPU usage -- CPU/GPU temperatures (LibreHardwareMonitor) -- Acrylic blur -- Settings panel (opacity, refresh rate, auto‑start) -- System tray icon -- Compact mode +## Project layout -### **Mid‑Term** -- Plugin system -- Theme packs -- Multi‑monitor support -- Auto‑positioning presets - ---- +```text +src/ + core/ sensors, settings, themes + ui/ HUD, tray, settings, layouts + widgets/ independent painter widgets + app.py application entry point +tests/ core behavior tests +``` -## **Compatibility** +## License -- **Windows 10+** — fully supported -- **Ubuntu / Linux** — *not tested yet* -- Python 3.10+ +MIT -If Glint doesn’t run on your system, open an issue with: -- CPU model -- GPU model -- OS version -- Python version +## Releases -I’ll check if support can be added. +Glint is distributed exclusively through GitHub Releases as minimal standalone archives—there are no Microsoft Store, Snap Store, or other store packages. ---- +The archives are currently unsigned. Windows SmartScreen and macOS Gatekeeper may therefore ask users to confirm that they trust the download. Code signing can be added later without changing the GitHub-only distribution model. -## **License** +Maintainers publish a release by updating `project.version` in `pyproject.toml`, merging the tested change to `main`, and pushing a matching tag such as `v1.0.0`. GitHub Actions then: -MIT License +1. verifies formatting, lint, tests, and the tag/version match; +2. builds native Windows, macOS, and Linux bundles with PyInstaller; +3. creates SHA-256 checksums; and +4. publishes the archives and generated notes to the tagged GitHub Release. ---- +The release workflow can also be started manually for an existing tag from the Actions page. diff --git a/glasshub-updater/updater-main.py b/glasshub-updater/updater-main.py index 69b7859..17a04bb 100644 --- a/glasshub-updater/updater-main.py +++ b/glasshub-updater/updater-main.py @@ -1,9 +1,14 @@ +"""Legacy updater placeholder. + +The unreleased updater was removed from packaging for 1.0 because installing +updates is now delegated to each platform's package manager. """ -updater-main.py ---------------- -the main entry point for the updater -""" +def main() -> int: + print("Update Glint through the package source used to install it.") + return 0 + -from project import repo +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/glasshub-updater/updater.py b/glasshub-updater/updater.py index 4b58a12..3bfee26 100644 --- a/glasshub-updater/updater.py +++ b/glasshub-updater/updater.py @@ -4,4 +4,3 @@ the update logic as a seprate run time """ - diff --git a/main.py b/main.py index 629fef4..f624f16 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,6 @@ -from PyQt6.QtWidgets import QApplication -from src.ui.hud import GlassHUD -from src.ui.tray import TrayManager -import sys +"""Development launcher; installed users can run ``glint``.""" -app = QApplication(sys.argv) -app.setQuitOnLastWindowClosed(False) +from src.app import main -hud = GlassHUD() -hud.show() - -tray = TrayManager(app, hud) - -sys.exit(app.exec()) +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 79badba..6ab3f26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools>=69.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "glasshud" -version = "0.1.0" -description = "A Modern Painter‑Rendered Desktop System Monitor" +name = "glint-monitor" +version = "1.0.0" +description = "A Modern Painter-Rendered Desktop System Monitor" readme = "README.md" requires-python = ">=3.10" authors = [ @@ -13,27 +13,33 @@ authors = [ ] dependencies = [ - "psutil", - "PyQt6", - "wmi", - "pywin32", - "requests" + "psutil>=5.9", + "PyQt6>=6.6", + "wmi>=1.5.1; platform_system == 'Windows'", + "pywin32>=306; platform_system == 'Windows'" ] # Optional but recommended license = { text = "MIT" } -keywords = ["system-monitor", "hud", "qt", "desktop", "windows"] - -# This tells Python where your package lives -packages = ["glasshud"] +keywords = ["system-monitor", "hud", "qt", "desktop", "windows", "linux", "macos"] [project.scripts] -glasshud = "glasshud.__main__:main" -glasshud-updater = "glasshub-updater.updater-main:main" - -[tool.setuptools] -package-dir = { "" = "." } +glint = "src.app:main" [tool.setuptools.packages.find] where = ["."] -include = ["glasshud*", "glasshub-updater*"] +include = ["src*"] + +[tool.setuptools.package-data] +src = ["themes.json"] + +[project.optional-dependencies] +dev = ["pytest>=8", "ruff>=0.6", "tomli>=2; python_version < '3.11'"] +release = ["pyinstaller>=6.10", "tomli>=2; python_version < '3.11'"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 120 +target-version = "py310" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e142a04..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -psutil -PyQt6 -wmi -pywin32 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..9f39e91 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Glint development and release automation.""" diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..217c688 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,121 @@ +"""Build and validate Glint's minimal GitHub Release bundles.""" + +from __future__ import annotations + +import argparse +import hashlib +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 support + import tomli as tomllib + +ROOT = Path(__file__).parents[1] + + +def project_version() -> str: + with (ROOT / "pyproject.toml").open("rb") as source: + return tomllib.load(source)["project"]["version"] + + +def verify_tag(tag: str) -> None: + expected = f"v{project_version()}" + if tag != expected: + raise SystemExit(f"Release tag {tag!r} does not match pyproject.toml version {expected!r}") + + +def build(asset_name: str, archive: str) -> Path: + """Create a native PyInstaller onedir bundle and archive it.""" + build_root = ROOT / "build" / "release" + bundle_root = ROOT / "dist" / "Glint" + output_root = ROOT / "dist-release" + shutil.rmtree(build_root, ignore_errors=True) + shutil.rmtree(ROOT / "dist", ignore_errors=True) + output_root.mkdir(exist_ok=True) + + command = [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--clean", + "--windowed", + "--onedir", + "--name", + "Glint", + "--workpath", + str(build_root), + "--specpath", + str(build_root), + "--add-data", + f"{ROOT / 'src' / 'themes.json'}:src", + "--add-data", + f"{ROOT / 'assets'}:assets", + str(ROOT / "main.py"), + ] + subprocess.run(command, cwd=ROOT, check=True) + + # Put the macOS .app and documentation inside the same simple top-level + # folder used by Windows and Linux. + if platform.system() == "Darwin": + application = ROOT / "dist" / "Glint.app" + if not application.exists(): + raise SystemExit(f"PyInstaller did not create expected bundle: {application}") + bundle_root.mkdir() + shutil.move(application, bundle_root / application.name) + elif not bundle_root.exists(): + raise SystemExit(f"PyInstaller did not create expected bundle: {bundle_root}") + bundle = bundle_root + for document in ("README.md", "LICENSE"): + shutil.copy2(ROOT / document, bundle / document) + + base = output_root / asset_name + if archive == "zip": + if platform.system() == "Darwin": + # ditto preserves macOS bundle metadata and framework symlinks. + result = base.with_suffix(".zip") + subprocess.run( + ["/usr/bin/ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", bundle, result], check=True + ) + else: + result = Path(shutil.make_archive(str(base), "zip", bundle.parent, bundle.name)) + else: + result = Path(shutil.make_archive(str(base), "gztar", bundle.parent, bundle.name)) + print(result) + return result + + +def checksums(directory: Path) -> Path: + assets = sorted(path for path in directory.iterdir() if path.is_file() and path.name != "SHA256SUMS") + target = directory / "SHA256SUMS" + lines = [f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}" for path in assets] + target.write_text("\n".join(lines) + "\n", encoding="utf-8") + return target + + +def main() -> None: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + verify = commands.add_parser("verify-tag") + verify.add_argument("tag") + package = commands.add_parser("build") + package.add_argument("--asset-name", required=True) + package.add_argument("--archive", choices=("zip", "gztar"), required=True) + sums = commands.add_parser("checksums") + sums.add_argument("directory", type=Path) + arguments = parser.parse_args() + if arguments.command == "verify-tag": + verify_tag(arguments.tag) + elif arguments.command == "build": + build(arguments.asset_name, arguments.archive) + else: + checksums(arguments.directory) + + +if __name__ == "__main__": + main() diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..68b62be --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,3 @@ +from src.app import main + +raise SystemExit(main()) diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..2ebe91c --- /dev/null +++ b/src/app.py @@ -0,0 +1,20 @@ +"""Glint application entry point.""" + +import sys + +from PyQt6.QtWidgets import QApplication + +from src.ui.hud import GlassHUD +from src.ui.tray import TrayManager + + +def main() -> int: + app = QApplication(sys.argv) + app.setApplicationName("Glint") + app.setOrganizationName("ZFordDev") + app.setQuitOnLastWindowClosed(False) + hud = GlassHUD() + hud.show() + tray = TrayManager(app, hud) + app._glint_objects = (hud, tray) # Keep Python wrappers alive. + return app.exec() diff --git a/src/core/sensors.py b/src/core/sensors.py index a63f2bf..60a514e 100644 --- a/src/core/sensors.py +++ b/src/core/sensors.py @@ -1,126 +1,125 @@ +"""Cross-platform system sensors used by the UI.""" + +from __future__ import annotations + +import logging +import platform +import shutil +import subprocess +import time +from typing import Any + import psutil -import wmi - -# OpenHardwareMonitor support -try: - import clr # pythonnet - clr.AddReference("OpenHardwareMonitorLib") - from OpenHardwareMonitor.Hardware import Computer - OHM_AVAILABLE = True -except Exception: - OHM_AVAILABLE = False - - -# ----------------------------- -# WMI SENSOR READER -# ----------------------------- -def get_wmi_temps(): - """ - Attempts to read CPU temperature using Windows WMI. - Works on many laptops, but not all hardware exposes sensors. - Returns temperature in °C or None. - """ - try: - w = wmi.WMI(namespace="root\\wmi") - sensors = w.MSAcpi_ThermalZoneTemperature() - if not sensors: - return None - - # Convert tenths of Kelvin to Celsius - temp = sensors[0].CurrentTemperature - return round((temp / 10) - 273.15, 1) - except Exception: - return None - - -# ----------------------------- -# OPENHARDWAREMONITOR SENSOR READER -# ----------------------------- -def get_ohm_temps(): - """ - Reads CPU and GPU temps via OpenHardwareMonitor if available. - Requires OHM running OR pythonnet + OHM DLL in working directory. - Returns dict: { "cpu": float | None, "gpu": float | None } - """ - if not OHM_AVAILABLE: - return {"cpu": None, "gpu": None} - - try: - comp = Computer() - comp.CPUEnabled = True - comp.GPUEnabled = True - comp.Open() - - cpu_temp = None - gpu_temp = None - - for hw in comp.Hardware: - hw.Update() - if hw.HardwareType == 2: # CPU - for sensor in hw.Sensors: - if sensor.SensorType == 2: # Temperature - cpu_temp = round(sensor.Value, 1) - if hw.HardwareType == 4: # GPU - for sensor in hw.Sensors: - if sensor.SensorType == 2: - gpu_temp = round(sensor.Value, 1) - - return {"cpu": cpu_temp, "gpu": gpu_temp} - - except Exception: - return {"cpu": None, "gpu": None} - - -# ----------------------------- -# DISK USAGE -# ----------------------------- -def get_disk_usage(): - """ - Returns a dict of drive letters and their usage percentages. - Example: { "C": 42.1, "D": 77.3 } - """ - usage = {} - for part in psutil.disk_partitions(): - if "cdrom" in part.opts or part.fstype == "": - continue + +logger = logging.getLogger(__name__) + + +class SensorReader: + """Collect metrics and calculate network throughput between reads.""" + + def __init__(self) -> None: + self._network = psutil.net_io_counters() + self._network_time = time.monotonic() + + @staticmethod + def _temperatures() -> dict[str, float | None]: + cpu: float | None = None + gpu: float | None = None try: - letter = part.device.replace("\\", "").replace(":", "") - usage[letter] = psutil.disk_usage(part.device).percent - except Exception: - pass - return usage - - -# ----------------------------- -# MASTER SENSOR FUNCTION -# ----------------------------- -def get_all_sensors(): - """ - Returns a unified dictionary of all sensor data. - UI layer should call ONLY this function. - """ - - # CPU / RAM - cpu = psutil.cpu_percent() - ram = psutil.virtual_memory().percent - - # Disks - disks = get_disk_usage() - - # Temperatures - wmi_temp = get_wmi_temps() - ohm_temps = get_ohm_temps() - - # Prefer OHM if available - cpu_temp = ohm_temps.get("cpu") or wmi_temp - gpu_temp = ohm_temps.get("gpu") - - return { - "cpu": cpu, - "ram": ram, - "disks": disks, - "temps": { - "cpu": cpu_temp, - "gpu": gpu_temp + groups = psutil.sensors_temperatures(fahrenheit=False) + except (AttributeError, OSError): + groups = {} + for name, entries in groups.items(): + for entry in entries: + label = f"{name} {entry.label}".lower() + if gpu is None and any(key in label for key in ("gpu", "amdgpu", "radeon")): + gpu = round(float(entry.current), 1) + elif cpu is None and any(key in label for key in ("cpu", "core", "package", "k10temp")): + cpu = round(float(entry.current), 1) + + if platform.system() == "Windows" and cpu is None: + try: + import wmi # type: ignore[import-not-found] + + readings = wmi.WMI(namespace=r"root\wmi").MSAcpi_ThermalZoneTemperature() + if readings: + cpu = round((float(readings[0].CurrentTemperature) / 10) - 273.15, 1) + except Exception as error: # noqa: BLE001 - WMI exposes provider-specific COM errors. + logger.debug("Windows temperature sensor unavailable: %s", error) + return {"cpu": cpu, "gpu": gpu} + + @staticmethod + def _gpu() -> dict[str, float | None]: + """Read NVIDIA CLI metrics, then vendor-neutral Windows counters.""" + if shutil.which("nvidia-smi"): + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"], + capture_output=True, + check=True, + text=True, + timeout=2, + ) + usage, temperature = result.stdout.splitlines()[0].split(",", maxsplit=1) + return {"usage": float(usage.strip()), "temperature": float(temperature.strip())} + except (OSError, subprocess.SubprocessError, ValueError, IndexError): + pass + if platform.system() == "Windows": + try: + import wmi # type: ignore[import-not-found] + + engines = wmi.WMI(namespace=r"root\cimv2").Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine() + usage = sum( + float(engine.UtilizationPercentage or 0) for engine in engines if "engtype_3D" in engine.Name + ) + return {"usage": min(100.0, usage), "temperature": None} + except Exception as error: # noqa: BLE001 - WMI exposes provider-specific COM errors. + logger.debug("Windows GPU counters unavailable: %s", error) + return {"usage": None, "temperature": None} + + @staticmethod + def _disks() -> dict[str, float]: + disks: dict[str, float] = {} + for partition in psutil.disk_partitions(all=False): + if "cdrom" in partition.opts.lower(): + continue + try: + label = partition.device.rstrip("\\/") or partition.mountpoint + disks[label] = psutil.disk_usage(partition.mountpoint).percent + except (OSError, PermissionError): + continue + return disks + + def _network_rates(self) -> dict[str, float]: + current = psutil.net_io_counters() + now = time.monotonic() + elapsed = max(now - self._network_time, 0.001) + rates = { + "upload": max(0.0, (current.bytes_sent - self._network.bytes_sent) / elapsed), + "download": max(0.0, (current.bytes_recv - self._network.bytes_recv) / elapsed), + } + self._network = current + self._network_time = now + return rates + + def get_all(self) -> dict[str, Any]: + gpu = self._gpu() + temperatures = self._temperatures() + if gpu["temperature"] is not None: + temperatures["gpu"] = gpu["temperature"] + return { + "cpu": psutil.cpu_percent(), + "ram": psutil.virtual_memory().percent, + "disks": self._disks(), + "temps": temperatures, + "gpu": gpu, + "network": self._network_rates(), } - } + + +_reader = SensorReader() + + +def get_all_sensors() -> dict[str, Any]: + """Return all metrics in the stable schema consumed by Glint widgets.""" + return _reader.get_all() diff --git a/src/core/settings_storage.py b/src/core/settings_storage.py new file mode 100644 index 0000000..8a3ea36 --- /dev/null +++ b/src/core/settings_storage.py @@ -0,0 +1,66 @@ +"""Versioned JSON settings with validation and platform-native storage.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +from PyQt6.QtCore import QStandardPaths + +DEFAULT_SETTINGS: dict[str, Any] = { + "schema_version": 1, + "refresh_interval_ms": 1000, + "opacity": 1.0, + "theme": "default", + "layout": "default", + "window": {"x": None, "y": None}, +} + + +def config_dir() -> Path: + root = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppConfigLocation) + path = Path(root or Path.home() / ".config" / "Glint") + path.mkdir(parents=True, exist_ok=True) + return path + + +def _validated(data: object) -> dict[str, Any]: + result = deepcopy(DEFAULT_SETTINGS) + if not isinstance(data, dict): + return result + interval = data.get("refresh_interval_ms") + if isinstance(interval, int) and 250 <= interval <= 60_000: + result["refresh_interval_ms"] = interval + opacity = data.get("opacity") + if isinstance(opacity, (int, float)) and 0.2 <= opacity <= 1: + result["opacity"] = float(opacity) + for key in ("theme", "layout"): + if isinstance(data.get(key), str) and data[key]: + result[key] = data[key] + window = data.get("window") + if isinstance(window, dict): + for coordinate in ("x", "y"): + value = window.get(coordinate) + if value is None or isinstance(value, int): + result["window"][coordinate] = value + return result + + +def load_settings(path: Path | None = None) -> dict[str, Any]: + target = path or config_dir() / "settings.json" + try: + return _validated(json.loads(target.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + return deepcopy(DEFAULT_SETTINGS) + + +def save_settings(settings: dict[str, Any], path: Path | None = None) -> dict[str, Any]: + target = path or config_dir() / "settings.json" + target.parent.mkdir(parents=True, exist_ok=True) + clean = _validated(settings) + temporary = target.with_suffix(".tmp") + temporary.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8") + temporary.replace(target) + return clean diff --git a/src/core/stats.py b/src/core/stats.py index 1adca14..09f0c8e 100644 --- a/src/core/stats.py +++ b/src/core/stats.py @@ -1,71 +1,7 @@ -import psutil +"""Compatibility wrapper for integrations using the pre-1.0 stats API.""" -def get_cpu_usage(): - """ - Returns CPU usage percentage. - """ - try: - return psutil.cpu_percent() - except Exception: - return None - - -def get_ram_usage(): - """ - Returns RAM usage percentage. - """ - try: - return psutil.virtual_memory().percent - except Exception: - return None - - -def get_disk_usage(): - """ - Returns a dict of drive letters and their usage percentages. - Example: { "C": 42.1, "D": 77.3 } - """ - usage = {} - try: - for part in psutil.disk_partitions(): - # Skip CD-ROMs and unformatted partitions - if "cdrom" in part.opts or part.fstype == "": - continue - - try: - letter = part.device.replace("\\", "").replace(":", "") - usage[letter] = psutil.disk_usage(part.device).percent - except Exception: - pass - except Exception: - pass - - return usage - - -def get_network_usage(): - """ - Returns network I/O stats since boot. - Useful for future HUD expansions. - """ - try: - io = psutil.net_io_counters() - return { - "sent": io.bytes_sent, - "recv": io.bytes_recv - } - except Exception: - return {"sent": None, "recv": None} +from src.core.sensors import get_all_sensors def get_basic_stats(): - """ - Returns a unified dictionary of basic system stats. - This is the safe, always-available fallback layer. - """ - return { - "cpu": get_cpu_usage(), - "ram": get_ram_usage(), - "disks": get_disk_usage(), - "network": get_network_usage() - } + return get_all_sensors() diff --git a/src/core/theme.py b/src/core/theme.py new file mode 100644 index 0000000..74048e5 --- /dev/null +++ b/src/core/theme.py @@ -0,0 +1,31 @@ +"""Theme loading and color conversion.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +from PyQt6.QtGui import QColor + +THEME_FILE = Path(__file__).parents[1] / "themes.json" + + +def load_themes(path: Path = THEME_FILE) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict) and "default" in data: + return data + except (OSError, json.JSONDecodeError): + pass + return {"default": {"font": "Sans Serif", "colors": {}}} + + +def load_theme(name: str = "default") -> dict[str, Any]: + themes = load_themes() + return deepcopy(themes.get(name, themes["default"])) + + +def color(theme: dict[str, Any], name: str, fallback: str) -> QColor: + return QColor(theme.get("colors", {}).get(name, fallback)) diff --git a/src/themes.json b/src/themes.json new file mode 100644 index 0000000..97499f4 --- /dev/null +++ b/src/themes.json @@ -0,0 +1,20 @@ +{ + "default": { + "font": "Sans Serif", + "radius": 18, + "colors": { + "background": "#B4121212", "overlay_top": "#28FFFFFF", "overlay_bottom": "#0CFFFFFF", + "border": "#2DFFFFFF", "text": "#F0F0F0", "track": "#14FFFFFF", + "good": "#50DC78", "warning": "#FFC850", "critical": "#FF6464" + } + }, + "midnight": { + "font": "Sans Serif", + "radius": 14, + "colors": { + "background": "#E010172A", "overlay_top": "#2438BDF8", "overlay_bottom": "#081E3A5F", + "border": "#4056C7FF", "text": "#E2E8F0", "track": "#24334155", + "good": "#22D3EE", "warning": "#FBBF24", "critical": "#FB7185" + } + } +} diff --git a/src/ui/hud.py b/src/ui/hud.py index c022686..74f73f4 100644 --- a/src/ui/hud.py +++ b/src/ui/hud.py @@ -1,321 +1,117 @@ -""" -hud.py ------- -main UI for windows runtime. -""" +"""Cross-platform, painter-rendered Glint HUD shell.""" -import sys +from __future__ import annotations -from PyQt6.QtWidgets import ( - QApplication, - QWidget, - QMenu, -) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QLinearGradient, QPainter, QPen +from PyQt6.QtWidgets import QApplication, QMenu, QWidget -from PyQt6.QtGui import ( - QPainter, - QColor, - QAction, - QFont, - QLinearGradient, - QPen, -) +from src.core.sensors import SensorReader +from src.core.settings_storage import load_settings, save_settings +from src.core.theme import color, load_theme +from src.ui.layout import create_widgets, load_layout, save_layout -from PyQt6.QtCore import ( - Qt, - QTimer, - QRectF, -) -from src.core.stats import get_basic_stats -from src.core.sensors import get_all_sensors - - -# --------------------------------------------------------- -# GLASS HUD -# --------------------------------------------------------- class GlassHUD(QWidget): - def __init__(self): + def __init__(self) -> None: super().__init__() - - # ------------------------------------------------- - # WINDOW CONFIG - # ------------------------------------------------- - self.setWindowFlags( - Qt.WindowType.FramelessWindowHint - | Qt.WindowType.Tool - | Qt.WindowType.WindowStaysOnBottomHint - ) - - self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - - # Compact modern sizing - self.resize(260, 260) - - # ------------------------------------------------- - # STATE - # ------------------------------------------------- + self.settings = load_settings() + self.theme = load_theme(self.settings["theme"]) + self.layout_data = load_layout(self.settings["layout"]) + self.widgets = create_widgets(self.layout_data) + self.sensor_reader = SensorReader() self.drag_pos = None - - self.cpu = 0 - self.ram = 0 - self.cputemp = 0 - self.gputemp = 0 - self.disks = {} - - # ------------------------------------------------- - # FONT - # ------------------------------------------------- - self.title_font = QFont("Segoe UI", 10) - self.value_font = QFont("Segoe UI", 9) - - # ------------------------------------------------- - # TIMER - # ------------------------------------------------- - self.timer = QTimer() + self.settings_window = None + flags = Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool + bottom_hint = getattr(Qt.WindowType, "WindowStaysOnBottomHint", None) + if bottom_hint is not None: + flags |= bottom_hint + self.setWindowFlags(flags) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.resize(int(self.layout_data.get("width", 280)), int(self.layout_data.get("height", 290))) + self.setWindowOpacity(self.settings["opacity"]) + for widget in self.widgets: + widget.set_theme(self.theme) + position = self.settings["window"] + if position["x"] is not None and position["y"] is not None: + self.move(position["x"], position["y"]) + self.timer = QTimer(self) self.timer.timeout.connect(self.update_stats) - self.timer.start(1000) - + self.timer.start(self.settings["refresh_interval_ms"]) self.update_stats() - # --------------------------------------------------------- - # UPDATE STATS - # --------------------------------------------------------- - def update_stats(self): - # Fetch comprehensive system stats including sensors - full_stats = get_all_sensors() - - self.cpu = full_stats["cpu"] - self.ram = full_stats["ram"] - self.disks = full_stats["disks"] - self.cputemp = full_stats["temps"]["cpu"] # Add CPU temp to local variable - self.gputemp = full_stats["temps"]["gpu"] # Add GPU temp to local variable ( might not work) - + def update_stats(self) -> None: + data = self.sensor_reader.get_all() + for widget in self.widgets: + widget.update(data) self.update() - # --------------------------------------------------------- - # BAR COLOR - # --------------------------------------------------------- - def get_bar_color(self, percent): - if percent < 50: - return QColor(80, 220, 120) - elif percent < 80: - return QColor(255, 200, 80) - else: - return QColor(255, 100, 100) - - # --------------------------------------------------------- - # DRAW BAR - # --------------------------------------------------------- - def draw_bar(self, painter, x, y, width, percent): - height = 10 - - # Background track - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QColor(255, 255, 255, 20)) - - painter.drawRoundedRect( - QRectF(x, y, width, height), - 5, - 5 - ) - - # Filled amount - fill_width = max(8, int(width * (percent / 100))) - - gradient = QLinearGradient(x, y, x + fill_width, y) - - color = self.get_bar_color(percent) - - gradient.setColorAt(0, color.lighter(120)) - gradient.setColorAt(1, color) - - painter.setBrush(gradient) - - painter.drawRoundedRect( - QRectF(x, y, fill_width, height), - 5, - 5 - ) - - # --------------------------------------------------------- - # DRAW STAT BLOCK - # --------------------------------------------------------- - def draw_stat( - self, - painter, - label, - percent, - x, - y - ): - # Text - painter.setPen(QColor(240, 240, 240)) - - painter.setFont(self.title_font) + def apply_settings(self, settings: dict) -> None: + self.settings = save_settings(settings) + self.theme = load_theme(self.settings["theme"]) + self.setWindowOpacity(self.settings["opacity"]) + self.timer.setInterval(self.settings["refresh_interval_ms"]) + for widget in self.widgets: + widget.set_theme(self.theme) + self.update() - painter.drawText( - x, - y, - f"{label} {percent}%" - ) + def open_settings(self) -> None: + from src.ui.settings import SettingsWindow - # Bar - self.draw_bar( - painter, - x, - y + 10, - 180, - percent - ) + if self.settings_window is None: + # Retain it in Python without assigning a native parent. Parented + # widgets are presented as tool panels on several desktops. + self.settings_window = SettingsWindow(self.settings) + self.settings_window.settings_changed.connect(self.apply_settings) + self.settings_window.show() + self.settings_window.raise_() + self.settings_window.activateWindow() - # --------------------------------------------------------- - # PAINT EVENT - # --------------------------------------------------------- - def paintEvent(self, event): + def paintEvent(self, event) -> None: painter = QPainter(self) - - painter.setRenderHint( - QPainter.RenderHint.Antialiasing - ) - + painter.setRenderHint(QPainter.RenderHint.Antialiasing) rect = self.rect().adjusted(1, 1, -1, -1) - - # ------------------------------------------------- - # GLASS BACKGROUND - # ------------------------------------------------- - glass = QLinearGradient(0, 0, 0, self.height()) - - glass.setColorAt( - 0, - QColor(255, 255, 255, 40) - ) - - glass.setColorAt( - 1, - QColor(255, 255, 255, 12) - ) - - # Base dark tint - painter.setBrush(QColor(18, 18, 18, 150)) + radius = int(self.theme.get("radius", 18)) painter.setPen(Qt.PenStyle.NoPen) - - painter.drawRoundedRect(rect, 18, 18) - - # Glass overlay - painter.setBrush(glass) - - painter.drawRoundedRect(rect, 18, 18) - - # ------------------------------------------------- - # BORDER - # ------------------------------------------------- - pen = QPen(QColor(255, 255, 255, 45)) - pen.setWidth(1) - - painter.setPen(pen) + painter.setBrush(color(self.theme, "background", "#B4121212")) + painter.drawRoundedRect(rect, radius, radius) + overlay = QLinearGradient(0, 0, 0, self.height()) + overlay.setColorAt(0, color(self.theme, "overlay_top", "#28FFFFFF")) + overlay.setColorAt(1, color(self.theme, "overlay_bottom", "#0CFFFFFF")) + painter.setBrush(overlay) + painter.drawRoundedRect(rect, radius, radius) + painter.setPen(QPen(color(self.theme, "border", "#2DFFFFFF"), 1)) painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(rect, radius, radius) + for widget in self.widgets: + widget.draw(painter) - painter.drawRoundedRect(rect, 18, 18) - - # ------------------------------------------------- - # INNER HIGHLIGHT - # ------------------------------------------------- - painter.setPen(QPen(QColor(255, 255, 255, 18))) - - painter.drawLine( - 20, - 14, - self.width() - 20, - 14 - ) - - # ------------------------------------------------- - # CONTENT - # ------------------------------------------------- - start_x = 22 - start_y = 38 - spacing = 38 - - self.draw_stat( - painter, - "CPU", - self.cpu, - start_x, - start_y - ) - - self.draw_stat( - painter, - "RAM", - self.ram, - start_x, - start_y + spacing - ) - - # Draw first 2 disks only - disk_y = start_y + spacing * 2 - - for i, (disk, usage) in enumerate( - list(self.disks.items())[:2] - ): - self.draw_stat( - painter, - disk, - usage, - start_x, - disk_y + (i * spacing) - ) - - # --------------------------------------------------------- - # MOUSE EVENTS - # --------------------------------------------------------- - def mousePressEvent(self, event): + def mousePressEvent(self, event) -> None: if event.button() == Qt.MouseButton.LeftButton: - self.drag_pos = ( - event.globalPosition().toPoint() - - self.frameGeometry().topLeft() - ) - + self.drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft() + # Wayland rejects application-driven top-level positioning. Let + # the window manager perform the drag, retaining manual movement + # below as a fallback for backends that do not support this call. + handle = self.windowHandle() + if handle is not None and handle.startSystemMove(): + self.drag_pos = None + event.accept() elif event.button() == Qt.MouseButton.RightButton: - self.open_context_menu(event) + menu = QMenu(self) + menu.addAction("Settings", self.open_settings) + menu.addSeparator() + menu.addAction("Exit", self._quit) + menu.exec(event.globalPosition().toPoint()) - def mouseMoveEvent(self, event): - if ( - self.drag_pos is not None - and event.buttons() & Qt.MouseButton.LeftButton - ): - self.move( - event.globalPosition().toPoint() - - self.drag_pos - ) + def mouseMoveEvent(self, event) -> None: + if self.drag_pos is not None and event.buttons() & Qt.MouseButton.LeftButton: + self.move(event.globalPosition().toPoint() - self.drag_pos) - def mouseReleaseEvent(self, event): + def mouseReleaseEvent(self, event) -> None: self.drag_pos = None + self.settings["window"] = {"x": self.x(), "y": self.y()} + save_settings(self.settings) - # --------------------------------------------------------- - # CONTEXT MENU - # --------------------------------------------------------- - def open_context_menu(self, event): - menu = QMenu(self) - - quit_action = QAction("Exit", self) - quit_action.triggered.connect(self.close) - - menu.addAction(quit_action) - - menu.exec( - event.globalPosition().toPoint() - ) - - -# --------------------------------------------------------- -# MAIN -# --------------------------------------------------------- -if __name__ == "__main__": - app = QApplication(sys.argv) - - hud = GlassHUD() - hud.show() - - sys.exit(app.exec()) + def _quit(self) -> None: + save_layout(self.widgets, self.width(), self.height(), self.settings["layout"]) + QApplication.instance().quit() diff --git a/src/ui/layout.py b/src/ui/layout.py new file mode 100644 index 0000000..ef9e25f --- /dev/null +++ b/src/ui/layout.py @@ -0,0 +1,70 @@ +"""Load, validate, instantiate, and save user widget layouts.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +from src.core.settings_storage import config_dir +from src.ui.widgets import WIDGET_TYPES, BaseWidget + +DEFAULT_LAYOUT: dict[str, Any] = { + "schema_version": 1, + "width": 280, + "height": 290, + "widgets": [ + {"type": "cpu", "x": 22, "y": 30, "width": 236, "height": 38}, + {"type": "ram", "x": 22, "y": 72, "width": 236, "height": 38}, + {"type": "disk", "x": 22, "y": 114, "width": 236, "height": 38}, + {"type": "gpu_usage", "x": 22, "y": 156, "width": 236, "height": 38}, + {"type": "gpu_temp", "x": 22, "y": 198, "width": 236, "height": 38}, + {"type": "network", "x": 22, "y": 244, "width": 236, "height": 28}, + ], +} + + +def layout_path(name: str = "default") -> Path: + safe_name = "".join(character for character in name if character.isalnum() or character in "-_") or "default" + return config_dir() / f"{safe_name}_layout.json" + + +def load_layout(name: str = "default", path: Path | None = None) -> dict[str, Any]: + target = path or layout_path(name) + try: + data = json.loads(target.read_text(encoding="utf-8")) + if not isinstance(data, dict) or not isinstance(data.get("widgets"), list): + raise TypeError + data["widgets"] = [ + item for item in data["widgets"] if isinstance(item, dict) and item.get("type") in WIDGET_TYPES + ] + return data + except (OSError, json.JSONDecodeError, ValueError): + return deepcopy(DEFAULT_LAYOUT) + + +def create_widgets(layout: dict[str, Any]) -> list[BaseWidget]: + widgets = [] + for definition in layout.get("widgets", []): + widget_type = definition.get("type") + if widget_type in WIDGET_TYPES: + options = {key: value for key, value in definition.items() if key != "type"} + widgets.append(WIDGET_TYPES[widget_type](**options)) + return widgets + + +def save_layout( + widgets: list[BaseWidget], width: int, height: int, name: str = "default", path: Path | None = None +) -> None: + target = path or layout_path(name) + target.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema_version": 1, + "width": width, + "height": height, + "widgets": [widget.serialize() for widget in widgets], + } + temporary = target.with_suffix(".tmp") + temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + temporary.replace(target) diff --git a/src/ui/lin_hud.py b/src/ui/lin_hud.py index 3429117..a0d8fda 100644 --- a/src/ui/lin_hud.py +++ b/src/ui/lin_hud.py @@ -1,6 +1,5 @@ -""" -lin_hud.py ----------- +"""Compatibility import; the main HUD is now cross-platform.""" -Linux runtime UI -""" \ No newline at end of file +from src.ui.hud import GlassHUD + +__all__ = ["GlassHUD"] diff --git a/src/ui/menu.py b/src/ui/menu.py index 002615b..2b5f384 100644 --- a/src/ui/menu.py +++ b/src/ui/menu.py @@ -1,8 +1,6 @@ -from PyQt6.QtWidgets import QApplication -from src.ui.hud import GlassHUD -import sys +"""Compatibility launcher for older development workflows.""" -app = QApplication(sys.argv) -hud = GlassHUD() -hud.show() -sys.exit(app.exec()) +from src.app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ui/settings.py b/src/ui/settings.py new file mode 100644 index 0000000..551c62f --- /dev/null +++ b/src/ui/settings.py @@ -0,0 +1,64 @@ +"""Settings window for runtime preferences.""" + +from copy import deepcopy + +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import ( + QComboBox, + QFormLayout, + QHBoxLayout, + QLabel, + QListWidget, + QSpinBox, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from src.core.theme import load_themes + + +class SettingsWindow(QWidget): + settings_changed = pyqtSignal(dict) + + def __init__(self, settings: dict) -> None: + # This must remain top-level instead of becoming a panel owned by the + # frameless HUD. + super().__init__(windowTitle="Glint Settings") + self.settings = deepcopy(settings) + self.resize(440, 360) + outer_layout = QVBoxLayout(self) + layout = QHBoxLayout() + self.navigation = QListWidget() + self.navigation.addItems(["General", "Appearance", "Layout"]) + self.pages = QStackedWidget() + general = QWidget() + form = QFormLayout(general) + self.refresh = QSpinBox(minimum=250, maximum=60_000, suffix=" ms", value=settings["refresh_interval_ms"]) + form.addRow("Refresh interval", self.refresh) + appearance = QWidget() + form = QFormLayout(appearance) + self.opacity = QSpinBox(minimum=20, maximum=100, suffix=" %", value=round(settings["opacity"] * 100)) + self.theme = QComboBox() + self.theme.addItems(load_themes().keys()) + self.theme.setCurrentText(settings["theme"]) + form.addRow("Opacity", self.opacity) + form.addRow("Theme", self.theme) + layout_page = QWidget() + QFormLayout(layout_page).addRow(QLabel("Layouts are stored as portable JSON in Glint's config folder.")) + for page in (general, appearance, layout_page): + self.pages.addWidget(page) + layout.addWidget(self.navigation) + layout.addWidget(self.pages) + outer_layout.addLayout(layout) + self.navigation.currentRowChanged.connect(self.pages.setCurrentIndex) + self.navigation.setCurrentRow(0) + self.refresh.valueChanged.connect(self._emit) + self.opacity.valueChanged.connect(self._emit) + self.theme.currentTextChanged.connect(self._emit) + + def _emit(self) -> None: + self.settings["refresh_interval_ms"] = self.refresh.value() + self.settings["opacity"] = self.opacity.value() / 100 + self.settings["theme"] = self.theme.currentText() + self.settings_changed.emit(deepcopy(self.settings)) diff --git a/src/ui/tray.py b/src/ui/tray.py index 09c1837..059355d 100644 --- a/src/ui/tray.py +++ b/src/ui/tray.py @@ -1,101 +1,78 @@ -from PyQt6.QtWidgets import QSystemTrayIcon, QMenu -from PyQt6.QtGui import QIcon, QAction +"""System tray integration with portable autostart support.""" + import os +import platform import sys +from pathlib import Path -class TrayManager: - def __init__(self, app, hud): - self.app = app - self.hud = hud - - # ------------------------------------------------- - # ICON - # ------------------------------------------------- - icon_path = os.path.join(os.path.dirname(__file__),"..", "..", "assets", "icon.svg") - self.tray = QSystemTrayIcon(QIcon(icon_path), app) - self.tray.setVisible(True) +from PyQt6.QtCore import QStandardPaths +from PyQt6.QtGui import QAction, QIcon +from PyQt6.QtWidgets import QMenu, QSystemTrayIcon - # ------------------------------------------------- - # MENU - # ------------------------------------------------- - self.menu = QMenu() - # Run on startup - self.startup_action = QAction("Run on Startup", self.menu) - self.startup_action.setCheckable(True) - self.startup_action.setChecked(self.is_in_startup()) +class TrayManager: + def __init__(self, app, hud) -> None: + self.app, self.hud = app, hud + icon = Path(__file__).parents[2] / "assets" / "icon.svg" + self.tray = QSystemTrayIcon(QIcon(str(icon)), app) + menu = QMenu() + menu.addAction("Show Glint", self.show_hud) + menu.addAction("Settings", hud.open_settings) + self.startup_action = QAction("Run on Startup", menu, checkable=True) + self.startup_action.setChecked(self.startup_path().exists()) self.startup_action.triggered.connect(self.toggle_startup) - self.menu.addAction(self.startup_action) - - # Exit - exit_action = QAction("Exit", self.menu) - exit_action.triggered.connect(self.exit_app) - self.menu.addAction(exit_action) - - self.tray.setContextMenu(self.menu) - - # ------------------------------------------------- - # DOUBLE CLICK = SHOW HUD - # ------------------------------------------------- + menu.addAction(self.startup_action) + menu.addSeparator() + menu.addAction("Exit", self.exit_app) + self.tray.setContextMenu(menu) self.tray.activated.connect(self.on_activated) - - # ----------------------------------------------------- - # STARTUP MANAGEMENT (Windows) - # ----------------------------------------------------- - def startup_shortcut_path(self): - import winreg - import pathlib - - # Startup folder - return os.path.join( - os.environ["APPDATA"], - "Microsoft", - "Windows", - "Start Menu", - "Programs", - "Startup", - "Glint.lnk" + self.tray.show() + + @staticmethod + def startup_path() -> Path: + if platform.system() == "Windows": + return ( + Path(os.environ.get("APPDATA", Path.home())) / "Microsoft/Windows/Start Menu/Programs/Startup/Glint.cmd" + ) + if platform.system() == "Darwin": + return Path.home() / "Library/LaunchAgents/dev.zford.glint.plist" + return ( + Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.ConfigLocation)) + / "autostart/glint.desktop" ) - def is_in_startup(self): - return os.path.exists(self.startup_shortcut_path()) - - def toggle_startup(self): - if self.startup_action.isChecked(): - self.add_to_startup() - else: - self.remove_from_startup() - - def add_to_startup(self): + def toggle_startup(self, enabled: bool) -> None: + path = self.startup_path() try: - import winshell - from win32com.client import Dispatch - - shortcut_path = self.startup_shortcut_path() - shell = Dispatch('WScript.Shell') - shortcut = shell.CreateShortcut(shortcut_path) - shortcut.TargetPath = sys.executable - shortcut.Arguments = "" - shortcut.WorkingDirectory = os.getcwd() - shortcut.IconLocation = sys.executable - shortcut.save() - except Exception as e: - print("Failed to add startup:", e) - - def remove_from_startup(self): - try: - os.remove(self.startup_shortcut_path()) - except Exception: - pass - - # ----------------------------------------------------- - # TRAY BEHAVIOR - # ----------------------------------------------------- - def on_activated(self, reason): + if not enabled: + path.unlink(missing_ok=True) + return + path.parent.mkdir(parents=True, exist_ok=True) + executable = Path(sys.executable) + if platform.system() == "Windows": + content = f'@start "" "{executable}" -m src\n' + elif platform.system() == "Darwin": + content = ( + f'' + f"Labeldev.zford.glintProgramArguments" + f"{executable}-msrc" + f"RunAtLoad" + ) + else: + content = f"[Desktop Entry]\nType=Application\nName=Glint\nExec={executable} -m src\nX-GNOME-Autostart-enabled=true\n" + path.write_text(content, encoding="utf-8") + except OSError: + self.startup_action.setChecked(not enabled) + + def show_hud(self) -> None: + self.hud.show() + self.hud.raise_() + self.hud.activateWindow() + + def on_activated(self, reason) -> None: if reason == QSystemTrayIcon.ActivationReason.DoubleClick: - self.hud.show() - self.hud.raise_() + self.show_hud() - def exit_app(self): - self.tray.setVisible(False) + def exit_app(self) -> None: + self.tray.hide() self.app.quit() diff --git a/src/ui/widgets/__init__.py b/src/ui/widgets/__init__.py new file mode 100644 index 0000000..ead4305 --- /dev/null +++ b/src/ui/widgets/__init__.py @@ -0,0 +1,13 @@ +from .base import BaseWidget +from .metrics import CpuWidget, DiskWidget, GpuTempWidget, GpuUsageWidget, NetworkWidget, RamWidget + +WIDGET_TYPES = { + "cpu": CpuWidget, + "ram": RamWidget, + "disk": DiskWidget, + "gpu_temp": GpuTempWidget, + "gpu_usage": GpuUsageWidget, + "network": NetworkWidget, +} + +__all__ = ["WIDGET_TYPES", "BaseWidget"] diff --git a/src/ui/widgets/base.py b/src/ui/widgets/base.py new file mode 100644 index 0000000..332cbb1 --- /dev/null +++ b/src/ui/widgets/base.py @@ -0,0 +1,63 @@ +"""Painter-rendered widget primitives.""" + +from __future__ import annotations + +from typing import Any + +from PyQt6.QtCore import QRectF, Qt +from PyQt6.QtGui import QFont, QLinearGradient, QPainter + +from src.core.theme import color + + +class BaseWidget: + widget_type = "base" + + def __init__(self, x: int = 22, y: int = 22, width: int = 216, height: int = 38, **options: Any) -> None: + self.bounds = QRectF(x, y, width, height) + self.options = options + self.theme: dict[str, Any] = {} + self.data: dict[str, Any] = {} + + def update(self, data: dict[str, Any]) -> None: + self.data = data + + def set_theme(self, theme: dict[str, Any]) -> None: + self.theme = theme + + def serialize(self) -> dict[str, Any]: + return { + "type": self.widget_type, + "x": int(self.bounds.x()), + "y": int(self.bounds.y()), + "width": int(self.bounds.width()), + "height": int(self.bounds.height()), + **self.options, + } + + def draw(self, painter: QPainter) -> None: + raise NotImplementedError + + def draw_bar(self, painter: QPainter, label: str, value: float | None, suffix: str = "%") -> None: + x, y, width = self.bounds.x(), self.bounds.y(), self.bounds.width() + shown = "Unavailable" if value is None else f"{value:.0f}{suffix}" + painter.setPen(color(self.theme, "text", "#F0F0F0")) + painter.setFont(QFont(self.theme.get("font", "Sans Serif"), 9)) + painter.drawText(int(x), int(y + 11), f"{label} {shown}") + track = QRectF(x, y + 18, width, 10) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(color(self.theme, "track", "#14FFFFFF")) + painter.drawRoundedRect(track, 5, 5) + if value is None: + return + bounded = min(100.0, max(0.0, float(value))) + fill_width = width * bounded / 100 + if fill_width <= 0: + return + key = "good" if bounded < 50 else "warning" if bounded < 80 else "critical" + bar_color = color(self.theme, key, "#50DC78") + gradient = QLinearGradient(x, y, x + fill_width, y) + gradient.setColorAt(0, bar_color.lighter(120)) + gradient.setColorAt(1, bar_color) + painter.setBrush(gradient) + painter.drawRoundedRect(QRectF(x, y + 18, fill_width, 10), 5, 5) diff --git a/src/ui/widgets/metrics.py b/src/ui/widgets/metrics.py new file mode 100644 index 0000000..fc4f833 --- /dev/null +++ b/src/ui/widgets/metrics.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from PyQt6.QtGui import QFont, QPainter + +from src.core.theme import color + +from .base import BaseWidget + + +class CpuWidget(BaseWidget): + widget_type = "cpu" + + def draw(self, painter: QPainter) -> None: + self.draw_bar(painter, "CPU", self.data.get("cpu")) + + +class RamWidget(BaseWidget): + widget_type = "ram" + + def draw(self, painter: QPainter) -> None: + self.draw_bar(painter, "RAM", self.data.get("ram")) + + +class DiskWidget(BaseWidget): + widget_type = "disk" + + def draw(self, painter: QPainter) -> None: + disks = self.data.get("disks", {}) + selected = self.options.get("disk") + if selected not in disks: + selected = next(iter(disks), None) + self.draw_bar(painter, selected or "Disk", disks.get(selected) if selected else None) + + +class GpuTempWidget(BaseWidget): + widget_type = "gpu_temp" + + def draw(self, painter: QPainter) -> None: + self.draw_bar(painter, "GPU temp", self.data.get("temps", {}).get("gpu"), "°C") + + +class GpuUsageWidget(BaseWidget): + widget_type = "gpu_usage" + + def draw(self, painter: QPainter) -> None: + self.draw_bar(painter, "GPU", self.data.get("gpu", {}).get("usage")) + + +def _rate(value: float) -> str: + units = ("B/s", "KB/s", "MB/s", "GB/s") + for unit in units: + if value < 1024 or unit == units[-1]: + return f"{value:.1f} {unit}" + value /= 1024 + return "0 B/s" + + +class NetworkWidget(BaseWidget): + widget_type = "network" + + def draw(self, painter: QPainter) -> None: + network = self.data.get("network", {}) + painter.setPen(color(self.theme, "text", "#F0F0F0")) + painter.setFont(QFont(self.theme.get("font", "Sans Serif"), 9)) + painter.drawText( + int(self.bounds.x()), + int(self.bounds.y() + 17), + f"Network ↓ {_rate(network.get('download', 0))} ↑ {_rate(network.get('upload', 0))}", + ) diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..c6f9d81 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,55 @@ +from unittest.mock import Mock, patch + +from src.core.sensors import SensorReader +from src.core.settings_storage import DEFAULT_SETTINGS, load_settings, save_settings +from src.core.theme import load_theme +from src.ui.layout import create_widgets, load_layout, save_layout + + +def test_settings_round_trip_and_validation(tmp_path): + target = tmp_path / "settings.json" + saved = save_settings({**DEFAULT_SETTINGS, "opacity": 0.7, "refresh_interval_ms": 500}, target) + assert load_settings(target) == saved + target.write_text('{"opacity": 4, "refresh_interval_ms": 2}', encoding="utf-8") + assert load_settings(target)["opacity"] == DEFAULT_SETTINGS["opacity"] + + +def test_invalid_json_uses_defaults(tmp_path): + target = tmp_path / "settings.json" + target.write_text("not json", encoding="utf-8") + assert load_settings(target) == DEFAULT_SETTINGS + + +def test_layout_round_trip(tmp_path): + widgets = create_widgets(load_layout(path=tmp_path / "missing.json")) + target = tmp_path / "layout.json" + save_layout(widgets, 300, 400, path=target) + loaded = load_layout(path=target) + assert (loaded["width"], loaded["height"]) == (300, 400) + assert [item["type"] for item in loaded["widgets"]] == [widget.widget_type for widget in widgets] + + +def test_network_throughput_is_delta_per_second(): + first = Mock(bytes_sent=100, bytes_recv=200) + second = Mock(bytes_sent=1124, bytes_recv=2248) + with ( + patch("src.core.sensors.psutil.net_io_counters", side_effect=[first, second]), + patch("src.core.sensors.time.monotonic", side_effect=[10.0, 12.0]), + ): + reader = SensorReader() + assert reader._network_rates() == {"upload": 512.0, "download": 1024.0} + + +def test_sensor_schema_is_stable_without_optional_hardware(): + reader = SensorReader() + with ( + patch.object(reader, "_gpu", return_value={"usage": None, "temperature": None}), + patch.object(reader, "_temperatures", return_value={"cpu": None, "gpu": None}), + patch.object(reader, "_disks", return_value={}), + ): + result = reader.get_all() + assert set(result) == {"cpu", "ram", "disks", "temps", "gpu", "network"} + + +def test_bundled_default_theme_loads(): + assert load_theme()["colors"]["text"] diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..89314ca --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,9 @@ +import pytest + +from scripts.release import project_version, verify_tag + + +def test_release_tag_must_match_project_version(): + verify_tag(f"v{project_version()}") + with pytest.raises(SystemExit): + verify_tag("v0.0.0-wrong") diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..055b2b9 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,17 @@ +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtWidgets import QApplication + +from src.core.settings_storage import DEFAULT_SETTINGS +from src.ui.settings import SettingsWindow + + +def test_settings_is_an_independent_window(): + app = QApplication.instance() or QApplication([]) + window = SettingsWindow(DEFAULT_SETTINGS) + assert window.parentWidget() is None + assert window.isWindow() + window.close() + app.processEvents() From 7b317c510a49d90d3298fd858d145d697c0fa6b4 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Thu, 20 Aug 2026 20:05:00 +1000 Subject: [PATCH 2/4] fix: make Linux CI Qt tests portable --- .github/workflows/python-app.yml | 7 ++++++- .github/workflows/release.yml | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8922694..b2cfd56 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -28,6 +28,11 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: pip + - name: Install Linux Qt runtime libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes libegl1 libgl1 libxkbcommon-x11-0 - name: Install project and development tools run: | python -m pip install --upgrade pip @@ -37,4 +42,4 @@ jobs: - name: Check formatting run: ruff format --check . - name: Test - run: pytest + run: python -m pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8987c3e..31d8147 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,12 +28,16 @@ jobs: with: python-version: "3.10" cache: pip + - name: Install Linux Qt runtime libraries + run: | + sudo apt-get update + sudo apt-get install --yes libegl1 libgl1 libxkbcommon-x11-0 - name: Install and test run: | python -m pip install -e ".[dev]" ruff format --check . ruff check . - QT_QPA_PLATFORM=offscreen pytest + QT_QPA_PLATFORM=offscreen python -m pytest - name: Verify tag matches project version run: python scripts/release.py verify-tag "${{ inputs.tag || github.ref_name }}" From ccec97bbaed7a4739556c2a62efd8a897489157a Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Thu, 20 Aug 2026 20:07:22 +1000 Subject: [PATCH 3/4] ci: add safe release rehearsal mode --- .github/workflows/release.yml | 22 +++++++++++++++++++--- README.md | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31d8147..811a78a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,9 +6,14 @@ on: workflow_dispatch: inputs: tag: - description: Existing v-prefixed tag to build and publish + description: v-prefixed version to validate required: true type: string + publish: + description: Publish an existing tag after successful builds + required: true + default: false + type: boolean concurrency: group: release-${{ github.ref }} @@ -21,9 +26,14 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Check out tagged release + if: github.event_name == 'push' || inputs.publish + uses: actions/checkout@v4 with: ref: ${{ inputs.tag || github.ref }} + - name: Check out rehearsal source + if: github.event_name == 'workflow_dispatch' && !inputs.publish + uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.10" @@ -61,9 +71,14 @@ jobs: archive: gztar runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - name: Check out tagged release + if: github.event_name == 'push' || inputs.publish + uses: actions/checkout@v4 with: ref: ${{ inputs.tag || github.ref }} + - name: Check out rehearsal source + if: github.event_name == 'workflow_dispatch' && !inputs.publish + uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.10" @@ -82,6 +97,7 @@ jobs: publish: needs: build + if: github.event_name == 'push' || inputs.publish runs-on: ubuntu-latest permissions: contents: write diff --git a/README.md b/README.md index 692d7cc..c30e1af 100644 --- a/README.md +++ b/README.md @@ -88,4 +88,4 @@ Maintainers publish a release by updating `project.version` in `pyproject.toml`, 3. creates SHA-256 checksums; and 4. publishes the archives and generated notes to the tagged GitHub Release. -The release workflow can also be started manually for an existing tag from the Actions page. +The workflow can also be started manually in rehearsal mode, which validates and builds without publishing. Enabling its `publish` input requires an existing matching tag and creates the release after all builds succeed. From b82dda393dd261d4f564f3c56d792561cf18735a Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Thu, 20 Aug 2026 20:15:36 +1000 Subject: [PATCH 4/4] fix: package macOS application bundles --- scripts/release.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 217c688..b996b19 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -66,6 +66,10 @@ def build(asset_name: str, archive: str) -> Path: application = ROOT / "dist" / "Glint.app" if not application.exists(): raise SystemExit(f"PyInstaller did not create expected bundle: {application}") + # A windowed onedir macOS build emits both Glint/ and the complete + # Glint.app. The former is only an intermediate COLLECT output; reuse + # its name for the human-friendly release folder. + shutil.rmtree(bundle_root) bundle_root.mkdir() shutil.move(application, bundle_root / application.name) elif not bundle_root.exists():