From af7e01016e5eb176d7b1767c0bc930f2c64a242a Mon Sep 17 00:00:00 2001 From: Lucas Delvoye <90345231+ldelvoye@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:32:08 -0700 Subject: [PATCH 1/4] feat(gcal): shared chip, header, ruler, and footer drawing --- src/smorg/integrations/gcal/chips.py | 66 +++++++++++++ src/smorg/integrations/gcal/footer.py | 54 +++++++++++ src/smorg/integrations/gcal/header.py | 43 ++++++++ src/smorg/integrations/gcal/ruler.py | 135 ++++++++++++++++++++++++++ tests/integrations/gcal/test_ruler.py | 59 +++++++++++ 5 files changed, 357 insertions(+) create mode 100644 src/smorg/integrations/gcal/chips.py create mode 100644 src/smorg/integrations/gcal/footer.py create mode 100644 src/smorg/integrations/gcal/header.py create mode 100644 src/smorg/integrations/gcal/ruler.py create mode 100644 tests/integrations/gcal/test_ruler.py diff --git a/src/smorg/integrations/gcal/chips.py b/src/smorg/integrations/gcal/chips.py new file mode 100644 index 0000000..a4e4dce --- /dev/null +++ b/src/smorg/integrations/gcal/chips.py @@ -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) diff --git a/src/smorg/integrations/gcal/footer.py b/src/smorg/integrations/gcal/footer.py new file mode 100644 index 0000000..a706580 --- /dev/null +++ b/src/smorg/integrations/gcal/footer.py @@ -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) diff --git a/src/smorg/integrations/gcal/header.py b/src/smorg/integrations/gcal/header.py new file mode 100644 index 0000000..8a21be6 --- /dev/null +++ b/src/smorg/integrations/gcal/header.py @@ -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 diff --git a/src/smorg/integrations/gcal/ruler.py b/src/smorg/integrations/gcal/ruler.py new file mode 100644 index 0000000..47af359 --- /dev/null +++ b/src/smorg/integrations/gcal/ruler.py @@ -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, + ) diff --git a/tests/integrations/gcal/test_ruler.py b/tests/integrations/gcal/test_ruler.py new file mode 100644 index 0000000..e974395 --- /dev/null +++ b/tests/integrations/gcal/test_ruler.py @@ -0,0 +1,59 @@ +from datetime import timedelta + +from smorg.integrations.gcal.ruler import MAX_LANES, lay_out + +from .helpers import NOW, PACIFIC, at, event + + +def test_the_full_day_lays_out_96_quarter_hour_rows(): + events = ( + event("early", at(0, 6, 45), at(0, 7, 15)), + event("odd", at(0, 12, 15), at(0, 12, 45)), + event("late", at(0, 19, 30), at(0, 21, 0)), + ) + + layout = lay_out(events, NOW.date(), PACIFIC, NOW) + + assert len(layout.rows) == 96 + assert layout.rows[0].time == at(0, 0) + assert layout.rows[95].time == at(0, 23, 45) + by_id = {placed.event.id: placed for placed in layout.placed} + assert by_id["early"].first_slot == 27 + assert by_id["odd"].first_slot == 49 + assert layout.rows[49].hour_rule is False + assert by_id["late"].first_slot == 78 + + +def test_a_short_event_still_gets_one_slot_and_the_now_line_lands_on_its_row(): + events = (event("a", at(0, 11, 41), at(0, 11, 44)),) + + layout = lay_out(events, NOW.date(), PACIFIC, NOW) + + placed = layout.placed[0] + assert placed.slot_count == 1 + assert layout.now_row == 46 + assert placed.first_slot == layout.now_row + + +def test_the_now_line_is_absent_on_another_day(): + events = (event("a", at(1, 9), at(1, 9, 30)),) + layout = lay_out(events, NOW.date() + timedelta(days=1), PACIFIC, NOW) + assert layout.now_row is None + + +def test_overlaps_split_into_lanes_and_the_fourth_is_hidden(): + events = ( + event("a", at(0, 11), at(0, 12)), + event("b", at(0, 11, 15), at(0, 11, 45)), + event("c", at(0, 11, 30), at(0, 12, 30)), + event("d", at(0, 11, 40), at(0, 13)), + event("e", at(0, 15), at(0, 16)), + ) + + layout = lay_out(events, NOW.date(), PACIFIC, NOW) + + by_id = {placed.event.id: placed for placed in layout.placed} + assert {by_id["a"].lane, by_id["b"].lane, by_id["c"].lane} == {0, 1, 2} + assert by_id["a"].lane_count == MAX_LANES + assert by_id["e"].lane_count == 1 + assert [hidden.id for hidden in layout.hidden] == ["d"] From 0896259a9e07cfd7e934ae3e6aa2b7018127d530 Mon Sep 17 00:00:00 2001 From: Lucas Delvoye <90345231+ldelvoye@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:32:08 -0700 Subject: [PATCH 2/4] feat(gcal): the day view, a full day at 15-minute rows that scrolls --- src/smorg/integrations/gcal/manifest.py | 9 +- src/smorg/integrations/gcal/panel.py | 10 +- src/smorg/integrations/gcal/views/day.py | 421 +++++++++++++++++++++++ tests/integrations/gcal/test_day.py | 133 +++++++ 4 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 src/smorg/integrations/gcal/views/day.py create mode 100644 tests/integrations/gcal/test_day.py diff --git a/src/smorg/integrations/gcal/manifest.py b/src/smorg/integrations/gcal/manifest.py index 4ff179c..aa7c2a8 100644 --- a/src/smorg/integrations/gcal/manifest.py +++ b/src/smorg/integrations/gcal/manifest.py @@ -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 @@ -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), + ), ) diff --git a/src/smorg/integrations/gcal/panel.py b/src/smorg/integrations/gcal/panel.py index eb1880e..91486ac 100644 --- a/src/smorg/integrations/gcal/panel.py +++ b/src/smorg/integrations/gcal/panel.py @@ -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 @@ -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() @@ -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) @@ -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 diff --git a/src/smorg/integrations/gcal/views/day.py b/src/smorg/integrations/gcal/views/day.py new file mode 100644 index 0000000..f14db38 --- /dev/null +++ b/src/smorg/integrations/gcal/views/day.py @@ -0,0 +1,421 @@ +"""One day as a full-day ruler with filled chips and the red now-line, under the week header.""" + +from __future__ import annotations + +import webbrowser +from collections.abc import Callable +from datetime import date, datetime, timedelta +from typing import TYPE_CHECKING + +from rich.console import Group, RenderableType +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical, VerticalScroll +from textual.geometry import Region, Spacing +from textual.widgets import Static + +from smorg.integrations.gcal.chips import FALLBACK_COLOR, format_chip, format_markers +from smorg.integrations.gcal.footer import format_footer +from smorg.integrations.gcal.header import format_day_header +from smorg.integrations.gcal.palette import RED +from smorg.integrations.gcal.ruler import Layout, Placed, lay_out +from smorg.integrations.gcal.source import Event +from smorg.integrations.gcal.views import CalendarView +from smorg.shell.cards import SELECTED_MARK +from smorg.shell.cursor import clamp_cursor, step_cursor +from smorg.shell.format import PLAIN_WIDTH, plain_lines +from smorg.shell.panel import GutteredScroll, PanelState, ViewBody +from smorg.shell.view_host import HostedView + +if TYPE_CHECKING: + from smorg.integrations.gcal.panel import CalendarPanel + +RULER_GUTTER = 9 +DAYS_PER_WEEK = 7 +SCROLL_STEP_ROWS = 4 +_BORDERED_FOOTER_MIN_ROWS = 30 +_EMPTY_DAY_ANCHOR_SLOT = 32 +_SELECTION_SPACING = Spacing(3, 0, 3, 0) +_HINTS = "↑↓ event ⇧↑↓ scroll ←→ day ⏎ details o open in Calendar t today esc menu" + + +def week_days(focused: date) -> tuple[date, ...]: + monday = focused - timedelta(days=focused.weekday()) + return tuple(monday + timedelta(days=offset) for offset in range(DAYS_PER_WEEK)) + + +def _chip_text(placed: Placed, row_offset: int) -> str: + event = placed.event + start = event.start.strftime("%H:%M") + if row_offset == 0: + return f"{start} {event.title}" + if row_offset == 1: + return f"{start}–{event.end.strftime('%H:%M')}" + if row_offset == 2 and event.location: + return event.location + return "" + + +def _format_gutter(label: str, selected: bool) -> Text: + gutter = Text() + if selected: + gutter.append(SELECTED_MARK, style="bold") + else: + gutter.append(" ") + gutter.append(f" {label:>5} ", style="dim") + gutter.append("│", style="dim") + return gutter + + +def _format_now_line(now: datetime, width: int) -> Text: + line = Text() + line.append(f" {now.strftime('%H:%M')} ", style=f"bold {RED}") + line.append("●", style=f"bold {RED}") + rule_width = max(0, width - RULER_GUTTER - 1) + rule = "─" * rule_width + line.append(rule, style=RED) + return line + + +def format_ruler_rows( + layout: Layout, + width: int, + colors: dict[str, str], + selected_id: str | None, + now: datetime, +) -> list[Text]: + """The ruler's rows for one day at `width` columns, chips laid into lanes.""" + content_width = max(1, width - RULER_GUTTER) + rows: list[Text] = [] + for index, row in enumerate(layout.rows): + occupants = [ + placed + for placed in layout.placed + if placed.first_slot <= row.slot < placed.first_slot + placed.slot_count + ] + selected_here = any(placed.event.id == selected_id for placed in occupants) + if row.hour_rule: + label = row.time.strftime("%H:%M") + else: + label = "" + line = _format_gutter(label, selected_here) + if occupants: + lane_count = max(placed.lane_count for placed in occupants) + lane_width = content_width // lane_count + lane_fill_width = lane_width - 1 + lanes = [Text(" " * lane_fill_width)] * lane_count + for placed in occupants: + text = _chip_text(placed, row.slot - placed.first_slot) + if row.slot == placed.first_slot: + chip_text = Text(text) + chip_text.append(" ") + chip_text.append_text(format_markers(placed.event)) + text = chip_text.plain + color = colors.get(placed.event.calendar_id, FALLBACK_COLOR) + past = placed.event.end <= now + lanes[placed.lane] = format_chip( + text, lane_width - 1, color, placed.event.id == selected_id, past + ) + for lane in lanes: + line.append_text(lane) + line.append(" ") + else: + line.append(" " * content_width) + rows.append(line) + if layout.now_row == index: + rows.append(_format_now_line(now, width)) + return rows + + +def _format_all_day(events: tuple[Event, ...], colors: dict[str, str]) -> Text: + strip = Text(" all day ", style="dim") + all_day = [event for event in events if event.all_day] + if not all_day: + strip.append("—", style="dim") + return strip + for event in all_day: + color = colors.get(event.calendar_id, FALLBACK_COLOR) + chip_width = len(event.title) + 2 + strip.append_text(format_chip(event.title, chip_width, color, False, False)) + strip.append(" ") + return strip + + +class _Ruler(GutteredScroll): + can_focus = False + + def __init__(self, draw: Callable[[], RenderableType], id: str) -> None: + super().__init__(id=id) + self._draw = draw + + def compose_content(self) -> ComposeResult: + yield ViewBody(self._draw, id="day-ruler-body") + + +class CalendarDay(Vertical, HostedView): + can_focus = True + BINDINGS = [ + Binding("up", "cursor_up", "select event", show=False), + Binding("down", "cursor_down", "select event", show=False), + Binding("shift+up", "scroll_earlier", "scroll an hour earlier", show=False), + Binding("shift+down", "scroll_later", "scroll an hour later", show=False), + Binding("left", "previous_day", "previous day", show=False), + Binding("right", "next_day", "next day", show=False), + Binding("enter", "open_event", "view event", show=False), + Binding("o", "open_selected", "open in Google Calendar", show=False), + Binding("t", "today", "jump to today", show=False), + Binding("escape", "back_to_menu", "back to menu", show=False), + ] + DEFAULT_CSS = """ + CalendarDay { width: 100%; max-width: 120; height: 1fr; } + CalendarDay > #day-header { dock: top; height: auto; } + CalendarDay > #day-ruler { height: 1fr; } + CalendarDay > #day-ruler > #day-ruler-body { height: auto; } + CalendarDay > #day-footer { dock: bottom; height: auto; } + """ + + def __init__(self, panel: CalendarPanel) -> None: + super().__init__() + self.panel = panel + self.cursor = 0 + self._cursor_day: date | None = None + + def compose(self) -> ComposeResult: + yield ViewBody(self._render_header, id="day-header") + yield _Ruler(self._render_ruler, id="day-ruler") + yield ViewBody(self._render_footer, id="day-footer") + + def _timed(self) -> tuple[Event, ...]: + focused = self.panel.focused() + timed = [event for event in self.panel.events_on(focused) if not event.all_day] + return tuple(timed) + + def _sync_cursor(self) -> None: + """A fresh day starts the cursor on the next upcoming event, or the last one gone by.""" + focused = self.panel.focused() + if self._cursor_day == focused: + return + self._cursor_day = focused + now = self.panel.now() + timed = self._timed() + self.cursor = 0 + for index, event in enumerate(timed): + if event.end >= now: + self.cursor = index + return + if timed: + self.cursor = len(timed) - 1 + + def selected_item(self) -> Event | None: + self._sync_cursor() + timed = self._timed() + if not timed: + return None + return timed[clamp_cursor(self.cursor, len(timed))] + + def _colors(self) -> dict[str, str]: + calendars = self.panel.calendars() + if calendars is None: + return {} + return {calendar.id: calendar.color for calendar in calendars.calendars} + + def body_width(self) -> int: + """The view's own width, or the plain-lines width before it is mounted and measured.""" + if not self.is_mounted: + return PLAIN_WIDTH + width = self.size.width + if width <= 0: + return PLAIN_WIDTH + return width + + def _ruler_width(self) -> int: + if not self.is_mounted: + return PLAIN_WIDTH - 1 + body = self.query_one("#day-ruler-body", Static) + width = body.content_size.width + if width <= 0: + return PLAIN_WIDTH - 1 + return width + + def _layout_for(self, focused: date) -> Layout: + events = self.panel.events_on(focused) + zone = self.panel.zone() + now = self.panel.now() + return lay_out(events, focused, zone, now) + + def _bordered(self) -> bool: + if not self.is_mounted: + return False + return self.size.height >= _BORDERED_FOOTER_MIN_ROWS + + def _render_header(self) -> RenderableType: + if self.panel.state is not PanelState.READY: + return self.panel.body_text() + width = self.body_width() + focused = self.panel.focused() + days = week_days(focused) + column_width = max(3, width // DAYS_PER_WEEK) + initials, numbers = format_day_header(days, self.panel.today(), focused, column_width) + events = self.panel.events_on(focused) + colors = self._colors() + all_day = _format_all_day(events, colors) + return Group(initials, numbers, all_day) + + def _render_ruler(self) -> RenderableType: + if self.panel.state is not PanelState.READY: + return Text() + width = self._ruler_width() + focused = self.panel.focused() + now = self.panel.now() + colors = self._colors() + selected = self.selected_item() + if selected is None: + selected_id = None + else: + selected_id = selected.id + layout = self._layout_for(focused) + rows = format_ruler_rows(layout, width, colors, selected_id, now) + return Group(*rows) + + def _render_footer(self) -> RenderableType: + if self.panel.state is not PanelState.READY: + return Text() + width = self.body_width() + selected = self.selected_item() + pieces: list[RenderableType] = [] + if selected is not None: + calendar = self.panel.calendar_for(selected.calendar_id) + if calendar is None: + calendar_name = "" + else: + calendar_name = calendar.name + bordered = self._bordered() + pieces.append(format_footer(selected, calendar_name, bordered)) + hints = Text(_HINTS, style="dim", justify="center") + hints.truncate(width, overflow="ellipsis") + pieces.append(hints) + return Group(*pieces) + + def refresh_content(self) -> None: + if not self.is_mounted: + return + self.query_one("#day-header", Static).refresh(layout=True) + self.query_one("#day-ruler-body", Static).refresh() + self.query_one("#day-footer", Static).refresh(layout=True) + + def content_lines(self) -> list[str]: + width = self.body_width() + header = self._render_header() + ruler_group = self._render_ruler() + footer = self._render_footer() + lines = plain_lines(header, width) + lines.extend(plain_lines(ruler_group, width)) + lines.extend(plain_lines(footer, width)) + return lines + + def _anchor_row(self) -> int: + focused = self.panel.focused() + layout = self._layout_for(focused) + today = self.panel.today() + if focused == today and layout.now_row is not None: + return layout.now_row + if layout.placed: + first_placed = min(layout.placed, key=lambda placed: placed.first_slot) + return first_placed.first_slot + return _EMPTY_DAY_ANCHOR_SLOT + + def _scroll_to_anchor(self) -> None: + if not self.is_mounted: + return + ruler = self.query_one("#day-ruler", _Ruler) + anchor_row = self._anchor_row() + viewport_height = ruler.size.height + third = viewport_height // 3 + offset = anchor_row - third + target = max(0, offset) + ruler.scroll_to(y=target, animate=False) + + def _scroll_selection_into_view(self) -> None: + if not self.is_mounted: + return + selected = self.selected_item() + if selected is None: + return + focused = self.panel.focused() + layout = self._layout_for(focused) + placed_selected = None + for placed in layout.placed: + if placed.event.id == selected.id: + placed_selected = placed + break + if placed_selected is None: + return + first_row = placed_selected.first_slot + if layout.now_row is not None and placed_selected.first_slot > layout.now_row: + first_row += 1 + region = Region(0, first_row, 1, placed_selected.slot_count) + ruler = self.query_one("#day-ruler", _Ruler) + ruler.scroll_to_region(region, spacing=_SELECTION_SPACING, animate=False) + + @property + def ruler_scroll_y(self) -> float: + return self.query_one("#day-ruler", VerticalScroll).scroll_y + + def on_show(self) -> None: + self.call_after_refresh(self._scroll_to_anchor) + + def _move(self, offset: int) -> None: + timed = self._timed() + if not timed: + return + self.cursor = step_cursor(self.cursor, offset, len(timed)) + self.panel.refresh() + self.call_after_refresh(self._scroll_selection_into_view) + + def action_cursor_up(self) -> None: + self._move(-1) + + def action_cursor_down(self) -> None: + self._move(1) + + def action_scroll_earlier(self) -> None: + ruler = self.query_one("#day-ruler", _Ruler) + ruler.scroll_relative(y=-SCROLL_STEP_ROWS, animate=False) + + def action_scroll_later(self) -> None: + ruler = self.query_one("#day-ruler", _Ruler) + ruler.scroll_relative(y=SCROLL_STEP_ROWS, animate=False) + + def action_previous_day(self) -> None: + focused = self.panel.focused() + previous_day = focused - timedelta(days=1) + self.panel.set_focused(previous_day) + self.call_after_refresh(self._scroll_to_anchor) + + def action_next_day(self) -> None: + focused = self.panel.focused() + next_day = focused + timedelta(days=1) + self.panel.set_focused(next_day) + self.call_after_refresh(self._scroll_to_anchor) + + def action_today(self) -> None: + today = self.panel.today() + self.panel.set_focused(today) + self.call_after_refresh(self._scroll_to_anchor) + + def action_open_event(self) -> None: + event = self.selected_item() + if event is None: + return + self.panel.open_event(event, CalendarView.DAY) + + def action_open_selected(self) -> None: + event = self.selected_item() + if event is None: + return + webbrowser.open(event.url) + self.panel.mark_seen(event) + + def action_back_to_menu(self) -> None: + self.panel.show_view(CalendarView.MENU) diff --git a/tests/integrations/gcal/test_day.py b/tests/integrations/gcal/test_day.py new file mode 100644 index 0000000..2533821 --- /dev/null +++ b/tests/integrations/gcal/test_day.py @@ -0,0 +1,133 @@ +import pytest + +from smorg.integrations.gcal.ruler import lay_out +from smorg.integrations.gcal.views import CalendarView +from smorg.integrations.gcal.views.day import CalendarDay +from smorg.shell.format import plain_lines + +from .helpers import PanelHarness, week_panel + + +def _text(view: CalendarDay) -> str: + return "\n".join(view.content_lines()) + + +@pytest.mark.asyncio +async def test_the_day_view_draws_header_chips_now_line_and_footer(): + panel = week_panel() + async with PanelHarness(panel).run_test(size=(120, 40)) as pilot: + await pilot.pause() + panel.show_view(CalendarView.DAY) + await pilot.pause() + view = panel.query_one(CalendarDay) + text = _text(view) + assert "M" in text and "14" in text + assert "Office" in text + assert "Infra standup" in text + assert "11:42" in text + assert "Design sync: calendar tab" in text + assert "coming with write permissions" not in text + assert "12:30 Design sync: calendar tab" in text + assert "14◥" in text + assert "00:00" in text and "23:00" in text + width = view.body_width() + assert all(len(line) <= width for line in view.content_lines()) + + body = view.query_one("#day-ruler-body") + body_width = body.content_size.width + ruler_group = view._render_ruler() + ruler_lines = plain_lines(ruler_group, body_width) + assert all(len(line) <= body_width for line in ruler_lines) + + +@pytest.mark.asyncio +async def test_entering_the_view_anchors_on_the_now_line(): + panel = week_panel() + async with PanelHarness(panel).run_test(size=(100, 30)) as pilot: + await pilot.pause() + panel.show_view(CalendarView.DAY) + await pilot.pause() + # on_show's call_after_refresh only runs on the next refresh cycle. + await pilot.pause() + view = panel.query_one(CalendarDay) + ruler = view.query_one("#day-ruler") + ruler_height = ruler.size.height + anchor_scroll_y = view.ruler_scroll_y + now_row = 46 + + assert anchor_scroll_y <= now_row < anchor_scroll_y + ruler_height + assert now_row - anchor_scroll_y < ruler_height / 2 + + await pilot.press("shift+down") + await pilot.press("shift+down") + await pilot.pause() + assert view.ruler_scroll_y == anchor_scroll_y + 8 + + await pilot.press("t") + await pilot.pause() + assert view.ruler_scroll_y == anchor_scroll_y + + +@pytest.mark.asyncio +async def test_keys_move_the_selection_and_the_focused_day(): + panel = week_panel() + async with PanelHarness(panel).run_test(size=(120, 40)) as pilot: + await pilot.pause() + panel.show_view(CalendarView.DAY) + await pilot.pause() + view = panel.query_one(CalendarDay) + selected = view.selected_item() + assert selected is not None + assert selected.id == "design" + + await pilot.press("down") + await pilot.pause() + selected = view.selected_item() + assert selected is not None + assert selected.id == "hours" + + focused = panel.focused() + layout = lay_out(panel.events_on(focused), focused, panel.zone(), panel.now()) + by_id = {placed.event.id: placed for placed in layout.placed} + hours = by_id["hours"] + first_row = hours.first_slot + if layout.now_row is not None and hours.first_slot > layout.now_row: + first_row += 1 + ruler = view.query_one("#day-ruler") + ruler_height = ruler.size.height + scroll_y = view.ruler_scroll_y + assert scroll_y <= first_row + assert first_row + hours.slot_count <= scroll_y + ruler_height + + await pilot.press("right") + assert panel.focused().day == 15 + selected = view.selected_item() + assert selected is not None + assert selected.id == "standup-tue" + assert "11:42" not in _text(view) + + await pilot.press("t") + assert panel.focused().day == 14 + + await pilot.press("escape") + assert panel.active_view is CalendarView.MENU + + +@pytest.mark.asyncio +async def test_a_short_terminal_keeps_header_and_footer_pinned(): + panel = week_panel() + async with PanelHarness(panel).run_test(size=(80, 24)) as pilot: + await pilot.pause() + panel.show_view(CalendarView.DAY) + await pilot.pause() + view = panel.query_one(CalendarDay) + text = _text(view) + assert "14" in text + assert "needs action" in text + + header = view.query_one("#day-header") + footer = view.query_one("#day-footer") + ruler = view.query_one("#day-ruler") + expected_ruler_height = 24 - header.size.height - footer.size.height + assert ruler.size.height == expected_ruler_height + assert ruler.size.height >= 10 From d9c5c1e2a98a205343ee7cd7e896900d35da4d1c Mon Sep 17 00:00:00 2001 From: Lucas Delvoye <90345231+ldelvoye@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:32:08 -0700 Subject: [PATCH 3/4] fix(gcal): keep empty event fields empty instead of "(unspecified)" --- src/smorg/integrations/gcal/source.py | 18 +++++++++++++----- tests/integrations/gcal/test_source.py | 2 ++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/smorg/integrations/gcal/source.py b/src/smorg/integrations/gcal/source.py index f86dba0..4f58851 100644 --- a/src/smorg/integrations/gcal/source.py +++ b/src/smorg/integrations/gcal/source.py @@ -277,7 +277,7 @@ def _event_of(raw: dict[str, Any], calendar_id: str, zone: ZoneInfo) -> Event | attendees=attendees, organizer=_organizer_of(raw), meet_url=_meet_url_of(raw), - location=sanitize_line(location, limit=200), + location=_optional_line(location, limit=200), description=_description_of(raw), attachments=_attachments_of(raw), recurring="recurringEventId" in raw, @@ -301,6 +301,14 @@ def _moment_of(raw: dict[str, Any], key: str, zone: ZoneInfo) -> tuple[datetime, return midnight, True +def _optional_line(value: str, limit: int = 120) -> str: + """A sanitized line, or empty when the field was empty; sanitize_line alone would name an + absent field "(unspecified)".""" + if not value: + return "" + return sanitize_line(value, limit=limit) + + def _title_of(raw: dict[str, Any]) -> str: summary = optional_string(raw, "summary") if not summary.strip(): @@ -331,14 +339,14 @@ def _attendees_of(raw: dict[str, Any]) -> tuple[Attendee, ...]: if not isinstance(entry, dict): raise Malformed(f"an attendee was {type(entry).__name__}, expected an object") raw_email = optional_string(entry, "email") - email = sanitize_line(raw_email) + email = _optional_line(raw_email) name = optional_string(entry, "displayName") if not name: name = email response_status = optional_string(entry, "responseStatus") attendees.append( Attendee( - name=sanitize_line(name), + name=_optional_line(name), email=email, response=_RESPONSES.get(response_status, Response.NONE), organizer=entry.get("organizer") is True, @@ -362,7 +370,7 @@ def _organizer_of(raw: dict[str, Any]) -> str: name = optional_string(organizer, "displayName") if not name: name = optional_string(organizer, "email") - return sanitize_line(name) + return _optional_line(name) def _description_of(raw: dict[str, Any]) -> str: @@ -383,5 +391,5 @@ def _attachments_of(raw: dict[str, Any]) -> tuple[str, ...]: if not isinstance(entry, dict): raise Malformed(f"an attachment was {type(entry).__name__}, expected an object") raw_title = optional_string(entry, "title") - titles.append(sanitize_line(raw_title)) + titles.append(_optional_line(raw_title)) return tuple(titles) diff --git a/tests/integrations/gcal/test_source.py b/tests/integrations/gcal/test_source.py index 1c733ca..dd55fd6 100644 --- a/tests/integrations/gcal/test_source.py +++ b/tests/integrations/gcal/test_source.py @@ -133,6 +133,7 @@ def test_events_land_in_the_settings_timezone_with_every_field_mapped(server): assert standup.description == "Agenda\n- mocks\n- landing" assert standup.attachments == ("notes.pdf",) assert standup.calendar_id == "lucas@example.com" + assert standup.location == "" def test_all_day_events_are_midnight_to_exclusive_midnight_and_keep_their_kind(server): @@ -157,6 +158,7 @@ def test_declined_and_cancelled_instances_are_dropped_and_untitled_events_are_na assert dentist.title == "(no title)" assert dentist.my_response is Response.NONE assert dentist.location == "Bay Dental, 3rd Ave" + assert dentist.organizer == "" def test_a_404_calendar_is_skipped_while_the_others_load(server): From 9b5a8233cd4b37263644718e73652d56e1def599 Mon Sep 17 00:00:00 2001 From: Lucas Delvoye <90345231+ldelvoye@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:32:08 -0700 Subject: [PATCH 4/4] fix(gcal): centre the landing once, not twice --- src/smorg/integrations/gcal/views/menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/smorg/integrations/gcal/views/menu.py b/src/smorg/integrations/gcal/views/menu.py index 6f9cc6d..d29571d 100644 --- a/src/smorg/integrations/gcal/views/menu.py +++ b/src/smorg/integrations/gcal/views/menu.py @@ -212,7 +212,7 @@ class CalendarMenu(Static, HostedView): ] can_focus = True DEFAULT_CSS = """ - CalendarMenu { height: 1fr; content-align: center middle; } + CalendarMenu { height: 1fr; width: 100%; content-align: left middle; } """ def __init__(self, panel: CalendarPanel) -> None: