Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/smorg/integrations/gcal/chips.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Filled event chips the way Google draws them, and the marker glyphs that ride on rows."""

from __future__ import annotations

from rich.text import Text

from smorg.integrations.gcal.source import Event, Response
from smorg.shell.format import truncating
from smorg.shell.terminal_palette import relative_luminance

FALLBACK_COLOR = "#616161"
MEET_GLYPH = "▶"
RECURRING_GLYPH = "⟲"
_RSVP_GLYPHS = {
Response.ACCEPTED: "●",
Response.TENTATIVE: "◐",
Response.NEEDS_ACTION: "○",
Response.DECLINED: "✗",
Response.NONE: "",
}
_LIGHT_TEXT_BELOW = 0.4


def _rgb(color: str) -> tuple[int, int, int]:
digits = color.lstrip("#")
if len(digits) != 6:
digits = "616161"
return int(digits[0:2], 16), int(digits[2:4], 16), int(digits[4:6], 16)


def chip_style(color: str) -> str:
"""Contrast text on the calendar colour: black on light chips, white on dark ones."""
rgb = _rgb(color)
if relative_luminance(rgb) < _LIGHT_TEXT_BELOW:
return f"white on {color}"
return f"black on {color}"


def format_chip(text: str, width: int, color: str, selected: bool, past: bool) -> Text:
style = chip_style(color)
if selected:
style = f"bold {style}"
if past:
style = f"dim {style}"
chip = Text(f" {text}", style=style)
chip.truncate(max(1, width), overflow="ellipsis", pad=True)
return chip


def rsvp_glyph(response: Response) -> str:
return _RSVP_GLYPHS[response]


def format_markers(event: Event) -> Text:
""" "▶ ○ ⟲" for a recurring Meet invite awaiting a reply; blank pieces are dropped."""
pieces: list[str] = []
if event.meet_url:
pieces.append(MEET_GLYPH)
glyph = rsvp_glyph(event.my_response)
if glyph:
pieces.append(glyph)
if event.recurring:
pieces.append(RECURRING_GLYPH)
joined = " ".join(pieces)
markers = Text(joined, style="dim")
return truncating(markers)
54 changes: 54 additions & 0 deletions src/smorg/integrations/gcal/footer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""The selected-event card under a ruler."""

from __future__ import annotations

from rich.console import Group, RenderableType
from rich.text import Text

from smorg.integrations.gcal.chips import rsvp_glyph
from smorg.integrations.gcal.source import Event, Response
from smorg.shell.cards import format_box, format_count
from smorg.shell.format import truncating

_RESPONSE_LABELS = {
Response.ACCEPTED: "accepted",
Response.TENTATIVE: "tentative",
Response.NEEDS_ACTION: "needs action",
Response.DECLINED: "declined",
Response.NONE: "",
}


def _format_head(event: Event) -> Text:
head = Text()
if event.all_day:
head.append("all day", style="dim")
else:
head.append(f"{event.start.strftime('%H:%M')}–{event.end.strftime('%H:%M')}", style="dim")
head.append(f" {event.title}", style="bold")
label = _RESPONSE_LABELS[event.my_response]
if label:
head.append(f" {rsvp_glyph(event.my_response)} {label}", style="dim")
return truncating(head)


def _format_meta(event: Event, calendar_name: str) -> Text:
pieces = [calendar_name]
if event.meet_url:
pieces.append("meet link")
if event.location:
pieces.append(event.location)
if event.attendees:
attendee_count = len(event.attendees)
attendee_label = format_count(attendee_count, "attendee")
pieces.append(attendee_label)
joined = " · ".join(pieces)
meta = Text(joined, style="dim")
return truncating(meta)


