#!/usr/bin/env python3
"""tanin-calendar — TANINUX calendar (GTK4 / PyGObject, libadwaita-free).

A native TANINUX calendar in the same Apple-flavoured design language as the
Music app and the Settings hub: a SIDEBAR on the left (brand, "New event",
a live mini-month navigator and a per-calendar show/hide list) and the CONTENT
on the right (a masthead with the month + year, a Day · Week · Month switcher
and prev/next/Today, over the actual view).

It reads events straight from Evolution Data Server (EDS) — the same backend
GNOME Online Accounts feeds — so any calendar you've added there (Google,
Nextcloud, local …) shows up automatically.

Views:
  • Month — a 6×7 grid of day cells with small coloured event chips.
  • Week / Day — a scrolling hour time-grid with absolutely-placed event
    blocks, an all-day strip, a now-line and drag-to-create.

Theming is libadwaita-free and self-contained: the stylesheet uses the global
TANINUX named colours (@accent_color, @window_bg_color, …) so the calendar
tracks the live Fuji accent and light/dark exactly like the rest of TANINUX.

If EDS isn't available (typelibs not installed), the window shows a native
status page instead of crashing — install `evolution-data-server`.

Env overrides (handy for previewing from the repo):
  TANIN_CAL_CSS   path to the stylesheet   (default /usr/share/tanin/calendar/tanin-calendar.css)
"""
import gettext
import gi
import os
import sys
import threading

gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib, Gdk, Gio, Pango  # noqa: E402

# --------------------------------------------------------------------------- #
#  Localisation. The UI source language is English; every user-facing string is
#  wrapped in _() so translations can be added LATER without touching the code —
#  drop a compiled catalogue at
#    <localedir>/<lang>/LC_MESSAGES/tanin-calendar.mo
#  (localedir defaults to /usr/share/locale; override with TANIN_CAL_LOCALEDIR).
#  Generate the template with e.g.  xgettext -L Python -k_ -o tanin-calendar.pot
#  tanin-calendar  then translate per language. Without a catalogue the English
#  source strings are used (fallback=True).
# --------------------------------------------------------------------------- #
_LOCALEDIR = os.environ.get("TANIN_CAL_LOCALEDIR", "/usr/share/locale")
try:
    _translation = gettext.translation(
        "tanin-calendar", _LOCALEDIR, fallback=True)
except Exception:  # noqa: BLE001 — never let i18n setup break startup
    _translation = gettext.NullTranslations()
_ = _translation.gettext

# --------------------------------------------------------------------------- #
#  EDS import guard — keep the app alive even when the typelibs are missing.
#  (On a fresh box without evolution-data-server, `gi.require_version` raises;
#   we degrade to a status page rather than failing to start.)
# --------------------------------------------------------------------------- #
EDS_OK = False
EDS_ERROR = ""
try:
    gi.require_version("EDataServer", "1.2")
    gi.require_version("ECal", "2.0")
    gi.require_version("ICalGLib", "4.0")
    from gi.repository import EDataServer, ECal, ICalGLib  # noqa: E402
    EDS_OK = True
except (ValueError, ImportError) as e:
    EDS_ERROR = str(e)

CSS_PATH = os.environ.get(
    "TANIN_CAL_CSS", "/usr/share/tanin/calendar/tanin-calendar.css"
)

# --------------------------------------------------------------------------- #
#  Time-grid geometry (Day / Week views)
# --------------------------------------------------------------------------- #
# Vertical scale of the hour axis. One constant shared by the axis, the day
# columns and the event-block placement so everything lines up to the pixel.
PX_PER_HOUR = 48
GRID_HEIGHT = PX_PER_HOUR * 24            # full 00:00–24:00 span
HOUR_AXIS_WIDTH = 56                      # width of the left "08:00" gutter
EVENT_MIN_HEIGHT = 22                     # never draw a sliver block
ALLDAY_ROW_HEIGHT = 30                    # the thin all-day strip up top
SNAP_MINUTES = 15                         # drag start/end snap granularity
DRAG_THRESHOLD = 6                        # px below which a drag counts as a click
SCROLL_TO_HOUR = 7                        # on open, reveal ~07:00 first

# Month / weekday names (English source). Indexed at use and wrapped in _() so
# a catalogue can translate them — e.g. _(MONTHS[m - 1]).
MONTHS = [
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
]
WEEKDAYS = [
    "Monday", "Tuesday", "Wednesday", "Thursday",
    "Friday", "Saturday", "Sunday",
]
SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
MINI_INITIALS = ["M", "T", "W", "T", "F", "S", "S"]


def _month(m):
    """Localised full month name for a 1-based month number."""
    return _(MONTHS[m - 1])


def _weekday(dow):
    """Localised full weekday name for a 1-based ISO weekday (1=Mon)."""
    return _(WEEKDAYS[dow - 1])


def _short(dow):
    """Localised short weekday name for a 1-based ISO weekday (1=Mon)."""
    return _(SHORT[dow - 1])


# --------------------------------------------------------------------------- #
#  EDS access
# --------------------------------------------------------------------------- #
class Event:
    """A flattened calendar event, decoupled from the EDS objects."""

    __slots__ = (
        "summary", "location", "description",
        "start", "end", "all_day",
        "cal_name", "cal_color",
        "source", "uid", "rid",
    )

    def __init__(self):
        self.summary = ""
        self.location = ""
        self.description = ""
        self.start = None       # GLib.DateTime (local) or None
        self.end = None         # GLib.DateTime (local) or None
        self.all_day = False
        self.cal_name = ""
        self.cal_color = ""     # "#rrggbb" or ""
        # Enough to locate this event back in EDS for delete/edit: the EDS
        # source (calendar) it came from, plus the iCal UID and (for recurring
        # instances) the recurrence-id. rid is None for a plain single event.
        self.source = None      # EDataServer.Source or None
        self.uid = ""           # iCal UID
        self.rid = None         # recurrence-id string or None

    @property
    def day_key(self):
        """A sortable (and groupable) yyyy-mm-dd key for the start day."""
        if self.start is None:
            return "0000-00-00"
        return self.start.format("%Y-%m-%d")


def _ical_time_to_datetime(itime):
    """ICalGLib.Time → GLib.DateTime in local time, or None.

    All-day events carry a DATE (no time/zone); we anchor those at local
    midnight so they group under the right day.
    """
    if itime is None:
        return None
    try:
        if itime.is_date():
            return GLib.DateTime.new_local(
                itime.get_year(), itime.get_month(), itime.get_day(), 0, 0, 0
            )
        # DATE-TIME → seconds since the epoch, then to a local wall clock.
        ts = itime.as_timet()
        if ts:
            return GLib.DateTime.new_from_unix_local(ts)
        return GLib.DateTime.new_local(
            itime.get_year(), itime.get_month(), itime.get_day(),
            itime.get_hour(), itime.get_minute(), 0,
        )
    except Exception:  # noqa: BLE001 — never let one odd event break the load
        return None


def _text_value(component_text):
    """ECal.ComponentText (or None) → plain string."""
    if component_text is None:
        return ""
    try:
        return component_text.get_value() or ""
    except Exception:  # noqa: BLE001
        return ""


def _component_uid(comp):
    """The iCal UID of an ECal.Component, or "" — needed to locate it in EDS."""
    try:
        return comp.get_uid() or ""
    except Exception:  # noqa: BLE001
        return ""


def _component_rid(comp):
    """The recurrence-id (as an ISO string) for a single instance of a
    recurring event, or None for a plain (non-recurring) event. Passed to
    remove_object_sync so we delete the right instance."""
    try:
        rid = comp.get_recurid_as_string()
        return rid or None
    except Exception:  # noqa: BLE001
        return None


def _source_color(source):
    """Calendar colour from the EDS source's calendar extension, or ""."""
    try:
        ext = source.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR)
        if ext is not None:
            color = ext.get_color()
            if color:
                return color
    except Exception:  # noqa: BLE001
        pass
    return ""


def load_events(start_dt, end_dt):
    """Read every event in [start_dt, end_dt) across all calendar sources.

    Returns (events, errors): a list of Event, and a list of human-readable
    warning strings for calendars that failed to open (offline accounts etc.).
    """
    events = []
    errors = []

    registry = EDataServer.SourceRegistry.new_sync(None)
    sources = registry.list_sources(
        EDataServer.SOURCE_EXTENSION_CALENDAR
    )

    # An S-expression query bounded to the visible window keeps EDS from
    # returning the whole history. occur-in-time-range wants ISO-8601 UTC.
    start_iso = ECal.isodate_from_time_t(start_dt.to_unix())
    end_iso = ECal.isodate_from_time_t(end_dt.to_unix())
    sexp = (
        '(occur-in-time-range? (make-time "%s") (make-time "%s"))'
        % (start_iso, end_iso)
    )

    for source in sources:
        if not source.get_enabled():
            continue
        cal_name = source.get_display_name() or _("Calendar")
        cal_color = _source_color(source)
        try:
            client = ECal.Client.connect_sync(
                source, ECal.ClientSourceType.EVENTS, 30, None
            )
        except GLib.Error as e:
            errors.append("%s: %s" % (cal_name, e.message))
            continue
        if client is None:
            errors.append(_("%s: not reachable") % cal_name)
            continue

        try:
            ok, comps = client.get_object_list_as_comps_sync(sexp, None)
        except GLib.Error as e:
            errors.append("%s: %s" % (cal_name, e.message))
            continue
        if not ok or not comps:
            continue

        for comp in comps:
            ev = _component_to_event(comp, cal_name, cal_color, source)
            if ev is not None:
                events.append(ev)

    events.sort(key=lambda e: (e.start.to_unix() if e.start else 0))
    return events, errors


