From 47d05dac4be509d572b4d17630dc85350c46ff77 Mon Sep 17 00:00:00 2001 From: tylertylerday Date: Sun, 26 Apr 2026 16:08:54 -0700 Subject: [PATCH 1/5] feat(calendar): add Google Calendar widget Surface the next upcoming event(s) from one or more Google Calendars in the bar, with a left-click that joins Meet/Zoom/Teams meetings or falls back to the calendar event page. Tooltip lists the next N events (default 3) merged across all configured calendars. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/_Sidebar.md | 1 + docs/widgets/(Widget)-Calendar.md | 127 ++++++ pyproject.toml | 3 + src/core/validation/widgets/yasb/calendar.py | 46 ++ src/core/widgets/yasb/calendar.py | 446 +++++++++++++++++++ 5 files changed, 623 insertions(+) create mode 100644 docs/widgets/(Widget)-Calendar.md create mode 100644 src/core/validation/widgets/yasb/calendar.py create mode 100644 src/core/widgets/yasb/calendar.py 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..45c197fe --- /dev/null +++ b/docs/widgets/(Widget)-Calendar.md @@ -0,0 +1,127 @@ +# Calendar Widget Options + +Shows the next upcoming Google Calendar event in the bar. Left-click joins the meeting (Google Meet, Zoom, or Microsoft Teams) by opening the join URL in the default browser. + +| 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 of strings | `['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. | +| `credentials_path` | string | `'~/.config/yasb/calendar/credentials.json'` | Path to the OAuth client JSON downloaded from Google Cloud Console. | +| `token_path` | string | `'~/.config/yasb/calendar/token.json'` | Where the refresh token is cached after first authorisation. | +| `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. | +| `tooltip_event_count` | integer | `3` | Number of upcoming events to show in the hover tooltip. The bar label always shows just the next one. Range 1–10. | +| `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: setup needed'` | Shown when `credentials.json` is missing. Click to open the setup docs. | +| `setup_url` | string | `'https://github.com/amnweb/yasb/blob/main/docs/widgets/calendar.md'` | URL opened by `open_setup`. | +| `tooltip` | boolean | `true` | Show a tooltip on hover with full title, time range, and location. | +| `icons` | dict | `{'meet': '', 'zoom': '', 'teams': '', 'other': '', 'none': '', 'calendar': ''}` | Per-platform icon glyphs. Set to whichever Nerd Font codepoints you prefer. | +| `callbacks` | dict | `{'on_left': 'join_meeting', 'on_middle': 'open_event', 'on_right': 'toggle_label'}` | Mouse callbacks. See *Callbacks* below. | + +## 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 + tooltip_event_count: 3 + hide_when_empty: true + icons: + meet: "󰼺" + zoom: "󰹅" + teams: "󰁳" + other: "" + none: "" + calendar: "" + callbacks: + on_left: "join_meeting" + on_middle: "open_event" + 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 set 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 `%USERPROFILE%\.config\yasb\calendar\credentials.json` (or set `credentials_path` to wherever you put it). +6. Start YASB. The first time the widget runs it opens a browser tab asking you to authorise read-only access to your calendar. After you accept, a refresh token is cached at `token_path` and no further prompts are needed. + +The token only grants read access. To revoke it, delete `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 | +|----------------|-----------| +| `join_meeting` | Open the meeting join URL in the default browser. Falls back to the calendar event page if no URL is found. If credentials are missing, opens `setup_url` instead. | +| `open_event` | Open the 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). | +| `open_setup` | Open `setup_url`. | + +## 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; +} +``` + +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..a7b7822c --- /dev/null +++ b/src/core/validation/widgets/yasb/calendar.py @@ -0,0 +1,46 @@ +from pydantic import Field + +from core.validation.widgets.base_model import ( + CallbacksConfig, + CustomBaseModel, + KeybindingConfig, +) + + +class CalendarIconsConfig(CustomBaseModel): + meet: str = "" + zoom: str = "" + teams: str = "" + other: str = "" + none: str = "" + calendar: str = "" + + +class CalendarCallbacksConfig(CallbacksConfig): + on_left: str = "join_meeting" + on_middle: str = "open_event" + 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"]) + credentials_path: str = "~/.config/yasb/calendar/credentials.json" + token_path: str = "~/.config/yasb/calendar/token.json" + 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) + tooltip_event_count: int = Field(default=3, ge=1, le=10) + hide_when_empty: bool = True + empty_label: str = "No upcoming events" + auth_label: str = "Calendar: setup needed" + setup_url: str = "https://github.com/amnweb/yasb/blob/main/docs/widgets/calendar.md" + tooltip: bool = True + icons: CalendarIconsConfig = CalendarIconsConfig() + keybindings: list[KeybindingConfig] = [] + callbacks: CalendarCallbacksConfig = CalendarCallbacksConfig() diff --git a/src/core/widgets/yasb/calendar.py b/src/core/widgets/yasb/calendar.py new file mode 100644 index 00000000..e3501e83 --- /dev/null +++ b/src/core/widgets/yasb/calendar.py @@ -0,0 +1,446 @@ +import logging +import os +import re +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, QUrl, pyqtSignal +from PyQt6.QtGui import QDesktopServices + +from core.utils.tooltip import set_tooltip +from core.utils.utilities import refresh_widget_style +from core.validation.widgets.yasb.calendar import CalendarConfig +from core.widgets.base import BaseWidget + +SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"] + +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: + # RFC 3339 — fromisoformat handles offsets including 'Z' on Python 3.11+ + return datetime.fromisoformat(slot["dateTime"].replace("Z", "+00:00")) + if "date" in slot: + # All-day event: anchor to UTC midnight so comparisons work + 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 _FetchSignals(QObject): + events_ready = pyqtSignal(list) + no_event = pyqtSignal() + needs_setup = pyqtSignal(str) + 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: # runs on a thread-pool worker + try: + try: + from google.auth.transport.requests import Request + from google.oauth2.credentials import Credentials + from google_auth_oauthlib.flow import InstalledAppFlow + from googleapiclient.discovery import build + except ImportError as e: + self._signals.error.emit(f"Missing google API deps: {e}") + return + + creds_path = Path(os.path.expanduser(self._config.credentials_path)) + token_path = Path(os.path.expanduser(self._config.token_path)) + + creds = None + if token_path.exists(): + try: + creds = Credentials.from_authorized_user_file(str(token_path), SCOPES) + except Exception as e: + logging.warning("CalendarWidget: ignoring unreadable token at %s: %s", token_path, e) + creds = None + + if creds and not creds.valid and creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + except Exception as e: + logging.warning("CalendarWidget: token refresh failed: %s", e) + creds = None + + if not creds or not creds.valid: + if not creds_path.exists(): + self._signals.needs_setup.emit(str(creds_path)) + return + flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), SCOPES) + # Blocks this worker thread while the user authorises in browser. + creds = flow.run_local_server(port=0) + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text(creds.to_json(), encoding="utf-8") + + service = build("calendar", "v3", credentials=creds, cache_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.tooltip_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._init_container() + self.build_widget_label(self._label_content, self._label_alt_content) + + 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("open_setup", self._cb_open_setup) + 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() + + # ---- fetch lifecycle ------------------------------------------------ + + def _cb_refresh(self) -> None: + if self._fetch_in_flight: + return + self._fetch_in_flight = True + 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._upcoming_events = events + self._state = "ok" + self._error = None + self._update_label() + + def _on_no_event(self) -> None: + self._fetch_in_flight = False + self._upcoming_events = [] + self._state = "empty" + self._error = None + self._update_label() + + def _on_needs_setup(self, creds_path: str) -> None: + self._fetch_in_flight = False + self._upcoming_events = [] + self._state = "setup" + self._error = creds_path + self._update_label() + + def _on_error(self, msg: str) -> None: + self._fetch_in_flight = False + self._error = msg + if not self._upcoming_events: + self._state = "error" + # Keep showing last good events if we have any — just record the 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 _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) + # If there are more widgets than parts, blank the leftovers + for j in range(len(parts), len(active_widgets)): + active_widgets[j].setText("") + + 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, + f"Google Calendar credentials missing.
Drop your OAuth client JSON at:
{self._error}", + ) + 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 i, ev in enumerate(self._upcoming_events): + 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 = "" + block = [f"{ev['title']}", when] + if ev.get("location"): + block.append(f"@ {ev['location']}") + if i == 0: + if ev.get("meeting_url"): + block.append(f"Click to join ({ev['meeting_kind']})") + elif ev.get("html_link"): + block.append("Click to open in Google Calendar") + blocks.append("
".join(line for line in block 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._cb_open_setup() + 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_open_setup(self) -> None: + QDesktopServices.openUrl(QUrl(self.config.setup_url)) From eb9a41ddef29b54f9ec0d32c69a0667c0eb140a3 Mon Sep 17 00:00:00 2001 From: tylertylerday Date: Mon, 27 Apr 2026 20:30:03 -0700 Subject: [PATCH 2/5] fix(calendar): disable static discovery so packaged builds work cx_Freeze bundles googleapiclient as .pyc inside library.zip but does not include the discovery_cache JSON data files. With the default static_discovery=True, build() looks for the missing calendar.v3.json and raises UnknownApiNameOrVersion instead of falling back to the network. Force network discovery so the widget works in both dev and packaged installs. --- src/core/widgets/yasb/calendar.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/widgets/yasb/calendar.py b/src/core/widgets/yasb/calendar.py index e3501e83..13449559 100644 --- a/src/core/widgets/yasb/calendar.py +++ b/src/core/widgets/yasb/calendar.py @@ -144,7 +144,13 @@ def run(self) -> None: # runs on a thread-pool worker token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_text(creds.to_json(), encoding="utf-8") - service = build("calendar", "v3", credentials=creds, cache_discovery=False) + 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() From 8fab5889a2ab3fb7fedbefe044c54b418fa1ba83 Mon Sep 17 00:00:00 2001 From: tylertylerday Date: Wed, 6 May 2026 13:59:59 -0700 Subject: [PATCH 3/5] chore: gitignore .claude/ for Claude Code local settings --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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/ From a4ef711d4a3d14723d182ba6653e996278a3678a Mon Sep 17 00:00:00 2001 From: tylertylerday Date: Wed, 6 May 2026 14:00:48 -0700 Subject: [PATCH 4/5] fix(calendar): include missing google_calendar service files The initial Google Calendar widget commit imported from core.widgets.services.google_calendar.auth and .auth_dialog but never committed those files, leaving the PR with a broken import on remote. Adding them now in their original form so the feature builds end-to-end. --- .../services/google_calendar/__init__.py | 0 .../widgets/services/google_calendar/auth.py | 92 +++++++++ .../services/google_calendar/auth_dialog.py | 189 ++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 src/core/widgets/services/google_calendar/__init__.py create mode 100644 src/core/widgets/services/google_calendar/auth.py create mode 100644 src/core/widgets/services/google_calendar/auth_dialog.py 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..4189e3a3 --- /dev/null +++ b/src/core/widgets/services/google_calendar/auth.py @@ -0,0 +1,92 @@ +"""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 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() -> Credentials: + """Run Google's installed-app OAuth flow. + + Opens the user's browser to Google's consent page and listens on a random + localhost port for the redirect. Blocks the calling thread until the user + completes (or cancels) sign-in. Saves the resulting token. + """ + 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) + creds = flow.run_local_server(port=0, open_browser=True) + 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..e70da7b8 --- /dev/null +++ b/src/core/widgets/services/google_calendar/auth_dialog.py @@ -0,0 +1,189 @@ +"""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, pyqtSignal +from PyQt6.QtWidgets import ( + QDialog, + QFrame, + QHBoxLayout, + 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) + + # 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._build_window() + self._build_ui() + + self._auth_success.connect(self._finish_success) + self._auth_error.connect(self._finish_error) + + 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(440, 240) + 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) + + 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( + "Your browser will open. Sign in to your Google account and authorise YASB. " + "This window will close automatically when complete." + ) + self._spinner.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("Waiting for sign-in in your browser…") + self._spinner.show() + 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() + 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 _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._primary_btn.hide() + self._secondary_btn.hide() + self._cancel_btn.setText("Close") + + def closeEvent(self, event): + self._stop = True + super().closeEvent(event) From 2a5248b8502ba37e67870c8b82d8c2143d1bba71 Mon Sep 17 00:00:00 2001 From: tylertylerday Date: Wed, 6 May 2026 14:02:33 -0700 Subject: [PATCH 5/5] feat(calendar): popup menu, notification dot, tooltip count, URL-based sign-in Round out the Google Calendar widget with the in-progress polish that was sitting unstaged: - Popup menu (event_count, blur, round_corners, alignment, direction) rendered as a PopupWidget with per-event rows that join meetings or open the event page on click. - Notification dot painted on the icon when an event is live or imminent, configurable corner/colour/margin/threshold via notification_dot. - tooltip_event_count config (default 1) lists the next N upcoming events in the hover tooltip, joined with blank lines. - URL-based sign-in flow: instead of auto-launching the default browser (which can hit Google's "Something went wrong" page when an existing Google session conflicts), the auth dialog now surfaces the authorisation URL with Copy/Open buttons so it can be pasted into an incognito or alternate browser. - Refactored OAuth helpers and dialog into their own service module so the widget file stays focused on rendering. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/widgets/(Widget)-Calendar.md | 108 ++++-- src/core/validation/widgets/yasb/calendar.py | 54 ++- .../widgets/services/google_calendar/auth.py | 35 +- .../services/google_calendar/auth_dialog.py | 70 +++- src/core/widgets/yasb/calendar.py | 365 ++++++++++++++---- 5 files changed, 512 insertions(+), 120 deletions(-) diff --git a/docs/widgets/(Widget)-Calendar.md b/docs/widgets/(Widget)-Calendar.md index 45c197fe..a7540766 100644 --- a/docs/widgets/(Widget)-Calendar.md +++ b/docs/widgets/(Widget)-Calendar.md @@ -1,6 +1,6 @@ # Calendar Widget Options -Shows the next upcoming Google Calendar event in the bar. Left-click joins the meeting (Google Meet, Zoom, or Microsoft Teams) by opening the join URL in the default browser. +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 | |-------------------------|---------|----------------------------------------------------------------------|-------------| @@ -9,23 +9,50 @@ Shows the next upcoming Google Calendar event in the bar. Left-click joins the m | `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 of strings | `['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. | -| `credentials_path` | string | `'~/.config/yasb/calendar/credentials.json'` | Path to the OAuth client JSON downloaded from Google Cloud Console. | -| `token_path` | string | `'~/.config/yasb/calendar/token.json'` | Where the refresh token is cached after first authorisation. | +| `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. | -| `tooltip_event_count` | integer | `3` | Number of upcoming events to show in the hover tooltip. The bar label always shows just the next one. Range 1–10. | | `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: setup needed'` | Shown when `credentials.json` is missing. Click to open the setup docs. | -| `setup_url` | string | `'https://github.com/amnweb/yasb/blob/main/docs/widgets/calendar.md'` | URL opened by `open_setup`. | +| `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. | -| `callbacks` | dict | `{'on_left': 'join_meeting', 'on_middle': 'open_event', 'on_right': 'toggle_label'}` | Mouse callbacks. See *Callbacks* below. | +| `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). | -## Example Configuration +## 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: @@ -42,33 +69,44 @@ calendar: grace_period_minutes: 5 skip_all_day: true max_title_length: 30 - tooltip_event_count: 3 hide_when_empty: true icons: meet: "󰼺" zoom: "󰹅" teams: "󰁳" - other: "" - none: "" - calendar: "" + 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: "join_meeting" - on_middle: "open_event" + on_left: "toggle_menu" + on_middle: "join_meeting" on_right: "toggle_label" ``` -## One-time Google Calendar Setup +## 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 set scope `https://www.googleapis.com/auth/calendar.readonly`. +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 `%USERPROFILE%\.config\yasb\calendar\credentials.json` (or set `credentials_path` to wherever you put it). -6. Start YASB. The first time the widget runs it opens a browser tab asking you to authorise read-only access to your calendar. After you accept, a refresh token is cached at `token_path` and no further prompts are needed. +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 `token.json` and remove the app from . +The token only grants read access. To revoke it, delete `%LOCALAPPDATA%\YASB\google_calendar_token.json` and remove the app from . ## Tokens @@ -85,11 +123,11 @@ Tokens you can use in `label` / `label_alt`: | Name | Behaviour | |----------------|-----------| -| `join_meeting` | Open the meeting join URL in the default browser. Falls back to the calendar event page if no URL is found. If credentials are missing, opens `setup_url` instead. | -| `open_event` | Open the event's `htmlLink` (Google Calendar web view). | +| `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). | -| `open_setup` | Open `setup_url`. | ## How the meeting URL is detected @@ -113,15 +151,25 @@ The widget frame gets state classes you can target from `styles.css`: color: #f5a; font-weight: 600; } -.calendar-widget.upcoming.meet { - color: #00897b; -} -.calendar-widget.zoom { color: #2d8cff; } -.calendar-widget.teams { color: #6264a7; } +.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-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/src/core/validation/widgets/yasb/calendar.py b/src/core/validation/widgets/yasb/calendar.py index a7b7822c..80e52427 100644 --- a/src/core/validation/widgets/yasb/calendar.py +++ b/src/core/validation/widgets/yasb/calendar.py @@ -1,3 +1,5 @@ +from enum import StrEnum + from pydantic import Field from core.validation.widgets.base_model import ( @@ -7,18 +9,45 @@ ) +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 = "" + 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 = "join_meeting" - on_middle: str = "open_event" + on_left: str = "toggle_menu" + on_middle: str = "join_meeting" on_right: str = "toggle_label" @@ -29,18 +58,17 @@ class CalendarConfig(CustomBaseModel): 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"]) - credentials_path: str = "~/.config/yasb/calendar/credentials.json" - token_path: str = "~/.config/yasb/calendar/token.json" 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) - tooltip_event_count: int = Field(default=3, ge=1, le=10) hide_when_empty: bool = True empty_label: str = "No upcoming events" - auth_label: str = "Calendar: setup needed" - setup_url: str = "https://github.com/amnweb/yasb/blob/main/docs/widgets/calendar.md" + 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/auth.py b/src/core/widgets/services/google_calendar/auth.py index 4189e3a3..2741e758 100644 --- a/src/core/widgets/services/google_calendar/auth.py +++ b/src/core/widgets/services/google_calendar/auth.py @@ -14,6 +14,8 @@ 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"] @@ -73,13 +75,17 @@ def save_creds(creds: Credentials) -> None: logging.error("GoogleCalendarAuth: failed to save token: %s", e) -def run_install_flow() -> Credentials: +def run_install_flow(on_url: Callable[[str], None] | None = None) -> Credentials: """Run Google's installed-app OAuth flow. - Opens the user's browser to Google's consent page and listens on a random - localhost port for the redirect. Blocks the calling thread until the user - completes (or cancels) sign-in. Saves the resulting token. + 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() @@ -87,6 +93,25 @@ def run_install_flow() -> Credentials: raise FileNotFoundError(f"OAuth client secrets missing at {creds_file}") flow = InstalledAppFlow.from_client_secrets_file(str(creds_file), SCOPES) - creds = flow.run_local_server(port=0, open_browser=True) + + 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 index e70da7b8..f4e935c4 100644 --- a/src/core/widgets/services/google_calendar/auth_dialog.py +++ b/src/core/widgets/services/google_calendar/auth_dialog.py @@ -10,11 +10,14 @@ import subprocess import threading -from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtCore import Qt, QTimer, QUrl, pyqtSignal +from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import ( + QApplication, QDialog, QFrame, QHBoxLayout, + QLineEdit, QVBoxLayout, ) @@ -30,6 +33,7 @@ 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): @@ -37,12 +41,14 @@ def __init__(self, parent=None): 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) @@ -53,7 +59,7 @@ def _build_window(self) -> None: self.setWindowFlag(Qt.WindowType.Window, True) self.setWindowFlag(Qt.WindowType.CustomizeWindowHint, True) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) - self.setFixedSize(440, 240) + self.setFixedSize(480, 280) self.build_view() self.build_app_icon() @@ -69,6 +75,24 @@ def _build_ui(self) -> None: 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) @@ -123,18 +147,24 @@ def _render_missing(self) -> None: def _render_ready(self) -> None: self._state = "ready" self._instructions.setText( - "Your browser will open. Sign in to your Google account and authorise YASB. " - "This window will close automatically when complete." + "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("Waiting for sign-in in your browser…") + 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() @@ -156,7 +186,7 @@ def _start_flow(self) -> None: def _run() -> None: try: - gcal_auth.run_install_flow() + gcal_auth.run_install_flow(on_url=self._auth_url.emit) if not self._stop: self._auth_success.emit() except Exception as exc: @@ -166,6 +196,31 @@ def _run() -> None: 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.") @@ -180,6 +235,9 @@ 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") diff --git a/src/core/widgets/yasb/calendar.py b/src/core/widgets/yasb/calendar.py index 13449559..e592a244 100644 --- a/src/core/widgets/yasb/calendar.py +++ b/src/core/widgets/yasb/calendar.py @@ -1,19 +1,19 @@ import logging -import os import re from datetime import UTC, datetime, timedelta -from pathlib import Path from typing import Any -from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, QUrl, pyqtSignal -from PyQt6.QtGui import QDesktopServices +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 refresh_widget_style -from core.validation.widgets.yasb.calendar import CalendarConfig +from core.utils.utilities import PopupWidget, refresh_widget_style +from core.validation.widgets.yasb.calendar import CalendarConfig, Corner from core.widgets.base import BaseWidget - -SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"] +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<>\"'\)]+") @@ -61,10 +61,8 @@ def _parse_event_time(slot: dict[str, Any] | None) -> datetime | None: if not slot: return None if "dateTime" in slot: - # RFC 3339 — fromisoformat handles offsets including 'Z' on Python 3.11+ return datetime.fromisoformat(slot["dateTime"].replace("Z", "+00:00")) if "date" in slot: - # All-day event: anchor to UTC midnight so comparisons work return datetime.fromisoformat(slot["date"]).replace(tzinfo=UTC) return None @@ -91,10 +89,47 @@ def _format_countdown(now: datetime, start: datetime, end: datetime) -> tuple[st 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(str) + needs_setup = pyqtSignal() error = pyqtSignal(str) @@ -105,44 +140,18 @@ def __init__(self, config: CalendarConfig, signals: _FetchSignals): self._signals = signals self.setAutoDelete(True) - def run(self) -> None: # runs on a thread-pool worker + def run(self) -> None: try: try: - from google.auth.transport.requests import Request - from google.oauth2.credentials import Credentials - from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build except ImportError as e: self._signals.error.emit(f"Missing google API deps: {e}") return - creds_path = Path(os.path.expanduser(self._config.credentials_path)) - token_path = Path(os.path.expanduser(self._config.token_path)) - - creds = None - if token_path.exists(): - try: - creds = Credentials.from_authorized_user_file(str(token_path), SCOPES) - except Exception as e: - logging.warning("CalendarWidget: ignoring unreadable token at %s: %s", token_path, e) - creds = None - - if creds and not creds.valid and creds.expired and creds.refresh_token: - try: - creds.refresh(Request()) - except Exception as e: - logging.warning("CalendarWidget: token refresh failed: %s", e) - creds = None - - if not creds or not creds.valid: - if not creds_path.exists(): - self._signals.needs_setup.emit(str(creds_path)) - return - flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), SCOPES) - # Blocks this worker thread while the user authorises in browser. - creds = flow.run_local_server(port=0) - token_path.parent.mkdir(parents=True, exist_ok=True) - token_path.write_text(creds.to_json(), encoding="utf-8") + creds = gcal_auth.get_creds() + if creds is None: + self._signals.needs_setup.emit() + return service = build( "calendar", @@ -201,7 +210,7 @@ def run(self) -> None: # runs on a thread-pool worker ) collected.sort(key=lambda pair: pair[0]) - top = [ev for _, ev in collected[: self._config.tooltip_event_count]] + top = [ev for _, ev in collected[: self._config.menu.event_count]] if top: self._signals.events_ready.emit(top) else: @@ -227,9 +236,16 @@ def __init__(self, config: CalendarConfig): 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_widget_label(self._label_content, self._label_alt_content) + 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) @@ -246,7 +262,7 @@ def __init__(self, config: CalendarConfig): 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("open_setup", self._cb_open_setup) + 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 @@ -254,16 +270,54 @@ def __init__(self, config: CalendarConfig): 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 @@ -271,24 +325,26 @@ def _on_events_ready(self, events: list[dict[str, Any]]) -> None: 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, creds_path: str) -> None: + def _on_needs_setup(self) -> None: self._fetch_in_flight = False + self._loader_line.stop() self._upcoming_events = [] self._state = "setup" - self._error = creds_path + 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" - # Keep showing last good events if we have any — just record the error. self._update_label() def _on_tick(self) -> None: @@ -342,6 +398,23 @@ def _frame_classes(self) -> str: 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() @@ -377,10 +450,14 @@ def _update_label(self) -> None: active_widgets[i].setText(inner) else: active_widgets[i].setText(part) - # If there are more widgets than parts, blank the leftovers 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) @@ -389,10 +466,7 @@ def _update_label(self) -> None: def _update_tooltip(self) -> None: if self._state == "setup": - set_tooltip( - self, - f"Google Calendar credentials missing.
Drop your OAuth client JSON at:
{self._error}", - ) + set_tooltip(self, "Google Calendar: click to sign in") return if self._state == "error": set_tooltip(self, f"Calendar error:
{self._error or 'unknown'}") @@ -400,24 +474,18 @@ def _update_tooltip(self) -> None: if not self._upcoming_events: set_tooltip(self, self.config.empty_label) return - blocks: list[str] = [] - for i, ev in enumerate(self._upcoming_events): + 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 = "" - block = [f"{ev['title']}", when] + lines = [f"{ev['title']}", when] if ev.get("location"): - block.append(f"@ {ev['location']}") - if i == 0: - if ev.get("meeting_url"): - block.append(f"Click to join ({ev['meeting_kind']})") - elif ev.get("html_link"): - block.append("Click to open in Google Calendar") - blocks.append("
".join(line for line in block if line)) + lines.append(f"@ {ev['location']}") + blocks.append("
".join(line for line in lines if line)) set_tooltip(self, "

".join(blocks)) # ---- click handlers ------------------------------------------------- @@ -432,7 +500,7 @@ def _cb_toggle_label(self) -> None: def _cb_join_meeting(self) -> None: if self._state == "setup": - self._cb_open_setup() + self._open_auth_dialog() return if not self._upcoming_events: return @@ -448,5 +516,170 @@ def _cb_open_event(self) -> None: if ev.get("html_link"): QDesktopServices.openUrl(QUrl(ev["html_link"])) - def _cb_open_setup(self) -> None: - QDesktopServices.openUrl(QUrl(self.config.setup_url)) + 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()