mirror of
https://github.com/Misterio77/Foundry.git
synced 2026-08-24 10:04:09 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user