feat(projects/khora): add month view

Render a six-week month grid with colored event chips, overflow links, adjacent-month dates, and navigation into day or agenda views.

Assisted-by: pi (gpt-5.6-sol)
This commit is contained in:
Gabriel Fontes
2026-08-15 16:25:51 -03:00
parent 2ef5d34edd
commit eccf8a2978
4 changed files with 143 additions and 17 deletions
+2 -1
View File
@@ -13,10 +13,11 @@ Khora is an early, read-only prototype. It currently provides:
- calendar discovery from the existing khal configuration;
- per-calendar visibility controls;
- day and week time grids with recurring and all-day events;
- a month grid with compact event previews;
- a forward-scrolling, read-only agenda; and
- explicit refreshes of khal's local index.
A month grid and event editing come next. The khal dependency is isolated in
Event creation and editing come next. The khal dependency is isolated in
`khora.khal_adapter` so its internal API can change without leaking through the
application.
+2 -2
View File
@@ -17,8 +17,8 @@ The order reflects engineering dependencies rather than product priority.
adjacent lanes instead of drawing on top of each other.
7. **Mini-calendar indicators — done.** A custom sidebar month widget shows up
to three calendar-colored event dots on each visible date.
8. **Month view** — build a full month grid with compact event chips, overflow
counts, and navigation consistent with the day and week grids.
8. **Month view — done.** A six-week month grid provides compact event chips,
overflow counts, and navigation into day and agenda views.
9. **Search** — search expanded local occurrences and jump from results to the
relevant date or event.
10. **Event creation** — create timed and all-day events in writable calendars,
+28 -1
View File
@@ -81,13 +81,40 @@ class KhoraApplication(Adw.Application):
}
.all-day-event,
.timed-event {
.timed-event,
.month-event {
margin: 1px 2px;
padding: 3px 5px;
border-radius: 4px;
background-color: alpha(@accent_bg_color, 0.14);
}
.month-weekdays {
padding: 8px 0;
border-bottom: 1px solid alpha(@window_fg_color, 0.15);
}
.month-cell {
padding: 3px;
border-left: 1px solid alpha(@window_fg_color, 0.1);
border-bottom: 1px solid alpha(@window_fg_color, 0.1);
}
.month-cell.other-month {
opacity: 0.5;
}
.month-cell.today .month-day {
background-color: @accent_bg_color;
color: @accent_fg_color;
border-radius: 999px;
}
.month-event {
min-height: 20px;
padding: 1px 4px;
}
.time-label {
color: alpha(@window_fg_color, 0.6);
font-size: 0.75em;
+111 -13
View File
@@ -12,7 +12,15 @@ from gi.repository import Adw, Gdk, Gio, GLib, Gtk, Pango
from .colors import contrasting_foreground, display_color
from .khal_adapter import KhalRepository
from .mini_calendar import MiniCalendar
from .model import Calendar, Event, layout_event_lanes, period_label, shifted_date, week_dates
from .model import (
Calendar,
Event,
layout_event_lanes,
month_grid_dates,
period_label,
shifted_date,
week_dates,
)
from .state import StateStore, UiState
@@ -299,16 +307,6 @@ class KhoraWindow(Adw.ApplicationWindow):
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":
self._view_content.append(
Adw.StatusPage(
icon_name="x-office-calendar-symbolic",
title="Month view",
description="Coming next.",
vexpand=True,
)
)
return
if self._view_mode == "agenda":
self._start_agenda()
return
@@ -322,11 +320,16 @@ class KhoraWindow(Adw.ApplicationWindow):
self._displayed_days = days
self._displayed_events = events_by_day
self._view_content.append(self._time_grid(days, events_by_day))
if self._view_mode == "month":
self._view_content.append(self._month_grid(days, events_by_day))
else:
self._view_content.append(self._time_grid(days, events_by_day))
def _visible_days(self) -> tuple[dt.date, ...]:
if self._view_mode == "week":
return week_dates(self._selected_day())
if self._view_mode == "month":
return month_grid_dates(self._selected_day())
return (self._selected_day(),)
def _clear_view(self) -> None:
@@ -408,6 +411,100 @@ class KhoraWindow(Adw.ApplicationWindow):
):
self._load_more_agenda()
def _month_grid(
self,
days: tuple[dt.date, ...],
events_by_day: dict[dt.date, tuple[Event, ...]],
) -> Gtk.Widget:
view = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
weekdays = Gtk.Box(homogeneous=True, css_classes=["month-weekdays"])
monday = dt.date(2024, 1, 1)
for offset in range(7):
weekdays.append(
Gtk.Label(
label=f"{monday + dt.timedelta(days=offset):%A}",
css_classes=["heading"],
)
)
view.append(weekdays)
grid = Gtk.Grid(
column_homogeneous=True,
row_homogeneous=True,
hexpand=True,
vexpand=True,
)
selected_month = self._selected_day().month
today = dt.date.today()
for index, day in enumerate(days):
classes = ["month-cell"]
if day.month != selected_month:
classes.append("other-month")
if day == today:
classes.append("today")
cell = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=2,
height_request=108,
css_classes=classes,
)
day_button = Gtk.Button(
label=str(day.day),
halign=Gtk.Align.END,
css_classes=["flat", "month-day"],
tooltip_text=f"Open {day:%A, %B} {day.day}",
)
day_button.connect(
"clicked",
lambda _button, selected=day: self._open_date(selected, "day"),
)
cell.append(day_button)
events = events_by_day[day]
for event in events[:3]:
prefix = "" if event.all_day else f"{event.start:%H:%M} "
label = Gtk.Label(
label=f"{prefix}{event.summary}",
xalign=0,
ellipsize=Pango.EllipsizeMode.END,
)
event_button = Gtk.Button(
child=label,
tooltip_text=f"{event.time_label} · {event.summary}",
css_classes=[
"flat",
"month-event",
self._event_color_class(event.color),
],
)
event_button.connect(
"clicked",
lambda _button, item=event: self._show_event(item),
)
cell.append(event_button)
if len(events) > 3:
overflow = Gtk.Button(
label=f"+{len(events) - 3} more",
halign=Gtk.Align.START,
css_classes=["flat", "caption"],
)
overflow.connect(
"clicked",
lambda _button, selected=day: self._open_date(selected, "agenda"),
)
cell.append(overflow)
row, column = divmod(index, 7)
grid.attach(cell, column, row, 1, 1)
view.append(grid)
return view
def _open_date(self, day: dt.date, view: str) -> None:
self._view_mode = view
self._view_button.set_label(view.title())
self._schedule_state_save()
self._calendar.select_day(day)
def _time_grid(
self,
days: tuple[dt.date, ...],
@@ -590,7 +687,8 @@ class KhoraWindow(Adw.ApplicationWindow):
provider.load_from_string(
f"""
.timed-event.{class_name},
.all-day-event.{class_name} {{
.all-day-event.{class_name},
.month-event.{class_name} {{
background-color: {color};
color: {foreground};
}}