From 8c1524a57fb8b55752ea97c2067703196d649345 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:17:37 +0000 Subject: [PATCH 1/4] chore(ci): update YASBTrayHook DLLs --- .../services/systray/hook/YASBTrayHook.dll | Bin 114176 -> 114176 bytes .../systray/hook/YASBTrayHook_arm64.dll | Bin 112640 -> 112640 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/src/core/widgets/services/systray/hook/YASBTrayHook.dll b/src/core/widgets/services/systray/hook/YASBTrayHook.dll index 97bca6be648640cae9d0270672147a7f1c3e68fe..ed281c5f164eb2feadc93557bfa7eb4a5bf607fb 100644 GIT binary patch delta 60 zcmZqp!`ASJZG!+KbEo2qW?{zd!i JXEWvo0RUE)5{du- diff --git a/src/core/widgets/services/systray/hook/YASBTrayHook_arm64.dll b/src/core/widgets/services/systray/hook/YASBTrayHook_arm64.dll index b8a3d43cee1ceab097be0a5fcff624cec3cf5cce..22e071dce7856b123c2e103e02f98d8b6948ef23 100644 GIT binary patch delta 3610 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 Date: Tue, 17 Mar 2026 08:26:35 +0700 Subject: [PATCH 2/4] feat(prayer_times): add Prayer Times widget - Implement Prayer Times widget with Aladhan API integration - Support configurable city, country, calculation method, and school - Display current and next prayer times with countdown timer - Add flash animation for prayer time notifications with grace period - Add retry mechanism for network failures - Support tooltip with full daily prayer schedule - Add popup menu with all prayer times for the day - Add callback actions and menu configuration options - Support debug option for flash configuration - Include icon mapping for each prayer time --- docs/widgets/(Widget)-Prayer-Times.md | 425 +++++++++++++ src/core/utils/widgets/prayer_times/api.py | 91 +++ .../validation/widgets/yasb/prayer_times.py | 75 +++ src/core/widgets/yasb/prayer_times.py | 580 ++++++++++++++++++ 4 files changed, 1171 insertions(+) create mode 100644 docs/widgets/(Widget)-Prayer-Times.md create mode 100644 src/core/utils/widgets/prayer_times/api.py create mode 100644 src/core/validation/widgets/yasb/prayer_times.py create mode 100644 src/core/widgets/yasb/prayer_times.py diff --git a/docs/widgets/(Widget)-Prayer-Times.md b/docs/widgets/(Widget)-Prayer-Times.md new file mode 100644 index 000000000..aaa297f39 --- /dev/null +++ b/docs/widgets/(Widget)-Prayer-Times.md @@ -0,0 +1,425 @@ +# Prayer Times Widget + +Displays Islamic prayer times fetched from the [Aladhan API](https://aladhan.com/prayer-times-api). Shows the next upcoming (or currently active) prayer by default, with an alt label that lists all daily prayer times. Left-clicking opens a popup card listing all prayers with their times and remaining countdowns. + +## Options + +| Option | Type | Default | Description | +|---------------------|---------|---------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `label` | string | `"{icon} {next_prayer} {next_prayer_time}"` | Format string for the primary label. Supports all placeholders listed below. | +| `label_alt` | string | `"Fajr {fajr} · Dhuhr {dhuhr} · Asr {asr} · Maghrib {maghrib} · Isha {isha}"` | Format string for the alternate label. | +| `class_name` | string | `""` | Additional CSS class name for the widget. | +| `latitude` | float | `51.5074` | Latitude of your location (−90 to 90). | +| `longitude` | float | `-0.1278` | Longitude of your location (−180 to 180). | +| `method` | integer | `2` | Aladhan calculation method ID. See [method list](#method-ids). | +| `school` | integer | `0` | Juristic school for Asr: `0` = Shafi'i / Standard, `1` = Hanafi. | +| `midnight_mode` | integer | `0` | Midnight mode: `0` = Standard (mid sunset-to-sunrise), `1` = Jafari (mid sunset-to-Fajr). | +| `tune` | string | `""` | Comma-separated minute offsets for each prayer (Imsak,Fajr,Sunrise,Dhuhr,Asr,Maghrib,Sunset,Isha,Midnight). | +| `timezone` | string | `""` | IANA timezone string (e.g. `"Asia/Jakarta"`). Defaults to the server's local timezone. | +| `shafaq` | string | `""` | Shafaq type used for Isha calculation in some methods (`general`, `ahmer`, `abyad`). | +| `prayers_to_show` | list | `["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"]` | Ordered list of prayers used to determine the active/next prayer, popup rows, and tooltip. Must match Aladhan names exactly. | +| `grace_period` | integer | `15` | Minutes to stay on the current prayer after its time before advancing to the next. Min `0`, max `120`. | +| `update_interval` | integer | `3600` | How often (in seconds) to re-fetch prayer times from the API. Min `60`, max `86400`. | +| `tooltip` | boolean | `true` | Show a hover tooltip summarising prayer times. Displays a **"Today's Prayers"** (or **"Tomorrow's Prayers"**) header, each prayer in `prayers_to_show` with its time, and a `◀` marker on the next upcoming prayer. | +| `icons` | dict | *(see below)* | Nerd Font icon per prayer name. Includes `mosque` shown in the popup header. | +| `menu` | dict | *(see below)* | Appearance and position settings for the popup card. | +| `flash` | dict | *(see below)* | Smooth animated glow effect triggered when a prayer time arrives. | +| `callbacks` | dict | `{on_left: "toggle_card", on_middle: "do_nothing", on_right: "toggle_label"}` | Mouse-click actions. | +| `animation` | dict | `{enabled: true, type: "fadeInOut", duration: 200}` | Animation settings for the toggle_card transition. | +| `label_shadow` | dict | `{enabled: false, color: "black", radius: 3, offset: [1, 1]}` | Label shadow options. | +| `container_shadow` | dict | `{enabled: false, color: "black", radius: 3, offset: [1, 1]}` | Container shadow options. | +| `keybindings` | list | `[]` | Hotkey bindings. | + +### Label Placeholders + +| Placeholder | Description | +|----------------------|---------------------------------------------------------------------------------------| +| `{icon}` | Icon for the currently active or next upcoming prayer | +| `{next_prayer}` | Name of the currently active or next upcoming prayer (e.g. `Asr`) | +| `{next_prayer_time}` | Time of the currently active or next upcoming prayer (e.g. `15:14`) | +| `{fajr}` | Fajr time | +| `{sunrise}` | Sunrise time | +| `{dhuhr}` | Dhuhr time | +| `{asr}` | Asr time | +| `{sunset}` | Sunset time | +| `{maghrib}` | Maghrib time | +| `{isha}` | Isha time | +| `{imsak}` | Imsak time | +| `{midnight}` | Midnight time | +| `{hijri_date}` | Full Hijri date (e.g. `23 Sha'bān 1446`) | +| `{hijri_day}` | Hijri day number | +| `{hijri_month}` | Hijri month name (English) | +| `{hijri_year}` | Hijri year | + +> **Note on `{next_prayer}` / `{icon}`:** During the `grace_period` window after a prayer's time, these values stay on the current prayer rather than jumping to the next one. + +### Default Icons + +```yaml +icons: + mosque: "\uf67f" # Shown in the popup card header + fajr: "\uf185" + sunrise: "\uf185" + dhuhr: "\uf185" + asr: "\uf185" + maghrib: "\uf186" + isha: "\uf186" + imsak: "\uf185" + sunset: "\uf185" + midnight: "\uf186" + default: "\uf017" # Fallback when no matching icon is found +``` + +### Menu Options + +Controls the popup card that opens on `toggle_card`. + +| Option | Type | Default | Description | +|----------------------|---------|------------|--------------------------------------------------------------------------| +| `blur` | boolean | `true` | Apply blur effect to the popup background. | +| `round_corners` | boolean | `true` | Round the popup corners (not supported on Windows 10). | +| `round_corners_type` | string | `"normal"` | Corner style: `"normal"` or `"small"` (not supported on Windows 10). | +| `border_color` | string | `"System"` | Border color: `"System"`, `None`, or a hex color e.g. `"#ff0000"`. | +| `alignment` | string | `"right"` | Popup alignment relative to the widget: `"left"`, `"center"`, `"right"`. | +| `direction` | string | `"down"` | Direction the popup opens: `"up"` or `"down"`. | +| `offset_top` | integer | `6` | Vertical offset in pixels from the bar edge. | +| `offset_left` | integer | `0` | Horizontal offset in pixels from the widget edge. | + +### Flash Options + +Controls the smooth animated glow effect that triggers when a prayer time arrives. + +| Option | Type | Default | Description | +|------------|---------|-------------|-----------------------------------------------------------------------------------------------------------| +| `enabled` | boolean | `true` | Whether to enable the flash effect. | +| `debug` | boolean | `false` | Trigger the flash animation immediately on widget startup (useful for testing colors and timing). | +| `duration` | integer | `30` | How long (in seconds) to run the flash after the prayer time arrives. Min `1`, max `3600`. | +| `interval` | integer | `500` | Duration in milliseconds of one half-cycle (fade to `color_a`, then back). Min `100`, max `5000`. | +| `color_a` | string | `"#ff8c00"` | The bright peak color the background pulses to on each cycle. | +| `color_b` | string | `"#1e1e2e"` | The dim base color the background fades from. Should match your container background. | + +The animation uses `QVariantAnimation` with an `InOutSine` easing curve, producing a smooth pulse rather than an abrupt flash. Colors ping-pong (`color_b` → `color_a` → `color_b` → …) for the full `duration`. The background is applied directly to the entire widget container so the glow covers the whole pill. The label also receives a `flash` CSS class so you can change the text color independently via CSS. + +### Grace Period + +The `grace_period` option (default `15` minutes) controls how long the widget stays on the current prayer after its time has passed, before moving to the next. + +**Example:** Asr at 15:14 with `grace_period: 15` → label shows `Asr 15:14` until 15:29, then switches to Maghrib. + +This affects: +- **Bar label** — `{next_prayer}` and `{icon}` stay on the current prayer during the grace window. +- **Popup card** — the active row shows an elapsed label (e.g. `5m ago`) instead of `passed` while still within the grace window. +- **Tomorrow's schedule** — fetching tomorrow's times is deferred until the last prayer's grace window has fully expired. + +## Callbacks + +| Callback | Description | +|----------------|---------------------------------------------| +| `toggle_card` | Open/close the popup card. | +| `toggle_label` | Toggle between primary and alternate label. | +| `update_label` | Force a label refresh. | +| `do_nothing` | No action. | + +## Minimal Configuration + +```yaml +prayer_times: + type: "yasb.prayer_times.PrayerTimesWidget" + options: + label: "{icon} {next_prayer} {next_prayer_time}" + latitude: -6.178306 + longitude: 106.631889 + method: 20 # Kementerian Agama Republik Indonesia + timezone: "Asia/Jakarta" +``` + +## Example Configuration + +```yaml +prayer_times: + type: "yasb.prayer_times.PrayerTimesWidget" + options: + label: "{icon} {next_prayer} {next_prayer_time}" + label_alt: "Fajr {fajr} · Dhuhr {dhuhr} · Asr {asr} · Maghrib {maghrib} · Isha {isha}" + latitude: -6.178306 + longitude: 106.631889 + method: 20 # Kementerian Agama Republik Indonesia + school: 0 # Shafi'i / Standard + midnight_mode: 0 + shafaq: "general" + tune: "5,3,5,7,9,-1,0,8,-6" # Minute offsets: Imsak,Fajr,Sunrise,Dhuhr,Asr,Maghrib,Sunset,Isha,Midnight + timezone: "Asia/Jakarta" + prayers_to_show: + - "Imsak" + - "Fajr" + - "Sunrise" + - "Dhuhr" + - "Asr" + - "Sunset" + - "Maghrib" + - "Isha" + grace_period: 15 # Stay on current prayer for 15 min after its time + update_interval: 3600 + tooltip: true + icons: + mosque: "\uf67f" + fajr: "\uf185" + sunrise: "\uf185" + dhuhr: "\uf185" + asr: "\uf185" + sunset: "\uf185" + maghrib: "\uf186" + isha: "\uf186" + imsak: "\uf185" + midnight: "\uf186" + default: "\uf017" + menu: + blur: true + round_corners: true + round_corners_type: "normal" + border_color: "System" + alignment: "right" + direction: "down" + offset_top: 6 + offset_left: 0 + flash: + enabled: true + debug: false + duration: 60 # Flash for 60 seconds + interval: 800 # 800ms per half-cycle + color_a: "#ff8c00" # Bright glow color + color_b: "#1e1e2e" # Dim base color (match your container background) + callbacks: + on_left: "toggle_card" + on_middle: "do_nothing" + on_right: "toggle_label" + animation: + enabled: true + type: "fadeInOut" + duration: 200 + label_shadow: + enabled: true + color: "#000000" + radius: 2 + offset: [1, 1] + container_shadow: + enabled: false + color: "black" + radius: 3 + offset: [1, 1] +``` + +## Method IDs + +Commonly used Aladhan calculation method IDs: + +| ID | Name | +|-----|--------------------------------------------------| +| 1 | University of Islamic Sciences, Karachi | +| 2 | Islamic Society of North America (ISNA) | +| 3 | Muslim World League | +| 4 | Umm Al-Qura University, Makkah | +| 5 | Egyptian General Authority of Survey | +| 11 | Majlis Ugama Islam Singapura, Singapore | +| 12 | Union Organization Islamic de France | +| 13 | Diyanet İşleri Başkanlığı, Turkey | +| 14 | Spiritual Administration of Muslims of Russia | +| 15 | Moonsighting Committee Worldwide (Khalid Shaukat)| +| 16 | Dubai, UAE | +| 17 | Jabatan Kemajuan Islam Malaysia (JAKIM) | +| 18 | Tunisia | +| 19 | Algeria | +| 20 | Kementerian Agama Republik Indonesia | +| 21 | Morocco | +| 22 | Comunidade Islâmica de Lisboa, Portugal | +| 23 | Ministry of Awqaf, Jordan and Palestine | + +For the full list and custom (`method=99`) options, see the [Aladhan API docs](https://aladhan.com/prayer-times-api). + +## Available Styles + +> **Note:** The active prayer name is added as a CSS class on the bar label (e.g. `.label.fajr`, `.label.maghrib`), allowing you to colour each prayer differently. + +```css +/* ── Bar widget ──────────────────────────────────────────────────── */ +.prayer-times-widget {} +.prayer-times-widget.your_class {} /* If class_name is set */ +.prayer-times-widget .widget-container {} +.prayer-times-widget .label {} +.prayer-times-widget .label.alt {} /* Alt label (toggle_label) */ +.prayer-times-widget .label.loading {} /* While API is fetching */ +.prayer-times-widget .icon {} /* Span elements without an explicit class (e.g. \uf67f) */ + +/* Per-prayer label classes (applied while that prayer is active/current) */ +.prayer-times-widget .label.fajr {} +.prayer-times-widget .label.sunrise {} +.prayer-times-widget .label.dhuhr {} +.prayer-times-widget .label.asr {} +.prayer-times-widget .label.sunset {} +.prayer-times-widget .label.maghrib {} +.prayer-times-widget .label.isha {} +.prayer-times-widget .label.imsak {} +.prayer-times-widget .label.midnight {} + +/* Flash: applied to the label during the animated background glow */ +.prayer-times-widget .label.flash {} /* Background color is animated in Python via QVariantAnimation */ +.prayer-times-widget .label.alt.flash {} /* Same, when the alt label is currently shown */ + +/* ── Popup card ──────────────────────────────────────────────────── */ +.prayer-times-menu {} +.prayer-times-menu .header {} +.prayer-times-menu .header .mosque-icon {} +.prayer-times-menu .header .title {} +.prayer-times-menu .header .hijri-date {} +.prayer-times-menu .rows-container {} +.prayer-times-menu .prayer-row {} +.prayer-times-menu .prayer-row.active {} /* Currently active prayer (within grace period) */ +.prayer-times-menu .prayer-row.passed {} /* Prayers whose grace period has fully expired */ +.prayer-times-menu .prayer-icon {} +.prayer-times-menu .prayer-name {} +.prayer-times-menu .prayer-time {} +.prayer-times-menu .prayer-remaining {} /* "in 2h 15m" / "5m ago" (grace window) / "passed" */ +.prayer-times-menu .footer {} +.prayer-times-menu .method-name {} /* Calculation method name shown in footer */ +.prayer-times-menu .loading-placeholder {} /* Shown before the first API response arrives */ +``` + +## Example Style + +```css +/* ── Bar widget ─────────────────────────────────────────────────── */ +.prayer-times-widget { + padding: 0 6px; +} +.prayer-times-widget .widget-container { + background-color: rgba(17, 17, 27, 0.5); + margin: 4px 0; + border-radius: 12px; + border: 1px solid #45475a; + padding: 0 10px; +} +.prayer-times-widget .widget-container:hover { + background-color: #282936; + border-color: #cba6f7; +} +.prayer-times-widget .icon { + font-size: 16px; + color: #cba6f7; + margin: 0 4px 0 0; +} +.prayer-times-widget .label { + font-size: 13px; + color: #cdd6f4; + font-weight: 600; +} +.prayer-times-widget .label.loading { + color: #6c7086; +} + +/* Per-prayer label accent colours */ +.prayer-times-widget .label.imsak { color: #74c7ec; } +.prayer-times-widget .label.fajr { color: #74c7ec; } +.prayer-times-widget .label.sunrise { color: #f9e2af; } +.prayer-times-widget .label.dhuhr { color: #f9e2af; } +.prayer-times-widget .label.asr { color: #fab387; } +.prayer-times-widget .label.sunset { color: #fab387; } +.prayer-times-widget .label.maghrib { color: #cba6f7; } +.prayer-times-widget .label.isha { color: #b4befe; } +.prayer-times-widget .label.midnight { color: #b4befe; } + +/* Flash: text color during the animated background glow */ +.prayer-times-widget .label.flash { + color: #ff8c00; +} +.prayer-times-widget .label.alt.flash { + color: #ff8c00; +} + +/* ── Popup card ─────────────────────────────────────────────────── */ +.prayer-times-menu { + background-color: rgba(30, 30, 46, 0.95); + min-width: 300px; +} +.prayer-times-menu .header { + background-color: rgba(17, 17, 27, 0.9); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} +.prayer-times-menu .header .mosque-icon { + font-size: 18px; + color: #cba6f7; +} +.prayer-times-menu .header .title { + font-size: 14px; + font-weight: 700; + font-family: 'Segoe UI'; + color: #ffffff; +} +.prayer-times-menu .header .hijri-date { + font-size: 11px; + font-weight: 600; + font-family: 'Segoe UI'; + color: #a6adc8; +} +.prayer-times-menu .rows-container { + padding: 6px 0; +} +.prayer-times-menu .prayer-row { + background-color: transparent; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} +.prayer-times-menu .prayer-row.active { + background-color: rgba(203, 166, 247, 0.12); + border-left: 2px solid #cba6f7; +} +.prayer-times-menu .prayer-row.passed { + opacity: 0.4; +} +.prayer-times-menu .prayer-icon { + font-size: 15px; + color: #cba6f7; +} +.prayer-times-menu .prayer-row.active .prayer-icon { color: #cba6f7; } +.prayer-times-menu .prayer-row.passed .prayer-icon { color: #7f849c; } +.prayer-times-menu .prayer-name { + font-size: 13px; + font-weight: 600; + font-family: 'Segoe UI'; + color: #cdd6f4; +} +.prayer-times-menu .prayer-row.active .prayer-name { color: #cba6f7; } +.prayer-times-menu .prayer-row.passed .prayer-name { color: #9399b2; } +.prayer-times-menu .prayer-time { + font-size: 13px; + font-weight: 700; + font-family: 'Segoe UI'; + color: #cdd6f4; +} +.prayer-times-menu .prayer-row.active .prayer-time { color: #cba6f7; } +.prayer-times-menu .prayer-remaining { + font-size: 11px; + font-weight: 600; + font-family: 'Segoe UI'; + color: #a6adc8; +} +.prayer-times-menu .prayer-row.active .prayer-remaining { + color: #cba6f7; + font-weight: 700; +} +.prayer-times-menu .footer { + background-color: rgba(17, 17, 27, 0.6); + border-top: 1px solid rgba(255, 255, 255, 0.08); +} +.prayer-times-menu .method-name { + font-size: 11px; + font-weight: 600; + font-family: 'Segoe UI'; + color: #7f849c; +} +.prayer-times-menu .loading-placeholder { + padding: 28px 16px; + font-size: 12px; + font-weight: 600; + font-family: 'Segoe UI'; + color: #6c7086; +} +``` diff --git a/src/core/utils/widgets/prayer_times/api.py b/src/core/utils/widgets/prayer_times/api.py new file mode 100644 index 000000000..0f968ce59 --- /dev/null +++ b/src/core/utils/widgets/prayer_times/api.py @@ -0,0 +1,91 @@ +import json +import logging +import traceback +from typing import Callable + +from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtSignal +from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest + +HEADER = (b"User-Agent", b"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0") +RETRY_INTERVAL_MS = 5_000 # retry every 5 s when the network is unavailable + + +class PrayerTimesDataFetcher(QObject): + """Fetches Islamic prayer times from the Aladhan API.""" + + finished = pyqtSignal(dict) + + def __init__(self, parent: QObject, url_factory: Callable[[], str], timeout_ms: int): + """ + Args: + parent: Qt parent object. + url_factory: A callable that returns the current API URL string. + Called on every request so the date is always today. + timeout_ms: Interval between automatic re-fetches, in milliseconds. + """ + super().__init__(parent) + self.started = False + self._url_factory = url_factory + self._manager = QNetworkAccessManager(self) + self._manager.finished.connect(self._handle_response) + self._timer = QTimer(self) + self._timer.setInterval(timeout_ms) + self._timer.timeout.connect(self.make_request) + # Single-shot timer used to retry quickly after a network failure. + self._retry_timer = QTimer(self) + self._retry_timer.setSingleShot(True) + self._retry_timer.timeout.connect(self.make_request) + + def start(self) -> None: + """Begin periodic fetching. The first request fires immediately.""" + self.make_request() + self._timer.start() + self.started = True + + def make_request(self) -> None: + """Make a single API request using the current URL from url_factory.""" + url = QUrl(self._url_factory()) + if not url.isValid(): + logging.error("Prayer times: built an invalid URL — check latitude/longitude settings.") + return + request = QNetworkRequest(url) + request.setRawHeader(*HEADER) + self._manager.get(request) + + def _handle_response(self, reply: QNetworkReply) -> None: + try: + error = reply.error() + status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) + if error == QNetworkReply.NetworkError.NoError: + raw = reply.readAll().data().decode("utf-8", errors="replace") + data = json.loads(raw) + if data.get("code") == 200: + self._retry_timer.stop() + self.finished.emit(data) + else: + logging.error(f"Prayer times API returned non-200 code: {data.get('code')} — {data.get('status')}") + self.finished.emit({}) + self._schedule_retry() + elif error == QNetworkReply.NetworkError.HostNotFoundError: + logging.warning("Prayer times: no internet connection or host not found.") + self.finished.emit({}) + self._schedule_retry() + else: + logging.error(f"Prayer times API network error {status}: {error}") + self.finished.emit({}) + self._schedule_retry() + except json.JSONDecodeError as e: + logging.error(f"Prayer times: invalid JSON in response: {e}") + self.finished.emit({}) + self._schedule_retry() + except Exception as e: + logging.error(f"Prayer times: unexpected error: {e}\n{traceback.format_exc()}") + self.finished.emit({}) + self._schedule_retry() + finally: + reply.deleteLater() + + def _schedule_retry(self) -> None: + """Schedule a quick retry if one is not already pending.""" + if not self._retry_timer.isActive(): + self._retry_timer.start(RETRY_INTERVAL_MS) diff --git a/src/core/validation/widgets/yasb/prayer_times.py b/src/core/validation/widgets/yasb/prayer_times.py new file mode 100644 index 000000000..7d4889cdd --- /dev/null +++ b/src/core/validation/widgets/yasb/prayer_times.py @@ -0,0 +1,75 @@ +from pydantic import Field + +from core.validation.widgets.base_model import ( + AnimationConfig, + CallbacksConfig, + CustomBaseModel, + KeybindingConfig, + ShadowConfig, +) + + +class PrayerTimesCallbacksConfig(CallbacksConfig): + on_left: str = "toggle_card" + on_middle: str = "do_nothing" + on_right: str = "toggle_label" + + +class PrayerTimesIconsConfig(CustomBaseModel): + mosque: str = "\uf67f" + fajr: str = "\uf185" + sunrise: str = "\uf185" + dhuhr: str = "\uf185" + asr: str = "\uf185" + maghrib: str = "\uf186" + isha: str = "\uf186" + imsak: str = "\uf185" + sunset: str = "\uf185" + midnight: str = "\uf186" + default: str = "\uf017" + + +class PrayerTimesMenuConfig(CustomBaseModel): + blur: bool = True + round_corners: bool = True + round_corners_type: str = "normal" + border_color: str = "System" + alignment: str = "right" + direction: str = "down" + offset_top: int = 6 + offset_left: int = 0 + + +class PrayerTimesFlashConfig(CustomBaseModel): + enabled: bool = True + debug: bool = False + duration: int = Field(default=30, ge=1, le=3600) + interval: int = Field(default=500, ge=100, le=5000) + color_a: str = "#ff8c00" + color_b: str = "#1e1e2e" + + +class PrayerTimesConfig(CustomBaseModel): + label: str = "{icon} {next_prayer} {next_prayer_time}" + label_alt: str = "Fajr {fajr} · Dhuhr {dhuhr} · Asr {asr} · Maghrib {maghrib} · Isha {isha}" + class_name: str = "" + latitude: float = Field(default=51.5074, ge=-90.0, le=90.0) + longitude: float = Field(default=-0.1278, ge=-180.0, le=180.0) + method: int = Field(default=2, ge=0, le=99) + school: int = Field(default=0, ge=0, le=1) + midnight_mode: int = Field(default=0, ge=0, le=1) + tune: str = "" + timezone: str = "" + shafaq: str = "" + prayers_to_show: list[str] = ["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"] + grace_period: int = Field(default=15, ge=0, le=120) + update_interval: int = Field(default=3600, ge=60, le=86400) + tooltip: bool = True + icons: PrayerTimesIconsConfig = PrayerTimesIconsConfig() + menu: PrayerTimesMenuConfig = PrayerTimesMenuConfig() + flash: PrayerTimesFlashConfig = PrayerTimesFlashConfig() + animation: AnimationConfig = AnimationConfig() + label_shadow: ShadowConfig = ShadowConfig() + container_shadow: ShadowConfig = ShadowConfig() + callbacks: PrayerTimesCallbacksConfig = PrayerTimesCallbacksConfig() + keybindings: list[KeybindingConfig] = [] diff --git a/src/core/widgets/yasb/prayer_times.py b/src/core/widgets/yasb/prayer_times.py new file mode 100644 index 000000000..d8854794e --- /dev/null +++ b/src/core/widgets/yasb/prayer_times.py @@ -0,0 +1,580 @@ +import logging +import re +import urllib.parse +from datetime import datetime, timedelta +from typing import Any + +from PyQt6.QtCore import QEasingCurve, Qt, QTimer, QVariantAnimation +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from core.utils.tooltip import set_tooltip +from core.utils.utilities import PopupWidget, add_shadow, build_widget_label, refresh_widget_style +from core.utils.widgets.animation_manager import AnimationManager +from core.utils.widgets.prayer_times.api import PrayerTimesDataFetcher +from core.validation.widgets.yasb.prayer_times import PrayerTimesConfig +from core.widgets.base import BaseWidget + +# Canonical ordering as returned by the Aladhan API. +ALL_PRAYER_NAMES = ["Imsak", "Fajr", "Sunrise", "Dhuhr", "Asr", "Sunset", "Maghrib", "Isha", "Midnight"] +_DEFAULT_PRAYERS: list[str] = ["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"] +_DELTA_PASSED = "passed" + +# Fixed column widths for popup prayer rows (pixels). +_POPUP_ICON_COL_W = 32 +_POPUP_NAME_COL_W = 80 +_POPUP_TIME_COL_W = 52 + + +class PrayerTimesWidget(BaseWidget): + """Widget that displays Islamic prayer times sourced from the Aladhan API. + + Renders the next upcoming prayer on the bar, supports an alternate label + for a quick all-prayer overview, and opens a popup card with individual + prayer rows and Hijri date information. A configurable flash animation + fires at the exact minute of each prayer. + """ + + validation_schema = PrayerTimesConfig + + def __init__(self, config: PrayerTimesConfig): + super().__init__(class_name=f"prayer-times-widget {config.class_name}") + self.config = config + self._show_alt_label = False + self._timings: dict[str, str] = {} + self._hijri: dict[str, Any] = {} + self._meta: dict[str, Any] = {} + self._popup: PopupWidget | None = None + self._popup_row_widgets: dict[str, dict[str, QWidget]] = {} + self._loading: bool = True + self._date_offset: int = 0 + self._current_date: str = datetime.now().strftime("%Y-%m-%d") + self._widgets: list[QWidget] = [] + self._widgets_alt: list[QWidget] = [] + + # --- Container --- + self._widget_container_layout = QHBoxLayout() + self._widget_container_layout.setSpacing(0) + self._widget_container_layout.setContentsMargins(0, 0, 0, 0) + self._widget_container = QFrame() + self._widget_container.setLayout(self._widget_container_layout) + self._widget_container.setProperty("class", "widget-container") + add_shadow(self._widget_container, config.container_shadow.model_dump()) + self.widget_layout.addWidget(self._widget_container) + + build_widget_label(self, config.label, config.label_alt, config.label_shadow.model_dump()) + + # --- Callbacks --- + self.register_callback("toggle_label", self._toggle_label) + self.register_callback("toggle_card", self._toggle_card) + self.register_callback("update_label", self._update_label) + self.callback_left = config.callbacks.on_left + self.callback_right = config.callbacks.on_right + self.callback_middle = config.callbacks.on_middle + + # --- API fetcher --- + self._fetcher = PrayerTimesDataFetcher( + self, + url_factory=self._build_api_url, + timeout_ms=config.update_interval * 1000, + ) + self._fetcher.finished.connect(self._on_data_received) + self._fetcher.start() + + # --- Minute timer: re-render label + open popup --- + self._minute_timer = QTimer(self) + self._minute_timer.setInterval(60_000) + self._minute_timer.timeout.connect(self._on_minute_tick) + self._minute_timer.start() + + # --- Flash animation --- + self._flash_anim = QVariantAnimation(self) + self._flash_anim.setEasingCurve(QEasingCurve.Type.InOutSine) + self._flash_anim.valueChanged.connect(self._on_flash_frame) + self._flash_anim.finished.connect(self._on_flash_half_done) + self._flash_stop_timer = QTimer(self) + self._flash_stop_timer.setSingleShot(True) + self._flash_stop_timer.timeout.connect(self._stop_flash) + + # Show loading placeholder immediately before first API response + self._update_label() + + # Trigger flash immediately for debugging + if config.flash.enabled and config.flash.debug: + QTimer.singleShot(500, self._start_flash) + + # ------------------------------------------------------------------ + # URL builder + # ------------------------------------------------------------------ + + def _build_api_url(self) -> str: + """Return the Aladhan timings URL for the target date (today or tomorrow).""" + today = (datetime.now() + timedelta(days=self._date_offset)).strftime("%d-%m-%Y") + params: dict[str, Any] = { + "latitude": self.config.latitude, + "longitude": self.config.longitude, + "method": self.config.method, + "school": self.config.school, + "midnightMode": self.config.midnight_mode, + } + if self.config.tune: + params["tune"] = self.config.tune + if self.config.timezone: + params["timezonestring"] = self.config.timezone + if self.config.shafaq: + params["shafaq"] = self.config.shafaq + return f"https://api.aladhan.com/v1/timings/{today}?{urllib.parse.urlencode(params)}" + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @property + def _prayers(self) -> list[str]: + """Return the configured list of prayers to show, falling back to defaults.""" + return self.config.prayers_to_show or _DEFAULT_PRAYERS + + @property + def _icon_map(self) -> dict[str, str]: + """Return a mapping from prayer name to its configured icon character.""" + ic = self.config.icons + return { + "Fajr": ic.fajr, + "Sunrise": ic.sunrise, + "Dhuhr": ic.dhuhr, + "Asr": ic.asr, + "Sunset": ic.sunset, + "Maghrib": ic.maghrib, + "Isha": ic.isha, + "Imsak": ic.imsak, + "Midnight": ic.midnight, + } + + def _parse_hhmm(self, time_str: str) -> tuple[int, int] | None: + """Parse a 'HH:MM' string, returning (hour, minute) or None on failure.""" + try: + return int(time_str[:2]), int(time_str[3:5]) + except ValueError, IndexError: + return None + + # ------------------------------------------------------------------ + # Data handling + # ------------------------------------------------------------------ + + def _on_data_received(self, data: dict) -> None: + if not data: + return + try: + self._timings = data["data"]["timings"] + self._hijri = data["data"]["date"]["hijri"] + self._meta = data["data"].get("meta", {}) + self._loading = False + self._update_label() + # If today's prayers are all done and we haven't switched to tomorrow yet, + # immediately re-fetch tomorrow's schedule. + if self._date_offset == 0 and self._all_prayers_passed(): + self._date_offset = 1 + self._fetcher.make_request() + except (KeyError, TypeError) as exc: + logging.error(f"Prayer times widget: failed to parse API response: {exc}") + + def _on_minute_tick(self) -> None: + # Reset to today when the calendar date changes (midnight rollover). + today = datetime.now().strftime("%Y-%m-%d") + if today != self._current_date: + self._current_date = today + self._date_offset = 0 + self._fetcher.make_request() + return + self._update_label() + if self.config.flash.enabled and self._check_prayer_time(): + self._start_flash() + if self._popup is not None: + try: + self._refresh_popup_rows() + except RuntimeError: + self._popup = None + + def _all_prayers_passed(self) -> bool: + """Return True if every prayer in prayers_to_show has already passed today (including grace period).""" + now = datetime.now() + grace = timedelta(minutes=self.config.grace_period) + for name in self._prayers: + time_str = self._timings.get(name, "") + if not time_str or time_str == "--:--": + continue + parsed = self._parse_hhmm(time_str) + if parsed is None: + continue + h, m = parsed + if now.replace(hour=h, minute=m, second=0, microsecond=0) + grace > now: + return False + return True + + # ------------------------------------------------------------------ + # Next prayer helpers + # ------------------------------------------------------------------ + + def _get_next_prayer(self) -> tuple[str, str]: + """Return (prayer_name, time_str) for the current or next upcoming prayer. + + A prayer is considered 'current' for grace_period minutes after its time, + so the label doesn't immediately jump to the next prayer when the time hits. + """ + if not self._timings: + return ("—", "--:--") + now = datetime.now() + grace = timedelta(minutes=self.config.grace_period) + target_date = now + timedelta(days=self._date_offset) + for name in self._prayers: + time_str = self._timings.get(name, "") + if not time_str or time_str == "--:--": + continue + parsed = self._parse_hhmm(time_str) + if parsed is None: + continue + h, m = parsed + if target_date.replace(hour=h, minute=m, second=0, microsecond=0) + grace > now: + return (name, time_str) + first = self._prayers[0] + return (first, self._timings.get(first, "--:--")) + + def _time_delta_text(self, time_str: str) -> str: + """Return human-readable remaining/elapsed label for a prayer time string.""" + if not time_str or time_str == "--:--": + return "" + parsed = self._parse_hhmm(time_str) + if parsed is None: + return "" + now = datetime.now() + target_date = now + timedelta(days=self._date_offset) + h, m = parsed + target = target_date.replace(hour=h, minute=m, second=0, microsecond=0) + delta = target - now + grace = timedelta(minutes=self.config.grace_period) + if delta.total_seconds() < 0: + # Within grace period: show how many minutes into the prayer we are + if abs(delta) < grace: + elapsed_min = int(abs(delta).total_seconds() // 60) + return f"{elapsed_min}m ago" + return _DELTA_PASSED + total_min = int(delta.total_seconds() // 60) + hours, mins = divmod(total_min, 60) + if hours > 0: + return f"in {hours}h {mins:02d}m" + return f"in {mins}m" + + # ------------------------------------------------------------------ + # Label options dict + # ------------------------------------------------------------------ + + def _build_label_options(self) -> dict[str, str]: + """Build the dict of {placeholder: value} for string substitution.""" + options: dict[str, str] = {} + for name in ALL_PRAYER_NAMES: + options[f"{{{name.lower()}}}"] = self._timings.get(name, "--:--") + next_name, next_time = self._get_next_prayer() + options["{next_prayer}"] = next_name + options["{next_prayer_time}"] = next_time + options["{icon}"] = self._icon_map.get(next_name, self.config.icons.default) + if self._hijri: + options["{hijri_day}"] = self._hijri.get("day", "") + options["{hijri_month}"] = self._hijri.get("month", {}).get("en", "") + options["{hijri_year}"] = self._hijri.get("year", "") + options["{hijri_date}"] = f"{options['{hijri_day}']} {options['{hijri_month}']} {options['{hijri_year}']}" + else: + for k in ("{hijri_day}", "{hijri_month}", "{hijri_year}", "{hijri_date}"): + options[k] = "" + return options + + # ------------------------------------------------------------------ + # Bar label update + # ------------------------------------------------------------------ + + def _update_label(self, update_class: bool = True) -> None: + active_widgets = self._widgets_alt if self._show_alt_label else self._widgets + active_content = self.config.label_alt if self._show_alt_label else self.config.label + if self._loading: + for widget in active_widgets: + if isinstance(widget, QLabel): + widget.setText("Loading...") + widget.setProperty("class", "label loading") + refresh_widget_style(widget) + return + label_options = self._build_label_options() + label_parts = [p for p in re.split(r"(.*?)", active_content) if p] + widget_index = 0 + for part in label_parts: + part = part.strip() + if not part or widget_index >= len(active_widgets): + continue + for placeholder, value in label_options.items(): + part = part.replace(placeholder, str(value)) + widget = active_widgets[widget_index] + if not isinstance(widget, QLabel): + widget_index += 1 + continue + if "" in part: + widget.setText(re.sub(r"|", "", part).strip()) + else: + widget.setText(part) + if update_class: + base = "label alt" if self._show_alt_label else "label" + next_name = label_options.get("{next_prayer}", "").lower() + widget.setProperty("class", f"{base} {next_name}") + refresh_widget_style(widget) + widget_index += 1 + self._update_tooltip() + + def _update_tooltip(self) -> None: + """Update the hover tooltip with a summary of today's (or tomorrow's) prayer times.""" + if not self.config.tooltip or not self._timings: + return + next_name, _ = self._get_next_prayer() + label = "Tomorrow" if self._date_offset > 0 else "Today" + lines: list[str] = [f"{label}'s Prayers"] + for name in self._prayers: + time_str = self._timings.get(name, "--:--") + marker = " ◀" if name == next_name else "" + lines.append(f"{name}: {time_str}{marker}") + set_tooltip(self, "
".join(lines)) + + # ------------------------------------------------------------------ + # Prayer-time flash + # ------------------------------------------------------------------ + + def _check_prayer_time(self) -> bool: + """Return True if the current minute matches any prayer in prayers_to_show.""" + if not self._timings or self._loading: + return False + now = datetime.now() + for name in self._prayers: + time_str = self._timings.get(name, "") + if not time_str or time_str == "--:--": + continue + parsed = self._parse_hhmm(time_str) + if parsed is None: + continue + h, m = parsed + if now.hour == h and now.minute == m: + return True + return False + + def _start_flash(self) -> None: + """Start a smooth ping-pong color animation for the configured duration.""" + if self._flash_stop_timer.isActive(): + return + flash_cfg = self.config.flash + self._flash_anim.stop() + self._flash_anim.setDuration(flash_cfg.interval) + self._flash_anim.setStartValue(QColor(flash_cfg.color_b)) + self._flash_anim.setEndValue(QColor(flash_cfg.color_a)) + self._flash_anim.start() + # Set label to flash text class immediately + active_widgets = self._widgets_alt if self._show_alt_label else self._widgets + base = "label alt" if self._show_alt_label else "label" + for widget in active_widgets: + if isinstance(widget, QLabel): + widget.setProperty("class", f"{base} flash") + refresh_widget_style(widget) + self._flash_stop_timer.start(flash_cfg.duration * 1000) + + def _on_flash_half_done(self) -> None: + """Reverse the animation on each half-cycle to create a ping-pong effect.""" + if not self._flash_stop_timer.isActive(): + return + start = self._flash_anim.startValue() + end = self._flash_anim.endValue() + self._flash_anim.setStartValue(end) + self._flash_anim.setEndValue(start) + self._flash_anim.start() + + def _on_flash_frame(self, color: QColor) -> None: + """Apply interpolated background color to the entire widget container each frame.""" + hex_color = color.name() + self._widget_container.setStyleSheet(f"background-color: {hex_color}; border-color: {hex_color};") + + def _stop_flash(self) -> None: + """Stop the flash animation and restore all styles.""" + self._flash_anim.stop() + self._widget_container.setStyleSheet("") + self._update_label(update_class=True) + + # ------------------------------------------------------------------ + # Popup card + # ------------------------------------------------------------------ + + def _toggle_card(self) -> None: + if self.config.animation.enabled: + AnimationManager.animate(self, self.config.animation.type, self.config.animation.duration) # type: ignore + self._show_popup() + + def _show_popup(self) -> None: + m = self.config.menu + self._popup = PopupWidget(self, m.blur, m.round_corners, m.round_corners_type, m.border_color) + self._popup.setProperty("class", "prayer-times-menu") + self._popup_row_widgets = {} + + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self._build_popup_header()) + + if self._loading: + loading_lbl = QLabel("Fetching prayer times...") + loading_lbl.setProperty("class", "loading-placeholder") + loading_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(loading_lbl) + else: + next_name, _ = self._get_next_prayer() + layout.addWidget(self._build_popup_rows(next_name)) + footer = self._build_popup_footer() + if footer is not None: + layout.addWidget(footer) + + self._popup.setLayout(layout) + self._popup.adjustSize() + self._popup.setPosition( + alignment=m.alignment, + direction=m.direction, + offset_left=m.offset_left, + offset_top=m.offset_top, + ) + self._popup.show() + + def _build_popup_header(self) -> QWidget: + """Build the popup header containing the mosque icon, title, and Hijri date.""" + header = QWidget() + header.setProperty("class", "header") + header_layout = QHBoxLayout(header) + header_layout.setContentsMargins(16, 12, 16, 12) + header_layout.setSpacing(8) + + mosque_icon = QLabel(self.config.icons.mosque) + mosque_icon.setProperty("class", "mosque-icon") + mosque_icon.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + + title_text = "Tomorrow's Prayers" if self._date_offset > 0 else "Prayer Times" + title_lbl = QLabel(title_text) + title_lbl.setProperty("class", "title") + title_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + + header_layout.addWidget(mosque_icon) + header_layout.addWidget(title_lbl) + header_layout.addStretch() + + if self._hijri: + month_en = self._hijri.get("month", {}).get("en", "") + hijri_lbl = QLabel(f"{self._hijri.get('day', '')} {month_en} {self._hijri.get('year', '')} AH") + hijri_lbl.setProperty("class", "hijri-date") + hijri_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) + header_layout.addWidget(hijri_lbl) + + return header + + def _build_popup_rows(self, next_name: str) -> QWidget: + """Build the prayer rows container and populate _popup_row_widgets.""" + icon_map = self._icon_map + ic = self.config.icons + + rows_container = QWidget() + rows_container.setProperty("class", "rows-container") + rows_layout = QVBoxLayout(rows_container) + rows_layout.setContentsMargins(0, 0, 0, 0) + rows_layout.setSpacing(0) + + for name in self._prayers: + time_str = self._timings.get(name, "--:--") + delta_text = self._time_delta_text(time_str) + is_next = name == next_name + is_passed = delta_text == _DELTA_PASSED + + row = QFrame() + row_class = "prayer-row" + if is_next: + row_class += " active" + elif is_passed: + row_class += " passed" + row.setProperty("class", row_class) + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(16, 10, 16, 10) + row_layout.setSpacing(10) + + icon_lbl = QLabel(icon_map.get(name, ic.default)) + icon_lbl.setProperty("class", "prayer-icon") + icon_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + icon_lbl.setFixedWidth(_POPUP_ICON_COL_W) + + name_lbl = QLabel(name) + name_lbl.setProperty("class", "prayer-name") + name_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + name_lbl.setFixedWidth(_POPUP_NAME_COL_W) + + time_lbl = QLabel(time_str) + time_lbl.setProperty("class", "prayer-time") + time_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + time_lbl.setFixedWidth(_POPUP_TIME_COL_W) + + remaining_lbl = QLabel(delta_text) + remaining_lbl.setProperty("class", "prayer-remaining") + remaining_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) + + row_layout.addWidget(icon_lbl) + row_layout.addWidget(name_lbl) + row_layout.addWidget(time_lbl) + row_layout.addStretch() + row_layout.addWidget(remaining_lbl) + + rows_layout.addWidget(row) + self._popup_row_widgets[name] = {"row": row, "remaining": remaining_lbl} + + return rows_container + + def _build_popup_footer(self) -> QWidget | None: + """Build the popup footer showing the calculation method name, or None if unavailable.""" + method_name = self._meta.get("method", {}).get("name", "") + if not method_name: + return None + footer = QWidget() + footer.setProperty("class", "footer") + footer_layout = QHBoxLayout(footer) + footer_layout.setContentsMargins(16, 8, 16, 8) + method_lbl = QLabel(method_name) + method_lbl.setProperty("class", "method-name") + method_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + footer_layout.addWidget(method_lbl) + return footer + + def _refresh_popup_rows(self) -> None: + """Update remaining-time labels and active/passed CSS classes every minute.""" + if not self._popup_row_widgets: + return + next_name, _ = self._get_next_prayer() + for name, widgets in self._popup_row_widgets.items(): + row: QFrame = widgets["row"] # type: ignore + remaining_lbl: QLabel = widgets["remaining"] # type: ignore + time_str = self._timings.get(name, "--:--") + delta_text = self._time_delta_text(time_str) + remaining_lbl.setText(delta_text) + row_class = "prayer-row" + if name == next_name: + row_class += " active" + elif delta_text == _DELTA_PASSED: + row_class += " passed" + row.setProperty("class", row_class) + refresh_widget_style(row) + refresh_widget_style(remaining_lbl) + + # ------------------------------------------------------------------ + # Toggle + # ------------------------------------------------------------------ + + def _toggle_label(self) -> None: + if self.config.animation.enabled: + AnimationManager.animate(self, self.config.animation.type, self.config.animation.duration) # type: ignore + self._show_alt_label = not self._show_alt_label + for widget in self._widgets: + widget.setVisible(not self._show_alt_label) + for widget in self._widgets_alt: + widget.setVisible(self._show_alt_label) + self._update_label() From b0abd8ac8c450ad91fde3fb70876e30160c19b14 Mon Sep 17 00:00:00 2001 From: Chaidir Ali Assegaf Date: Wed, 22 Apr 2026 08:59:43 +0700 Subject: [PATCH 3/4] refactor(prayer_times): clean up imports and update widget label building method --- src/core/widgets/yasb/prayer_times.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/widgets/yasb/prayer_times.py b/src/core/widgets/yasb/prayer_times.py index d8854794e..bd6217d89 100644 --- a/src/core/widgets/yasb/prayer_times.py +++ b/src/core/widgets/yasb/prayer_times.py @@ -8,9 +8,9 @@ from PyQt6.QtGui import QColor from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget +from core.utils.animation_manager import AnimationManager from core.utils.tooltip import set_tooltip -from core.utils.utilities import PopupWidget, add_shadow, build_widget_label, refresh_widget_style -from core.utils.widgets.animation_manager import AnimationManager +from core.utils.utilities import PopupWidget, add_shadow, refresh_widget_style from core.utils.widgets.prayer_times.api import PrayerTimesDataFetcher from core.validation.widgets.yasb.prayer_times import PrayerTimesConfig from core.widgets.base import BaseWidget @@ -62,7 +62,7 @@ def __init__(self, config: PrayerTimesConfig): add_shadow(self._widget_container, config.container_shadow.model_dump()) self.widget_layout.addWidget(self._widget_container) - build_widget_label(self, config.label, config.label_alt, config.label_shadow.model_dump()) + self.build_widget_label(config.label, config.label_alt, config.label_shadow.model_dump()) # --- Callbacks --- self.register_callback("toggle_label", self._toggle_label) From 342cdf4fe1860d93de1b51b544efc5b4fa49f982 Mon Sep 17 00:00:00 2001 From: Chaidir Ali Assegaf Date: Fri, 22 May 2026 09:20:40 +0700 Subject: [PATCH 4/4] refactor(prayer_times): remove unused animation and shadow configurations test(prayer_times): add import tests for PrayerTimesWidget module --- .claude/settings.local.json | 15 ++++++++++++ .../validation/widgets/yasb/prayer_times.py | 5 ---- src/core/widgets/yasb/prayer_times.py | 16 ++++--------- tests/test_prayer_times_import.py | 23 +++++++++++++++++++ 4 files changed, 43 insertions(+), 16 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 tests/test_prayer_times_import.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..75748299b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(git ls-tree *)", + "Bash(grep -v \"^[+-][[:space:]]*$\")", + "Bash(git checkout *)", + "Bash(python *)", + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git branch *)", + "Bash(git merge *)", + "Bash(git commit *)" + ] + } +} diff --git a/src/core/validation/widgets/yasb/prayer_times.py b/src/core/validation/widgets/yasb/prayer_times.py index 7d4889cdd..81a742b74 100644 --- a/src/core/validation/widgets/yasb/prayer_times.py +++ b/src/core/validation/widgets/yasb/prayer_times.py @@ -1,11 +1,9 @@ from pydantic import Field from core.validation.widgets.base_model import ( - AnimationConfig, CallbacksConfig, CustomBaseModel, KeybindingConfig, - ShadowConfig, ) @@ -68,8 +66,5 @@ class PrayerTimesConfig(CustomBaseModel): icons: PrayerTimesIconsConfig = PrayerTimesIconsConfig() menu: PrayerTimesMenuConfig = PrayerTimesMenuConfig() flash: PrayerTimesFlashConfig = PrayerTimesFlashConfig() - animation: AnimationConfig = AnimationConfig() - label_shadow: ShadowConfig = ShadowConfig() - container_shadow: ShadowConfig = ShadowConfig() callbacks: PrayerTimesCallbacksConfig = PrayerTimesCallbacksConfig() keybindings: list[KeybindingConfig] = [] diff --git a/src/core/widgets/yasb/prayer_times.py b/src/core/widgets/yasb/prayer_times.py index bd6217d89..c083ec9e3 100644 --- a/src/core/widgets/yasb/prayer_times.py +++ b/src/core/widgets/yasb/prayer_times.py @@ -8,9 +8,8 @@ from PyQt6.QtGui import QColor from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget -from core.utils.animation_manager import AnimationManager from core.utils.tooltip import set_tooltip -from core.utils.utilities import PopupWidget, add_shadow, refresh_widget_style +from core.utils.utilities import PopupWidget, refresh_widget_style from core.utils.widgets.prayer_times.api import PrayerTimesDataFetcher from core.validation.widgets.yasb.prayer_times import PrayerTimesConfig from core.widgets.base import BaseWidget @@ -49,8 +48,8 @@ def __init__(self, config: PrayerTimesConfig): self._loading: bool = True self._date_offset: int = 0 self._current_date: str = datetime.now().strftime("%Y-%m-%d") - self._widgets: list[QWidget] = [] - self._widgets_alt: list[QWidget] = [] + self._widgets: list[QLabel] = [] + self._widgets_alt: list[QLabel] = [] # --- Container --- self._widget_container_layout = QHBoxLayout() @@ -59,10 +58,9 @@ def __init__(self, config: PrayerTimesConfig): self._widget_container = QFrame() self._widget_container.setLayout(self._widget_container_layout) self._widget_container.setProperty("class", "widget-container") - add_shadow(self._widget_container, config.container_shadow.model_dump()) self.widget_layout.addWidget(self._widget_container) - self.build_widget_label(config.label, config.label_alt, config.label_shadow.model_dump()) + self.build_widget_label(config.label, config.label_alt) # --- Callbacks --- self.register_callback("toggle_label", self._toggle_label) @@ -176,7 +174,7 @@ def _on_data_received(self, data: dict) -> None: self._date_offset = 1 self._fetcher.make_request() except (KeyError, TypeError) as exc: - logging.error(f"Prayer times widget: failed to parse API response: {exc}") + logging.error("Prayer times widget: failed to parse API response: %s", exc) def _on_minute_tick(self) -> None: # Reset to today when the calendar date changes (midnight rollover). @@ -405,8 +403,6 @@ def _stop_flash(self) -> None: # ------------------------------------------------------------------ def _toggle_card(self) -> None: - if self.config.animation.enabled: - AnimationManager.animate(self, self.config.animation.type, self.config.animation.duration) # type: ignore self._show_popup() def _show_popup(self) -> None: @@ -570,8 +566,6 @@ def _refresh_popup_rows(self) -> None: # ------------------------------------------------------------------ def _toggle_label(self) -> None: - if self.config.animation.enabled: - AnimationManager.animate(self, self.config.animation.type, self.config.animation.duration) # type: ignore self._show_alt_label = not self._show_alt_label for widget in self._widgets: widget.setVisible(not self._show_alt_label) diff --git a/tests/test_prayer_times_import.py b/tests/test_prayer_times_import.py new file mode 100644 index 000000000..731f41e8f --- /dev/null +++ b/tests/test_prayer_times_import.py @@ -0,0 +1,23 @@ +import importlib +import sys +import unittest +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" + +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + + +class PrayerTimesImportTest(unittest.TestCase): + def test_prayer_times_widget_module_imports(self) -> None: + sys.modules.pop("core.widgets.yasb.prayer_times", None) + + module = importlib.import_module("core.widgets.yasb.prayer_times") + + self.assertTrue(hasattr(module, "PrayerTimesWidget")) + + +if __name__ == "__main__": + unittest.main()