def format_footer(event: Event, calendar_name: str, bordered: bool) -> RenderableType:
lines = [_format_head(event), _format_meta(event, calendar_name)]
if bordered:
return format_box(list(lines))
return Group(*lines)
43 changes: 43 additions & 0 deletions src/smorg/integrations/gcal/header.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""The Google-style day header: weekday initials over date numbers, today circled in blue."""

from __future__ import annotations

from datetime import date

from rich.text import Text

from smorg.integrations.gcal.palette import RED, TODAY_BLUE

_WEEKDAY_INITIALS = ("M", "T", "W", "T", "F", "S", "S")
TODAY_FOLD = "◥"


def format_day_header(
days: tuple[date, ...], today: date, focused: date, column_width: int
) -> tuple[Text, Text]:
"""Two centered rows, one initial and one number per column; today in Google blue with the
icon's folded corner at its shoulder, the focused day underlined when it is a different day."""
initials = Text()
numbers = Text()
for day in days:
initial = _WEEKDAY_INITIALS[day.weekday()]
number = f"{day.day:>2}"
if day == today:
number_style = f"bold {TODAY_BLUE}"
elif day == focused:
number_style = "bold underline"
else:
number_style = "dim"
centered = initial.center(column_width)
initials.append(centered, style="dim")
cell_width = len(number) + 1
pad_left = (column_width - cell_width) // 2
pad_right = column_width - cell_width - pad_left
numbers.append(" " * pad_left)
numbers.append(number, style=number_style)
if day == today:
numbers.append(TODAY_FOLD, style=f"bold {RED}")
else:
numbers.append(" ")
numbers.append(" " * pad_right)
return initials, numbers
9 changes: 7 additions & 2 deletions src/smorg/integrations/gcal/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from smorg.auth.oauth import BundledProvider, OAuthMethod, ServerMetadata
from smorg.auth.store import Credentials
from smorg.core.contract import AuthPath, Item, Manifest
from smorg.core.contract import Action, ActionClass, AuthPath, Item, Manifest
from smorg.integrations.gcal.panel import CalendarPanel
from smorg.integrations.gcal.source import fetch

Expand All @@ -38,7 +38,12 @@
display_name="Google Calendar",
connections=(AuthPath(id="oauth", method=METHOD),),
stale_after=timedelta(minutes=5),
actions=(),
actions=(
Action(
id="open", label="Open in Google Calendar", key="o", action_class=ActionClass.LAUNCH
),
Action(id="today", label="Jump to today", key="t", action_class=ActionClass.LOCAL),
),
)


Expand Down
10 changes: 9 additions & 1 deletion src/smorg/integrations/gcal/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
window_for,
)
from smorg.integrations.gcal.views import CalendarView
from smorg.integrations.gcal.views.day import CalendarDay
from smorg.integrations.gcal.views.menu import CalendarMenu
from smorg.shell.animation import FrameClock
from smorg.shell.view_host import HostedView, ViewHostPanel
Expand All @@ -41,9 +42,10 @@ def __init__(self) -> None:

def compose(self) -> ComposeResult:
yield CalendarMenu(self)
yield CalendarDay(self)

def view_classes(self) -> dict[CalendarView, type[HostedView]]:
return {CalendarView.MENU: CalendarMenu}
return {CalendarView.MENU: CalendarMenu, CalendarView.DAY: CalendarDay}

def on_mount(self) -> None:
super().on_mount()
Expand Down Expand Up @@ -137,6 +139,9 @@ def invites(self) -> tuple[Event, ...]:
return tuple(pending)

def open_event(self, event: Event, return_view: CalendarView) -> None:
if CalendarView.EVENT not in self.view_classes():
self.notify("coming in the next milestone")
return
self.viewed = event
self.return_view = return_view
self.mark_seen(event)
Expand All @@ -152,4 +157,7 @@ def seen_items(self) -> tuple[Item, ...]:
def selected_item(self) -> Item | None:
if self.active_view is CalendarView.EVENT:
return self.viewed
if self.active_view is CalendarView.DAY and self.is_mounted:
day_view = self.query_one(CalendarDay)
return day_view.selected_item()
return None
135 changes: 135 additions & 0 deletions src/smorg/integrations/gcal/ruler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Laying a day's timed events onto rows: the one walk the day and week views both draw from."""

