From 88b2725f811aed655d8bbfa082d3194c348b83e1 Mon Sep 17 00:00:00 2001 From: Gabriel Fontes Date: Sat, 15 Aug 2026 15:09:22 -0300 Subject: [PATCH] feat(projects/khora): add week agenda view Assisted-by: pi (gpt-5.6-sol) --- projects/khora/README.md | 8 ++-- projects/khora/khora/khal_adapter.py | 26 +++++++++--- projects/khora/khora/model.py | 5 +++ projects/khora/khora/window.py | 63 ++++++++++++++++++++++------ projects/khora/tests/test_model.py | 14 ++++++- 5 files changed, 93 insertions(+), 23 deletions(-) diff --git a/projects/khora/README.md b/projects/khora/README.md index ec23c5e0..6879f562 100644 --- a/projects/khora/README.md +++ b/projects/khora/README.md @@ -12,12 +12,12 @@ Khora is an early, read-only prototype. It currently provides: - a native GTK 4/libadwaita interface; - calendar discovery from the existing khal configuration; - per-calendar visibility controls; -- a day agenda with recurring and all-day events; and +- day and week agendas with recurring and all-day events; and - explicit refreshes of khal's local index. -Editing, week and month views, event details, and filesystem monitoring come -next. The khal dependency is isolated in `khora.khal_adapter` so its internal -API can change without leaking through the application. +Editing, a month view, event details, and filesystem monitoring come next. The +khal dependency is isolated in `khora.khal_adapter` so its internal API can +change without leaking through the application. ## Development diff --git a/projects/khora/khora/khal_adapter.py b/projects/khora/khora/khal_adapter.py index 3c9c51cb..56a7a128 100644 --- a/projects/khora/khora/khal_adapter.py +++ b/projects/khora/khora/khal_adapter.py @@ -29,13 +29,27 @@ class KhalRepository: ) def events_on(self, day: dt.date, visible: set[str] | None = None) -> tuple[Event, ...]: + return self.events_for_days((day,), visible)[day] + + def events_for_days( + self, + days: tuple[dt.date, ...], + visible: set[str] | None = None, + ) -> dict[dt.date, tuple[Event, ...]]: self._collection.update_db() - events = ( - self._to_event(event) - for event in self._collection.get_events_on(day) - if visible is None or event.calendar in visible - ) - return tuple(sorted(events, key=self._sort_key)) + return { + day: tuple( + sorted( + ( + self._to_event(event) + for event in self._collection.get_events_on(day) + if visible is None or event.calendar in visible + ), + key=self._sort_key, + ) + ) + for day in days + } @staticmethod def _to_event(event) -> Event: diff --git a/projects/khora/khora/model.py b/projects/khora/khora/model.py index 864929ce..83798363 100644 --- a/projects/khora/khora/model.py +++ b/projects/khora/khora/model.py @@ -4,6 +4,11 @@ import datetime as dt from dataclasses import dataclass +def week_dates(day: dt.date) -> tuple[dt.date, ...]: + monday = day - dt.timedelta(days=day.weekday()) + return tuple(monday + dt.timedelta(days=offset) for offset in range(7)) + + @dataclass(frozen=True) class Calendar: name: str diff --git a/projects/khora/khora/window.py b/projects/khora/khora/window.py index 363d8500..f4f3d4b6 100644 --- a/projects/khora/khora/window.py +++ b/projects/khora/khora/window.py @@ -10,7 +10,7 @@ from gi.repository import Adw, Gio, GLib, Gtk from .colors import display_color from .khal_adapter import KhalRepository -from .model import Event +from .model import Event, week_dates class KhoraWindow(Adw.ApplicationWindow): @@ -24,6 +24,7 @@ class KhoraWindow(Adw.ApplicationWindow): self.set_default_size(960, 680) self._repository = repository self._visible_calendars: set[str] = set() + self._view_mode = "day" self._toolbar = Adw.ToolbarView() self.set_content(self._toolbar) @@ -35,8 +36,7 @@ class KhoraWindow(Adw.ApplicationWindow): assert repository is not None self._visible_calendars = {calendar.name for calendar in repository.calendars} - self._event_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE) - self._event_list.add_css_class("boxed-list") + self._agenda_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) self._empty = Adw.StatusPage( icon_name="x-office-calendar-symbolic", title="No events", @@ -57,6 +57,15 @@ class KhoraWindow(Adw.ApplicationWindow): today = Gtk.Button(label="Today", action_name="win.today") header.pack_start(today) + + view_switcher = Gtk.Box(css_classes=["linked"]) + day = Gtk.ToggleButton(label="Day", active=True) + week = Gtk.ToggleButton(label="Week", group=day) + day.connect("toggled", self._on_view_toggled, "day") + week.connect("toggled", self._on_view_toggled, "week") + view_switcher.append(day) + view_switcher.append(week) + header.pack_start(view_switcher) header.pack_end(Gtk.Button(icon_name="view-refresh-symbolic", action_name="win.refresh")) self._install_action("today", self._on_today) @@ -94,7 +103,7 @@ class KhoraWindow(Adw.ApplicationWindow): def _build_agenda(self) -> Gtk.Widget: overlay = Gtk.Overlay() scroller = Gtk.ScrolledWindow( - child=self._event_list, + child=self._agenda_content, hscrollbar_policy=Gtk.PolicyType.NEVER, margin_top=24, margin_bottom=24, @@ -117,21 +126,46 @@ class KhoraWindow(Adw.ApplicationWindow): return dt.date(selected.get_year(), selected.get_month(), selected.get_day_of_month()) def _refresh(self) -> None: - if self._repository is None or not hasattr(self, "_event_list"): + if self._repository is None or not hasattr(self, "_agenda_content"): return + days = ( + week_dates(self._selected_day()) + if self._view_mode == "week" + else (self._selected_day(),) + ) try: - events = self._repository.events_on(self._selected_day(), self._visible_calendars) + events_by_day = self._repository.events_for_days(days, self._visible_calendars) except Exception as error: # khal exposes several backend-specific errors self._show_toast(str(error)) return - while row := self._event_list.get_row_at_index(0): - self._event_list.remove(row) - for event in events: - self._event_list.append(self._event_row(event)) - self._empty.set_visible(not events) - self._event_list.set_visible(bool(events)) + while child := self._agenda_content.get_first_child(): + self._agenda_content.remove(child) + + has_events = any(events_by_day.values()) + if self._view_mode == "week": + for day in days: + self._agenda_content.append(self._day_group(day, events_by_day[day])) + elif has_events: + event_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE) + event_list.add_css_class("boxed-list") + for event in events_by_day[days[0]]: + event_list.append(self._event_row(event)) + self._agenda_content.append(event_list) + + self._empty.set_visible(not has_events and self._view_mode == "day") + self._agenda_content.set_visible(has_events or self._view_mode == "week") + + @staticmethod + def _day_group(day: dt.date, events: tuple[Event, ...]) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup(title=f"{day:%A, %B} {day.day}") + if events: + for event in events: + group.add(KhoraWindow._event_row(event)) + else: + group.set_description("No events") + return group @staticmethod def _event_row(event: Event) -> Adw.ActionRow: @@ -159,6 +193,11 @@ class KhoraWindow(Adw.ApplicationWindow): self._calendar.select_day(GLib.DateTime.new_now_local()) self._refresh() + def _on_view_toggled(self, button: Gtk.ToggleButton, mode: str) -> None: + if button.get_active(): + self._view_mode = mode + self._refresh() + def _install_action(self, name: str, callback) -> None: action = Gio.SimpleAction.new(name, None) action.connect("activate", callback) diff --git a/projects/khora/tests/test_model.py b/projects/khora/tests/test_model.py index c01e6eda..37634db3 100644 --- a/projects/khora/tests/test_model.py +++ b/projects/khora/tests/test_model.py @@ -1,6 +1,6 @@ import datetime as dt -from khora.model import Event +from khora.model import Event, week_dates def test_timed_event_label_uses_24_hour_time() -> None: @@ -14,6 +14,18 @@ def test_timed_event_label_uses_24_hour_time() -> None: assert event.time_label == "13:30–15:00" +def test_week_dates_runs_from_monday_through_sunday() -> None: + assert week_dates(dt.date(2026, 8, 15)) == ( + dt.date(2026, 8, 10), + dt.date(2026, 8, 11), + dt.date(2026, 8, 12), + dt.date(2026, 8, 13), + dt.date(2026, 8, 14), + dt.date(2026, 8, 15), + dt.date(2026, 8, 16), + ) + + def test_all_day_event_label() -> None: event = Event( summary="Escape the calendar mines",