Files
taninux/packaging/tanin-calendar/tanin-calendar
T
karim e8d93e6b1d gui: native libadwaita-free UI backend + dark shell; packaging/iso updates
- 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)
2026-06-25 00:40:21 +02:00

2091 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:0024: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 (0023)
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:0008: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 024h."""
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:0010: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))