e8d93e6b1d
- gui/ui: native GTK4 widget vocabulary (.tn-*) behind the ui.* seam, default backend now native; style_apple.css design language - shell: sidebar/topbar/content all #0f0f0f in dark (matches eww panels) - new pages: wine, online_accounts - packaging: tanin-icons (accent folder theme), tanin-calendar, gtklock, taninux-gtk3-theme, flatpak install handler - iso/distro: profile + manifest tweaks (ISO binary not included)
2091 lines
80 KiB
Python
2091 lines
80 KiB
Python
#!/usr/bin/env python3
|
||
"""tanin-calendar — TANINUX calendar (GTK4 / libadwaita / PyGObject).
|
||
|
||
A GNOME/Apple-Calendar-style MONTH GRID over your calendars. It reads events
|
||
straight from Evolution Data Server (EDS) — the same backend GNOME Online
|
||
Accounts feeds — so any calendar you've added in GNOME Online Accounts (Google,
|
||
Nextcloud, local …) shows up here automatically.
|
||
|
||
View:
|
||
• Header bar: title = current month + year ("June 2026"), prev/next month
|
||
nav, and a "Today" button that jumps back to the current month.
|
||
• A month grid: a weekday header row (Mon…Sun, week starts Monday) and a
|
||
6×7 grid of day cells. Each cell shows its day number, dims days outside the
|
||
current month, highlights today, and lists the day's events as small
|
||
coloured chips (up to ~3, then a muted "+N more"). Clicking an event chip
|
||
opens a detail dialog; clicking a day cell opens a popover listing all that
|
||
day's events.
|
||
|
||
Theming inherits from tanin-libadwaita (Fuji monochrome). A stylesheet ships
|
||
alongside for the grid, cells and event chips; the rest is stock libadwaita.
|
||
|
||
If EDS isn't available (typelibs not installed), the window shows an
|
||
Adw.StatusPage 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 gi
|
||
import os
|
||
import sys
|
||
import threading
|
||
|
||
gi.require_version("Gtk", "4.0")
|
||
gi.require_version("Adw", "1")
|
||
from gi.repository import Gtk, Adw, GLib, Gdk, Gio, Pango # noqa: E402
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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 StatusPage 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"
|
||
)
|
||
|
||
# The calendar content is clamped to this width so wide/maximised windows show
|
||
# centred, readable cells instead of letting the grid stretch edge-to-edge.
|
||
GRID_MAX_WIDTH = 960
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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
|
||
|
||
# English month/weekday names for headings (independent of system locale, so
|
||
# the UI stays English even on a non-English-locale box).
|
||
MONTHS_EN = [
|
||
"January", "February", "March", "April", "May", "June",
|
||
"July", "August", "September", "October", "November", "December",
|
||
]
|
||
WEEKDAYS_EN = [
|
||
"Monday", "Tuesday", "Wednesday", "Thursday",
|
||
"Friday", "Saturday", "Sunday",
|
||
]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# UI
|
||
# --------------------------------------------------------------------------- #
|
||
class CalendarWindow(Adw.ApplicationWindow):
|
||
def __init__(self, app):
|
||
super().__init__(application=app)
|
||
self.set_title("Calendar")
|
||
self.set_default_size(960, 760)
|
||
|
||
# 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.
|
||
self.view_mode = "month"
|
||
self._switching = False # guard so programmatic stack changes don't loop
|
||
|
||
# The day the user has SELECTED (distinct from "today"). Drives the
|
||
# always-visible bottom agenda. Defaults to today. Kept as a (y, m, d)
|
||
# tuple so it survives month navigation cleanly.
|
||
self.selected_day = (self.cur_year, self.cur_month, self.cur_day)
|
||
|
||
# Cache of the most recent load so the bottom agenda can re-render on
|
||
# selection changes without hitting EDS again.
|
||
self._last_buckets = {}
|
||
|
||
toolbar = Adw.ToolbarView()
|
||
toolbar.add_top_bar(self._build_header())
|
||
toolbar.add_top_bar(self._build_strip())
|
||
|
||
# The active view (and any status pages) live in this content box so we
|
||
# can swap them out per reload without rebuilding the chrome.
|
||
self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
self.content.set_vexpand(True)
|
||
self.content.set_hexpand(True)
|
||
|
||
# The always-visible bottom agenda for the SELECTED day. It sits below
|
||
# the grid/week views (and re-renders on selection without reloading).
|
||
self.bottom_agenda = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
self.bottom_agenda.add_css_class("selected-agenda")
|
||
self.bottom_agenda.set_hexpand(True)
|
||
|
||
body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
body.set_vexpand(True)
|
||
body.set_hexpand(True)
|
||
body.append(self.content)
|
||
body.append(self.bottom_agenda)
|
||
toolbar.set_content(body)
|
||
|
||
# A ToastOverlay wraps everything so we can surface save/error toasts.
|
||
self._toast_overlay = Adw.ToastOverlay()
|
||
self._toast_overlay.set_child(toolbar)
|
||
|
||
self.set_content(self._toast_overlay)
|
||
|
||
# Sync the stack to the default view. 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).
|
||
self.view_stack.set_visible_child_name("month")
|
||
GLib.idle_add(self._initial_load)
|
||
|
||
def _initial_load(self):
|
||
try:
|
||
self._reload()
|
||
except Exception: # noqa: BLE001
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
# ---- focused date helpers --------------------------------------------- #
|
||
def _focus_date(self):
|
||
"""The focused day as a GLib.DateTime (local midnight)."""
|
||
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()
|
||
|
||
# ---- selection helpers ------------------------------------------------ #
|
||
def _selected_date(self):
|
||
"""The selected day as a GLib.DateTime (local midnight)."""
|
||
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. Re-renders the grid highlight (cheap, no
|
||
EDS reload) and refreshes the bottom agenda for the new day."""
|
||
new = (day.get_year(), day.get_month(), day.get_day_of_month())
|
||
if new == self.selected_day:
|
||
return
|
||
self.selected_day = new
|
||
self._reload()
|
||
|
||
# ---- header ----------------------------------------------------------- #
|
||
def _build_header(self):
|
||
header = Adw.HeaderBar()
|
||
|
||
# Centre: a Day | Week | Month view switcher bound to a ViewStack.
|
||
# The stack pages carry no content of their own — they exist only to
|
||
# drive the switcher; the real view renders into self.content below.
|
||
self.view_stack = Adw.ViewStack()
|
||
for name, title in (("day", "Day"), ("week", "Week"),
|
||
("month", "Month")):
|
||
self.view_stack.add_titled(Gtk.Box(), name, title)
|
||
|
||
switcher = Adw.ViewSwitcher()
|
||
switcher.set_stack(self.view_stack)
|
||
switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE)
|
||
header.set_title_widget(switcher)
|
||
|
||
self.view_stack.connect(
|
||
"notify::visible-child-name", self._on_view_changed)
|
||
|
||
# left: "+" New event. A labelled icon button so the action reads
|
||
# clearly without crowding the centred switcher.
|
||
new_btn = Gtk.Button()
|
||
new_btn.set_icon_name("list-add-symbolic")
|
||
new_btn.set_tooltip_text("New event")
|
||
new_btn.add_css_class("flat")
|
||
new_btn.connect("clicked", self._on_new_event)
|
||
header.pack_start(new_btn)
|
||
|
||
# right: Today (jump to the current date in the active view).
|
||
today_btn = Gtk.Button(label="Today")
|
||
today_btn.add_css_class("flat")
|
||
today_btn.connect("clicked", self._on_today)
|
||
header.pack_end(today_btn)
|
||
|
||
return header
|
||
|
||
# ---- strip (month-left / year-right + nav) ---------------------------- #
|
||
def _build_strip(self):
|
||
"""The masthead strip below the header: prev/next nav, the big month
|
||
name on the LEFT and the big year on the RIGHT (year lighter/muted).
|
||
|
||
Wrapped in an Adw.Clamp so the strip lines up with the clamped grid on
|
||
wide windows instead of flinging the year off to the far edge.
|
||
"""
|
||
clamp = Adw.Clamp()
|
||
clamp.set_maximum_size(GRID_MAX_WIDTH)
|
||
clamp.set_tightening_threshold(GRID_MAX_WIDTH)
|
||
clamp.add_css_class("cal-strip")
|
||
|
||
strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
strip.add_css_class("cal-strip-inner")
|
||
clamp.set_child(strip)
|
||
|
||
prev_btn = Gtk.Button()
|
||
prev_btn.set_icon_name("go-previous-symbolic")
|
||
prev_btn.add_css_class("flat")
|
||
prev_btn.add_css_class("circular")
|
||
prev_btn.set_tooltip_text("Previous")
|
||
prev_btn.connect("clicked", self._on_prev)
|
||
|
||
next_btn = Gtk.Button()
|
||
next_btn.set_icon_name("go-next-symbolic")
|
||
next_btn.add_css_class("flat")
|
||
next_btn.add_css_class("circular")
|
||
next_btn.set_tooltip_text("Next")
|
||
next_btn.connect("clicked", self._on_next)
|
||
|
||
nav = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2)
|
||
nav.add_css_class("cal-nav")
|
||
nav.append(prev_btn)
|
||
nav.append(next_btn)
|
||
nav.set_valign(Gtk.Align.CENTER)
|
||
strip.append(nav)
|
||
|
||
# Month name + optional day subtitle, grouped so they share a baseline
|
||
# and never jump when the subtitle toggles between views.
|
||
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
title_box.set_valign(Gtk.Align.CENTER)
|
||
|
||
self.month_label = Gtk.Label(label="")
|
||
self.month_label.add_css_class("month-title")
|
||
self.month_label.set_halign(Gtk.Align.START)
|
||
self.month_label.set_xalign(0)
|
||
title_box.append(self.month_label)
|
||
|
||
# Day view also shows the weekday/day number next to the month.
|
||
self.day_sub_label = Gtk.Label(label="")
|
||
self.day_sub_label.add_css_class("day-subtitle")
|
||
self.day_sub_label.set_halign(Gtk.Align.START)
|
||
self.day_sub_label.set_xalign(0)
|
||
self.day_sub_label.set_valign(Gtk.Align.CENTER)
|
||
title_box.append(self.day_sub_label)
|
||
strip.append(title_box)
|
||
|
||
# Spacer pushes the year to the far right.
|
||
spacer = Gtk.Box()
|
||
spacer.set_hexpand(True)
|
||
strip.append(spacer)
|
||
|
||
# Year, large + muted, on the RIGHT.
|
||
self.year_label = Gtk.Label(label="")
|
||
self.year_label.add_css_class("year-title")
|
||
self.year_label.set_halign(Gtk.Align.END)
|
||
self.year_label.set_xalign(1)
|
||
self.year_label.set_valign(Gtk.Align.CENTER)
|
||
strip.append(self.year_label)
|
||
|
||
return clamp
|
||
|
||
def _update_strip(self):
|
||
"""Refresh the masthead for the current focus + view."""
|
||
focus = self._focus_date()
|
||
self.month_label.set_label(MONTHS_EN[self.cur_month - 1])
|
||
self.year_label.set_label(str(self.cur_year))
|
||
|
||
if self.view_mode == "day":
|
||
wd = WEEKDAYS_EN[focus.get_day_of_week() - 1]
|
||
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)
|
||
|
||
# ---- view switching --------------------------------------------------- #
|
||
def _on_view_changed(self, _stack, _param):
|
||
if self._switching:
|
||
return
|
||
name = self.view_stack.get_visible_child_name()
|
||
if name and name != self.view_mode:
|
||
self.view_mode = name
|
||
self._reload()
|
||
|
||
# ---- 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). A single focused-date state drives all three views."""
|
||
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
|
||
# clamp the day so the 1st-driven grid math stays valid
|
||
self.cur_day = min(
|
||
self.cur_day,
|
||
_days_in_month(self.cur_year, self.cur_month))
|
||
self._reload()
|
||
|
||
# ---- content ---------------------------------------------------------- #
|
||
def _set_content(self, widget):
|
||
"""Swap the single child of the content box."""
|
||
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):
|
||
"""Six-week window the grid actually displays.
|
||
|
||
Returns (grid_start, grid_end) where:
|
||
• grid_start is the Monday on/just before the 1st of the month,
|
||
• grid_end is exclusive (grid_start + 42 days).
|
||
Fetching over this window means every visible cell is populated.
|
||
"""
|
||
first = GLib.DateTime.new_local(self.cur_year, self.cur_month, 1, 0, 0, 0)
|
||
# get_day_of_week: 1=Mon … 7=Sun → days to step back to reach Monday.
|
||
offset = first.get_day_of_week() - 1
|
||
grid_start = first.add_days(-offset)
|
||
grid_end = grid_start.add_days(42) # 6 weeks × 7 days, exclusive
|
||
return grid_start, grid_end
|
||
|
||
def _week_bounds(self):
|
||
"""Monday→Sunday week containing the focused date.
|
||
|
||
Returns (week_start, week_end) where week_start is that Monday (local
|
||
midnight) and week_end is exclusive (week_start + 7 days).
|
||
"""
|
||
focus = self._focus_date()
|
||
offset = focus.get_day_of_week() - 1 # 1=Mon … 7=Sun
|
||
week_start = focus.add_days(-offset)
|
||
return week_start, week_start.add_days(7)
|
||
|
||
def _day_bounds(self):
|
||
"""[focused day, next day) — the single day's range."""
|
||
start = self._focus_date()
|
||
return start, start.add_days(1)
|
||
|
||
def _reload(self):
|
||
# Keep the switcher in sync with view_mode (e.g. after Today / restore).
|
||
self._switching = True
|
||
if self.view_stack.get_visible_child_name() != self.view_mode:
|
||
self.view_stack.set_visible_child_name(self.view_mode)
|
||
self._switching = False
|
||
|
||
self._update_strip()
|
||
|
||
if not EDS_OK:
|
||
self._set_content(self._eds_missing_page())
|
||
self._clear_bottom_agenda()
|
||
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. A token
|
||
# drops stale loads if the user navigates/switches before this finishes.
|
||
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
|
||
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._update_bottom_agenda()
|
||
return False
|
||
|
||
# ---- month grid ------------------------------------------------------- #
|
||
def _build_grid(self, grid_start, buckets, errors):
|
||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
outer.set_vexpand(True)
|
||
outer.set_hexpand(True)
|
||
|
||
if errors:
|
||
outer.append(self._error_banner(errors))
|
||
|
||
grid = Gtk.Grid()
|
||
grid.add_css_class("month-grid")
|
||
grid.set_vexpand(True)
|
||
grid.set_hexpand(True)
|
||
grid.set_row_homogeneous(True)
|
||
grid.set_column_homogeneous(True)
|
||
|
||
# Weekday header row (Mon…Sun).
|
||
short = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||
for col, name in enumerate(short):
|
||
lbl = Gtk.Label(label=name)
|
||
lbl.add_css_class("weekday-header")
|
||
lbl.set_hexpand(True)
|
||
grid.attach(lbl, col, 0, 1, 1)
|
||
|
||
today_key = GLib.DateTime.new_now_local().format("%Y-%m-%d")
|
||
|
||
# 6 week-rows × 7 day-columns.
|
||
for week in range(6):
|
||
for dow in range(7):
|
||
day = grid_start.add_days(week * 7 + dow)
|
||
cell = self._build_cell(day, buckets, today_key)
|
||
grid.attach(cell, dow, week + 1, 1, 1)
|
||
|
||
# Clamp the grid so it stays centred and readable on wide/maximised
|
||
# windows instead of stretching the cells edge-to-edge ("pulled out of
|
||
# shape"). The clamp caps the width; the grid itself fills that.
|
||
clamp = Adw.Clamp()
|
||
clamp.set_maximum_size(GRID_MAX_WIDTH)
|
||
clamp.set_tightening_threshold(GRID_MAX_WIDTH)
|
||
clamp.add_css_class("grid-clamp")
|
||
clamp.set_child(grid)
|
||
clamp.set_vexpand(True)
|
||
clamp.set_hexpand(True)
|
||
|
||
scroller = Gtk.ScrolledWindow()
|
||
scroller.set_vexpand(True)
|
||
scroller.set_hexpand(True)
|
||
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
scroller.set_child(clamp)
|
||
outer.append(scroller)
|
||
return outer
|
||
|
||
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")
|
||
|
||
# Day number, top-left.
|
||
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)
|
||
|
||
# Event chips (up to 3), then a muted "+N more".
|
||
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 anywhere in the cell → SELECT that day. The bottom agenda then
|
||
# shows its events (this replaces the old click-a-day popover).
|
||
click = Gtk.GestureClick()
|
||
click.set_button(1)
|
||
click.connect("released", lambda *_a, d=day: self._select_day(d))
|
||
cell.add_controller(click)
|
||
|
||
# Right-click on a month day cell → "New event" defaulting to this day.
|
||
self._attach_day_menu(cell, day)
|
||
return cell
|
||
|
||
def _event_chip(self, ev):
|
||
"""A small coloured chip: colour bar + time + truncated title.
|
||
|
||
Activatable; clicking opens the event-detail dialog.
|
||
"""
|
||
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 view (time grid, one column) --------------------------------- #
|
||
def _build_day(self, day, buckets, errors):
|
||
"""The shared time grid restricted to a single full-width day column."""
|
||
return self._build_time_grid([day], buckets, errors, single=True)
|
||
|
||
# ---- week view (time grid, seven columns) ----------------------------- #
|
||
def _build_week(self, week_start, buckets, errors):
|
||
"""The shared time grid across Mon→Sun (seven equal day columns)."""
|
||
days = [week_start.add_days(i) for i in range(7)]
|
||
return self._build_time_grid(days, buckets, errors, single=False)
|
||
|
||
# ---- shared time grid ------------------------------------------------- #
|
||
def _build_time_grid(self, days, buckets, errors, single):
|
||
"""A GNOME/Apple-style time grid.
|
||
|
||
Layout (top → bottom):
|
||
• a pinned header row (outside the scroller): an empty axis-width
|
||
spacer, then one header per day (weekday name + date, today lit);
|
||
• a thin all-day row (axis spacer + one all-day cell per day);
|
||
• a vertically scrolling area: a fixed hour axis on the LEFT (00–23)
|
||
and one ``.day-column`` per day, all sharing PX_PER_HOUR. Timed
|
||
events are absolutely placed in a per-column Gtk.Fixed at
|
||
y = start-hour-fraction × PX_PER_HOUR (height = duration × scale,
|
||
min EVENT_MIN_HEIGHT). A now-line is drawn on today's column.
|
||
|
||
Each day column carries a Gtk.GestureDrag for drag-to-create.
|
||
"""
|
||
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()
|
||
|
||
short = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||
|
||
# --- 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 = WEEKDAYS_EN[day.get_day_of_week() - 1]
|
||
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(),
|
||
MONTHS_EN[day.get_month() - 1], 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() - 1])
|
||
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")
|
||
|
||
# Hour axis: a fixed-width column of "HH:00" labels, one per hour.
|
||
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)
|
||
|
||
# On open, scroll so ~07:00–08:00 is visible. Deferred to idle so the
|
||
# adjustment has its real upper bound by then.
|
||
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")
|
||
|
||
# Hour + half-hour hairlines as thin Fixed children spanning the width.
|
||
# We don't know the column's pixel width yet, so the lines are added as
|
||
# full-width boxes via a size allocation hook.
|
||
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)
|
||
|
||
# A selection rectangle reused for drag-to-create (hidden until a drag).
|
||
# It is a child of THIS column's Gtk.Fixed, so it can only ever live
|
||
# inside this one day column. Its width tracks the column's real pixel
|
||
# width (see _on_width below); x is pinned to 0 so it never spills into
|
||
# a neighbouring column — it only ever grows vertically (time).
|
||
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)
|
||
# The column's current pixel width, kept up to date by the
|
||
# notify::width handler and reused to size the selection box. Starts at
|
||
# 1 so a drag before the first allocation still draws something sane.
|
||
col_w = {"w": 1}
|
||
|
||
# Timed event blocks, with best-effort side-by-side overlap splitting.
|
||
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 on today's column.
|
||
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)
|
||
|
||
# Resize hook: stretch hairlines / now-line / blocks to the real width.
|
||
def _on_width(_col, w, _h, _b=None):
|
||
# Remember THIS column's real pixel width so the drag-selection box
|
||
# can be sized to exactly one column (never the whole grid).
|
||
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)
|
||
# Keep an in-progress selection box pinned to one column's width.
|
||
if sel_box.get_visible():
|
||
_bh2 = sel_box.get_height()
|
||
sel_box.set_size_request(col_w["w"], max(1, _bh2))
|
||
# Gtk.Fixed has no allocate signal; track size via the widget's
|
||
# "notify" on width through a size-allocate via a tick callback.
|
||
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)
|
||
# Width = this single column's tracked pixel width; x pinned to 0 so
|
||
# the box stays wholly inside the column the drag started on.
|
||
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)
|
||
|
||
# Right-click on the empty time grid → "New event here" at the clicked
|
||
# time. Right-clicks that land on an event block are consumed by the
|
||
# block's own button-3 menu, so they don't reach the column.
|
||
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)
|
||
# Clamp to the visible day.
|
||
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)
|
||
|
||
# Right-click → context menu (Delete / Details). A separate button-3
|
||
# GestureClick: it does not conflict with the left-button click above.
|
||
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)
|
||
|
||
# Tint the block with the calendar colour (left accent + faint fill).
|
||
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:
|
||
# Treat as a click: 1-hour event at the clicked hour (snapped).
|
||
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
|
||
# Clamp within the day [0, 24h]. Keep at least one snap step.
|
||
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):
|
||
"""Attach a button-3 (right-click) context menu to an event widget.
|
||
|
||
The menu offers Delete (removes from EDS off-thread) and Details (the
|
||
existing detail dialog). The left-button gestures on the same widget are
|
||
untouched, so normal click-to-open still works.
|
||
"""
|
||
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):
|
||
# Anchor the popover at the pointer within the clicked widget.
|
||
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):
|
||
"""Attach a button-3 menu offering "New event here" to a time-grid day
|
||
column. ``get_minutes`` maps the click Y (within the column) → minutes
|
||
since midnight so the dialog opens pre-filled at the clicked time."""
|
||
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):
|
||
"""Attach a button-3 menu offering "New event" to a month-view day
|
||
cell (defaults the date to that 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):
|
||
"""Open the create dialog pre-filled with ``day`` and the time the user
|
||
right-clicked (a one-hour default). Reuses the drag/click→create path so
|
||
snapping/clamping match drag-to-create exactly."""
|
||
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.
|
||
|
||
Like every other EDS access in this app, connect_sync/remove_object_sync
|
||
spin the GLib main context and DEADLOCK if called on the GTK main loop,
|
||
so the blocking work happens off-thread and the result is applied back
|
||
via GLib.idle_add.
|
||
"""
|
||
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 (missing source).")
|
||
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
|
||
|
||
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
|
||
|
||
# ---- bottom selected-day agenda --------------------------------------- #
|
||
def _clear_bottom_agenda(self):
|
||
child = self.bottom_agenda.get_first_child()
|
||
while child is not None:
|
||
nxt = child.get_next_sibling()
|
||
self.bottom_agenda.remove(child)
|
||
child = nxt
|
||
|
||
def _update_bottom_agenda(self):
|
||
"""Render the always-visible bottom section listing the SELECTED day's
|
||
events. Day view is itself an agenda for the focused day, so we hide the
|
||
bottom section there to avoid duplicating it."""
|
||
self._clear_bottom_agenda()
|
||
# Day and Week are now full time grids that show events in place, so the
|
||
# bottom selected-day agenda would only duplicate them — hide it there.
|
||
# It stays for Month (where cells only fit a few chips).
|
||
if self.view_mode in ("day", "week"):
|
||
self.bottom_agenda.set_visible(False)
|
||
return
|
||
self.bottom_agenda.set_visible(True)
|
||
|
||
day = self._selected_date()
|
||
wd = WEEKDAYS_EN[day.get_day_of_week() - 1]
|
||
heading_text = "%s, %d %s" % (
|
||
wd, day.get_day_of_month(), MONTHS_EN[day.get_month() - 1])
|
||
|
||
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||
inner.add_css_class("selected-agenda-inner")
|
||
|
||
heading = Gtk.Label(label=heading_text)
|
||
heading.add_css_class("selected-heading")
|
||
heading.set_halign(Gtk.Align.START)
|
||
heading.set_xalign(0)
|
||
inner.append(heading)
|
||
|
||
day_events = list(self._last_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("selected-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)
|
||
|
||
# Clamp the bottom agenda to align with the (clamped) grid above.
|
||
clamp = Adw.Clamp()
|
||
clamp.set_maximum_size(GRID_MAX_WIDTH)
|
||
clamp.set_tightening_threshold(GRID_MAX_WIDTH)
|
||
clamp.set_child(inner)
|
||
|
||
scroller = Gtk.ScrolledWindow()
|
||
scroller.add_css_class("selected-agenda-scroller")
|
||
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
scroller.set_min_content_height(120)
|
||
scroller.set_max_content_height(220)
|
||
scroller.set_propagate_natural_height(True)
|
||
scroller.set_child(clamp)
|
||
self.bottom_agenda.append(scroller)
|
||
|
||
# ---- day popover ------------------------------------------------------ #
|
||
def _show_day(self, day, day_events):
|
||
"""Popover/dialog listing all of a day's events (overflow reachable)."""
|
||
dialog = Adw.Dialog()
|
||
wd = WEEKDAYS_EN[day.get_day_of_week() - 1]
|
||
title = "%s, %d %s %d" % (wd, day.get_day_of_month(),
|
||
MONTHS_EN[day.get_month() - 1],
|
||
day.get_year())
|
||
dialog.set_title(title)
|
||
dialog.set_content_width(420)
|
||
|
||
tv = Adw.ToolbarView()
|
||
tv.add_top_bar(Adw.HeaderBar())
|
||
|
||
page = Adw.PreferencesPage()
|
||
group = Adw.PreferencesGroup()
|
||
group.set_title(title)
|
||
|
||
if not day_events:
|
||
empty = Adw.ActionRow()
|
||
empty.set_title("No events")
|
||
empty.set_subtitle("There is nothing scheduled for this day.")
|
||
group.add(empty)
|
||
else:
|
||
for ev in day_events:
|
||
group.add(self._event_row(ev, dialog))
|
||
page.add(group)
|
||
|
||
tv.set_content(page)
|
||
dialog.set_child(tv)
|
||
dialog.present(self)
|
||
|
||
def _event_row(self, ev, parent_dialog=None):
|
||
row = Adw.ActionRow()
|
||
row.set_title(GLib.markup_escape_text(ev.summary))
|
||
row.set_title_lines(2)
|
||
|
||
subtitle_bits = [self._time_label(ev)]
|
||
if ev.location:
|
||
subtitle_bits.append(ev.location)
|
||
row.set_subtitle(GLib.markup_escape_text(" · ".join(subtitle_bits)))
|
||
row.set_subtitle_lines(1)
|
||
|
||
row.add_prefix(self._color_dot(ev.cal_color))
|
||
|
||
row.set_activatable(True)
|
||
|
||
def _open(_r, e=ev):
|
||
if parent_dialog is not None:
|
||
parent_dialog.close()
|
||
self._show_detail(e)
|
||
row.connect("activated", _open)
|
||
chevron = Gtk.Image.new_from_icon_name("go-next-symbolic")
|
||
chevron.add_css_class("dim-label")
|
||
row.add_suffix(chevron)
|
||
return row
|
||
|
||
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:
|
||
# only show the end time when it's on the same day
|
||
if ev.end.format("%Y-%m-%d") == ev.start.format("%Y-%m-%d"):
|
||
return "%s – %s" % (start, ev.end.format("%H:%M"))
|
||
return start
|
||
|
||
# ---- detail ----------------------------------------------------------- #
|
||
def _show_detail(self, ev):
|
||
dialog = Adw.Dialog()
|
||
dialog.set_title(ev.summary or "Event")
|
||
dialog.set_content_width(420)
|
||
|
||
tv = Adw.ToolbarView()
|
||
tv.add_top_bar(Adw.HeaderBar())
|
||
|
||
page = Adw.PreferencesPage()
|
||
|
||
head = Adw.PreferencesGroup()
|
||
head.set_title(ev.summary or "(No title)")
|
||
page.add(head)
|
||
|
||
info = Adw.PreferencesGroup()
|
||
info.add(self._detail_row("Time", self._detail_time(ev)))
|
||
if ev.location:
|
||
info.add(self._detail_row("Location", ev.location))
|
||
info.add(self._detail_row("Calendar", ev.cal_name or "—"))
|
||
page.add(info)
|
||
|
||
if ev.description:
|
||
desc_group = Adw.PreferencesGroup()
|
||
desc_group.set_title("Description")
|
||
desc_label = Gtk.Label(label=ev.description)
|
||
desc_label.set_wrap(True)
|
||
desc_label.set_xalign(0)
|
||
desc_label.set_selectable(True)
|
||
desc_label.set_margin_top(6)
|
||
desc_label.set_margin_bottom(6)
|
||
desc_label.set_margin_start(12)
|
||
desc_label.set_margin_end(12)
|
||
desc_group.add(desc_label)
|
||
page.add(desc_group)
|
||
|
||
tv.set_content(page)
|
||
dialog.set_child(tv)
|
||
dialog.present(self)
|
||
|
||
def _detail_row(self, title, value):
|
||
row = Adw.ActionRow()
|
||
row.set_title(title)
|
||
row.set_subtitle(value)
|
||
row.set_subtitle_selectable(True)
|
||
return row
|
||
|
||
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):
|
||
"""Open the New-event form.
|
||
|
||
``defaults`` is an optional dict of pre-fill values
|
||
``{date, start_dt, end_dt, all_day}`` (any/all may be omitted). A plain
|
||
"+" click passes nothing → the form falls back to the selected day with
|
||
a 09:00–10:00 default. A drag-to-create passes the dragged column date
|
||
and the snapped start/end times.
|
||
"""
|
||
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 the whole app — do it off-thread, then present
|
||
# the dialog back on the main thread.
|
||
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 _present_new_event_dialog(self, writable, defaults=None):
|
||
# Resolve the pre-fill defaults. ``date`` drives the calendar picker;
|
||
# ``start_dt``/``end_dt`` (GLib.DateTime, local) drive the time spinners.
|
||
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
|
||
dialog = Adw.Dialog()
|
||
dialog.set_title("New Event")
|
||
dialog.set_content_width(440)
|
||
|
||
tv = Adw.ToolbarView()
|
||
header = Adw.HeaderBar()
|
||
header.set_show_end_title_buttons(False)
|
||
header.set_show_start_title_buttons(False)
|
||
|
||
cancel_btn = Gtk.Button(label="Cancel")
|
||
cancel_btn.connect("clicked", lambda _b: dialog.close())
|
||
header.pack_start(cancel_btn)
|
||
|
||
save_btn = Gtk.Button(label="Save")
|
||
save_btn.add_css_class("suggested-action")
|
||
header.pack_end(save_btn)
|
||
tv.add_top_bar(header)
|
||
|
||
page = Adw.PreferencesPage()
|
||
|
||
# --- main group: title + calendar -------------------------------- #
|
||
grp = Adw.PreferencesGroup()
|
||
|
||
title_row = Adw.EntryRow()
|
||
title_row.set_title("Title")
|
||
grp.add(title_row)
|
||
|
||
cal_row = Adw.ComboRow()
|
||
cal_row.set_title("Calendar")
|
||
cal_model = Gtk.StringList()
|
||
for _src, name in writable:
|
||
cal_model.append(name)
|
||
cal_row.set_model(cal_model)
|
||
grp.add(cal_row)
|
||
page.add(grp)
|
||
|
||
# --- when group: all-day switch + date + times ------------------- #
|
||
when = Adw.PreferencesGroup()
|
||
when.set_title("When")
|
||
|
||
allday_row = Adw.SwitchRow()
|
||
allday_row.set_title("All day")
|
||
allday_row.set_active(def_all_day)
|
||
when.add(allday_row)
|
||
|
||
sel = def_date
|
||
date_row = Adw.ActionRow()
|
||
date_row.set_title("Date")
|
||
cal_picker = Gtk.Calendar()
|
||
cal_picker.select_day(
|
||
GLib.DateTime.new_local(
|
||
sel.get_year(), sel.get_month(), sel.get_day_of_month(),
|
||
12, 0, 0))
|
||
date_btn = Gtk.MenuButton()
|
||
date_btn.add_css_class("flat")
|
||
date_btn.set_valign(Gtk.Align.CENTER)
|
||
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 %b %Y"))
|
||
_fmt_date_btn()
|
||
cal_picker.connect("day-selected", _fmt_date_btn)
|
||
date_row.add_suffix(date_btn)
|
||
date_row.set_activatable_widget(date_btn)
|
||
when.add(date_row)
|
||
|
||
start_row = Adw.SpinRow.new_with_range(0, 23, 1)
|
||
start_row.set_title("Start hour")
|
||
start_row.set_value(def_sh)
|
||
when.add(start_row)
|
||
|
||
start_min_row = Adw.SpinRow.new_with_range(0, 55, 5)
|
||
start_min_row.set_title("Start minute")
|
||
start_min_row.set_value(def_sm)
|
||
when.add(start_min_row)
|
||
|
||
end_row = Adw.SpinRow.new_with_range(0, 23, 1)
|
||
end_row.set_title("End hour")
|
||
end_row.set_value(def_eh)
|
||
when.add(end_row)
|
||
|
||
end_min_row = Adw.SpinRow.new_with_range(0, 55, 5)
|
||
end_min_row.set_title("End minute")
|
||
end_min_row.set_value(def_em)
|
||
when.add(end_min_row)
|
||
page.add(when)
|
||
|
||
# All-day hides the time rows.
|
||
def _toggle_allday(*_a):
|
||
show = not allday_row.get_active()
|
||
for r in (start_row, start_min_row, end_row, end_min_row):
|
||
r.set_visible(show)
|
||
allday_row.connect("notify::active", _toggle_allday)
|
||
_toggle_allday()
|
||
|
||
# --- optional details -------------------------------------------- #
|
||
details = Adw.PreferencesGroup()
|
||
details.set_title("Details")
|
||
loc_row = Adw.EntryRow()
|
||
loc_row.set_title("Location")
|
||
details.add(loc_row)
|
||
desc_row = Adw.EntryRow()
|
||
desc_row.set_title("Description")
|
||
details.add(desc_row)
|
||
page.add(details)
|
||
|
||
tv.set_content(page)
|
||
dialog.set_child(tv)
|
||
|
||
def _on_save(_b):
|
||
title = title_row.get_text().strip()
|
||
if not title:
|
||
title_row.add_css_class("error")
|
||
self._toast("Please enter a title.")
|
||
return
|
||
src, _name = writable[cal_row.get_selected()]
|
||
all_day = allday_row.get_active()
|
||
d = cal_picker.get_date()
|
||
year, month, day = d.get_year(), d.get_month(), d.get_day_of_month()
|
||
sh = int(start_row.get_value())
|
||
sm = int(start_min_row.get_value())
|
||
eh = int(end_row.get_value())
|
||
em = int(end_min_row.get_value())
|
||
|
||
spec = {
|
||
"summary": title,
|
||
"location": loc_row.get_text().strip(),
|
||
"description": desc_row.get_text().strip(),
|
||
"all_day": all_day,
|
||
"year": year, "month": month, "day": day,
|
||
"sh": sh, "sm": sm, "eh": eh, "em": em,
|
||
}
|
||
save_btn.set_sensitive(False)
|
||
cancel_btn.set_sensitive(False)
|
||
save_btn.set_label("Saving…")
|
||
self._save_event_async(src, spec, dialog, save_btn, cancel_btn)
|
||
|
||
save_btn.connect("clicked", _on_save)
|
||
dialog.present(self)
|
||
|
||
def _save_event_async(self, source, spec, dialog, save_btn, cancel_btn):
|
||
"""Build the VEVENT and write it via ECal in a WORKER THREAD.
|
||
|
||
EVERY EDS call here (connect_sync, create_object_sync) is synchronous and
|
||
spins the GLib main context, so it MUST run off the GTK main loop or the
|
||
window deadlocks. On completion we hop back to the main thread via
|
||
GLib.idle_add to close the dialog + reload (success) or report (error).
|
||
"""
|
||
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.close()
|
||
self._toast("Event created.")
|
||
self._reload()
|
||
return False
|
||
|
||
def _toast(self, text):
|
||
if hasattr(self, "_toast_overlay") and self._toast_overlay is not None:
|
||
self._toast_overlay.add_toast(Adw.Toast.new(text))
|
||
|
||
# ---- placeholder pages ------------------------------------------------ #
|
||
def _eds_missing_page(self):
|
||
page = Adw.StatusPage()
|
||
page.set_icon_name("dialog-warning-symbolic")
|
||
page.set_title("Evolution Data Server not installed")
|
||
page.set_description(
|
||
"Displaying calendars requires the "
|
||
"evolution-data-server package.\n\n" + EDS_ERROR
|
||
)
|
||
return page
|
||
|
||
def _error_page(self, message):
|
||
page = Adw.StatusPage()
|
||
page.set_icon_name("dialog-error-symbolic")
|
||
page.set_title("Calendars could not be loaded")
|
||
page.set_description(message)
|
||
return page
|
||
|
||
def _error_banner(self, errors):
|
||
banner = Adw.Banner()
|
||
banner.set_title("Some calendars are not reachable")
|
||
banner.set_revealed(True)
|
||
banner.set_tooltip_text("\n".join(errors))
|
||
return banner
|
||
|
||
|
||
def _writable_calendars():
|
||
"""List (source, display_name) for every enabled, WRITABLE calendar source.
|
||
|
||
The registry build is a synchronous EDS call, but it returns quickly and is
|
||
only invoked from the New-event click (which opens a dialog before any write
|
||
happens off-thread). Read-only calendars (Source.get_writable() is False) are
|
||
skipped so users can only create where the backend permits.
|
||
"""
|
||
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.
|
||
|
||
Building from Y/M/D directly avoids the timezone truncation you get when
|
||
converting a local-midnight unix timestamp to a UTC DATE (which can land on
|
||
the previous calendar day).
|
||
"""
|
||
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)))
|
||
# All-day DTEND is the exclusive next day.
|
||
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)
|
||
# Guard against an end at/before the start (roll to +1h).
|
||
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
|
||
|
||
|
||
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).
|
||
|
||
NB: ``Gdk.Rectangle(x=…, y=…)`` SILENTLY IGNORES its kwargs (boxed-struct
|
||
init is deprecated/no-op), so the fields must be assigned after the fact or
|
||
every popover would anchor at (0, 0) instead of the pointer.
|
||
"""
|
||
rect = Gdk.Rectangle()
|
||
rect.x = int(x)
|
||
rect.y = int(y)
|
||
rect.width = 1
|
||
rect.height = 1
|
||
return rect
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Time-grid math helpers
|
||
# --------------------------------------------------------------------------- #
|
||
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.
|
||
|
||
Returns a GTK ``alpha(#rrggbb, …)`` expression so the block reads as a
|
||
faint wash of the calendar colour over the dark Fuji background.
|
||
"""
|
||
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 = [] # list of (ev, s, e, col)
|
||
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
|
||
# find the lowest free column among events still overlapping ``s``
|
||
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)
|
||
|
||
# restore input order
|
||
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(Adw.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:
|
||
Gtk.StyleContext.add_provider_for_display(
|
||
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(CalendarApp().run(sys.argv))
|