From f4cc409dd3fba0ca21f40358d6e69e1300b3dc98 Mon Sep 17 00:00:00 2001 From: Gabriel Fontes Date: Sat, 15 Aug 2026 15:43:08 -0300 Subject: [PATCH] fix(projects/khora): adopt calendar-style header navigation Arrange branding, today and date navigation around a dynamic period title, with refresh and view controls on the right. Assisted-by: pi (gpt-5.6-sol) --- projects/khora/khora/model.py | 26 ++++++++++++++++ projects/khora/khora/window.py | 50 ++++++++++++++++++++++++++++-- projects/khora/tests/test_model.py | 23 +++++++++++++- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/projects/khora/khora/model.py b/projects/khora/khora/model.py index 832ebf49..51ac63f3 100644 --- a/projects/khora/khora/model.py +++ b/projects/khora/khora/model.py @@ -1,5 +1,6 @@ from __future__ import annotations +import calendar import datetime as dt from dataclasses import dataclass @@ -9,6 +10,31 @@ def week_dates(day: dt.date) -> tuple[dt.date, ...]: return tuple(monday + dt.timedelta(days=offset) for offset in range(7)) +def period_label(day: dt.date, mode: str) -> str: + if mode == "month": + return f"{day:%B} {day.year}" + if mode != "week": + return f"{day:%A, %B} {day.day}, {day.year}" + + start, *_, end = week_dates(day) + if start.year != end.year: + return f"{start:%B} {start.day}, {start.year} – {end:%B} {end.day}, {end.year}" + if start.month != end.month: + return f"{start:%B} {start.day} – {end:%B} {end.day}, {start.year}" + return f"{start:%B} {start.day}–{end.day}, {start.year}" + + +def shifted_date(day: dt.date, mode: str, direction: int) -> dt.date: + if mode != "month": + step = 7 if mode == "week" else 1 + return day + dt.timedelta(days=step * direction) + + month_index = day.year * 12 + day.month - 1 + direction + year, zero_based_month = divmod(month_index, 12) + month = zero_based_month + 1 + return dt.date(year, month, min(day.day, calendar.monthrange(year, month)[1])) + + def event_slot_range(event: Event, day: dt.date) -> tuple[int, int]: """Return the half-hour rows occupied by a timed event on one day.""" assert isinstance(event.start, dt.datetime) diff --git a/projects/khora/khora/window.py b/projects/khora/khora/window.py index b056f89d..54946c21 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, Pango from .colors import display_color from .khal_adapter import KhalRepository -from .model import Event, event_slot_range, week_dates +from .model import Event, event_slot_range, period_label, shifted_date, week_dates class KhoraWindow(Adw.ApplicationWindow): @@ -53,16 +53,53 @@ class KhoraWindow(Adw.ApplicationWindow): def _build_header(self) -> Adw.HeaderBar: header = Adw.HeaderBar() + header.set_title_widget(Gtk.Box()) + + brand = Gtk.Box(spacing=8, margin_end=12) + brand.append(Gtk.Image(icon_name="x-office-calendar-symbolic", pixel_size=24)) + brand.append(Gtk.Label(label="Khora", css_classes=["title"])) + header.pack_start(brand) header.pack_start(Gtk.Button(label="Today", action_name="win.today")) + navigation = Gtk.Box() + previous = Gtk.Button( + icon_name="go-previous-symbolic", + action_name="win.previous", + css_classes=["flat"], + tooltip_text="Previous period", + ) + following = Gtk.Button( + icon_name="go-next-symbolic", + action_name="win.next", + css_classes=["flat"], + tooltip_text="Next period", + ) + navigation.append(previous) + navigation.append(following) + header.pack_start(navigation) + + self._period_label = Gtk.Label(css_classes=["title"], margin_start=6) + header.pack_start(self._period_label) + menu = Gio.Menu() for mode in ("day", "week", "month", "agenda"): menu.append(mode.title(), f"win.view::{mode}") self._view_button = Gtk.MenuButton(label="Week", menu_model=menu) - header.set_title_widget(self._view_button) - header.pack_end(Gtk.Button(icon_name="view-refresh-symbolic", action_name="win.refresh")) + + controls = Gtk.Box(spacing=6) + controls.append( + Gtk.Button( + icon_name="view-refresh-symbolic", + action_name="win.refresh", + tooltip_text="Refresh calendars", + ) + ) + controls.append(self._view_button) + header.pack_end(controls) self._install_action("today", self._on_today) + self._install_action("previous", lambda *_: self._navigate(-1)) + self._install_action("next", lambda *_: self._navigate(1)) self._install_action("refresh", lambda *_: self._refresh()) view_action = Gio.SimpleAction.new("view", GLib.VariantType.new("s")) view_action.connect("activate", self._on_view_selected) @@ -122,6 +159,7 @@ class KhoraWindow(Adw.ApplicationWindow): if self._repository is None or not hasattr(self, "_view_content"): return + self._period_label.set_label(period_label(self._selected_day(), self._view_mode)) self._clear_view() self._empty.set_visible(False) if self._view_mode == "month": @@ -292,6 +330,12 @@ class KhoraWindow(Adw.ApplicationWindow): self._calendar.select_day(GLib.DateTime.new_now_local()) self._refresh() + def _navigate(self, direction: int) -> None: + target = shifted_date(self._selected_day(), self._view_mode, direction) + selected = GLib.DateTime.new_local(target.year, target.month, target.day, 0, 0, 0) + self._calendar.select_day(selected) + self._refresh() + def _on_view_selected(self, _action: Gio.SimpleAction, parameter: GLib.Variant) -> None: self._view_mode = parameter.get_string() self._view_button.set_label(self._view_mode.title()) diff --git a/projects/khora/tests/test_model.py b/projects/khora/tests/test_model.py index 2a2bc041..caa3ce01 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, event_slot_range, week_dates +from khora.model import Event, event_slot_range, period_label, shifted_date, week_dates def test_timed_event_label_uses_24_hour_time() -> None: @@ -26,6 +26,27 @@ def test_week_dates_runs_from_monday_through_sunday() -> None: ) +def test_period_labels_follow_the_active_view() -> None: + day = dt.date(2026, 8, 15) + + assert period_label(day, "day") == "Saturday, August 15, 2026" + assert period_label(day, "week") == "August 10–16, 2026" + assert period_label(day, "month") == "August 2026" + assert period_label(day, "agenda") == "Saturday, August 15, 2026" + + +def test_week_label_handles_month_boundaries() -> None: + assert period_label(dt.date(2026, 9, 1), "week") == "August 31 – September 6, 2026" + + +def test_shifted_date_uses_the_active_view_interval() -> None: + day = dt.date(2026, 8, 15) + + assert shifted_date(day, "day", 1) == dt.date(2026, 8, 16) + assert shifted_date(day, "week", -1) == dt.date(2026, 8, 8) + assert shifted_date(dt.date(2026, 1, 31), "month", 1) == dt.date(2026, 2, 28) + + def test_event_slot_range_rounds_to_half_hours() -> None: event = Event( summary="Oddly timed meeting",