feat(projects/khora): show event dots in mini calendar

Replace the stock sidebar calendar with a compact month widget that marks each visible date with up to three calendar-colored event dots.

Assisted-by: pi (gpt-5.6-sol)
This commit is contained in:
Gabriel Fontes
2026-08-15 16:24:01 -03:00
parent af7bab8759
commit 2ef5d34edd
6 changed files with 200 additions and 22 deletions
+2 -3
View File
@@ -15,9 +15,8 @@ The order reflects engineering dependencies rather than product priority.
starts at the selected date.
6. **Overlapping event layout — done.** Simultaneous timed events occupy
adjacent lanes instead of drawing on top of each other.
7. **Mini-calendar indicators** — show calendar-colored event dots on dates in
the sidebar. GTK's stock calendar cannot render these, so this needs a small
custom month widget.
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.
9. **Search** — search expanded local occurrences and jump from results to the
+32 -3
View File
@@ -33,11 +33,40 @@ class KhoraApplication(Adw.Application):
font-size: 0.82em;
}
.mini-calendar label {
min-width: 20px;
min-height: 20px;
.mini-calendar .day-button {
min-width: 26px;
min-height: 30px;
margin: 0;
padding: 1px;
border-radius: 999px;
}
.mini-calendar .selected {
background-color: @accent_bg_color;
color: @accent_fg_color;
}
.mini-calendar .other-month {
opacity: 0.45;
}
.mini-calendar .today .day-number {
color: @accent_color;
font-weight: bold;
}
.mini-calendar .selected .day-number {
color: @accent_fg_color;
}
.mini-calendar .weekday {
opacity: 0.65;
font-size: 0.8em;
}
.mini-calendar .event-dots {
min-height: 8px;
font-size: 0.55em;
}
.calendar-grid-header {
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import datetime as dt
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GObject, Gtk
from .colors import display_color
from .model import month_grid_dates, shifted_date
class MiniCalendar(Gtk.Box):
__gsignals__ = {
"day-selected": (GObject.SignalFlags.RUN_FIRST, None, ()),
"month-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
}
def __init__(self, selected_day: dt.date | None = None) -> None:
super().__init__(
orientation=Gtk.Orientation.VERTICAL,
spacing=4,
css_classes=["mini-calendar"],
)
self.selected_day = selected_day or dt.date.today()
self._display_month = self.selected_day.replace(day=1)
self._event_colors: dict[dt.date, tuple[str | None, ...]] = {}
self._render()
@property
def visible_days(self) -> tuple[dt.date, ...]:
return month_grid_dates(self._display_month)
def select_day(self, day: dt.date) -> None:
month_changed = (day.year, day.month) != (
self._display_month.year,
self._display_month.month,
)
self.selected_day = day
self._display_month = day.replace(day=1)
self._render()
if month_changed:
self.emit("month-changed")
self.emit("day-selected")
def set_event_colors(
self,
colors: dict[dt.date, tuple[str | None, ...]],
) -> None:
self._event_colors = colors
self._render()
def _render(self) -> None:
while child := self.get_first_child():
self.remove(child)
header = Gtk.Box()
previous = Gtk.Button(icon_name="go-previous-symbolic", css_classes=["flat"])
previous.connect("clicked", lambda *_: self._move_month(-1))
following = Gtk.Button(icon_name="go-next-symbolic", css_classes=["flat"])
following.connect("clicked", lambda *_: self._move_month(1))
header.append(previous)
header.append(
Gtk.Label(
label=f"{self._display_month:%B} {self._display_month.year}",
hexpand=True,
css_classes=["heading"],
)
)
header.append(following)
self.append(header)
grid = Gtk.Grid(column_homogeneous=True, row_spacing=2, column_spacing=2)
monday = dt.date(2024, 1, 1)
for column in range(7):
grid.attach(
Gtk.Label(
label=f"{monday + dt.timedelta(days=column):%a}",
css_classes=["weekday"],
),
column,
0,
1,
1,
)
today = dt.date.today()
for index, day in enumerate(self.visible_days):
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
content.append(Gtk.Label(label=str(day.day), css_classes=["day-number"]))
dots = self._event_colors.get(day, ())
dot = Gtk.Label(css_classes=["event-dots"])
dot.set_markup(
"".join(
f'<span foreground="{display_color(color)}">●</span>'
for color in tuple(dict.fromkeys(dots))[:3]
)
or " "
)
content.append(dot)
classes = ["flat", "day-button"]
if day.month != self._display_month.month:
classes.append("other-month")
if day == today:
classes.append("today")
if day == self.selected_day:
classes.append("selected")
button = Gtk.Button(child=content, css_classes=classes)
button.connect("clicked", lambda _button, selected=day: self.select_day(selected))
row, column = divmod(index, 7)
grid.attach(button, column, row + 1, 1, 1)
self.append(grid)
def _move_month(self, direction: int) -> None:
self._display_month = shifted_date(self._display_month, "month", direction).replace(day=1)
self._render()
self.emit("month-changed")
+6
View File
@@ -10,6 +10,12 @@ def week_dates(day: dt.date) -> tuple[dt.date, ...]:
return tuple(monday + dt.timedelta(days=offset) for offset in range(7))
def month_grid_dates(day: dt.date) -> tuple[dt.date, ...]:
first = day.replace(day=1)
start = first - dt.timedelta(days=first.weekday())
return tuple(start + dt.timedelta(days=offset) for offset in range(42))
def period_label(day: dt.date, mode: str) -> str:
if mode == "month":
return f"{day:%B} {day.year}"
+32 -16
View File
@@ -11,6 +11,7 @@ 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 .state import StateStore, UiState
@@ -96,6 +97,7 @@ class KhoraWindow(Adw.ApplicationWindow):
self._split.connect("notify::position", lambda *_: self._schedule_state_save())
self._toolbar.set_content(self._split)
self._refresh()
self._refresh_mini_calendar()
self._install_file_monitors()
self._clock_source = GLib.timeout_add_seconds(30, self._on_clock_tick)
self.connect("close-request", self._on_close_request)
@@ -146,7 +148,7 @@ class KhoraWindow(Adw.ApplicationWindow):
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())
self._install_action("refresh", lambda *_: self._refresh_all())
self._install_action("zoom-in", lambda *_: self._zoom_time_grid(1))
self._install_action("zoom-out", lambda *_: self._zoom_time_grid(-1))
self._install_action("zoom-reset", lambda *_: self._set_time_grid_zoom(DEFAULT_SLOT_HEIGHT))
@@ -165,12 +167,9 @@ class KhoraWindow(Adw.ApplicationWindow):
margin_end=10,
css_classes=["sidebar"],
)
self._calendar = Gtk.Calendar(
show_day_names=True,
show_heading=True,
css_classes=["mini-calendar"],
)
self._calendar = MiniCalendar()
self._calendar.connect("day-selected", lambda *_: self._refresh())
self._calendar.connect("month-changed", lambda *_: self._refresh_mini_calendar())
box.append(self._calendar)
box.append(Gtk.Label(label="Calendars", xalign=0, css_classes=["heading"]))
@@ -258,7 +257,7 @@ class KhoraWindow(Adw.ApplicationWindow):
def _refresh_after_vdir_change(self) -> bool:
self._refresh_source = 0
self._refresh()
self._refresh_all()
return GLib.SOURCE_REMOVE
def _error_page(self, error: str) -> Adw.StatusPage:
@@ -269,8 +268,28 @@ class KhoraWindow(Adw.ApplicationWindow):
)
def _selected_day(self) -> dt.date:
selected: GLib.DateTime = self._calendar.get_date()
return dt.date(selected.get_year(), selected.get_month(), selected.get_day_of_month())
return self._calendar.selected_day
def _refresh_all(self) -> None:
self._refresh()
self._refresh_mini_calendar()
def _refresh_mini_calendar(self) -> None:
if self._repository is None or not hasattr(self, "_calendar"):
return
days = self._calendar.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
self._calendar.set_event_colors(
{
day: tuple(event.color for event in events_by_day[day])
for day in days
if events_by_day[day]
}
)
def _refresh(self) -> None:
if self._repository is None or not hasattr(self, "_view_content"):
@@ -654,17 +673,14 @@ class KhoraWindow(Adw.ApplicationWindow):
else:
self._visible_calendars.discard(name)
self._schedule_state_save()
self._refresh()
self._refresh_all()
def _on_today(self, *_args) -> None:
self._calendar.select_day(GLib.DateTime.new_now_local())
self._refresh()
self._calendar.select_day(dt.date.today())
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()
self._calendar.select_day(target)
def _on_view_selected(self, _action: Gio.SimpleAction, parameter: GLib.Variant) -> None:
self._view_mode = parameter.get_string()
@@ -727,7 +743,7 @@ class KhoraWindow(Adw.ApplicationWindow):
today = dt.date.today()
if self._view_mode in {"day", "week"} and today != self._rendered_today:
self._rendered_today = today
self._refresh()
self._refresh_all()
else:
self._position_time_indicator()
return GLib.SOURCE_CONTINUE
+9
View File
@@ -4,6 +4,7 @@ from khora.model import (
Event,
event_slot_range,
layout_event_lanes,
month_grid_dates,
period_label,
shifted_date,
week_dates,
@@ -33,6 +34,14 @@ def test_week_dates_runs_from_monday_through_sunday() -> None:
)
def test_month_grid_contains_six_complete_weeks() -> None:
days = month_grid_dates(dt.date(2026, 8, 15))
assert len(days) == 42
assert days[0] == dt.date(2026, 7, 27)
assert days[-1] == dt.date(2026, 9, 6)
def test_period_labels_follow_the_active_view() -> None:
day = dt.date(2026, 8, 15)