def _component_to_event(comp, cal_name, cal_color, source=None):
    """ECal.Component → Event (or None if it has no usable start)."""
    try:
        ev = Event()
        ev.cal_name = cal_name
        ev.cal_color = cal_color
        ev.source = source
        ev.uid = _component_uid(comp)
        ev.rid = _component_rid(comp)
        ev.summary = _text_value(comp.get_summary()) or _("(No title)")
        ev.location = comp.get_location() or ""

        descs = comp.get_descriptions()
        if descs:
            ev.description = _text_value(descs[0])

        dtstart = comp.get_dtstart()
        if dtstart is not None:
            itime = dtstart.get_value()
            if itime is not None:
                ev.all_day = bool(itime.is_date())
                ev.start = _ical_time_to_datetime(itime)

        dtend = comp.get_dtend()
        if dtend is not None:
            iend = dtend.get_value()
            if iend is not None:
                ev.end = _ical_time_to_datetime(iend)

        if ev.start is None:
            return None
        return ev
    except Exception:  # noqa: BLE001
        return None


# --------------------------------------------------------------------------- #
#  Window — native sidebar (left) + content (right) shell
# --------------------------------------------------------------------------- #
class CalendarWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        super().__init__(application=app)
        self.set_title(_("Calendar"))
        self.set_default_size(1180, 800)
        # Native TANINUX look: same .tn / .tn-dark hooks as the Music app.
        self.add_css_class("tn")
        self.add_css_class("tn-dark")
        self.add_css_class("tn-cal")

        # Single focused-date state (the 1st-of-month + day of focus) drives
        # all three views. The month grid spans the whole calendar month padded
        # to full weeks (Mon-start); Day/Week derive their range from focus_day.
        now = GLib.DateTime.new_now_local()
        self.cur_year = now.get_year()
        self.cur_month = now.get_month()
        self.cur_day = now.get_day_of_month()

        # Which view is active: "day" / "week" / "month". Default = month;
        # TANIN_CAL_VIEW can preselect one (handy for the eww panel launcher).
        self.view_mode = os.environ.get("TANIN_CAL_VIEW", "month")
        if self.view_mode not in ("day", "week", "month"):
            self.view_mode = "month"

        # The day the user has SELECTED (distinct from "today"). Drives the
        # always-visible bottom agenda. Defaults to today.
        self.selected_day = (self.cur_year, self.cur_month, self.cur_day)

        # Render cache so the calendar list / mini-month can re-render on
        # selection or visibility changes without hitting EDS again.
        self._last_buckets = {}
        self._cur_start = self._focus_date()
        self._cur_errors = []
        self._known_cals = {}      # cal_name -> colour (accumulated across loads)
        self._hidden_cals = set()  # cal_names the user has toggled off
        self._grid_weeks = 6       # month-grid row count (set by _grid_bounds)

        # ------------------------------------------------------------------ #
        #  Shell: sidebar (left) · content (right), under a toast overlay.
        # ------------------------------------------------------------------ #
        body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        body.append(self._build_sidebar())
        body.append(self._build_content())

        self._overlay = Gtk.Overlay()
        self._overlay.set_child(body)
        self._toast_revealer = self._build_toast()
        self._overlay.add_overlay(self._toast_revealer)
        self.set_child(self._overlay)

        # Defer the first load to an idle callback so the window maps
        # IMMEDIATELY even if EDS is slow, and never let a load error keep the
        # window from showing (it prints instead of breaking).
        GLib.idle_add(self._initial_load)

    def _initial_load(self):
        self._sync_segment()
        self._update_masthead()
        self._refresh_mini_month()
        try:
            self._reload()
        except Exception:  # noqa: BLE001
            import traceback
            traceback.print_exc()
        return False

    # ---- focused / selected date helpers ---------------------------------- #
    def _focus_date(self):
        return GLib.DateTime.new_local(
            self.cur_year, self.cur_month, self.cur_day, 0, 0, 0)

    def _set_focus(self, dt):
        self.cur_year = dt.get_year()
        self.cur_month = dt.get_month()
        self.cur_day = dt.get_day_of_month()

    def _selected_date(self):
        y, m, d = self.selected_day
        return GLib.DateTime.new_local(y, m, d, 0, 0, 0)

    def _selected_key(self):
        y, m, d = self.selected_day
        return "%04d-%02d-%02d" % (y, m, d)

    def _select_day(self, day):
        """Select a day cell/column: refresh the highlight + bottom agenda from
        the cache (cheap, no EDS reload)."""
        new = (day.get_year(), day.get_month(), day.get_day_of_month())
        if new == self.selected_day:
            return
        self.selected_day = new
        self._render_view()

    # ====================================================================== #
    #  Sidebar
    # ====================================================================== #
    def _build_sidebar(self):
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        box.add_css_class("tn-sidebar")
        box.add_css_class("tn-cal-sidebar")
        # Fixed width: never grows when the window widens (the content pane,
        # which is hexpand, absorbs all extra width).
        box.set_size_request(208, -1)
        box.set_hexpand(False)

        brand = Gtk.Label(label=_("Calendar"), xalign=0.0)
        brand.add_css_class("tn-brand")
        box.append(brand)

        # _("New event") — a full-width glassy accent button (same accent-wash
        # look as the selected sidebar rows in the other TANINUX apps).
        new_btn = Gtk.Button()
        new_btn.add_css_class("tn-cal-new")
        new_btn.set_tooltip_text(_("Create a new event"))
        nb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        nb.set_halign(Gtk.Align.CENTER)
        nb.append(Gtk.Image.new_from_icon_name("list-add-symbolic"))
        nb.append(Gtk.Label(label=_("New event")))
        new_btn.set_child(nb)
        new_btn.connect("clicked", self._on_new_event)
        box.append(new_btn)

        box.append(self._build_mini_month())

        # Calendars header + list.
        cals_header = Gtk.Label(label=_("Calendars"), xalign=0.0)
        cals_header.add_css_class("tn-nav-header")
        box.append(cals_header)

        self._cal_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        self._cal_list.add_css_class("tn-cal-list")
        scroller = Gtk.ScrolledWindow()
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.set_vexpand(True)
        scroller.set_child(self._cal_list)
        box.append(scroller)
        return box

    # ---- mini-month navigator --------------------------------------------- #
    def _build_mini_month(self):
        wrap = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        wrap.add_css_class("tn-mini")

        header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2)
        header.add_css_class("tn-mini-header")

        self._mini_label = Gtk.Label(xalign=0.0, hexpand=True)
        self._mini_label.add_css_class("tn-mini-title")
        header.append(self._mini_label)

        prev_btn = Gtk.Button(icon_name="go-previous-symbolic")
        prev_btn.add_css_class("flat")
        prev_btn.add_css_class("circular")
        prev_btn.set_tooltip_text(_("Previous month"))
        prev_btn.connect("clicked", lambda *_: self._mini_step(-1))
        header.append(prev_btn)

        next_btn = Gtk.Button(icon_name="go-next-symbolic")
        next_btn.add_css_class("flat")
        next_btn.add_css_class("circular")
        next_btn.set_tooltip_text(_("Next month"))
        next_btn.connect("clicked", lambda *_: self._mini_step(1))
        header.append(next_btn)
        wrap.append(header)

        self._mini_grid = Gtk.Grid()
        self._mini_grid.add_css_class("tn-mini-grid")
        self._mini_grid.set_column_homogeneous(True)
        wrap.append(self._mini_grid)
        return wrap

    def _mini_step(self, direction):
        """Move the mini-month (and the main view) by one calendar month."""
        m = self.cur_month + direction
        y = self.cur_year
        if m < 1:
            m, y = 12, y - 1
        elif m > 12:
            m, y = 1, y + 1
        self.cur_month, self.cur_year = m, y
        self.cur_day = min(self.cur_day, _days_in_month(y, m))
        self._reload()

    def _refresh_mini_month(self):
        grid = getattr(self, "_mini_grid", None)
        if grid is None:
            return
        child = grid.get_first_child()
        while child is not None:
            nxt = child.get_next_sibling()
            grid.remove(child)
            child = nxt

        self._mini_label.set_label(
            "%s %d" % (_month(self.cur_month), self.cur_year))

        for col, name in enumerate(MINI_INITIALS):
            lbl = Gtk.Label(label=name)
            lbl.add_css_class("tn-mini-dow")
            grid.attach(lbl, col, 0, 1, 1)

        first = GLib.DateTime.new_local(self.cur_year, self.cur_month, 1, 0, 0, 0)
        offset = first.get_day_of_week() - 1
        start = first.add_days(-offset)
        today_key = GLib.DateTime.new_now_local().format("%Y-%m-%d")
        buckets = self._filtered(self._last_buckets)

        for i in range(42):
            day = start.add_days(i)
            key = day.format("%Y-%m-%d")
            btn = Gtk.Button(label=str(day.get_day_of_month()))
            btn.add_css_class("flat")
            btn.add_css_class("tn-mini-day")
            # Centre (don't fill the cell) so the round highlight is a true
            # circle, not an ellipse stretched to the column width.
            btn.set_halign(Gtk.Align.CENTER)
            btn.set_valign(Gtk.Align.CENTER)
            in_month = (day.get_month() == self.cur_month
                        and day.get_year() == self.cur_year)
            if not in_month:
                btn.add_css_class("other-month")
            if key == today_key:
                btn.add_css_class("today")
            if key == self._selected_key():
                btn.add_css_class("selected")
            if buckets.get(key):
                btn.add_css_class("has-events")
            btn.connect("clicked", lambda _b, d=day: self._jump_to(d))
            grid.attach(btn, i % 7, i // 7 + 1, 1, 1)

    def _jump_to(self, day):
        """Mini-month day click: focus + select that day and reload."""
        self._set_focus(day)
        self.selected_day = (day.get_year(), day.get_month(),
                             day.get_day_of_month())
        self._reload()

    # ---- calendar show/hide list ------------------------------------------ #
    def _refresh_calendar_list(self):
        box = getattr(self, "_cal_list", None)
        if box is None:
            return
        child = box.get_first_child()
        while child is not None:
            nxt = child.get_next_sibling()
            box.remove(child)
            child = nxt

        if not self._known_cals:
            empty = Gtk.Label(label=_("No calendars"), xalign=0.0)
            empty.add_css_class("tn-cal-list-empty")
            box.append(empty)
            return

        for name in sorted(self._known_cals):
            color = self._known_cals[name]
            row = Gtk.Button()
            row.add_css_class("flat")
            row.add_css_class("tn-cal-item")
            if name in self._hidden_cals:
                row.add_css_class("hidden")
            inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
            dot = self._color_dot(color, size=12)
            dot.set_valign(Gtk.Align.CENTER)
            inner.append(dot)
            lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True)
            lbl.add_css_class("tn-cal-item-label")
            lbl.set_ellipsize(Pango.EllipsizeMode.END)
            inner.append(lbl)
            row.set_child(inner)
            row.connect("clicked", lambda _b, n=name: self._toggle_calendar(n))
            box.append(row)

    def _toggle_calendar(self, name):
        if name in self._hidden_cals:
            self._hidden_cals.discard(name)
        else:
            self._hidden_cals.add(name)
        self._refresh_calendar_list()
        self._render_view()

    def _filtered(self, buckets):
        """Drop events whose calendar the user has toggled off."""
        if not self._hidden_cals:
            return buckets
        out = {}
        for key, evs in buckets.items():
            keep = [e for e in evs if e.cal_name not in self._hidden_cals]
            if keep:
                out[key] = keep
        return out

    # ====================================================================== #
    #  Content (right): masthead + view area + bottom agenda
    # ====================================================================== #
    def _build_content(self):
        pane = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        pane.add_css_class("tn-cal-content")
        pane.set_hexpand(True)
        pane.set_vexpand(True)

        pane.append(self._build_masthead())

        # The active view (and any status pages) live here so we can swap them
        # out per reload without rebuilding the chrome. The Month view builds its
        # selected-day agenda INSIDE this area (directly under the grid) so there
        # is no gap; Day/Week fill it with the scrolling time grid.
        self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.content.set_vexpand(True)
        self.content.set_hexpand(True)
        pane.append(self.content)
        return pane

    def _build_masthead(self):
        bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        bar.add_css_class("tn-cal-masthead")

        # Left: big month name + light year, with an optional day subtitle.
        title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        title_box.set_valign(Gtk.Align.CENTER)
        title_box.set_hexpand(True)

        self.month_label = Gtk.Label(xalign=0.0)
        self.month_label.add_css_class("tn-cal-month")
        title_box.append(self.month_label)

        self.year_label = Gtk.Label(xalign=0.0)
        self.year_label.add_css_class("tn-cal-year")
        title_box.append(self.year_label)

        self.day_sub_label = Gtk.Label(xalign=0.0)
        self.day_sub_label.add_css_class("tn-cal-daysub")
        self.day_sub_label.set_valign(Gtk.Align.BASELINE)
        title_box.append(self.day_sub_label)
        bar.append(title_box)

        # Right cluster: prev/next · Heute · Day|Week|Month segmented switcher.
        prev_btn = Gtk.Button(icon_name="go-previous-symbolic")
        prev_btn.add_css_class("flat")
        prev_btn.add_css_class("circular")
        prev_btn.set_tooltip_text(_("Back"))
        prev_btn.connect("clicked", self._on_prev)
        bar.append(prev_btn)

        next_btn = Gtk.Button(icon_name="go-next-symbolic")
        next_btn.add_css_class("flat")
        next_btn.add_css_class("circular")
        next_btn.set_tooltip_text(_("Forward"))
        next_btn.connect("clicked", self._on_next)
        bar.append(next_btn)

        today_btn = Gtk.Button(label=_("Today"))
        today_btn.add_css_class("pill")
        today_btn.add_css_class("tn-cal-today")
        today_btn.connect("clicked", self._on_today)
        bar.append(today_btn)

        seg = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        seg.add_css_class("linked")
        seg.add_css_class("tn-segment")
        self._seg_buttons = {}
        first = None
        for key, label in (("day", _("Day")), ("week", _("Week")),
                           ("month", _("Month"))):
            tb = Gtk.ToggleButton(label=label)
            tb.add_css_class("tn-segment-btn")
            if first is None:
                first = tb
            else:
                tb.set_group(first)
            self._seg_buttons[key] = tb
            seg.append(tb)
        # Set the initial active button BEFORE connecting handlers — grouping a
        # toggle button auto-activates the first one and would otherwise fire
        # "toggled" and flip view_mode to "day" during construction.
        self._seg_buttons[self.view_mode].set_active(True)
        for key, tb in self._seg_buttons.items():
            tb.connect("toggled", self._on_segment, key)
        bar.append(seg)
        return bar

    def _sync_segment(self):
        """Reflect view_mode in the segmented control without re-triggering."""
        for key, tb in self._seg_buttons.items():
            tb.handler_block_by_func(self._on_segment)
            tb.set_active(key == self.view_mode)
            tb.handler_unblock_by_func(self._on_segment)

    def _on_segment(self, btn, key):
        if not btn.get_active() or key == self.view_mode:
            return
        self.view_mode = key
        self._reload()

    def _update_masthead(self):
        focus = self._focus_date()
        self.month_label.set_label(_month(self.cur_month))
        self.year_label.set_label(str(self.cur_year))
        if self.view_mode == "day":
            wd = _weekday(focus.get_day_of_week())
            self.day_sub_label.set_label(_("· %s, %d.") % (wd, self.cur_day))
            self.day_sub_label.set_visible(True)
        else:
            self.day_sub_label.set_label("")
            self.day_sub_label.set_visible(False)

    # ---- nav -------------------------------------------------------------- #
    def _on_today(self, _btn):
        now = GLib.DateTime.new_now_local()
        self.cur_year = now.get_year()
        self.cur_month = now.get_month()
        self.cur_day = now.get_day_of_month()
        self.selected_day = (self.cur_year, self.cur_month, self.cur_day)
        self._reload()

    def _on_prev(self, _btn):
        self._step(-1)

    def _on_next(self, _btn):
        self._step(1)

    def _step(self, direction):
        """Move by one view-unit: 1 day (Day), 1 week (Week), 1 month (Month)."""
        if self.view_mode == "day":
            self._set_focus(self._focus_date().add_days(direction))
        elif self.view_mode == "week":
            self._set_focus(self._focus_date().add_days(7 * direction))
        else:  # month
            self.cur_month += direction
            if self.cur_month < 1:
                self.cur_month = 12
                self.cur_year -= 1
            elif self.cur_month > 12:
                self.cur_month = 1
                self.cur_year += 1
            self.cur_day = min(
                self.cur_day, _days_in_month(self.cur_year, self.cur_month))
        self._reload()

    # ---- content swap ----------------------------------------------------- #
    def _set_content(self, widget):
        child = self.content.get_first_child()
        while child is not None:
            nxt = child.get_next_sibling()
            self.content.remove(child)
            child = nxt
        self.content.append(widget)

    def _grid_bounds(self):
        first = GLib.DateTime.new_local(self.cur_year, self.cur_month, 1, 0, 0, 0)
        offset = first.get_day_of_week() - 1
        # Always show a grayed LEADING week of the previous month — mirrors the
        # trailing next-month days. When the month starts on Monday (offset 0)
        # there would be none, so force a full week back.
        if offset == 0:
            offset = 7
        grid_start = first.add_days(-offset)
        days_total = offset + _days_in_month(self.cur_year, self.cur_month)
        weeks = max(6, (days_total + 6) // 7)
        # Guarantee at least one trailing grayed day (mirror the leading week).
        if weeks * 7 - days_total < 1:
            weeks += 1
        self._grid_weeks = weeks
        grid_end = grid_start.add_days(weeks * 7)
        return grid_start, grid_end

    def _week_bounds(self):
        focus = self._focus_date()
        offset = focus.get_day_of_week() - 1
        week_start = focus.add_days(-offset)
        return week_start, week_start.add_days(7)

    def _day_bounds(self):
        start = self._focus_date()
        return start, start.add_days(1)

    # ---- load / render ---------------------------------------------------- #
    def _reload(self):
        self._sync_segment()
        self._update_masthead()
        self._refresh_mini_month()

        if not EDS_OK:
            self._set_content(self._eds_missing_page())
            return

        if self.view_mode == "day":
            start, end = self._day_bounds()
        elif self.view_mode == "week":
            start, end = self._week_bounds()
        else:
            start, end = self._grid_bounds()

        # Load EDS events in a WORKER THREAD. The ECal/SourceRegistry *_sync calls
        # spin the GLib main context internally, which DEADLOCKS when invoked from
        # the running GTK main loop (the window then never paints). Do the
        # blocking read off-thread and apply the result back on the main thread.
        mode = self.view_mode
        self._load_token = getattr(self, "_load_token", 0) + 1
        token = self._load_token

        def worker():
            try:
                result, err = load_events(start, end), None
            except Exception as e:  # noqa: BLE001
                result, err = None, str(e)
            GLib.idle_add(self._on_loaded, token, mode, start, result, err)

        threading.Thread(target=worker, daemon=True).start()

    def _on_loaded(self, token, mode, start, result, err):
        if token != getattr(self, "_load_token", 0) or mode != self.view_mode:
            return False  # a newer load superseded this one
        if err is not None:
            self._set_content(self._error_page(err))
            return False
        events, errors = result
        buckets = {}
        for ev in events:
            buckets.setdefault(ev.day_key, []).append(ev)
        self._last_buckets = buckets
        self._cur_start = start
        self._cur_errors = errors
        for evs in buckets.values():
            for e in evs:
                self._known_cals.setdefault(e.cal_name, e.cal_color)
        self._refresh_calendar_list()
        self._render_view()
        return False

    def _render_view(self):
        """(Re-)build the active view from the cache + visibility filter. Cheap;
        no EDS round-trip — used after a load, a selection or a cal toggle."""
        buckets = self._filtered(self._last_buckets)
        start, mode, errors = self._cur_start, self.view_mode, self._cur_errors
        if mode == "day":
            self._set_content(self._build_day(start, buckets, errors))
        elif mode == "week":
            self._set_content(self._build_week(start, buckets, errors))
        else:
            self._set_content(self._build_grid(start, buckets, errors))
        self._refresh_mini_month()

    # ====================================================================== #
    #  Month grid
    # ====================================================================== #
    def _build_grid(self, grid_start, buckets, errors):
        # Everything (grid + selected-day agenda) scrolls as one column so the
        # agenda sits DIRECTLY under the grid with no gap.
        column = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        column.add_css_class("tn-cal-page")
        column.set_halign(Gtk.Align.CENTER)  # centre the calendar on the page

        if errors:
            column.append(self._error_banner(errors))

        grid = Gtk.Grid()
        grid.add_css_class("month-grid")
        grid.set_halign(Gtk.Align.CENTER)
        grid.set_valign(Gtk.Align.START)
        grid.set_column_homogeneous(True)

        for col, name in enumerate(SHORT):
            lbl = Gtk.Label(label=_(name))
            lbl.add_css_class("weekday-header")
            grid.attach(lbl, col, 0, 1, 1)

        today_key = GLib.DateTime.new_now_local().format("%Y-%m-%d")

        cells = []
        for week in range(self._grid_weeks):
            for dow in range(7):
                day = grid_start.add_days(week * 7 + dow)
                cell = self._build_cell(day, buckets, today_key)
                cells.append(cell)
                grid.attach(cell, dow, week + 1, 1, 1)

        # Square cells, CAPPED so the calendar never grows on wide windows: each
        # cell is min(MAX, available_width / 7), the grid is centred. Tracked on
        # the scroller's width (it fills the content area); a guard avoids loops.
        MAX_CELL, MIN_CELL = 132, 64
        last = {"size": 0}

        def _square(scroller, *_a):
            w = scroller.get_width()
            if w <= 0:
                return
            size = min(MAX_CELL, max(MIN_CELL, (w - 24) // 7))
            if size == last["size"]:
                return
            last["size"] = size
            for c in cells:
                c.set_size_request(size, size)

        column.append(grid)

        # The selected-day agenda, directly beneath the grid (no gap).
        agenda = self._build_agenda_panel()
        if agenda is not None:
            column.append(agenda)

        scroller = Gtk.ScrolledWindow()
        scroller.set_vexpand(True)
        scroller.set_hexpand(True)
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.set_child(column)
        scroller.connect("notify::width", _square)
        GLib.idle_add(_square, scroller)
        return scroller

    def _build_cell(self, day, buckets, today_key):
        """One day cell: day number + up to 3 event chips + '+N more'."""
        key = day.format("%Y-%m-%d")
        day_events = buckets.get(key, [])
        in_month = (day.get_month() == self.cur_month
                    and day.get_year() == self.cur_year)

        cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        cell.add_css_class("day-cell")
        if not in_month:
            cell.add_css_class("other-month")
        if key == today_key:
            cell.add_css_class("today")
        if key == self._selected_key():
            cell.add_css_class("selected")

        num = Gtk.Label(label=str(day.get_day_of_month()))
        num.add_css_class("day-number")
        num.set_halign(Gtk.Align.START)
        num.set_xalign(0)
        cell.append(num)

        MAX_CHIPS = 3
        for ev in day_events[:MAX_CHIPS]:
            cell.append(self._event_chip(ev))
        extra = len(day_events) - MAX_CHIPS
        if extra > 0:
            more = Gtk.Label(label=_("+%d more") % extra)
            more.add_css_class("more-label")
            more.set_halign(Gtk.Align.START)
            more.set_xalign(0)
            cell.append(more)

        click = Gtk.GestureClick()
        click.set_button(1)
        click.connect("released", lambda *_a, d=day: self._select_day(d))
        cell.add_controller(click)

        self._attach_day_menu(cell, day)
        return cell

    def _event_chip(self, ev):
        """A small coloured chip: colour bar + time + truncated title."""
        btn = Gtk.Button()
        btn.add_css_class("event-chip")
        btn.add_css_class("flat")
        btn.set_hexpand(True)
        btn.connect("clicked", lambda _b, e=ev: self._show_detail(e))

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
        dot = self._color_dot(ev.cal_color, size=8)
        dot.add_css_class("chip-dot")
        row.append(dot)

        text = ev.summary
        if not ev.all_day and ev.start is not None:
            text = "%s %s" % (ev.start.format("%H:%M"), ev.summary)
        lbl = Gtk.Label(label=text)
        lbl.add_css_class("chip-label")
        lbl.set_halign(Gtk.Align.START)
        lbl.set_xalign(0)
        lbl.set_ellipsize(Pango.EllipsizeMode.END)
        lbl.set_hexpand(True)
        row.append(lbl)

        btn.set_child(row)
        btn.set_tooltip_text(ev.summary)
        return btn

    # ====================================================================== #
    #  Day / Week time grid (shared)
    # ====================================================================== #
    def _build_day(self, day, buckets, errors):
        return self._build_time_grid([day], buckets, errors, single=True)

    def _build_week(self, week_start, buckets, errors):
        days = [week_start.add_days(i) for i in range(7)]
        return self._build_time_grid(days, buckets, errors, single=False)

    def _build_time_grid(self, days, buckets, errors, single):
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        outer.set_vexpand(True)
        outer.set_hexpand(True)
        if errors:
            outer.append(self._error_banner(errors))

        today_key = GLib.DateTime.new_now_local().format("%Y-%m-%d")
        now = GLib.DateTime.new_now_local()

        # --- pinned header row (weekday + date), outside the scroller ------- #
        header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        header.add_css_class("timegrid-header")
        corner = Gtk.Box()
        corner.add_css_class("timegrid-corner")
        corner.set_size_request(HOUR_AXIS_WIDTH, -1)
        header.append(corner)
        for day in days:
            key = day.format("%Y-%m-%d")
            wd = _weekday(day.get_day_of_week())
            col_head = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
            col_head.add_css_class("timegrid-col-header")
            col_head.set_hexpand(True)
            if key == today_key:
                col_head.add_css_class("today")
            if key == self._selected_key():
                col_head.add_css_class("selected")
            if single:
                name = Gtk.Label(
                    label=_("%s, %d. %s %d") % (
                        wd, day.get_day_of_month(),
                        _month(day.get_month()), day.get_year()))
                name.add_css_class("timegrid-col-name")
                col_head.append(name)
            else:
                nm = Gtk.Label(label=_short(day.get_day_of_week()))
                nm.add_css_class("timegrid-col-name")
                col_head.append(nm)
                num = Gtk.Label(label=str(day.get_day_of_month()))
                num.add_css_class("timegrid-col-date")
                col_head.append(num)
            hclick = Gtk.GestureClick()
            hclick.set_button(1)
            hclick.connect("released", lambda *_a, d=day: self._select_day(d))
            col_head.add_controller(hclick)
            header.append(col_head)

        # --- all-day row (axis spacer + one cell per day) ------------------ #
        allday = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        allday.add_css_class("allday-row")
        ad_axis = Gtk.Label(label=_("all-day"))
        ad_axis.add_css_class("allday-axis")
        ad_axis.set_size_request(HOUR_AXIS_WIDTH, ALLDAY_ROW_HEIGHT)
        allday.append(ad_axis)
        for day in days:
            key = day.format("%Y-%m-%d")
            cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
            cell.add_css_class("allday-cell")
            cell.set_hexpand(True)
            if key == today_key:
                cell.add_css_class("today")
            ad_events = [e for e in buckets.get(key, []) if e.all_day]
            for ev in ad_events:
                cell.append(self._allday_chip(ev))
            allday.append(cell)

        # --- the scrolling time grid --------------------------------------- #
        grid_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        grid_row.add_css_class("timegrid-body")

        axis = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        axis.add_css_class("time-axis")
        axis.set_size_request(HOUR_AXIS_WIDTH, GRID_HEIGHT)
        for hour in range(24):
            lbl = Gtk.Label(label="%02d:00" % hour)
            lbl.add_css_class("hour-label")
            lbl.set_halign(Gtk.Align.END)
            lbl.set_valign(Gtk.Align.START)
            lbl.set_xalign(1)
            lbl.set_size_request(-1, PX_PER_HOUR)
            axis.append(lbl)
        grid_row.append(axis)

        for day in days:
            grid_row.append(self._build_day_column(
                day, buckets, today_key, now))

        scroller = Gtk.ScrolledWindow()
        scroller.set_vexpand(True)
        scroller.set_hexpand(True)
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.set_child(grid_row)

        outer.append(header)
        outer.append(allday)
        outer.append(scroller)

        def _scroll_to_morning():
            adj = scroller.get_vadjustment()
            if adj is not None:
                target = min(SCROLL_TO_HOUR * PX_PER_HOUR,
                             max(0, adj.get_upper() - adj.get_page_size()))
                adj.set_value(target)
            return False
        GLib.idle_add(_scroll_to_morning)
        return outer

    def _build_day_column(self, day, buckets, today_key, now):
        """One day's column: hairline hour lines, absolutely-placed event
        blocks, an optional now-line, and a drag-to-create gesture."""
        key = day.format("%Y-%m-%d")

        column = Gtk.Fixed()
        column.add_css_class("day-column")
        column.set_hexpand(True)
        column.set_size_request(-1, GRID_HEIGHT)
        if key == today_key:
            column.add_css_class("today")

        lines = []
        for hour in range(24):
            full = Gtk.Box()
            full.add_css_class("hour-line")
            full.set_size_request(10, 1)
            column.put(full, 0, hour * PX_PER_HOUR)
            lines.append(full)
            if hour < 24:
                half = Gtk.Box()
                half.add_css_class("half-line")
                half.set_size_request(10, 1)
                column.put(half, 0, int((hour + 0.5) * PX_PER_HOUR))
                lines.append(half)

        sel_box = Gtk.Box()
        sel_box.add_css_class("drag-selection")
        sel_box.set_visible(False)
        sel_box.set_halign(Gtk.Align.START)
        column.put(sel_box, 0, 0)
        col_w = {"w": 1}

        timed = [e for e in buckets.get(key, [])
                 if not e.all_day and e.start is not None]
        timed.sort(key=lambda e: e.start.to_unix())
        placements = _layout_overlaps(timed, day)
        blocks = []
        for ev, col_i, col_n in placements:
            y, h = self._event_geometry(ev, day)
            block = self._event_block(ev)
            column.put(block, 0, y)
            blocks.append((block, y, h, col_i, col_n))

        now_line = None
        if key == today_key:
            now_line = Gtk.Box()
            now_line.add_css_class("now-line")
            now_line.set_size_request(10, 2)
            frac = (now.get_hour() * 60 + now.get_minute()) / 60.0
            column.put(now_line, 0, int(frac * PX_PER_HOUR) - 1)

        def _on_width(_col, w, _h, _b=None):
            col_w["w"] = max(1, w)
            for ln in lines:
                ln.set_size_request(w, 1)
            if now_line is not None:
                now_line.set_size_request(w, 2)
            for block, _y, _bh, col_i, col_n in blocks:
                bw = max(1, int(w / col_n) - 2)
                column.move(block, col_i * int(w / col_n) + 1, _y)
                block.set_size_request(bw, _bh)
            if sel_box.get_visible():
                _bh2 = sel_box.get_height()
                sel_box.set_size_request(col_w["w"], max(1, _bh2))
        column.connect("notify::width", lambda c, _p:
                       _on_width(c, c.get_width(), c.get_height()))

        # --- drag-to-create ------------------------------------------------ #
        drag = Gtk.GestureDrag()
        drag.set_button(1)
        state = {"start_y": 0.0}

        def _drag_begin(_g, _sx, sy):
            state["start_y"] = sy
            sel_box.set_visible(True)
            w = col_w["w"] or max(1, column.get_width())
            sel_box.set_size_request(max(1, w), 1)
            column.move(sel_box, 0, int(sy))

        def _drag_update(_g, ox, oy):
            sy = state["start_y"]
            cy = sy + oy
            top = max(0, min(sy, cy))
            bot = min(GRID_HEIGHT, max(sy, cy))
            w = col_w["w"] or max(1, column.get_width())
            sel_box.set_size_request(max(1, w), max(1, int(bot - top)))
            column.move(sel_box, 0, int(top))

        def _drag_end(_g, ox, oy):
            sel_box.set_visible(False)
            sy = state["start_y"]
            cy = sy + oy
            self._drag_to_new_event(day, sy, cy, abs(oy))

        drag.connect("drag-begin", _drag_begin)
        drag.connect("drag-update", _drag_update)
        drag.connect("drag-end", _drag_end)
        column.add_controller(drag)

        self._attach_newhere_menu(column, day, get_minutes=_y_to_minutes)
        return column

    def _event_geometry(self, ev, day):
        """(y_px, height_px) for a timed event clamped to this day's 0–24h."""
        day_start = GLib.DateTime.new_local(
            day.get_year(), day.get_month(), day.get_day_of_month(), 0, 0, 0)
        day_end = day_start.add_days(1)
        s = ev.start
        e = ev.end if ev.end is not None else ev.start.add_hours(1)
        s_unix = max(s.to_unix(), day_start.to_unix())
        e_unix = min(e.to_unix(), day_end.to_unix())
        start_min = (s_unix - day_start.to_unix()) / 60.0
        end_min = (e_unix - day_start.to_unix()) / 60.0
        y = int(start_min / 60.0 * PX_PER_HOUR)
        h = max(EVENT_MIN_HEIGHT, int((end_min - start_min) / 60.0 * PX_PER_HOUR))
        return y, h

    def _event_block(self, ev):
        """A positioned, clickable event block: time + title, calendar tint."""
        btn = Gtk.Button()
        btn.add_css_class("event-block")
        btn.add_css_class("flat")
        btn.connect("clicked", lambda _b, e=ev: self._show_detail(e))
        btn.set_tooltip_text(ev.summary)

        self._attach_event_menu(btn, ev)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        time_lbl = Gtk.Label(label=self._time_label(ev))
        time_lbl.add_css_class("event-block-time")
        time_lbl.set_halign(Gtk.Align.START)
        time_lbl.set_xalign(0)
        time_lbl.set_ellipsize(Pango.EllipsizeMode.END)
        box.append(time_lbl)
        title = Gtk.Label(label=ev.summary)
        title.add_css_class("event-block-title")
        title.set_halign(Gtk.Align.START)
        title.set_xalign(0)
        title.set_ellipsize(Pango.EllipsizeMode.END)
        box.append(title)
        btn.set_child(box)

        if ev.cal_color:
            cls = _color_class(ev.cal_color)
            css = (".event-block.cb-%s { border-left: 3px solid %s; "
                   "background-color: %s; }"
                   % (cls, ev.cal_color, _tint(ev.cal_color)))
            provider = Gtk.CssProvider()
            try:
                provider.load_from_data(css.encode("utf-8"))
                btn.get_style_context().add_provider(
                    provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
                btn.add_css_class("cb-%s" % cls)
            except Exception:  # noqa: BLE001
                pass
        return btn

    def _allday_chip(self, ev):
        """A compact all-day pill for the all-day row."""
        btn = Gtk.Button()
        btn.add_css_class("allday-chip")
        btn.add_css_class("flat")
        btn.connect("clicked", lambda _b, e=ev: self._show_detail(e))
        btn.set_tooltip_text(ev.summary)
        lbl = Gtk.Label(label=ev.summary)
        lbl.add_css_class("allday-chip-label")
        lbl.set_halign(Gtk.Align.START)
        lbl.set_xalign(0)
        lbl.set_ellipsize(Pango.EllipsizeMode.END)
        btn.set_child(lbl)
        if ev.cal_color:
            cls = _color_class(ev.cal_color)
            css = (".allday-chip.cb-%s { border-left: 3px solid %s; }"
                   % (cls, ev.cal_color))
            provider = Gtk.CssProvider()
            try:
                provider.load_from_data(css.encode("utf-8"))
                btn.get_style_context().add_provider(
                    provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
                btn.add_css_class("cb-%s" % cls)
            except Exception:  # noqa: BLE001
                pass
        return btn

    def _drag_to_new_event(self, day, y0, y1, drag_dist):
        """Map two Y positions on a day column → start/end times and open the
        New-event dialog pre-filled. A near-zero drag (a click) defaults to a
        one-hour event at the clicked hour."""
        if drag_dist < DRAG_THRESHOLD:
            start_min = _snap(_y_to_minutes(y0), SNAP_MINUTES)
            end_min = start_min + 60
        else:
            a = _snap(_y_to_minutes(min(y0, y1)), SNAP_MINUTES)
            b = _snap(_y_to_minutes(max(y0, y1)), SNAP_MINUTES)
            if b <= a:
                b = a + SNAP_MINUTES
            start_min, end_min = a, b
        start_min = max(0, min(start_min, 24 * 60 - SNAP_MINUTES))
        end_min = max(start_min + SNAP_MINUTES, min(end_min, 24 * 60))

        base = GLib.DateTime.new_local(
            day.get_year(), day.get_month(), day.get_day_of_month(), 0, 0, 0)
        start_dt = base.add_seconds(start_min * 60)
        end_dt = base.add_seconds(end_min * 60)
        self._on_new_event(None, defaults={
            "date": base, "start_dt": start_dt, "end_dt": end_dt,
            "all_day": False,
        })

    # ---- right-click context menus ---------------------------------------- #
    def _attach_event_menu(self, widget, ev):
        gesture = Gtk.GestureClick()
        gesture.set_button(3)

        popover = Gtk.Popover()
        popover.set_has_arrow(True)
        popover.set_parent(widget)
        popover.set_position(Gtk.PositionType.BOTTOM)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        box.add_css_class("context-menu")

        del_btn = Gtk.Button(label=_("Delete"))
        del_btn.add_css_class("flat")
        del_btn.add_css_class("context-menu-item")
        del_btn.add_css_class("destructive")
        del_btn.set_halign(Gtk.Align.FILL)
        del_btn.get_child().set_halign(Gtk.Align.START)

        def _on_delete(_b, e=ev):
            popover.popdown()
            self._delete_event(e)
        del_btn.connect("clicked", _on_delete)
        box.append(del_btn)

        det_btn = Gtk.Button(label=_("Details"))
        det_btn.add_css_class("flat")
        det_btn.add_css_class("context-menu-item")
        det_btn.set_halign(Gtk.Align.FILL)
        det_btn.get_child().set_halign(Gtk.Align.START)

        def _on_details(_b, e=ev):
            popover.popdown()
            self._show_detail(e)
        det_btn.connect("clicked", _on_details)
        box.append(det_btn)

        popover.set_child(box)

        def _on_pressed(_g, _n, x, y):
            popover.set_pointing_to(_point_rect(x, y))
            popover.popup()
        gesture.connect("pressed", _on_pressed)
        widget.add_controller(gesture)

    def _attach_newhere_menu(self, widget, day, get_minutes=None):
        gesture = Gtk.GestureClick()
        gesture.set_button(3)

        popover = Gtk.Popover()
        popover.set_has_arrow(True)
        popover.set_parent(widget)
        popover.set_position(Gtk.PositionType.BOTTOM)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        box.add_css_class("context-menu")

        new_btn = Gtk.Button(label=_("New event here"))
        new_btn.add_css_class("flat")
        new_btn.add_css_class("context-menu-item")
        new_btn.set_halign(Gtk.Align.FILL)
        new_btn.get_child().set_halign(Gtk.Align.START)
        box.append(new_btn)
        popover.set_child(box)

        click_state = {"y": 0.0}

        def _on_new(_b, d=day):
            popover.popdown()
            self._new_event_at(d, click_state["y"], get_minutes)
        new_btn.connect("clicked", _on_new)

        def _on_pressed(_g, _n, x, y):
            click_state["y"] = y
            popover.set_pointing_to(_point_rect(x, y))
            popover.popup()
        gesture.connect("pressed", _on_pressed)
        widget.add_controller(gesture)

    def _attach_day_menu(self, widget, day):
        gesture = Gtk.GestureClick()
        gesture.set_button(3)

        popover = Gtk.Popover()
        popover.set_has_arrow(True)
        popover.set_parent(widget)
        popover.set_position(Gtk.PositionType.BOTTOM)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        box.add_css_class("context-menu")
        new_btn = Gtk.Button(label=_("New event"))
        new_btn.add_css_class("flat")
        new_btn.add_css_class("context-menu-item")
        new_btn.set_halign(Gtk.Align.FILL)
        new_btn.get_child().set_halign(Gtk.Align.START)
        box.append(new_btn)
        popover.set_child(box)

        def _on_new(_b, d=day):
            popover.popdown()
            base = GLib.DateTime.new_local(
                d.get_year(), d.get_month(), d.get_day_of_month(), 0, 0, 0)
            self._on_new_event(None, defaults={"date": base, "all_day": False})
        new_btn.connect("clicked", _on_new)

        def _on_pressed(_g, _n, x, y):
            popover.set_pointing_to(_point_rect(x, y))
            popover.popup()
        gesture.connect("pressed", _on_pressed)
        widget.add_controller(gesture)

    def _new_event_at(self, day, y, get_minutes):
        if get_minutes is not None:
            start_min = _snap(get_minutes(y), SNAP_MINUTES)
        else:
            start_min = _snap(_y_to_minutes(y), SNAP_MINUTES)
        start_min = max(0, min(start_min, 24 * 60 - SNAP_MINUTES))
        end_min = min(start_min + 60, 24 * 60)
        base = GLib.DateTime.new_local(
            day.get_year(), day.get_month(), day.get_day_of_month(), 0, 0, 0)
        start_dt = base.add_seconds(start_min * 60)
        end_dt = base.add_seconds(end_min * 60)
        self._on_new_event(None, defaults={
            "date": base, "start_dt": start_dt, "end_dt": end_dt,
            "all_day": False,
        })

    # ---- delete (write) --------------------------------------------------- #
    def _delete_event(self, ev):
        """Remove an event from EDS in a WORKER THREAD, then reload + toast."""
        if not EDS_OK:
            self._toast(_("Evolution Data Server is not available."))
            return
        if ev.source is None or not ev.uid:
            self._toast(_("This event cannot be deleted."))
            return

        source = ev.source
        uid = ev.uid
        rid = ev.rid

        def worker():
            err = None
            try:
                client = ECal.Client.connect_sync(
                    source, ECal.ClientSourceType.EVENTS, 30, None)
                if client is None:
                    raise RuntimeError(_("calendar not reachable"))
                ok = client.remove_object_sync(
                    uid, rid, ECal.ObjModType.THIS,
                    ECal.OperationFlags.NONE, None)
                if not ok:
                    raise RuntimeError(_("the calendar rejected the deletion"))
            except Exception as e:  # noqa: BLE001
                err = str(e)
            GLib.idle_add(self._on_event_deleted, err)

        threading.Thread(target=worker, daemon=True).start()

    def _on_event_deleted(self, err):
        if err is not None:
            self._toast(_("Could not delete event: %s") % err)
            return False
        self._toast(_("Event deleted."))
        self._reload()
        return False

    # ====================================================================== #
    #  Selected-day agenda (Month view) — built directly under the grid
    # ====================================================================== #
    def _build_agenda_panel(self):
        """The selected-day agenda widget for the Month view, or None for
        Day/Week (those are full time grids that already show events in place)."""
        if self.view_mode in ("day", "week"):
            return None

        inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        inner.add_css_class("tn-cal-agenda")
        inner.add_css_class("tn-cal-agenda-inner")
        inner.set_hexpand(True)  # span the grid width (column is centred)

        day = self._selected_date()
        wd = _weekday(day.get_day_of_week())
        heading = Gtk.Label(label=_("%s, %d. %s") % (
            wd, day.get_day_of_month(), _month(day.get_month())))
        heading.add_css_class("tn-cal-agenda-heading")
        heading.set_halign(Gtk.Align.START)
        heading.set_xalign(0)
        inner.append(heading)

        buckets = self._filtered(self._last_buckets)
        day_events = list(buckets.get(self._selected_key(), []))
        all_day = [e for e in day_events if e.all_day]
        timed = [e for e in day_events if not e.all_day]
        timed.sort(key=lambda e: (e.start.to_unix() if e.start else 0))

        if not day_events:
            empty = Gtk.Label(label=_("No events"))
            empty.add_css_class("tn-cal-agenda-empty")
            empty.set_halign(Gtk.Align.START)
            empty.set_xalign(0)
            inner.append(empty)
        else:
            listbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
            for ev in all_day + timed:
                listbox.append(self._agenda_row(ev))
            inner.append(listbox)
        return inner

    def _agenda_row(self, ev):
        """A roomy agenda line: colour dot + time range + title + location."""
        btn = Gtk.Button()
        btn.add_css_class("agenda-row")
        btn.add_css_class("flat")
        btn.set_hexpand(True)
        btn.connect("clicked", lambda _b, e=ev: self._show_detail(e))

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)

        dot = self._color_dot(ev.cal_color, size=10)
        dot.set_valign(Gtk.Align.CENTER)
        row.append(dot)

        time_lbl = Gtk.Label(label=self._time_label(ev))
        time_lbl.add_css_class("agenda-time")
        time_lbl.set_halign(Gtk.Align.START)
        time_lbl.set_xalign(0)
        time_lbl.set_width_chars(11)
        time_lbl.set_valign(Gtk.Align.CENTER)
        row.append(time_lbl)

        text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
        text.set_valign(Gtk.Align.CENTER)
        title = Gtk.Label(label=ev.summary)
        title.add_css_class("agenda-title")
        title.set_halign(Gtk.Align.START)
        title.set_xalign(0)
        title.set_ellipsize(Pango.EllipsizeMode.END)
        text.append(title)
        if ev.location:
            loc = Gtk.Label(label=ev.location)
            loc.add_css_class("agenda-location")
            loc.set_halign(Gtk.Align.START)
            loc.set_xalign(0)
            loc.set_ellipsize(Pango.EllipsizeMode.END)
            text.append(loc)
        text.set_hexpand(True)
        row.append(text)

        btn.set_child(row)
        btn.set_tooltip_text(ev.summary)
        return btn

    # ---- shared small bits ------------------------------------------------ #
    def _color_dot(self, color, size=10):
        dot = Gtk.Box()
        dot.add_css_class("event-dot")
        dot.set_size_request(size, size)
        dot.set_valign(Gtk.Align.CENTER)
        if color:
            css = (".event-dot.c-%s { background-color: %s; }"
                   % (_color_class(color), color))
            provider = Gtk.CssProvider()
            try:
                provider.load_from_data(css.encode("utf-8"))
                dot.get_style_context().add_provider(
                    provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
                dot.add_css_class("c-%s" % _color_class(color))
            except Exception:  # noqa: BLE001
                pass
        return dot

    def _time_label(self, ev):
        if ev.all_day or ev.start is None:
            return _("All day")
        start = ev.start.format("%H:%M")
        if ev.end is not None:
            if ev.end.format("%Y-%m-%d") == ev.start.format("%Y-%m-%d"):
                return "%s – %s" % (start, ev.end.format("%H:%M"))
        return start

    # ====================================================================== #
    #  Native dialogs (detail + new event), toast, status pages
    # ====================================================================== #
    def _new_dialog(self, title, width=440):
        """A modal native dialog window in the TANINUX look."""
        dlg = Gtk.Window(modal=True, title=title)
        dlg.add_css_class("tn")
        dlg.add_css_class("tn-dark")
        dlg.add_css_class("tn-cal-dialog")
        dlg.set_transient_for(self)
        dlg.set_default_size(width, -1)
        return dlg

    def _detail_row(self, label_text, value, selectable=False):
        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        row.add_css_class("tn-row")
        key = Gtk.Label(label=label_text, xalign=0.0)
        key.add_css_class("tn-subtitle")
        key.set_size_request(90, -1)
        key.set_valign(Gtk.Align.START)
        row.append(key)
        val = Gtk.Label(label=value, xalign=0.0, hexpand=True)
        val.add_css_class("tn-title")
        val.set_wrap(True)
        val.set_selectable(selectable)
        row.append(val)
        return row

    def _show_detail(self, ev):
        dlg = self._new_dialog(ev.summary or _("Event"), 440)

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
        outer.add_css_class("tn-cal-dialog-box")

        head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        dot = self._color_dot(ev.cal_color, size=14)
        dot.set_valign(Gtk.Align.CENTER)
        head.append(dot)
        title = Gtk.Label(label=ev.summary or _("(No title)"), xalign=0.0)
        title.add_css_class("tn-cal-dialog-title")
        title.set_wrap(True)
        title.set_hexpand(True)
        head.append(title)
        outer.append(head)

        card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        card.add_css_class("tn-card")
        card.append(self._detail_row(_("Time"), self._detail_time(ev), True))
        if ev.location:
            card.append(self._detail_row(_("Location"), ev.location, True))
        card.append(self._detail_row(_("Calendar"), ev.cal_name or "—"))
        if ev.description:
            card.append(self._detail_row(_("Note"), ev.description, True))
        outer.append(card)

        btns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        btns.set_halign(Gtk.Align.END)
        del_btn = Gtk.Button(label=_("Delete"))
        del_btn.add_css_class("pill")
        del_btn.add_css_class("destructive-action")

        def _on_del(_b):
            dlg.destroy()
            self._delete_event(ev)
        del_btn.connect("clicked", _on_del)
        btns.append(del_btn)

        close_btn = Gtk.Button(label=_("Close"))
        close_btn.add_css_class("pill")
        close_btn.connect("clicked", lambda _b: dlg.destroy())
        btns.append(close_btn)
        outer.append(btns)

        dlg.set_child(outer)
        dlg.present()

    def _detail_time(self, ev):
        if ev.all_day or ev.start is None:
            day = ev.start.format("%d.%m.%Y") if ev.start else ""
            return _("All day · %s") % day if day else _("All day")
        out = ev.start.format("%a, %d.%m.%Y %H:%M")
        if ev.end is not None:
            if ev.end.format("%Y-%m-%d") == ev.start.format("%Y-%m-%d"):
                out += " – %s" % ev.end.format("%H:%M")
            else:
                out += " – %s" % ev.end.format("%d.%m.%Y %H:%M")
        return out

    # ---- event creation (write) ------------------------------------------- #
    def _on_new_event(self, _btn, defaults=None):
        if not EDS_OK:
            self._toast(_("Evolution Data Server is not available."))
            return
        # _writable_calendars() hits EDS (SourceRegistry.new_sync). A sync EDS
        # call on the main loop DEADLOCKS — do it off-thread, then present.
        def worker():
            try:
                writable = _writable_calendars()
            except Exception:  # noqa: BLE001
                writable = []
            GLib.idle_add(self._after_writable, writable, defaults)

        threading.Thread(target=worker, daemon=True).start()

    def _after_writable(self, writable, defaults=None):
        if not writable:
            self._toast(_("No writable calendar found."))
            return False
        self._present_new_event_dialog(writable, defaults)
        return False

    def _form_row(self, label_text, widget, fill=True):
        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        row.add_css_class("tn-row")
        key = Gtk.Label(label=label_text, xalign=0.0)
        key.add_css_class("tn-title")
        key.set_size_request(90, -1)
        key.set_valign(Gtk.Align.CENTER)
        row.append(key)
        widget.set_valign(Gtk.Align.CENTER)
        if fill:
            # Entries / dropdowns stretch to the row width.
            widget.set_hexpand(True)
            widget.set_halign(Gtk.Align.FILL)
        else:
            # Switches / the time picker keep their natural size, left-aligned.
            widget.set_hexpand(False)
            widget.set_halign(Gtk.Align.START)
        row.append(widget)
        return row

    def _present_new_event_dialog(self, writable, defaults=None):
        defaults = defaults or {}
        def_date = defaults.get("date") or self._selected_date()
        def_start = defaults.get("start_dt")
        def_end = defaults.get("end_dt")
        def_all_day = bool(defaults.get("all_day", False))
        def_sh = def_start.get_hour() if def_start is not None else 9
        def_sm = def_start.get_minute() if def_start is not None else 0
        def_eh = def_end.get_hour() if def_end is not None else 10
        def_em = def_end.get_minute() if def_end is not None else 0

        dlg = self._new_dialog(_("New event"), 460)
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
        outer.add_css_class("tn-cal-dialog-box")

        title_lbl = Gtk.Label(label=_("New event"), xalign=0.0)
        title_lbl.add_css_class("tn-cal-dialog-title")
        outer.append(title_lbl)

        # --- title + calendar --------------------------------------------- #
        card1 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        card1.add_css_class("tn-card")

        title_entry = Gtk.Entry()
        title_entry.set_placeholder_text(_("Title"))
        card1.append(self._form_row(_("Title"), title_entry))

        cal_dd = Gtk.DropDown()
        cal_model = Gtk.StringList()
        for _src, name in writable:
            cal_model.append(name)
        cal_dd.set_model(cal_model)
        card1.append(self._form_row(_("Calendar"), cal_dd))
        outer.append(card1)

        # --- when --------------------------------------------------------- #
        card2 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        card2.add_css_class("tn-card")

        allday_sw = Gtk.Switch()
        allday_sw.set_active(def_all_day)
        card2.append(self._form_row(_("All day"), allday_sw, fill=False))

        cal_picker = Gtk.Calendar()
        # Set via properties (select_day is deprecated). Gtk.Calendar's "month"
        # property is 0-based, unlike GLib.DateTime's 1-based month.
        cal_picker.set_property("year", def_date.get_year())
        cal_picker.set_property("month", def_date.get_month() - 1)
        cal_picker.set_property("day", def_date.get_day_of_month())
        date_btn = Gtk.MenuButton()
        date_btn.add_css_class("pill")
        date_btn.set_valign(Gtk.Align.CENTER)
        date_btn.set_halign(Gtk.Align.START)
        date_pop = Gtk.Popover()
        date_pop.set_child(cal_picker)
        date_btn.set_popover(date_pop)

        def _fmt_date_btn(*_a):
            d = cal_picker.get_date()
            date_btn.set_label(d.format("%a, %d.%m.%Y"))
        _fmt_date_btn()
        cal_picker.connect("day-selected", _fmt_date_btn)
        card2.append(self._form_row(_("Date"), date_btn))

        start_box, start_h, start_m = _time_picker(def_sh, def_sm)
        start_row = self._form_row(_("Start"), start_box, fill=False)
        card2.append(start_row)
        end_box, end_h, end_m = _time_picker(def_eh, def_em)
        end_row = self._form_row(_("End"), end_box, fill=False)
        card2.append(end_row)
        outer.append(card2)

        def _toggle_allday(*_a):
            show = not allday_sw.get_active()
            start_row.set_visible(show)
            end_row.set_visible(show)
        allday_sw.connect("notify::active", _toggle_allday)
        _toggle_allday()

        # --- details ------------------------------------------------------ #
        card3 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        card3.add_css_class("tn-card")
        loc_entry = Gtk.Entry()
        loc_entry.set_placeholder_text(_("Location"))
        card3.append(self._form_row(_("Location"), loc_entry))
        desc_entry = Gtk.Entry()
        desc_entry.set_placeholder_text(_("Note"))
        card3.append(self._form_row(_("Note"), desc_entry))
        outer.append(card3)

        # --- buttons ------------------------------------------------------ #
        btns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        btns.set_halign(Gtk.Align.END)
        cancel_btn = Gtk.Button(label=_("Cancel"))
        cancel_btn.add_css_class("pill")
        cancel_btn.connect("clicked", lambda _b: dlg.destroy())
        btns.append(cancel_btn)
        save_btn = Gtk.Button(label=_("Save"))
        save_btn.add_css_class("pill")
        save_btn.add_css_class("suggested-action")
        btns.append(save_btn)
        outer.append(btns)

        scroller = Gtk.ScrolledWindow()
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.set_propagate_natural_height(True)
        scroller.set_max_content_height(720)
        scroller.set_child(outer)
        dlg.set_child(scroller)

        def _on_save(_b):
            title = title_entry.get_text().strip()
            if not title:
                title_entry.add_css_class("error")
                self._toast(_("Please enter a title."))
                return
            src, _name = writable[cal_dd.get_selected()]
            all_day = allday_sw.get_active()
            d = cal_picker.get_date()
            spec = {
                "summary": title,
                "location": loc_entry.get_text().strip(),
                "description": desc_entry.get_text().strip(),
                "all_day": all_day,
                "year": d.get_year(), "month": d.get_month(),
                "day": d.get_day_of_month(),
                "sh": start_h.get_selected(), "sm": start_m.get_selected() * 5,
                "eh": end_h.get_selected(), "em": end_m.get_selected() * 5,
            }
            save_btn.set_sensitive(False)
            cancel_btn.set_sensitive(False)
            save_btn.set_label(_("Saving…"))
            self._save_event_async(src, spec, dlg, save_btn, cancel_btn)

        save_btn.connect("clicked", _on_save)
        dlg.present()

    def _save_event_async(self, source, spec, dialog, save_btn, cancel_btn):
        """Build the VEVENT and write it via ECal in a WORKER THREAD."""
        def worker():
            err = None
            try:
                client = ECal.Client.connect_sync(
                    source, ECal.ClientSourceType.EVENTS, 30, None)
                if client is None:
                    raise RuntimeError(_("calendar not reachable"))
                comp = _build_vevent(spec)
                ok, _uid = client.create_object_sync(
                    comp, ECal.OperationFlags.NONE, None)
                if not ok:
                    raise RuntimeError(_("the calendar rejected the event"))
            except Exception as e:  # noqa: BLE001
                err = str(e)
            GLib.idle_add(self._on_event_saved, err, dialog,
                          save_btn, cancel_btn)

        threading.Thread(target=worker, daemon=True).start()

    def _on_event_saved(self, err, dialog, save_btn, cancel_btn):
        if err is not None:
            save_btn.set_sensitive(True)
            cancel_btn.set_sensitive(True)
            save_btn.set_label(_("Save"))
            self._toast(_("Could not save event: %s") % err)
            return False
        dialog.destroy()
        self._toast(_("Event created."))
        self._reload()
        return False

    # ---- toast ------------------------------------------------------------ #
    def _build_toast(self):
        revealer = Gtk.Revealer()
        revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_UP)
        revealer.set_halign(Gtk.Align.CENTER)
        revealer.set_valign(Gtk.Align.END)
        revealer.set_margin_bottom(24)
        self._toast_label = Gtk.Label()
        self._toast_label.add_css_class("tn-toast")
        revealer.set_child(self._toast_label)
        return revealer

    def _toast(self, message):
        self._toast_label.set_label(message)
        self._toast_revealer.set_reveal_child(True)
        GLib.timeout_add_seconds(3, self._hide_toast)

    def _hide_toast(self):
        self._toast_revealer.set_reveal_child(False)
        return False

    # ---- status / error pages --------------------------------------------- #
    def _status_page(self, icon_name, title, description):
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        box.add_css_class("tn-status-page")
        box.set_valign(Gtk.Align.CENTER)
        box.set_halign(Gtk.Align.CENTER)
        box.set_vexpand(True)
        box.set_hexpand(True)
        icon = Gtk.Image.new_from_icon_name(icon_name)
        icon.set_pixel_size(96)
        icon.add_css_class("tn-status-icon")
        box.append(icon)
        t = Gtk.Label(label=title)
        t.add_css_class("title-1")
        t.set_wrap(True)
        t.set_justify(Gtk.Justification.CENTER)
        box.append(t)
        d = Gtk.Label(label=description)
        d.add_css_class("tn-status-desc")
        d.set_wrap(True)
        d.set_justify(Gtk.Justification.CENTER)
        box.append(d)
        return box

    def _eds_missing_page(self):
        return self._status_page(
            "x-office-calendar-symbolic",
            _("Evolution Data Server is not installed"),
            _("Displaying calendars requires the evolution-data-server "
              "package.") + "\n\n" + EDS_ERROR)

    def _error_page(self, message):
        return self._status_page(
            "dialog-error-symbolic",
            _("Calendars could not be loaded"), message)

    def _error_banner(self, errors):
        banner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        banner.add_css_class("tn-cal-banner")
        icon = Gtk.Image.new_from_icon_name("dialog-warning-symbolic")
        banner.append(icon)
        lbl = Gtk.Label(label=_("Some calendars are not reachable"),
                        xalign=0.0, hexpand=True)
        lbl.set_tooltip_text("\n".join(errors))
        banner.append(lbl)
        return banner


# --------------------------------------------------------------------------- #
#  Form helpers
# --------------------------------------------------------------------------- #
def _time_picker(hour, minute):
    """A clean HH : MM picker — two compact dropdowns. Returns
    (box, hour_dd, min_dd); the hour is dd.get_selected(), the minute is
    dd.get_selected() * 5 (5-minute steps)."""
    box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
    box.add_css_class("tn-cal-time")
    box.set_halign(Gtk.Align.START)

    h_dd = Gtk.DropDown.new_from_strings(["%02d" % h for h in range(24)])
    h_dd.add_css_class("tn-cal-time-dd")
    h_dd.set_selected(max(0, min(23, hour)))
    box.append(h_dd)

    sep = Gtk.Label(label=":")
    sep.add_css_class("tn-cal-time-sep")
    sep.set_valign(Gtk.Align.CENTER)
    box.append(sep)

    m_dd = Gtk.DropDown.new_from_strings(["%02d" % m for m in range(0, 60, 5)])
    m_dd.add_css_class("tn-cal-time-dd")
    m_dd.set_selected(max(0, min(11, minute // 5)))
    box.append(m_dd)
    return box, h_dd, m_dd


# --------------------------------------------------------------------------- #
#  EDS write helpers
# --------------------------------------------------------------------------- #
def _writable_calendars():
    """List (source, display_name) for every enabled, WRITABLE calendar source."""
    out = []
    try:
        registry = EDataServer.SourceRegistry.new_sync(None)
    except Exception:  # noqa: BLE001
        return out
    for source in registry.list_sources(
            EDataServer.SOURCE_EXTENSION_CALENDAR):
        try:
            if not source.get_enabled():
                continue
            if not source.get_writable():
                continue
        except Exception:  # noqa: BLE001
            continue
        out.append((source, source.get_display_name() or _("Calendar")))
    return out


def _ical_date(year, month, day):
    """A floating DATE (is_date=True) with the given Y/M/D set explicitly."""
    t = ICalGLib.Time.new_null_date()
    t.set_date(year, month, day)
    t.set_is_date(True)
    return t


def _ical_datetime_utc(year, month, day, hour, minute):
    """A DATE-TIME in UTC for the given local wall clock."""
    local = GLib.DateTime.new_local(year, month, day, hour, minute, 0)
    utc = ICalGLib.Timezone.get_utc_timezone()
    return ICalGLib.Time.new_from_timet_with_zone(local.to_unix(), False, utc)


def _build_vevent(spec):
    """Assemble a VEVENT ICalGLib.Component from the form spec dict."""
    comp = ICalGLib.Component.new_vevent()
    comp.add_property(ICalGLib.Property.new_uid(GLib.uuid_string_random()))

    now_utc = ICalGLib.Time.new_from_timet_with_zone(
        GLib.DateTime.new_now_utc().to_unix(), False,
        ICalGLib.Timezone.get_utc_timezone())
    comp.add_property(ICalGLib.Property.new_dtstamp(now_utc))

    comp.add_property(ICalGLib.Property.new_summary(spec["summary"]))
    if spec.get("location"):
        comp.add_property(ICalGLib.Property.new_location(spec["location"]))
    if spec.get("description"):
        comp.add_property(
            ICalGLib.Property.new_description(spec["description"]))

    all_day = spec["all_day"]
    y, m, d = spec["year"], spec["month"], spec["day"]

    if all_day:
        comp.add_property(ICalGLib.Property.new_dtstart(_ical_date(y, m, d)))
        nxt = GLib.DateTime.new_local(y, m, d, 0, 0, 0).add_days(1)
        comp.add_property(ICalGLib.Property.new_dtend(
            _ical_date(nxt.get_year(), nxt.get_month(),
                       nxt.get_day_of_month())))
    else:
        comp.add_property(ICalGLib.Property.new_dtstart(
            _ical_datetime_utc(y, m, d, spec["sh"], spec["sm"])))
        local_start = GLib.DateTime.new_local(y, m, d, spec["sh"], spec["sm"], 0)
        local_end = GLib.DateTime.new_local(y, m, d, spec["eh"], spec["em"], 0)
        if local_end.to_unix() <= local_start.to_unix():
            local_end = local_start.add_hours(1)
        comp.add_property(ICalGLib.Property.new_dtend(
            _ical_datetime_utc(
                local_end.get_year(), local_end.get_month(),
                local_end.get_day_of_month(),
                local_end.get_hour(), local_end.get_minute())))
    return comp


# --------------------------------------------------------------------------- #
#  Small helpers
# --------------------------------------------------------------------------- #
def _color_class(color):
    """A CSS-safe class suffix derived from a colour string."""
    return "".join(c for c in color if c.isalnum()) or "x"


def _point_rect(x, y):
    """A 1×1 Gdk.Rectangle at (x, y)."""
    rect = Gdk.Rectangle()
    rect.x = int(x)
    rect.y = int(y)
    rect.width = 1
    rect.height = 1
    return rect


def _y_to_minutes(y):
    """A Y pixel offset within a day column → minutes since 00:00."""
    return (y / float(PX_PER_HOUR)) * 60.0


def _snap(minutes, step):
    """Round ``minutes`` to the nearest ``step`` (e.g. 15)."""
    return int(round(minutes / float(step)) * step)


def _tint(color):
    """A translucent fill derived from a calendar colour for event blocks."""
    return "alpha(%s, 0.22)" % color


def _layout_overlaps(timed, day):
    """Best-effort overlap layout for a day's timed events.

    Greedy sweep: events are grouped into clusters that transitively overlap;
    within a cluster each event gets the first column index that's free, and
    every event in the cluster is told how many columns the cluster spans so
    blocks split the width evenly. Returns a list of (event, col_index,
    col_count) in input order.
    """
    if not timed:
        return []

    def _bounds(ev):
        s = ev.start.to_unix()
        e = (ev.end if ev.end is not None else ev.start.add_hours(1)).to_unix()
        if e <= s:
            e = s + 3600
        return s, e

    out = []
    cluster = []
    cluster_max_end = None

    def _flush(cluster):
        n = 0
        for _ev, _s, _e, col in cluster:
            n = max(n, col + 1)
        for ev, _s, _e, col in cluster:
            out.append((ev, col, n))

    for ev in timed:
        s, e = _bounds(ev)
        if cluster and s >= cluster_max_end:
            _flush(cluster)
            cluster = []
            cluster_max_end = None
        used = set()
        for _ev2, _s2, e2, col2 in cluster:
            if e2 > s:
                used.add(col2)
        col = 0
        while col in used:
            col += 1
        cluster.append((ev, s, e, col))
        cluster_max_end = e if cluster_max_end is None else max(cluster_max_end, e)
    if cluster:
        _flush(cluster)

    order = {id(ev): i for i, ev in enumerate(timed)}
    out.sort(key=lambda t: order[id(t[0])])
    return out


def _days_in_month(year, month):
    """Number of days in the given month (1..12), Gregorian."""
    if month == 12:
        nxt = GLib.DateTime.new_local(year + 1, 1, 1, 0, 0, 0)
    else:
        nxt = GLib.DateTime.new_local(year, month + 1, 1, 0, 0, 0)
    return nxt.add_days(-1).get_day_of_month()


# --------------------------------------------------------------------------- #
#  Application
# --------------------------------------------------------------------------- #
class CalendarApp(Gtk.Application):
    def __init__(self):
        super().__init__(application_id="ch.gabrielevarano.TaninCalendar",
                         flags=Gio.ApplicationFlags.FLAGS_NONE)

    def do_activate(self):
        self._load_css()
        win = self.props.active_window
        if not win:
            win = CalendarWindow(self)
        win.present()

    def _load_css(self):
        if not os.path.exists(CSS_PATH):
            return
        provider = Gtk.CssProvider()
        try:
            provider.load_from_path(CSS_PATH)
        except Exception:  # noqa: BLE001
            return
        display = Gdk.Display.get_default()
        if display is not None:
            # USER+1 so our look beats the auto-loaded ~/.config/gtk-4.0/gtk.css
            # (adw-gtk3) sidebar/headerbar rules, like the native UI naht does.
            Gtk.StyleContext.add_provider_for_display(
                display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1)


if __name__ == "__main__":
    sys.exit(CalendarApp().run(sys.argv))
