feat(projects/khora): add calendar grid views

Add a Google Calendar-style day and week time grid behind a compact view dropdown, while retaining the existing list as the agenda view.

Assisted-by: pi (gpt-5.6-sol)
This commit is contained in:
Gabriel Fontes
2026-08-15 15:38:53 -03:00
parent c774f2b4e7
commit 10af41bcba
5 changed files with 234 additions and 68 deletions
+5 -4
View File
@@ -12,12 +12,13 @@ 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;
- day and week agendas with recurring and all-day events; and
- day and week time grids with recurring and all-day events;
- a read-only agenda for the selected day; and
- explicit refreshes of khal's local index.
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.
A month grid, a forward-scrolling agenda, editing, 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
+32 -8
View File
@@ -28,16 +28,40 @@ class KhoraApplication(Adw.Application):
provider = Gtk.CssProvider()
provider.load_from_string(
"""
.view-tab {
border-radius: 0;
border-bottom: 2px solid transparent;
padding: 8px 14px 6px;
.calendar-grid-header {
background-color: @headerbar_bg_color;
border-bottom: 1px solid alpha(@window_fg_color, 0.15);
}
.view-tab:checked {
background: transparent;
border-bottom-color: @accent_color;
box-shadow: none;
.day-header {
min-height: 42px;
padding: 8px 4px;
border-left: 1px solid alpha(@window_fg_color, 0.1);
}
.all-day-event,
.timed-event {
margin: 1px 2px;
padding: 3px 5px;
border-radius: 4px;
background-color: alpha(@accent_bg_color, 0.14);
}
.time-label {
color: alpha(@window_fg_color, 0.6);
font-size: 0.75em;
}
.day-column {
border-left: 1px solid alpha(@window_fg_color, 0.1);
}
.hour-line {
border-top: 1px solid alpha(@window_fg_color, 0.13);
}
.half-hour-line {
border-top: 1px solid alpha(@window_fg_color, 0.05);
}
"""
)
+19
View File
@@ -9,6 +9,25 @@ def week_dates(day: dt.date) -> tuple[dt.date, ...]:
return tuple(monday + dt.timedelta(days=offset) for offset in range(7))
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)
assert isinstance(event.end, dt.datetime)
if event.start.date() < day:
start = 0
else:
start = event.start.hour * 2 + event.start.minute // 30
if event.end.date() > day:
end = 48
else:
minutes = event.end.hour * 60 + event.end.minute
end = (minutes + 29) // 30
return max(0, start), min(48, max(start + 1, end))
@dataclass(frozen=True)
class Calendar:
name: str
+155 -55
View File
@@ -6,11 +6,11 @@ import gi
gi.require_version("Adw", "1")
gi.require_version("Gtk", "4.0")
from gi.repository import Adw, Gio, GLib, Gtk
from gi.repository import Adw, Gio, GLib, Gtk, Pango
from .colors import display_color
from .khal_adapter import KhalRepository
from .model import Event, week_dates
from .model import Event, event_slot_range, week_dates
class KhoraWindow(Adw.ApplicationWindow):
@@ -21,10 +21,10 @@ class KhoraWindow(Adw.ApplicationWindow):
error: str | None = None,
) -> None:
super().__init__(application=application, title="Khora")
self.set_default_size(960, 680)
self.set_default_size(1280, 800)
self._repository = repository
self._visible_calendars: set[str] = set()
self._view_mode = "day"
self._view_mode = "week"
self._toolbar = Adw.ToolbarView()
self.set_content(self._toolbar)
@@ -36,16 +36,16 @@ class KhoraWindow(Adw.ApplicationWindow):
assert repository is not None
self._visible_calendars = {calendar.name for calendar in repository.calendars}
self._agenda_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
self._view_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._empty = Adw.StatusPage(
icon_name="x-office-calendar-symbolic",
title="No events",
description="A suspiciously peaceful day.",
)
split = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL, position=280)
split = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL, position=260)
split.set_start_child(self._build_sidebar())
split.set_end_child(self._build_agenda())
split.set_end_child(self._build_calendar_view())
split.set_resize_start_child(False)
split.set_shrink_start_child(False)
self._toolbar.set_content(split)
@@ -53,22 +53,20 @@ class KhoraWindow(Adw.ApplicationWindow):
def _build_header(self) -> Adw.HeaderBar:
header = Adw.HeaderBar()
header.pack_start(Gtk.Button(label="Today", action_name="win.today"))
today = Gtk.Button(label="Today", action_name="win.today")
header.pack_start(today)
view_switcher = Gtk.Box()
day = Gtk.ToggleButton(label="Day", active=True, css_classes=["flat", "view-tab"])
week = Gtk.ToggleButton(label="Week", group=day, css_classes=["flat", "view-tab"])
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.set_title_widget(view_switcher)
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"))
self._install_action("today", self._on_today)
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)
self.add_action(view_action)
return header
def _build_sidebar(self) -> Gtk.Widget:
@@ -99,15 +97,11 @@ class KhoraWindow(Adw.ApplicationWindow):
box.append(calendars)
return box
def _build_agenda(self) -> Gtk.Widget:
def _build_calendar_view(self) -> Gtk.Widget:
overlay = Gtk.Overlay()
scroller = Gtk.ScrolledWindow(
child=self._agenda_content,
child=self._view_content,
hscrollbar_policy=Gtk.PolicyType.NEVER,
margin_top=24,
margin_bottom=24,
margin_start=24,
margin_end=24,
)
overlay.set_child(scroller)
overlay.add_overlay(self._empty)
@@ -125,46 +119,152 @@ 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, "_agenda_content"):
if self._repository is None or not hasattr(self, "_view_content"):
return
days = (
week_dates(self._selected_day())
if self._view_mode == "week"
else (self._selected_day(),)
)
self._clear_view()
self._empty.set_visible(False)
if self._view_mode == "month":
self._view_content.append(
Adw.StatusPage(
icon_name="x-office-calendar-symbolic",
title="Month view",
description="Coming next.",
vexpand=True,
)
)
return
days = self._visible_days()
try:
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 child := self._agenda_content.get_first_child():
self._agenda_content.remove(child)
if self._view_mode == "agenda":
self._render_agenda(days[0], events_by_day[days[0]])
else:
self._view_content.append(self._time_grid(days, events_by_day))
has_events = any(events_by_day.values())
def _visible_days(self) -> tuple[dt.date, ...]:
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)
return week_dates(self._selected_day())
return (self._selected_day(),)
self._empty.set_visible(not has_events and self._view_mode == "day")
self._agenda_content.set_visible(has_events or self._view_mode == "week")
def _clear_view(self) -> None:
while child := self._view_content.get_first_child():
self._view_content.remove(child)
def _render_agenda(self, day: dt.date, events: tuple[Event, ...]) -> None:
if not events:
self._empty.set_visible(True)
return
group = Adw.PreferencesGroup(
title=f"{day:%A, %B} {day.day}",
margin_top=24,
margin_bottom=24,
margin_start=24,
margin_end=24,
)
for event in events:
group.add(self._event_row(event))
self._view_content.append(group)
def _time_grid(
self,
days: tuple[dt.date, ...],
events_by_day: dict[dt.date, tuple[Event, ...]],
) -> Gtk.Widget:
view = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
view.append(self._day_headers(days, events_by_day))
timeline = Gtk.Box()
timeline.append(self._time_gutter())
columns = Gtk.Box(homogeneous=True, hexpand=True)
for day in days:
timed = tuple(event for event in events_by_day[day] if not event.all_day)
columns.append(self._day_column(day, timed))
timeline.append(columns)
view.append(timeline)
return view
def _day_headers(
self,
days: tuple[dt.date, ...],
events_by_day: dict[dt.date, tuple[Event, ...]],
) -> Gtk.Widget:
row = Gtk.Box(css_classes=["calendar-grid-header"])
row.append(Gtk.Box(width_request=64))
headers = Gtk.Box(homogeneous=True, hexpand=True)
today = dt.date.today()
for day in days:
box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=4,
css_classes=["day-header"],
)
label = Gtk.Label(label=f"{day:%a} {day.day}")
if day == today:
label.add_css_class("accent")
box.append(label)
all_day = tuple(event for event in events_by_day[day] if event.all_day)
for event in all_day:
event_label = Gtk.Label(
label=event.summary,
xalign=0,
ellipsize=Pango.EllipsizeMode.END,
tooltip_text=event.summary,
css_classes=["all-day-event"],
)
box.append(event_label)
headers.append(box)
row.append(headers)
return row
@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
def _time_gutter() -> Gtk.Widget:
gutter = Gtk.Grid(row_homogeneous=True, width_request=64)
for slot in range(48):
label = Gtk.Label(
label=f"{slot // 2:02}:00" if slot % 2 == 0 else "",
xalign=1,
yalign=0,
margin_end=8,
css_classes=["time-label"],
)
label.set_size_request(-1, 24)
gutter.attach(label, 0, slot, 1, 1)
return gutter
@staticmethod
def _day_column(day: dt.date, events: tuple[Event, ...]) -> Gtk.Widget:
column = Gtk.Grid(row_homogeneous=True, hexpand=True, css_classes=["day-column"])
for slot in range(48):
line = Gtk.Box(css_classes=["hour-line" if slot % 2 == 0 else "half-hour-line"])
line.set_size_request(-1, 24)
column.attach(line, 0, slot, 1, 1)
for event in events:
start, end = event_slot_range(event, day)
label = Gtk.Label(
xalign=0,
yalign=0,
wrap=True,
lines=2,
ellipsize=Pango.EllipsizeMode.END,
tooltip_text=f"{event.time_label} · {event.summary}",
css_classes=["timed-event"],
)
summary = GLib.markup_escape_text(event.summary)
color = display_color(event.color)
label.set_markup(
f'<span foreground="{color}">▌</span> '
f"<b>{summary}</b>\n<small>{event.time_label}</small>"
)
column.attach(label, 0, start, 1, end - start)
return column
@staticmethod
def _event_row(event: Event) -> Adw.ActionRow:
@@ -192,10 +292,10 @@ 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 _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())
self._refresh()
def _install_action(self, name: str, callback) -> None:
action = Gio.SimpleAction.new(name, None)
+23 -1
View File
@@ -1,6 +1,6 @@
import datetime as dt
from khora.model import Event, week_dates
from khora.model import Event, event_slot_range, week_dates
def test_timed_event_label_uses_24_hour_time() -> None:
@@ -26,6 +26,28 @@ def test_week_dates_runs_from_monday_through_sunday() -> None:
)
def test_event_slot_range_rounds_to_half_hours() -> None:
event = Event(
summary="Oddly timed meeting",
calendar="Work",
start=dt.datetime(2026, 8, 15, 9, 10),
end=dt.datetime(2026, 8, 15, 10, 40),
)
assert event_slot_range(event, dt.date(2026, 8, 15)) == (18, 22)
def test_event_slot_range_clamps_events_to_the_day() -> None:
event = Event(
summary="Long ordeal",
calendar="Work",
start=dt.datetime(2026, 8, 14, 23, 0),
end=dt.datetime(2026, 8, 16, 1, 0),
)
assert event_slot_range(event, dt.date(2026, 8, 15)) == (0, 48)
def test_all_day_event_label() -> None:
event = Event(
summary="Escape the calendar mines",