from __future__ import annotations

import math
from dataclasses import dataclass
from datetime import date, datetime, timedelta, tzinfo

from smorg.integrations.gcal.source import Event

SLOT_MINUTES = 15
SLOTS_PER_DAY = 96
MAX_LANES = 3


@dataclass(frozen=True)
class Placed:
event: Event
first_slot: int
slot_count: int
lane: int
lane_count: int


@dataclass(frozen=True)
class Row:
slot: int
time: datetime
hour_rule: bool


@dataclass(frozen=True)
class Layout:
start: datetime
rows: tuple[Row, ...]
placed: tuple[Placed, ...]
hidden: tuple[Event, ...]
now_row: int | None


def _timed_on(events: tuple[Event, ...], day: date, zone: tzinfo) -> list[Event]:
day_start = datetime.combine(day, datetime.min.time(), tzinfo=zone)
day_end = day_start + timedelta(days=1)
timed = [
event
for event in events
if not event.all_day and event.start < day_end and event.end > day_start
]
timed.sort(key=lambda event: (event.start, event.end))
return timed


def _slot_of(moment: datetime, start: datetime) -> int:
elapsed_minutes = (moment - start).total_seconds() / 60
slot = math.floor(elapsed_minutes / SLOT_MINUTES)
return slot


def _assign_lanes(spans: list[tuple[int, int, Event]]) -> tuple[list[Placed], list[Event]]:
"""Greedy interval partitioning over slot ranges; a cluster's width is its widest overlap."""
lane_ends: list[int] = []
assigned: list[tuple[int, int, int, Event]] = []
hidden: list[Event] = []
for first, count, event in spans:
last = first + count
lane = None
for index, lane_end in enumerate(lane_ends):
if lane_end <= first:
lane = index
break
if lane is None:
lane = len(lane_ends)
lane_ends.append(last)
else:
lane_ends[lane] = last
if lane >= MAX_LANES:
hidden.append(event)
continue
assigned.append((first, count, lane, event))

placed: list[Placed] = []
for first, count, lane, event in assigned:
last = first + count
overlapping_lanes = {
other_lane
for other_first, other_count, other_lane, _ in assigned
if other_first < last and other_first + other_count > first
}
lane_count = max(overlapping_lanes) + 1
placed.append(
Placed(
event=event,
first_slot=first,
slot_count=count,
lane=lane,
lane_count=lane_count,
)
)
return placed, hidden


def lay_out(events: tuple[Event, ...], day: date, zone: tzinfo, now: datetime) -> Layout:
start = datetime.combine(day, datetime.min.time(), tzinfo=zone)
timed = _timed_on(events, day, zone)

spans: list[tuple[int, int, Event]] = []
for event in timed:
start_slot = _slot_of(event.start, start)
first_slot = max(0, start_slot)
elapsed_minutes = (event.end - start).total_seconds() / 60
end_slot = math.ceil(elapsed_minutes / SLOT_MINUTES)
last_slot = min(SLOTS_PER_DAY, end_slot)
slot_count = max(1, last_slot - first_slot)
spans.append((first_slot, slot_count, event))
placed, hidden = _assign_lanes(spans)

if now.date() == day:
now_row = _slot_of(now, start)
else:
now_row = None

rows: list[Row] = []
for slot in range(SLOTS_PER_DAY):
offset = timedelta(minutes=slot * SLOT_MINUTES)
time = start + offset
hour_rule = time.minute == 0
rows.append(Row(slot=slot, time=time, hour_rule=hour_rule))

return Layout(
start=start,
rows=tuple(rows),
placed=tuple(placed),
hidden=tuple(hidden),
now_row=now_row,
)
Loading