From 564ad0e9500d371368dd3a2e740ed7ddabbf5a83 Mon Sep 17 00:00:00 2001 From: Chaidir Ali Assegaf Date: Tue, 17 Mar 2026 08:26:35 +0700 Subject: [PATCH] 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()