diff --git a/.gitignore b/.gitignore
index 7dc3dd95..3d5912ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,5 @@ build_x64/
.cache
# IDE files
compile_commands.json
+# Claude Code project-local settings
+.claude/
diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md
index 414b4105..c4d444c9 100644
--- a/docs/_Sidebar.md
+++ b/docs/_Sidebar.md
@@ -15,6 +15,7 @@
- [Battery](./(Widget)-Battery)
- [Bluetooth](./(Widget)-Bluetooth)
- [Brightness](./(Widget)-Brightness)
+ - [Calendar](./(Widget)-Calendar)
- [Cava](./(Widget)-Cava)
- [Copilot](./(Widget)-Copilot)
- [CPU](./(Widget)-CPU)
diff --git a/docs/widgets/(Widget)-Calendar.md b/docs/widgets/(Widget)-Calendar.md
new file mode 100644
index 00000000..a7540766
--- /dev/null
+++ b/docs/widgets/(Widget)-Calendar.md
@@ -0,0 +1,175 @@
+# Calendar Widget Options
+
+Shows the next upcoming Google Calendar event in the bar. Left-click opens a popup menu listing the next several events with one-click join buttons; middle-click joins the very next meeting (Google Meet, Zoom, Teams) directly.
+
+| Option | Type | Default | Description |
+|-------------------------|---------|----------------------------------------------------------------------|-------------|
+| `label` | string | `'{icon} {title} {countdown}'` | Format string for the bar label. Tokens: `{icon}`, `{title}`, `{start_time}`, `{countdown}`, `{status}`, `{meeting_kind}`. |
+| `label_alt` | string | `'{icon} {title} at {start_time}'` | Alternative label, swapped via `toggle_label`. |
+| `class_name` | string | `''` | Extra CSS class appended to the widget frame. |
+| `update_interval` | integer | `60` | Seconds between Google Calendar API polls. Range 15–3600. |
+| `tick_interval` | integer | `1000` | Milliseconds between countdown re-renders (no API call). Range 250–60000. |
+| `calendar_ids` | list | `['primary']` | Calendar IDs to read. Events from all listed calendars are merged and sorted by start time. Use `primary`, another email, or any calendar ID from your Google Calendar settings. |
+| `look_ahead_minutes` | integer | `0` | If > 0, only show events starting within this many minutes. `0` = always show the next event regardless of how far away. |
+| `grace_period_minutes` | integer | `5` | Keep showing an in-progress event until this many minutes after its start. Range 0–120. |
+| `skip_all_day` | boolean | `true` | Skip all-day events when picking the "next" event. |
+| `max_title_length` | integer | `30` | Truncate event titles longer than this. |
+| `hide_when_empty` | boolean | `true` | Hide the widget when there are no upcoming events; otherwise show `empty_label`. |
+| `empty_label` | string | `'No upcoming events'` | Shown when there is no upcoming event (only if `hide_when_empty` is false). |
+| `auth_label` | string | `'Calendar: sign in'` | Bar text shown when sign-in is needed. Click the widget to open the auth dialog. |
+| `tooltip` | boolean | `true` | Show a tooltip on hover with full title, time range, and location. |
+| `tooltip_event_count` | integer | `1` | Number of upcoming events to list in the tooltip (1–20). |
+| `icons` | dict | `{'meet': '', 'zoom': '', 'teams': '', 'other': '', 'none': '', 'calendar': ''}` | Per-platform icon glyphs. Set to whichever Nerd Font codepoints you prefer. |
+| `menu` | dict | See [Menu options](#menu-options) | Popup configuration. |
+| `notification_dot` | dict | See [Notification dot](#notification-dot) | Coloured dot painted on the icon when an event is live or imminent. |
+| `callbacks` | dict | `{'on_left': 'toggle_menu', 'on_middle': 'join_meeting', 'on_right': 'toggle_label'}` | Mouse callbacks. See [Callbacks](#callbacks). |
+
+## Menu options
+
+The popup that opens on left-click. Mirrors the GitHub widget's menu config.
+
+| Option | Type | Default | Description |
+|---------------------|---------|-------------|-------------|
+| `blur` | boolean | `true` | Apply Mica/acrylic blur behind the popup. |
+| `round_corners` | boolean | `true` | Round the popup's corners. |
+| `round_corners_type`| string | `'normal'` | `normal` or `small`. |
+| `border_color` | string | `'System'` | Border colour. `System` follows the OS accent. |
+| `alignment` | string | `'right'` | `left`, `center`, or `right` relative to the bar widget. |
+| `direction` | string | `'down'` | `down` or `up`. |
+| `offset_top` | integer | `6` | Pixel offset from the bar edge. |
+| `offset_left` | integer | `0` | Horizontal offset. |
+| `event_count` | integer | `5` | Number of upcoming events to show. Range 1–20. |
+
+## Notification dot
+
+A coloured dot painted on the icon to flag a live or imminent meeting.
+
+| Option | Type | Default | Description |
+|-----------------------|---------|----------------|-------------|
+| `enabled` | boolean | `true` | Master switch. |
+| `corner` | string | `'bottom_left'`| `top_left`, `top_right`, `bottom_left`, `bottom_right`. |
+| `color` | string | `'red'` | Any CSS colour. |
+| `margin` | list | `[1, 1]` | `[x, y]` margin in pixels. |
+| `threshold_minutes` | integer | `10` | Show the dot when the next event starts within this many minutes (or is live). Range 0–240. |
+
+## Example configuration
+
+```yaml
+calendar:
+ type: "yasb.calendar.CalendarWidget"
+ options:
+ label: "{icon} {title} {countdown}"
+ label_alt: "{icon} {title} at {start_time}"
+ update_interval: 60
+ tick_interval: 1000
+ calendar_ids:
+ - "primary"
+ - "you@example.com"
+ look_ahead_minutes: 120
+ grace_period_minutes: 5
+ skip_all_day: true
+ max_title_length: 30
+ hide_when_empty: true
+ icons:
+ meet: ""
+ zoom: ""
+ teams: ""
+ other: ""
+ none: ""
+ calendar: ""
+ menu:
+ alignment: "right"
+ direction: "down"
+ offset_top: 6
+ event_count: 5
+ notification_dot:
+ enabled: true
+ corner: "bottom_left"
+ color: "#f5a"
+ threshold_minutes: 5
+ callbacks:
+ on_left: "toggle_menu"
+ on_middle: "join_meeting"
+ on_right: "toggle_label"
+```
+
+## One-time Google Calendar setup
+
+The widget reads your calendar via the Google Calendar API. You only have to do this once.
+
+1. Open the [Google Cloud Console](https://console.cloud.google.com/) and create (or pick) a project.
+2. Enable the **Google Calendar API** for that project (APIs & Services → Library).
+3. Configure the OAuth consent screen as **External**, add your own Google account as a test user, and request scope `https://www.googleapis.com/auth/calendar.readonly`.
+4. Create credentials → **OAuth client ID** → application type **Desktop app**. Download the JSON file.
+5. Save it as `%LOCALAPPDATA%\YASB\google_calendar_credentials.json`.
+6. Start YASB. The bar widget shows `Calendar: sign in`. Click it — an auth dialog opens, then your browser. After you authorise, the dialog closes and your events appear.
+
+The auth dialog has an **Open Folder** button that takes you straight to `%LOCALAPPDATA%\YASB\` so you can drop the credentials file in.
+
+The token only grants read access. To revoke it, delete `%LOCALAPPDATA%\YASB\google_calendar_token.json` and remove the app from .
+
+## Tokens
+
+Tokens you can use in `label` / `label_alt`:
+
+- `{icon}` — picked from `icons` based on the meeting platform (`meet`/`zoom`/`teams`/`other`/`none`).
+- `{title}` — event title, truncated to `max_title_length`.
+- `{start_time}` — local time of the event start in `HH:MM`.
+- `{countdown}` — `in 12m`, `in 1h 20m`, `now`, `started 3m ago`.
+- `{status}` — `upcoming`, `live`, `ended` (also applied as a CSS class).
+- `{meeting_kind}` — `meet`, `zoom`, `teams`, `other`, or `none`.
+
+## Callbacks
+
+| Name | Behaviour |
+|----------------|-----------|
+| `toggle_menu` | Open or close the popup of upcoming events. If sign-in is needed, opens the auth dialog instead. |
+| `join_meeting` | Open the meeting join URL of the very next event. Falls back to the calendar event page if no URL is found. If sign-in is needed, opens the auth dialog. |
+| `open_event` | Open the next event's `htmlLink` (Google Calendar web view). |
+| `toggle_label` | Swap between `label` and `label_alt`. |
+| `refresh` | Force a re-poll of the API (skipped if a poll is already in flight). |
+
+## How the meeting URL is detected
+
+In priority order:
+
+1. `event.hangoutLink` — Google Meet links auto-attached to the event.
+2. `event.conferenceData.entryPoints[]` — first entry with `entryPointType: video`. Classified by host (`zoom.us`, `teams.microsoft.com`, etc.).
+3. Regex over the event's `location` and `description` for `https://*.zoom.us/...`, `https://teams.microsoft.com/l/meetup-join/...`, or `https://meet.google.com/xxx-xxxx-xxx`.
+
+If nothing matches, `join_meeting` falls back to opening the event in Google Calendar.
+
+## Style example
+
+The widget frame gets state classes you can target from `styles.css`:
+
+```css
+.calendar-widget {
+ padding: 0 8px;
+}
+.calendar-widget.live {
+ color: #f5a;
+ font-weight: 600;
+}
+.calendar-widget.upcoming.meet { color: #00897b; }
+.calendar-widget.zoom { color: #2d8cff; }
+.calendar-widget.teams { color: #6264a7; }
+.calendar-widget.setup,
+.calendar-widget.error { color: #ff8a65; }
+
+.calendar-menu {
+ background: rgba(20, 20, 20, 0.85);
+ color: #fff;
+ min-width: 320px;
+ padding: 8px;
+}
+.calendar-menu .header { font-size: 14px; padding: 6px 8px; }
+.calendar-menu .item { padding: 6px 8px; border-radius: 4px; }
+.calendar-menu .item.live { color: #f5a; font-weight: 600; }
+.calendar-menu .item .title { font-weight: 600; }
+.calendar-menu .item .description { color: rgba(255,255,255,0.6); font-size: 11px; }
+.calendar-menu .item .join,
+.calendar-menu .item .open { padding: 2px 8px; border-radius: 3px; background: #2d8cff; color: #fff; }
+```
+
+State classes added to the frame: one of `loading`, `ok`, `empty`, `setup`, `error`, plus when state is `ok`: the meeting kind (`meet`/`zoom`/`teams`/`other`/`none`) and the status (`upcoming`/`live`/`ended`).
diff --git a/pyproject.toml b/pyproject.toml
index c3db743e..f17e958e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,9 @@ dependencies = [
"winrt.windows.devices.wifi==3.2.1",
"winrt.windows.security.credentials==3.2.1",
"qt-css-engine @ git+https://github.com/Video-Nomad/qt-css-engine@dev",
+ "google-api-python-client>=2.130",
+ "google-auth-oauthlib>=1.2",
+ "google-auth-httplib2>=0.2",
]
[project.urls]
diff --git a/src/core/validation/widgets/yasb/calendar.py b/src/core/validation/widgets/yasb/calendar.py
new file mode 100644
index 00000000..80e52427
--- /dev/null
+++ b/src/core/validation/widgets/yasb/calendar.py
@@ -0,0 +1,74 @@
+from enum import StrEnum
+
+from pydantic import Field
+
+from core.validation.widgets.base_model import (
+ CallbacksConfig,
+ CustomBaseModel,
+ KeybindingConfig,
+)
+
+
+class Corner(StrEnum):
+ TOP_LEFT = "top_left"
+ TOP_RIGHT = "top_right"
+ BOTTOM_LEFT = "bottom_left"
+ BOTTOM_RIGHT = "bottom_right"
+
+
+class CalendarIconsConfig(CustomBaseModel):
+ meet: str = ""
+ zoom: str = ""
+ teams: str = ""
+ other: str = ""
+ none: str = ""
+ calendar: str = ""
+
+
+class CalendarMenuConfig(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
+ event_count: int = Field(default=5, ge=1, le=20)
+
+
+class CalendarNotificationDotConfig(CustomBaseModel):
+ enabled: bool = True
+ corner: Corner = Corner.BOTTOM_LEFT
+ color: str = "red"
+ margin: list[int] = [1, 1]
+ threshold_minutes: int = Field(default=10, ge=0, le=240)
+
+
+class CalendarCallbacksConfig(CallbacksConfig):
+ on_left: str = "toggle_menu"
+ on_middle: str = "join_meeting"
+ on_right: str = "toggle_label"
+
+
+class CalendarConfig(CustomBaseModel):
+ label: str = "{icon} {title} {countdown}"
+ label_alt: str = "{icon} {title} at {start_time}"
+ class_name: str = ""
+ update_interval: int = Field(default=60, ge=15, le=3600)
+ tick_interval: int = Field(default=1000, ge=250, le=60000)
+ calendar_ids: list[str] = Field(default_factory=lambda: ["primary"])
+ look_ahead_minutes: int = Field(default=0, ge=0, le=10080)
+ grace_period_minutes: int = Field(default=5, ge=0, le=120)
+ skip_all_day: bool = True
+ max_title_length: int = Field(default=30, ge=5, le=200)
+ hide_when_empty: bool = True
+ empty_label: str = "No upcoming events"
+ auth_label: str = "Calendar: sign in"
+ tooltip: bool = True
+ tooltip_event_count: int = Field(default=1, ge=1, le=20)
+ icons: CalendarIconsConfig = CalendarIconsConfig()
+ menu: CalendarMenuConfig = CalendarMenuConfig()
+ notification_dot: CalendarNotificationDotConfig = CalendarNotificationDotConfig()
+ keybindings: list[KeybindingConfig] = []
+ callbacks: CalendarCallbacksConfig = CalendarCallbacksConfig()
diff --git a/src/core/widgets/services/google_calendar/__init__.py b/src/core/widgets/services/google_calendar/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/core/widgets/services/google_calendar/auth.py b/src/core/widgets/services/google_calendar/auth.py
new file mode 100644
index 00000000..2741e758
--- /dev/null
+++ b/src/core/widgets/services/google_calendar/auth.py
@@ -0,0 +1,117 @@
+"""OAuth helpers for the Google Calendar widget.
+
+Uses Google's installed-app flow — `InstalledAppFlow.run_local_server` opens the
+user's browser, runs a localhost HTTP listener for the redirect, and returns
+credentials. Calendar scope has no device-flow equivalent for installed apps.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from core.utils.system import app_data_path
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from google.oauth2.credentials import Credentials
+
+SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
+
+
+def credentials_path() -> Path:
+ """Path where the user drops the OAuth client secrets JSON from Google Cloud Console."""
+ return app_data_path("google_calendar_credentials.json")
+
+
+def token_path() -> Path:
+ """Path where the authorised user token is persisted."""
+ return app_data_path("google_calendar_token.json")
+
+
+def get_creds() -> Credentials | None:
+ """Load saved credentials, refreshing them if expired. Returns None when missing/invalid.
+
+ Caller is expected to invoke `run_install_flow` if this returns None and a
+ credentials JSON exists at `credentials_path()`.
+ """
+ from google.auth.transport.requests import Request
+ from google.oauth2.credentials import Credentials
+
+ token = token_path()
+ if not token.exists():
+ return None
+
+ try:
+ creds = Credentials.from_authorized_user_file(str(token), SCOPES)
+ except Exception as e:
+ logging.warning("GoogleCalendarAuth: ignoring unreadable token at %s: %s", token, e)
+ return None
+
+ if creds.valid:
+ return creds
+
+ if creds.expired and creds.refresh_token:
+ try:
+ creds.refresh(Request())
+ save_creds(creds)
+ return creds
+ except Exception as e:
+ logging.warning("GoogleCalendarAuth: token refresh failed: %s", e)
+ return None
+
+ return None
+
+
+def save_creds(creds: Credentials) -> None:
+ """Persist credentials JSON to `token_path()`."""
+ path = token_path()
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(creds.to_json(), encoding="utf-8")
+ except Exception as e:
+ logging.error("GoogleCalendarAuth: failed to save token: %s", e)
+
+
+def run_install_flow(on_url: Callable[[str], None] | None = None) -> Credentials:
+ """Run Google's installed-app OAuth flow.
+
+ Listens on a random localhost port for the redirect and blocks until the
+ user completes (or cancels) sign-in. When ``on_url`` is provided, the auth
+ URL is forwarded to it and the default browser is NOT opened automatically
+ — the caller is responsible for surfacing the URL to the user. Otherwise
+ the default browser is opened to Google's consent page as before.
+ """
+ import webbrowser
+
+ from google_auth_oauthlib.flow import InstalledAppFlow
+
+ creds_file = credentials_path()
+ if not creds_file.exists():
+ raise FileNotFoundError(f"OAuth client secrets missing at {creds_file}")
+
+ flow = InstalledAppFlow.from_client_secrets_file(str(creds_file), SCOPES)
+
+ if on_url is None:
+ creds = flow.run_local_server(port=0, open_browser=True)
+ else:
+ # google_auth_oauthlib computes the auth URL inside run_local_server
+ # (it depends on the random port) and reaches a browser via
+ # webbrowser.get(name).open(url). Intercept webbrowser.get so we can
+ # capture the URL and skip the auto-open.
+ class _CaptureBrowser:
+ def open(self, url: str, *_args, **_kwargs) -> bool:
+ on_url(url)
+ return True
+
+ original_get = webbrowser.get
+ webbrowser.get = lambda *_a, **_kw: _CaptureBrowser()
+ try:
+ creds = flow.run_local_server(port=0, open_browser=True, authorization_prompt_message="")
+ finally:
+ webbrowser.get = original_get
+
+ save_creds(creds)
+ return creds
diff --git a/src/core/widgets/services/google_calendar/auth_dialog.py b/src/core/widgets/services/google_calendar/auth_dialog.py
new file mode 100644
index 00000000..f4e935c4
--- /dev/null
+++ b/src/core/widgets/services/google_calendar/auth_dialog.py
@@ -0,0 +1,247 @@
+"""Auth dialog for the Google Calendar widget.
+
+Mirrors the shape of `services/github/auth_dialog.py` but drives Google's
+installed-app flow (browser + localhost redirect) instead of device flow.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import threading
+
+from PyQt6.QtCore import Qt, QTimer, QUrl, pyqtSignal
+from PyQt6.QtGui import QDesktopServices
+from PyQt6.QtWidgets import (
+ QApplication,
+ QDialog,
+ QFrame,
+ QHBoxLayout,
+ QLineEdit,
+ QVBoxLayout,
+)
+
+from core.ui.components.button import Button
+from core.ui.components.loader import Spinner
+from core.ui.components.text_block import TextBlock
+from core.ui.views.view_base import ViewBase
+from core.utils.system import app_data_path
+from core.widgets.services.google_calendar import auth as gcal_auth
+
+
+class GoogleCalendarAuthDialog(ViewBase, QDialog):
+ auth_completed = pyqtSignal()
+ _auth_success = pyqtSignal()
+ _auth_error = pyqtSignal(str)
+ _auth_url = pyqtSignal(str)
+
+ # state ∈ {"missing", "ready", "running", "done"}
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._stop = False
+ self._state = "missing"
+ self._flow_thread: threading.Thread | None = None
+ self._auth_url_value = ""
+
+ self._build_window()
+ self._build_ui()
+
+ self._auth_success.connect(self._finish_success)
+ self._auth_error.connect(self._finish_error)
+ self._auth_url.connect(self._show_url)
+
+ QTimer.singleShot(0, self._render_initial_state)
+
+ def _build_window(self) -> None:
+ self.setWindowTitle("Google Calendar Authorization - YASB")
+ self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
+ self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
+ self.setWindowFlag(Qt.WindowType.Window, True)
+ self.setWindowFlag(Qt.WindowType.CustomizeWindowHint, True)
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
+ self.setFixedSize(480, 280)
+ self.build_view()
+ self.build_app_icon()
+
+ def _build_ui(self) -> None:
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(24, 20, 24, 16)
+ layout.setSpacing(8)
+
+ self._title_label = TextBlock("Sign in to Google Calendar", variant="subtitle", parent=self)
+ layout.addWidget(self._title_label)
+
+ self._instructions = TextBlock("", variant="caption", parent=self)
+ self._instructions.setWordWrap(True)
+ layout.addWidget(self._instructions)
+
+ url_row = QHBoxLayout()
+ url_row.setContentsMargins(0, 6, 0, 0)
+ url_row.setSpacing(6)
+ self._url_field = QLineEdit(self)
+ self._url_field.setReadOnly(True)
+ self._url_field.setPlaceholderText("Authorization URL will appear here…")
+ self._url_field.hide()
+ url_row.addWidget(self._url_field, 1)
+ self._copy_btn = Button("Copy", font_size=12, font_weight="demibold", parent=self)
+ self._copy_btn.clicked.connect(self._copy_url)
+ self._copy_btn.hide()
+ url_row.addWidget(self._copy_btn)
+ self._open_url_btn = Button("Open", font_size=12, font_weight="demibold", parent=self)
+ self._open_url_btn.clicked.connect(self._open_url)
+ self._open_url_btn.hide()
+ url_row.addWidget(self._open_url_btn)
+ layout.addLayout(url_row)
+
+ spinner_row = QHBoxLayout()
+ spinner_row.setContentsMargins(0, 4, 0, 0)
+ self._spinner = Spinner(size=24, parent=self)
+ self._spinner.hide()
+ spinner_row.addWidget(self._spinner)
+ spinner_row.addStretch()
+ layout.addLayout(spinner_row)
+
+ layout.addStretch()
+
+ self._separator = QFrame()
+ self._separator.setFixedHeight(1)
+ self._separator.setStyleSheet("background-color: rgba(255,255,255,0.08);")
+ layout.addWidget(self._separator)
+ layout.addSpacing(4)
+
+ btn_row = QHBoxLayout()
+ btn_row.setSpacing(8)
+ btn_row.addStretch()
+
+ self._secondary_btn = Button("Open Folder", font_size=12, font_weight="demibold", parent=self)
+ self._secondary_btn.clicked.connect(self._open_folder)
+ btn_row.addWidget(self._secondary_btn)
+
+ self._primary_btn = Button("Sign In", variant="accent", font_size=12, font_weight="demibold", parent=self)
+ self._primary_btn.clicked.connect(self._on_primary_clicked)
+ btn_row.addWidget(self._primary_btn)
+
+ self._cancel_btn = Button("Cancel", font_size=12, font_weight="demibold", parent=self)
+ self._cancel_btn.clicked.connect(self.reject)
+ btn_row.addWidget(self._cancel_btn)
+
+ layout.addLayout(btn_row)
+
+ def _render_initial_state(self) -> None:
+ if not gcal_auth.credentials_path().exists():
+ self._render_missing()
+ else:
+ self._render_ready()
+
+ def _render_missing(self) -> None:
+ self._state = "missing"
+ self._instructions.setText(
+ "OAuth client secrets file not found. Place your credentials JSON "
+ f"from Google Cloud Console at:\n{gcal_auth.credentials_path()}"
+ )
+ self._spinner.hide()
+ self._primary_btn.setText("Recheck")
+ self._primary_btn.setEnabled(True)
+ self._secondary_btn.show()
+
+ def _render_ready(self) -> None:
+ self._state = "ready"
+ self._instructions.setText(
+ "Click Sign In to generate an authorisation URL. You can copy it into "
+ "an incognito window if your default browser session is blocking sign-in."
+ )
+ self._spinner.hide()
+ self._url_field.hide()
+ self._copy_btn.hide()
+ self._open_url_btn.hide()
+ self._primary_btn.setText("Sign In")
+ self._primary_btn.setEnabled(True)
+ self._secondary_btn.hide()
+
+ def _render_running(self) -> None:
+ self._state = "running"
+ self._instructions.setText("Generating authorisation URL…")
+ self._spinner.show()
+ self._url_field.hide()
+ self._copy_btn.hide()
+ self._open_url_btn.hide()
+ self._primary_btn.setEnabled(False)
+ self._secondary_btn.hide()
+
+ def _on_primary_clicked(self) -> None:
+ if self._state == "missing":
+ self._render_initial_state()
+ elif self._state == "ready":
+ self._start_flow()
+
+ def _open_folder(self) -> None:
+ folder = app_data_path()
+ try:
+ os.startfile(str(folder)) # type: ignore[attr-defined] # Windows-only
+ except Exception:
+ subprocess.Popen(["explorer", str(folder)])
+
+ def _start_flow(self) -> None:
+ self._render_running()
+
+ def _run() -> None:
+ try:
+ gcal_auth.run_install_flow(on_url=self._auth_url.emit)
+ if not self._stop:
+ self._auth_success.emit()
+ except Exception as exc:
+ if not self._stop:
+ self._auth_error.emit(str(exc))
+
+ self._flow_thread = threading.Thread(target=_run, daemon=True)
+ self._flow_thread.start()
+
+ def _show_url(self, url: str) -> None:
+ self._auth_url_value = url
+ self._instructions.setText(
+ "Open this URL to sign in. Use an incognito window if your default "
+ "browser session is causing problems. This dialog will close once "
+ "sign-in completes."
+ )
+ self._url_field.setText(url)
+ self._url_field.setCursorPosition(0)
+ self._url_field.show()
+ self._copy_btn.show()
+ self._open_url_btn.show()
+
+ def _copy_url(self) -> None:
+ if not self._auth_url_value:
+ return
+ QApplication.clipboard().setText(self._auth_url_value)
+ self._copy_btn.setText("Copied")
+ QTimer.singleShot(1500, lambda: self._copy_btn.setText("Copy"))
+
+ def _open_url(self) -> None:
+ if not self._auth_url_value:
+ return
+ QDesktopServices.openUrl(QUrl(self._auth_url_value))
+
+ def _finish_success(self) -> None:
+ self._state = "done"
+ self._show_result_page("Signed in successfully.")
+ self.auth_completed.emit()
+ QTimer.singleShot(1200, self.accept)
+
+ def _finish_error(self, message: str) -> None:
+ self._state = "done"
+ self._show_result_page(f"Sign-in failed:\n{message}")
+
+ def _show_result_page(self, message: str) -> None:
+ self._title_label.hide()
+ self._instructions.setText(message)
+ self._spinner.hide()
+ self._url_field.hide()
+ self._copy_btn.hide()
+ self._open_url_btn.hide()
+ self._primary_btn.hide()
+ self._secondary_btn.hide()
+ self._cancel_btn.setText("Close")
+
+ def closeEvent(self, event):
+ self._stop = True
+ super().closeEvent(event)
diff --git a/src/core/widgets/yasb/calendar.py b/src/core/widgets/yasb/calendar.py
new file mode 100644
index 00000000..e592a244
--- /dev/null
+++ b/src/core/widgets/yasb/calendar.py
@@ -0,0 +1,685 @@
+import logging
+import re
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from PyQt6.QtCore import QObject, QPoint, QRunnable, Qt, QThreadPool, QTimer, QUrl, pyqtSignal
+from PyQt6.QtGui import QColor, QDesktopServices, QPainter, QPaintEvent
+from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QScrollArea, QVBoxLayout, QWidget
+
+from core.ui.components.loader import LoaderLine
+from core.utils.tooltip import set_tooltip
+from core.utils.utilities import PopupWidget, refresh_widget_style
+from core.validation.widgets.yasb.calendar import CalendarConfig, Corner
+from core.widgets.base import BaseWidget
+from core.widgets.services.google_calendar import auth as gcal_auth
+from core.widgets.services.google_calendar.auth_dialog import GoogleCalendarAuthDialog
+
+ZOOM_RE = re.compile(r"https://[\w.-]+\.zoom\.us/(?:j|my|w)/[^\s<>\"'\)]+")
+TEAMS_RE = re.compile(r"https://teams\.microsoft\.com/l/meetup-join/[^\s<>\"'\)]+")
+MEET_RE = re.compile(r"https://meet\.google\.com/[a-z]{3}-[a-z]{4}-[a-z]{3}", re.I)
+
+
+def _classify_url(url: str) -> str:
+ if "meet.google.com" in url:
+ return "meet"
+ if "zoom.us" in url:
+ return "zoom"
+ if "teams.microsoft.com" in url or "teams.live.com" in url:
+ return "teams"
+ return "other"
+
+
+def extract_meeting_url(event: dict[str, Any]) -> tuple[str | None, str]:
+ """Pick the best join URL out of a Google Calendar event payload.
+
+ Priority: hangoutLink → conferenceData video entry point → regex over
+ location + description. Returns (url, kind) where kind ∈
+ {"meet","zoom","teams","other","none"}.
+ """
+ hangout = event.get("hangoutLink")
+ if hangout:
+ return hangout, "meet"
+
+ conf = event.get("conferenceData") or {}
+ for ep in conf.get("entryPoints") or []:
+ if ep.get("entryPointType") == "video":
+ uri = ep.get("uri")
+ if uri:
+ return uri, _classify_url(uri)
+
+ haystack = (event.get("location") or "") + "\n" + (event.get("description") or "")
+ for rx, kind in ((MEET_RE, "meet"), (ZOOM_RE, "zoom"), (TEAMS_RE, "teams")):
+ m = rx.search(haystack)
+ if m:
+ return m.group(0), kind
+
+ return None, "none"
+
+
+def _parse_event_time(slot: dict[str, Any] | None) -> datetime | None:
+ if not slot:
+ return None
+ if "dateTime" in slot:
+ return datetime.fromisoformat(slot["dateTime"].replace("Z", "+00:00"))
+ if "date" in slot:
+ return datetime.fromisoformat(slot["date"]).replace(tzinfo=UTC)
+ return None
+
+
+def _format_countdown(now: datetime, start: datetime, end: datetime) -> tuple[str, str]:
+ """Return (countdown_text, status). status ∈ {upcoming, live, ended}."""
+ if now >= end:
+ return "ended", "ended"
+ if now >= start:
+ elapsed = int((now - start).total_seconds() // 60)
+ if elapsed <= 0:
+ return "now", "live"
+ return f"started {elapsed}m ago", "live"
+ delta = start - now
+ secs = int(delta.total_seconds())
+ if secs < 60:
+ return "in <1m", "upcoming"
+ mins = secs // 60
+ if mins < 60:
+ return f"in {mins}m", "upcoming"
+ hours, rem = divmod(mins, 60)
+ if rem == 0:
+ return f"in {hours}h", "upcoming"
+ return f"in {hours}h {rem}m", "upcoming"
+
+
+class _NotificationLabel(QLabel):
+ """QLabel that can paint a coloured dot in any corner."""
+
+ def __init__(self, *args: Any, color: str, corner: Corner, margin: list[int], **kwargs: Any):
+ super().__init__(*args, **kwargs)
+ self._show_dot = False
+ self._color = color
+ self._corner = corner
+ self._margin = margin
+
+ def show_dot(self, enabled: bool) -> None:
+ if enabled == self._show_dot:
+ return
+ self._show_dot = enabled
+ self.update()
+
+ def paintEvent(self, a0: QPaintEvent | None) -> None:
+ super().paintEvent(a0)
+ if not self._show_dot:
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setBrush(QColor(self._color))
+ painter.setPen(Qt.PenStyle.NoPen)
+ radius = 6
+ mx, my = self._margin[0], self._margin[1]
+ if self._corner == Corner.TOP_LEFT:
+ x, y = mx, my
+ elif self._corner == Corner.TOP_RIGHT:
+ x, y = self.width() - radius - mx, my
+ elif self._corner == Corner.BOTTOM_LEFT:
+ x, y = mx, self.height() - radius - my
+ else: # BOTTOM_RIGHT
+ x, y = self.width() - radius - mx, self.height() - radius - my
+ painter.drawEllipse(QPoint(x + radius // 2, y + radius // 2), radius // 2, radius // 2)
+
+
+class _FetchSignals(QObject):
+ events_ready = pyqtSignal(list)
+ no_event = pyqtSignal()
+ needs_setup = pyqtSignal()
+ error = pyqtSignal(str)
+
+
+class _FetchTask(QRunnable):
+ def __init__(self, config: CalendarConfig, signals: _FetchSignals):
+ super().__init__()
+ self._config = config
+ self._signals = signals
+ self.setAutoDelete(True)
+
+ def run(self) -> None:
+ try:
+ try:
+ from googleapiclient.discovery import build
+ except ImportError as e:
+ self._signals.error.emit(f"Missing google API deps: {e}")
+ return
+
+ creds = gcal_auth.get_creds()
+ if creds is None:
+ self._signals.needs_setup.emit()
+ return
+
+ service = build(
+ "calendar",
+ "v3",
+ credentials=creds,
+ cache_discovery=False,
+ static_discovery=False,
+ )
+ now = datetime.now(UTC)
+ grace = timedelta(minutes=self._config.grace_period_minutes)
+ time_min = (now - grace).isoformat()
+ cutoff = None
+ if self._config.look_ahead_minutes > 0:
+ cutoff = now + timedelta(minutes=self._config.look_ahead_minutes)
+
+ collected: list[tuple[datetime, dict[str, Any]]] = []
+ for cid in self._config.calendar_ids:
+ list_kwargs: dict[str, Any] = dict(
+ calendarId=cid,
+ timeMin=time_min,
+ maxResults=10,
+ singleEvents=True,
+ orderBy="startTime",
+ )
+ if cutoff is not None:
+ list_kwargs["timeMax"] = cutoff.isoformat()
+ try:
+ events = service.events().list(**list_kwargs).execute()
+ except Exception as e:
+ logging.warning("CalendarWidget: list failed for %s: %s", cid, e)
+ continue
+ for ev in events.get("items", []):
+ start_slot = ev.get("start") or {}
+ end_slot = ev.get("end") or {}
+ if self._config.skip_all_day and "dateTime" not in start_slot:
+ continue
+ start = _parse_event_time(start_slot)
+ end = _parse_event_time(end_slot)
+ if not start or not end or end <= now:
+ continue
+ url, kind = extract_meeting_url(ev)
+ collected.append(
+ (
+ start,
+ {
+ "title": ev.get("summary", "(no title)"),
+ "start": start.isoformat(),
+ "end": end.isoformat(),
+ "meeting_url": url,
+ "meeting_kind": kind,
+ "html_link": ev.get("htmlLink", ""),
+ "location": ev.get("location", "") or "",
+ "calendar_id": cid,
+ },
+ )
+ )
+
+ collected.sort(key=lambda pair: pair[0])
+ top = [ev for _, ev in collected[: self._config.menu.event_count]]
+ if top:
+ self._signals.events_ready.emit(top)
+ else:
+ self._signals.no_event.emit()
+ except Exception as e:
+ logging.exception("CalendarWidget: fetch failed")
+ self._signals.error.emit(str(e))
+
+
+class CalendarWidget(BaseWidget):
+ validation_schema = CalendarConfig
+
+ def __init__(self, config: CalendarConfig):
+ super().__init__(
+ timer_interval=config.update_interval * 1000,
+ class_name=f"calendar-widget {config.class_name}".strip(),
+ )
+ self.config = config
+ self._label_content = config.label
+ self._label_alt_content = config.label_alt
+ self._show_alt_label = False
+ self._upcoming_events: list[dict[str, Any]] = []
+ self._state: str = "loading"
+ self._error: str | None = None
+ self._fetch_in_flight = False
+ self._auth_dialog: GoogleCalendarAuthDialog | None = None
+ self._menu: PopupWidget | None = None
+ self._dot_labels: list[_NotificationLabel] = []
+ self._dot_labels_alt: list[_NotificationLabel] = []
+
+ self._init_container()
+ self._build_labels(self._label_content, self._label_alt_content)
+
+ self._loader_line = LoaderLine(self)
+ self._loader_line.attach_to_widget(self._widget_frame)
+
+ self._signals = _FetchSignals()
+ self._signals.events_ready.connect(self._on_events_ready)
+ self._signals.no_event.connect(self._on_no_event)
+ self._signals.needs_setup.connect(self._on_needs_setup)
+ self._signals.error.connect(self._on_error)
+ self._pool = QThreadPool.globalInstance()
+
+ self._tick_timer = QTimer(self)
+ self._tick_timer.timeout.connect(self._on_tick)
+ self._tick_timer.start(config.tick_interval)
+
+ self.register_callback("join_meeting", self._cb_join_meeting)
+ self.register_callback("open_event", self._cb_open_event)
+ self.register_callback("toggle_label", self._cb_toggle_label)
+ self.register_callback("refresh", self._cb_refresh)
+ self.register_callback("toggle_menu", self._cb_toggle_menu)
+ self.callback_left = config.callbacks.on_left
+ self.callback_middle = config.callbacks.on_middle
+ self.callback_right = config.callbacks.on_right
+ self.callback_timer = "refresh"
+
+ self.start_timer()
+
+ # ---- label construction --------------------------------------------
+
+ def _build_labels(self, content: str, content_alt: str) -> None:
+ self._widgets = self._make_label_row(content, is_alt=False)
+ self._widgets_alt = self._make_label_row(content_alt, is_alt=True)
+
+ def _make_label_row(self, content: str, is_alt: bool) -> list[QLabel]:
+ widgets: list[QLabel] = []
+ for raw in re.split(r"(.*?)", content):
+ part = raw.strip()
+ if not part:
+ continue
+ if "" in part:
+ m = re.search(r'class=(["\'])([^"\']+?)\1', part)
+ cls = m.group(2) if m else "icon"
+ inner = re.sub(r"|", "", part).strip()
+ lbl = _NotificationLabel(
+ inner,
+ color=self.config.notification_dot.color,
+ corner=self.config.notification_dot.corner,
+ margin=self.config.notification_dot.margin,
+ )
+ lbl.setProperty("class", cls)
+ if is_alt:
+ self._dot_labels_alt.append(lbl)
+ else:
+ self._dot_labels.append(lbl)
+ else:
+ lbl = QLabel(part)
+ lbl.setProperty("class", "label alt" if is_alt else "label")
+ lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._widget_container_layout.addWidget(lbl)
+ widgets.append(lbl)
+ lbl.setVisible(not is_alt)
+ return widgets
+
+ # ---- fetch lifecycle ------------------------------------------------
+
+ def _cb_refresh(self) -> None:
+ if self._fetch_in_flight:
+ return
+ self._fetch_in_flight = True
+ self._loader_line.start()
+ self._pool.start(_FetchTask(self.config, self._signals))
+
+ def _on_events_ready(self, events: list[dict[str, Any]]) -> None:
+ self._fetch_in_flight = False
+ self._loader_line.stop()
+ self._upcoming_events = events
+ self._state = "ok"
+ self._error = None
+ self._update_label()
+
+ def _on_no_event(self) -> None:
+ self._fetch_in_flight = False
+ self._loader_line.stop()
+ self._upcoming_events = []
+ self._state = "empty"
+ self._error = None
+ self._update_label()
+
+ def _on_needs_setup(self) -> None:
+ self._fetch_in_flight = False
+ self._loader_line.stop()
+ self._upcoming_events = []
+ self._state = "setup"
+ self._error = None
+ self._update_label()
+
+ def _on_error(self, msg: str) -> None:
+ self._fetch_in_flight = False
+ self._loader_line.stop()
+ self._error = msg
+ if not self._upcoming_events:
+ self._state = "error"
+ self._update_label()
+
+ def _on_tick(self) -> None:
+ if self._state == "ok" and self._upcoming_events:
+ self._update_label()
+
+ # ---- rendering ------------------------------------------------------
+
+ def _build_tokens(self) -> dict[str, str]:
+ if not self._upcoming_events:
+ return {}
+ ev = self._upcoming_events[0]
+ now = datetime.now(UTC)
+ start = datetime.fromisoformat(ev["start"])
+ end = datetime.fromisoformat(ev["end"])
+ countdown, status = _format_countdown(now, start, end)
+ kind = ev["meeting_kind"]
+ icons = self.config.icons.model_dump()
+ icon = icons.get(kind) or icons.get("calendar") or ""
+ title = ev["title"] or "(no title)"
+ max_len = self.config.max_title_length
+ if len(title) > max_len:
+ title = title[: max_len - 1] + "…"
+ try:
+ start_time = start.astimezone().strftime("%H:%M")
+ except Exception:
+ start_time = "??:??"
+ return {
+ "{title}": title,
+ "{start_time}": start_time,
+ "{countdown}": countdown,
+ "{status}": status,
+ "{meeting_kind}": kind,
+ "{icon}": icon,
+ }
+
+ def _frame_classes(self) -> str:
+ parts = ["widget", "calendar-widget", self.config.class_name, self._state]
+ if self._state == "ok" and self._upcoming_events:
+ ev = self._upcoming_events[0]
+ parts.append(ev.get("meeting_kind", "none"))
+ try:
+ now = datetime.now(UTC)
+ start = datetime.fromisoformat(ev["start"])
+ end = datetime.fromisoformat(ev["end"])
+ _, status = _format_countdown(now, start, end)
+ parts.append(status)
+ except Exception:
+ pass
+ if self._error and self._state == "ok":
+ parts.append("stale")
+ return " ".join(p for p in parts if p)
+
+ def _should_show_dot(self) -> bool:
+ if not self.config.notification_dot.enabled:
+ return False
+ if self._state != "ok" or not self._upcoming_events:
+ return False
+ ev = self._upcoming_events[0]
+ try:
+ now = datetime.now(UTC)
+ start = datetime.fromisoformat(ev["start"])
+ end = datetime.fromisoformat(ev["end"])
+ except Exception:
+ return False
+ if now >= start and now < end:
+ return True
+ threshold = timedelta(minutes=self.config.notification_dot.threshold_minutes)
+ return start - now <= threshold
+
+ def _update_label(self) -> None:
+ if self._state == "empty" and self.config.hide_when_empty:
+ self.hide()
+ return
+ if not self.isVisible():
+ self.show()
+
+ if self._state == "ok":
+ tpl = self._label_alt_content if self._show_alt_label else self._label_content
+ tokens = self._build_tokens()
+ elif self._state == "setup":
+ tpl, tokens = self.config.auth_label, {}
+ elif self._state == "empty":
+ tpl, tokens = self.config.empty_label, {}
+ else: # loading / error
+ tpl, tokens = self.config.empty_label, {}
+
+ rendered = tpl
+ for k, v in tokens.items():
+ rendered = rendered.replace(k, str(v))
+
+ active_widgets = (
+ self._widgets_alt if self._show_alt_label and self._widgets_alt and self._state == "ok" else self._widgets
+ )
+ if active_widgets:
+ parts = re.split(r"(.*?)", rendered)
+ parts = [p for p in parts if p and p.strip()]
+ for i, part in enumerate(parts):
+ if i >= len(active_widgets):
+ break
+ if "" in part:
+ inner = re.sub(r"|", "", part).strip()
+ active_widgets[i].setText(inner)
+ else:
+ active_widgets[i].setText(part)
+ for j in range(len(parts), len(active_widgets)):
+ active_widgets[j].setText("")
+
+ show_dot = self._should_show_dot()
+ active_dots = self._dot_labels_alt if self._show_alt_label else self._dot_labels
+ for dot in active_dots:
+ dot.show_dot(show_dot)
+
+ self._widget_frame.setProperty("class", self._frame_classes())
+ refresh_widget_style(self._widget_frame)
+
+ if self.config.tooltip:
+ self._update_tooltip()
+
+ def _update_tooltip(self) -> None:
+ if self._state == "setup":
+ set_tooltip(self, "Google Calendar: click to sign in")
+ return
+ if self._state == "error":
+ set_tooltip(self, f"Calendar error:
{self._error or 'unknown'}")
+ return
+ if not self._upcoming_events:
+ set_tooltip(self, self.config.empty_label)
+ return
+ blocks: list[str] = []
+ for ev in self._upcoming_events[: self.config.tooltip_event_count]:
+ try:
+ start = datetime.fromisoformat(ev["start"]).astimezone()
+ end = datetime.fromisoformat(ev["end"]).astimezone()
+ when = f"{start.strftime('%a %b %d, %H:%M')} – {end.strftime('%H:%M')}"
+ except Exception:
+ when = ""
+ lines = [f"{ev['title']}", when]
+ if ev.get("location"):
+ lines.append(f"@ {ev['location']}")
+ blocks.append("
".join(line for line in lines if line))
+ set_tooltip(self, "
".join(blocks))
+
+ # ---- click handlers -------------------------------------------------
+
+ def _cb_toggle_label(self) -> None:
+ self._show_alt_label = not self._show_alt_label
+ for w in self._widgets:
+ w.setVisible(not self._show_alt_label)
+ for w in self._widgets_alt:
+ w.setVisible(self._show_alt_label)
+ self._update_label()
+
+ def _cb_join_meeting(self) -> None:
+ if self._state == "setup":
+ self._open_auth_dialog()
+ return
+ if not self._upcoming_events:
+ return
+ ev = self._upcoming_events[0]
+ url = ev.get("meeting_url") or ev.get("html_link")
+ if url:
+ QDesktopServices.openUrl(QUrl(url))
+
+ def _cb_open_event(self) -> None:
+ if not self._upcoming_events:
+ return
+ ev = self._upcoming_events[0]
+ if ev.get("html_link"):
+ QDesktopServices.openUrl(QUrl(ev["html_link"]))
+
+ def _cb_toggle_menu(self) -> None:
+ if self._menu is not None and self._menu.isVisible():
+ self._menu.hide()
+ return
+ if self._state == "setup":
+ self._open_auth_dialog()
+ return
+ self._show_menu()
+
+ # ---- auth dialog ----------------------------------------------------
+
+ def _open_auth_dialog(self) -> None:
+ if self._auth_dialog is not None:
+ self._auth_dialog.raise_()
+ self._auth_dialog.activateWindow()
+ return
+ self._auth_dialog = GoogleCalendarAuthDialog()
+ self._auth_dialog.auth_completed.connect(self._on_auth_completed)
+ self._auth_dialog.finished.connect(self._on_auth_dialog_closed)
+ self._auth_dialog.show()
+
+ def _on_auth_completed(self) -> None:
+ self._cb_refresh()
+
+ def _on_auth_dialog_closed(self, *_: Any) -> None:
+ self._auth_dialog = None
+
+ # ---- popup menu -----------------------------------------------------
+
+ def _show_menu(self) -> None:
+ self._menu = PopupWidget(
+ self,
+ self.config.menu.blur,
+ self.config.menu.round_corners,
+ self.config.menu.round_corners_type,
+ self.config.menu.border_color,
+ )
+ self._menu.setProperty("class", "calendar-menu")
+
+ main_layout = QVBoxLayout(self._menu)
+ main_layout.setSpacing(0)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+
+ header = QLabel("Google Calendar")
+ header.setProperty("class", "header")
+ main_layout.addWidget(header)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ scroll.setStyleSheet(
+ """
+ QScrollArea { background: transparent; border: none; border-radius:0; }
+ QScrollBar:vertical { border: none; background: transparent; width: 4px; }
+ QScrollBar::handle:vertical { background: rgba(255,255,255,0.2); min-height: 10px; border-radius: 2px; }
+ QScrollBar::handle:vertical:hover { background: rgba(255,255,255,0.35); }
+ QScrollBar::sub-line:vertical, QScrollBar::add-line:vertical { height: 0; }
+ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
+ """
+ )
+ main_layout.addWidget(scroll)
+
+ body = QWidget()
+ body.setProperty("class", "contents")
+ body_layout = QVBoxLayout(body)
+ body_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ body_layout.setContentsMargins(0, 0, 0, 0)
+ body_layout.setSpacing(0)
+
+ events = self._upcoming_events
+ if events:
+ section = QFrame()
+ section.setProperty("class", "section")
+ section_layout = QVBoxLayout(section)
+ section_layout.setContentsMargins(0, 0, 0, 0)
+ section_layout.setSpacing(0)
+ count = len(events)
+ for index, ev in enumerate(events):
+ pos: list[str] = []
+ if index == 0:
+ pos.append("first")
+ if index == count - 1:
+ pos.append("last")
+ section_layout.addWidget(self._build_menu_row(ev, pos, parent=section))
+ body_layout.addWidget(section)
+ else:
+ empty = QLabel("No upcoming events")
+ empty.setProperty("class", "empty")
+ empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ body_layout.addWidget(empty)
+
+ scroll.setWidget(body)
+
+ self._menu.adjustSize()
+ self._menu.setPosition(
+ alignment=self.config.menu.alignment,
+ direction=self.config.menu.direction,
+ offset_left=self.config.menu.offset_left,
+ offset_top=self.config.menu.offset_top,
+ )
+ self._menu.show()
+
+ def _build_menu_row(self, ev: dict[str, Any], position_classes: list[str], parent: QWidget) -> QFrame:
+ kind = ev.get("meeting_kind", "none")
+ icons = self.config.icons.model_dump()
+ icon_text = icons.get(kind) or icons.get("calendar") or ""
+
+ try:
+ now = datetime.now(UTC)
+ start = datetime.fromisoformat(ev["start"])
+ end = datetime.fromisoformat(ev["end"])
+ when = f"{start.astimezone().strftime('%a %H:%M')} – {end.astimezone().strftime('%H:%M')}"
+ countdown, status = _format_countdown(now, start, end)
+ except Exception:
+ when = ""
+ countdown, status = "", "upcoming"
+
+ classes = ["item", kind, status, *position_classes]
+ container = QFrame(parent)
+ container.setProperty("class", " ".join(dict.fromkeys(c for c in classes if c)))
+
+ row = QHBoxLayout(container)
+ row.setContentsMargins(0, 0, 0, 0)
+ row.setSpacing(0)
+
+ if icon_text:
+ icon_label = QLabel(icon_text)
+ icon_label.setProperty("class", f"icon {kind}")
+ row.addWidget(icon_label)
+
+ text = QWidget()
+ text_layout = QVBoxLayout(text)
+ text_layout.setContentsMargins(0, 0, 0, 0)
+ text_layout.setSpacing(0)
+ title_label = QLabel(ev["title"] or "(no title)")
+ title_label.setProperty("class", "title")
+ text_layout.addWidget(title_label)
+ meta_label = QLabel(f"{when} • {countdown}".strip(" •"))
+ meta_label.setProperty("class", "description")
+ text_layout.addWidget(meta_label)
+ row.addWidget(text, 1)
+
+ url = ev.get("meeting_url")
+ link = ev.get("html_link") or ""
+ if url:
+ join = QLabel("Join")
+ join.setProperty("class", "join")
+ join.setCursor(Qt.CursorShape.PointingHandCursor)
+ join.mousePressEvent = lambda _e, u=url: self._on_menu_link(u)
+ row.addWidget(join)
+ elif link:
+ open_lbl = QLabel("Open")
+ open_lbl.setProperty("class", "open")
+ open_lbl.setCursor(Qt.CursorShape.PointingHandCursor)
+ open_lbl.mousePressEvent = lambda _e, u=link: self._on_menu_link(u)
+ row.addWidget(open_lbl)
+
+ # Click anywhere on the row opens the event in Google Calendar
+ if link:
+ container.mousePressEvent = lambda _e, u=link: self._on_menu_link(u)
+
+ return container
+
+ def _on_menu_link(self, url: str) -> None:
+ QDesktopServices.openUrl(QUrl(url))
+ if self._menu is not None:
+ self._menu.hide()