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