diff --git a/projects/khora/README.md b/projects/khora/README.md index 9932fded..c7ccc4bb 100644 --- a/projects/khora/README.md +++ b/projects/khora/README.md @@ -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. diff --git a/projects/khora/ROADMAP.md b/projects/khora/ROADMAP.md index 004404f6..7409c195 100644 --- a/projects/khora/ROADMAP.md +++ b/projects/khora/ROADMAP.md @@ -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, diff --git a/projects/khora/khora/application.py b/projects/khora/khora/application.py index 0ac05c8f..033dba71 100644 --- a/projects/khora/khora/application.py +++ b/projects/khora/khora/application.py @@ -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; diff --git a/projects/khora/khora/window.py b/projects/khora/khora/window.py index 56bb0fed..00c69bf0 100644 --- a/projects/khora/khora/window.py +++ b/projects/khora/khora/window.py @@ -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}; }}