From 3a07038b8499d277a4065b393322373b6f6fdf77 Mon Sep 17 00:00:00 2001 From: Gabriel Fontes Date: Sat, 15 Aug 2026 16:07:20 -0300 Subject: [PATCH] feat(projects/khora): show event details Open timed, all-day, and agenda events in a read-only details dialog with their time, calendar, location, description, URL, organizer, and attendees. Assisted-by: pi (gpt-5.6-sol) --- projects/khora/ROADMAP.md | 4 +- projects/khora/khora/khal_adapter.py | 5 ++ projects/khora/khora/model.py | 30 ++++++++ projects/khora/khora/window.py | 84 ++++++++++++++++++----- projects/khora/tests/test_khal_adapter.py | 28 ++++++++ projects/khora/tests/test_model.py | 23 +++++++ 6 files changed, 156 insertions(+), 18 deletions(-) diff --git a/projects/khora/ROADMAP.md b/projects/khora/ROADMAP.md index 2be460b7..62100157 100644 --- a/projects/khora/ROADMAP.md +++ b/projects/khora/ROADMAP.md @@ -3,8 +3,8 @@ Planned improvements, ordered roughly from least to most implementation complexity. The order reflects engineering dependencies rather than product priority. -1. **Event details** — open an event from any view and show its complete time, - calendar, location, description, URL, organizer, and attendees. +1. **Event details — done.** Open an event from any view and show its complete + time, calendar, location, description, URL, organizer, and attendees. 2. **Keyboard navigation** — previous/next period, Today, view switching, refresh, and zoom shortcuts. 3. **Session persistence** — remember the selected view, zoom, sidebar width, diff --git a/projects/khora/khora/khal_adapter.py b/projects/khora/khora/khal_adapter.py index 902bc86e..de98807a 100644 --- a/projects/khora/khora/khal_adapter.py +++ b/projects/khora/khora/khal_adapter.py @@ -62,6 +62,11 @@ class KhalRepository: all_day=event.allday, location=event.location or "", color=event.color, + uid=str(event.uid or ""), + description=event.description or "", + url=event.url or "", + organizer=event.organizer or "", + attendees=event.attendees or "", ) @staticmethod diff --git a/projects/khora/khora/model.py b/projects/khora/khora/model.py index 0faa90d1..a257b5ed 100644 --- a/projects/khora/khora/model.py +++ b/projects/khora/khora/model.py @@ -71,6 +71,11 @@ class Event: all_day: bool = False location: str = "" color: str | None = None + uid: str = "" + description: str = "" + url: str = "" + organizer: str = "" + attendees: str = "" @property def time_label(self) -> str: @@ -80,3 +85,28 @@ class Event: assert isinstance(self.start, dt.datetime) assert isinstance(self.end, dt.datetime) return f"{self.start:%H:%M}–{self.end:%H:%M}" + + @property + def when_label(self) -> str: + if self.all_day: + assert isinstance(self.start, dt.date) + assert isinstance(self.end, dt.date) + if self.end <= self.start + dt.timedelta(days=1): + return f"{self.start:%A, %B} {self.start.day}, {self.start.year} · All day" + last_day = self.end - dt.timedelta(days=1) + return ( + f"{self.start:%B} {self.start.day} – " + f"{last_day:%B} {last_day.day}, {last_day.year} · All day" + ) + + assert isinstance(self.start, dt.datetime) + assert isinstance(self.end, dt.datetime) + if self.start.date() == self.end.date(): + return ( + f"{self.start:%A, %B} {self.start.day}, {self.start.year} · " + f"{self.time_label}" + ) + return ( + f"{self.start:%B} {self.start.day}, {self.start:%H:%M} – " + f"{self.end:%B} {self.end.day}, {self.end:%H:%M}, {self.end.year}" + ) diff --git a/projects/khora/khora/window.py b/projects/khora/khora/window.py index 13d94460..87e00bbe 100644 --- a/projects/khora/khora/window.py +++ b/projects/khora/khora/window.py @@ -307,10 +307,18 @@ class KhoraWindow(Adw.ApplicationWindow): label=event.summary, xalign=0, ellipsize=Pango.EllipsizeMode.END, - tooltip_text=event.summary, - css_classes=["all-day-event", self._event_color_class(event.color)], ) - box.append(event_label) + event_button = Gtk.Button( + child=event_label, + tooltip_text=event.summary, + css_classes=[ + "flat", + "all-day-event", + self._event_color_class(event.color), + ], + ) + event_button.connect("clicked", lambda _button, item=event: self._show_event(item)) + box.append(event_button) headers.append(box) row.append(headers) return row @@ -343,24 +351,32 @@ class KhoraWindow(Adw.ApplicationWindow): label = Gtk.Label( xalign=0, yalign=0, - valign=Gtk.Align.START, - halign=Gtk.Align.FILL, wrap=event_height >= 40, lines=2 if event_height >= 40 else 1, ellipsize=Pango.EllipsizeMode.END, - margin_top=start * self._slot_height, - height_request=event_height, - tooltip_text=f"{event.time_label} · {event.summary}", - css_classes=["timed-event", self._event_color_class(event.color)], ) summary = GLib.markup_escape_text(event.summary) if event_height >= 40: label.set_markup(f"{summary}\n{event.time_label}") elif event_height >= 22: label.set_markup(f"{summary}") - overlay.add_overlay(label) - overlay.set_measure_overlay(label, False) - overlay.set_clip_overlay(label, True) + event_button = Gtk.Button( + child=label, + valign=Gtk.Align.START, + halign=Gtk.Align.FILL, + margin_top=start * self._slot_height, + height_request=event_height, + tooltip_text=f"{event.time_label} · {event.summary}", + css_classes=[ + "flat", + "timed-event", + self._event_color_class(event.color), + ], + ) + event_button.connect("clicked", lambda _button, item=event: self._show_event(item)) + overlay.add_overlay(event_button) + overlay.set_measure_overlay(event_button, False) + overlay.set_clip_overlay(event_button, True) if day == dt.date.today(): indicator = self._current_time_indicator() @@ -426,15 +442,51 @@ class KhoraWindow(Adw.ApplicationWindow): self._event_color_providers[class_name] = provider return class_name - @staticmethod - def _event_row(event: Event) -> Adw.ActionRow: + def _event_row(self, event: Event) -> Adw.ActionRow: details = f"{event.time_label} · {event.calendar}" if event.location: details += f" · {event.location}" - row = Adw.ActionRow(title=event.summary, subtitle=details) - row.add_prefix(KhoraWindow._color_dot(event.color)) + row = Adw.ActionRow(title=event.summary, subtitle=details, activatable=True) + row.add_prefix(self._color_dot(event.color)) + row.connect("activated", lambda _row: self._show_event(event)) return row + def _show_event(self, event: Event) -> None: + details = Adw.PreferencesGroup() + calendar = Adw.ActionRow(title="Calendar", subtitle=event.calendar) + calendar.add_prefix(self._color_dot(event.color)) + details.add(calendar) + + for title, value in ( + ("Location", event.location), + ("Organizer", event.organizer), + ("Attendees", event.attendees), + ("Description", event.description), + ): + if value: + details.add( + Adw.ActionRow( + title=title, + subtitle=value, + subtitle_lines=0, + subtitle_selectable=True, + ) + ) + + if event.url: + website = Adw.ActionRow(title="Website") + website.add_suffix(Gtk.LinkButton(uri=event.url, label="Open link")) + details.add(website) + + dialog = Adw.AlertDialog( + heading=event.summary, + body=event.when_label, + extra_child=details, + content_width=520, + ) + dialog.add_response("close", "Close") + dialog.present(self) + @staticmethod def _color_dot(color: str | None) -> Gtk.Label: dot = Gtk.Label(valign=Gtk.Align.CENTER) diff --git a/projects/khora/tests/test_khal_adapter.py b/projects/khora/tests/test_khal_adapter.py index ba5d0b88..80d44b28 100644 --- a/projects/khora/tests/test_khal_adapter.py +++ b/projects/khora/tests/test_khal_adapter.py @@ -1,3 +1,6 @@ +import datetime as dt +from types import SimpleNamespace + from khora.khal_adapter import KhalRepository @@ -22,3 +25,28 @@ def test_calendar_accounts_come_from_their_parent_directory() -> None: calendars = repository.calendars assert tuple(calendar.account for calendar in calendars) == ("personal", "university") + + +def test_event_details_cross_the_khal_boundary() -> None: + event = KhalRepository._to_event( + SimpleNamespace( + summary="Standup", + calendar="Work", + start_local=dt.datetime(2026, 8, 15, 9, 0), + end_local=dt.datetime(2026, 8, 15, 9, 30), + allday=False, + location="Meeting room", + color="#3584e4", + uid="event-id", + description="Discuss the calendar mines", + url="https://example.com/meeting", + organizer="organizer@example.com", + attendees="one@example.com,two@example.com", + ) + ) + + assert event.uid == "event-id" + assert event.description == "Discuss the calendar mines" + assert event.url == "https://example.com/meeting" + assert event.organizer == "organizer@example.com" + assert event.attendees == "one@example.com,two@example.com" diff --git a/projects/khora/tests/test_model.py b/projects/khora/tests/test_model.py index caa3ce01..fa8996b5 100644 --- a/projects/khora/tests/test_model.py +++ b/projects/khora/tests/test_model.py @@ -69,6 +69,29 @@ def test_event_slot_range_clamps_events_to_the_day() -> None: assert event_slot_range(event, dt.date(2026, 8, 15)) == (0, 48) +def test_event_when_label_includes_its_date() -> None: + event = Event( + summary="Write a calendar", + calendar="Personal", + start=dt.datetime(2026, 8, 15, 13, 30), + end=dt.datetime(2026, 8, 15, 15, 0), + ) + + assert event.when_label == "Saturday, August 15, 2026 · 13:30–15:00" + + +def test_multiday_all_day_event_when_label_uses_inclusive_last_day() -> None: + event = Event( + summary="Conference", + calendar="Work", + start=dt.date(2026, 8, 15), + end=dt.date(2026, 8, 18), + all_day=True, + ) + + assert event.when_label == "August 15 – August 17, 2026 · All day" + + def test_all_day_event_label() -> None: event = Event( summary="Escape the calendar mines",