From 3132c28eaf9e9573469d65a2663451ad44ef9021 Mon Sep 17 00:00:00 2001 From: Lucas Delvoye <90345231+ldelvoye@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:59:03 -0700 Subject: [PATCH 1/2] feat(gcal): the selected chip breathes The selected event keeps its calendar colour and lifts up to a third of the way toward white, settling back on the same 1.6-second wave as the landing's dots. The text colour comes from the base colour so it never flips between black and white mid-pulse. The rhythm constants move to the palette so the dots and the chip share them. --- src/smorg/integrations/gcal/chips.py | 52 ++++++++++++++++++----- src/smorg/integrations/gcal/palette.py | 6 ++- src/smorg/integrations/gcal/views/menu.py | 8 ++-- tests/integrations/gcal/test_chips.py | 20 +++++++++ 4 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 tests/integrations/gcal/test_chips.py diff --git a/src/smorg/integrations/gcal/chips.py b/src/smorg/integrations/gcal/chips.py index a4e4dce..7673ab2 100644 --- a/src/smorg/integrations/gcal/chips.py +++ b/src/smorg/integrations/gcal/chips.py @@ -2,8 +2,11 @@ from __future__ import annotations +import math + from rich.text import Text +from smorg.integrations.gcal.palette import BREATH_SECONDS from smorg.integrations.gcal.source import Event, Response from smorg.shell.format import truncating from smorg.shell.terminal_palette import relative_luminance @@ -19,6 +22,7 @@ Response.NONE: "", } _LIGHT_TEXT_BELOW = 0.4 +_PULSE_LIFT = 0.35 def _rgb(color: str) -> tuple[int, int, int]: @@ -28,20 +32,48 @@ def _rgb(color: str) -> tuple[int, int, int]: 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.""" +def _text_color(color: str) -> str: rgb = _rgb(color) if relative_luminance(rgb) < _LIGHT_TEXT_BELOW: - return f"white on {color}" - return f"black on {color}" + return "white" + return "black" + + +def chip_style(color: str) -> str: + """Contrast text on the calendar colour: black on light chips, white on dark ones.""" + text_color = _text_color(color) + return f"{text_color} on {color}" + + +def pulsed_fill(color: str, elapsed: float) -> str: + """The calendar colour lifted toward white by up to `_PULSE_LIFT`, on the breathing wave.""" + phase = (elapsed / BREATH_SECONDS) % 1.0 + brightness = 0.5 + 0.5 * math.cos(2 * math.pi * phase) + lift = _PULSE_LIFT * brightness + red, green, blue = _rgb(color) + r = round(red + (255 - red) * lift) + g = round(green + (255 - green) * lift) + b = round(blue + (255 - blue) * lift) + return f"#{r:02x}{g:02x}{b:02x}" -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}" +def format_chip( + text: str, + width: int, + color: str, + selected: bool, + past: bool, + fill: str | None = None, +) -> Text: + if selected and fill is not None: + text_color = _text_color(color) + style = f"bold {text_color} on {fill}" + else: + 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 diff --git a/src/smorg/integrations/gcal/palette.py b/src/smorg/integrations/gcal/palette.py index 1bec466..15c6fec 100644 --- a/src/smorg/integrations/gcal/palette.py +++ b/src/smorg/integrations/gcal/palette.py @@ -1,4 +1,5 @@ -"""Google's colours, spent sparingly: blue for today, red for whatever wants attention.""" +"""Google's colours, spent sparingly, and the breathing rhythm the dots and the selected chip +share.""" from __future__ import annotations @@ -8,3 +9,6 @@ YELLOW = "#fbbc04" GREEN = "#34a853" BRAND_DOTS = (BRAND_BLUE, RED, YELLOW, GREEN) + +BREATH_FPS = 10 +BREATH_SECONDS = 1.6 diff --git a/src/smorg/integrations/gcal/views/menu.py b/src/smorg/integrations/gcal/views/menu.py index d29571d..cb9239a 100644 --- a/src/smorg/integrations/gcal/views/menu.py +++ b/src/smorg/integrations/gcal/views/menu.py @@ -15,7 +15,7 @@ from textual.binding import Binding from textual.widgets import Static -from smorg.integrations.gcal.palette import BRAND_DOTS, RED, TODAY_BLUE +from smorg.integrations.gcal.palette import BRAND_DOTS, BREATH_FPS, BREATH_SECONDS, RED, TODAY_BLUE from smorg.integrations.gcal.source import CALENDAR_HOME, Event, EventKind, Response from smorg.integrations.gcal.views import CalendarView from smorg.shell.animation import FrameClock @@ -47,8 +47,6 @@ "9": ("###", "#.#", "###", "..#", "###"), } -_DOTS_FPS = 10 -_BREATH_SECONDS = 1.6 _DOT_GLYPH = "●" @@ -134,7 +132,7 @@ def _format_countdown(event: Event | None, now: datetime) -> list[Text]: def _dot_style(index: int, elapsed: float) -> str: - phase = (elapsed / _BREATH_SECONDS + index / 4) % 1.0 + phase = (elapsed / BREATH_SECONDS + index / 4) % 1.0 brightness = 0.5 + 0.5 * math.cos(2 * math.pi * phase) if brightness > 0.66: return "bold" @@ -220,7 +218,7 @@ def __init__(self, panel: CalendarPanel) -> None: self.panel = panel self.cursor = 0 self.elapsed = 0.0 - self.dots_clock = FrameClock(self, _DOTS_FPS, self._tick) + self.dots_clock = FrameClock(self, BREATH_FPS, self._tick) def on_show(self) -> None: self.dots_clock.start() diff --git a/tests/integrations/gcal/test_chips.py b/tests/integrations/gcal/test_chips.py new file mode 100644 index 0000000..b7ff78e --- /dev/null +++ b/tests/integrations/gcal/test_chips.py @@ -0,0 +1,20 @@ +from rich.style import Style + +from smorg.integrations.gcal.chips import format_chip, pulsed_fill +from smorg.integrations.gcal.palette import BREATH_SECONDS + + +def test_the_selected_chip_keeps_its_fill_and_breathes(): + peak = pulsed_fill("#039be5", 0.0) + trough = pulsed_fill("#039be5", BREATH_SECONDS / 2) + assert peak != trough + assert trough == "#039be5" + + selected = format_chip("Standup", 12, "#039be5", True, False, fill=peak) + selected_style = Style.parse(selected.style) + assert selected_style.bgcolor is not None + assert selected_style.bold + + unselected = format_chip("Standup", 12, "#039be5", False, False) + unselected_style = Style.parse(unselected.style) + assert selected_style.color == unselected_style.color From 47c2fc8886bac897920610ac69b3a8b0ae1b5297 Mon Sep 17 00:00:00 2001 From: Lucas Delvoye <90345231+ldelvoye@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:59:03 -0700 Subject: [PATCH 2/2] feat(gcal): the adaptive week view Several day columns share one scrolling 15-minute ruler: 7, 5, 3, or 1 columns at 140, 110, 90, and fewer terminal columns. Five-column weeks slide so a focused weekend day still has a column, and a hidden weekend shows as +N in the header. The focused column is tinted one lift step off the terminal's real background, lighter on dark and darker on light. Left and right change the day, the brackets change the week, and the paired bindings share a description so the help overlay merges each pair onto one row. The now-line is drawn over the slot row that contains the current time instead of being inserted as an extra row, so it no longer lands on the next slot's boundary or cuts a chip in another column. The ruler body, lane rows, and gutter are lifted out of the day view for the week view to reuse, each view runs its own frame clock for the pulsing chip, and the gutter's selection mark is gone now that the pulse marks it. --- src/smorg/integrations/gcal/header.py | 5 +- src/smorg/integrations/gcal/panel.py | 11 +- src/smorg/integrations/gcal/views/day.py | 202 +++++--- src/smorg/integrations/gcal/views/week.py | 536 ++++++++++++++++++++++ src/smorg/shell/terminal_palette.py | 13 + tests/integrations/gcal/helpers.py | 6 + tests/integrations/gcal/test_day.py | 2 - tests/integrations/gcal/test_menu.py | 4 +- tests/integrations/gcal/test_week.py | 206 +++++++++ 9 files changed, 911 insertions(+), 74 deletions(-) create mode 100644 src/smorg/integrations/gcal/views/week.py create mode 100644 tests/integrations/gcal/test_week.py diff --git a/src/smorg/integrations/gcal/header.py b/src/smorg/integrations/gcal/header.py index 8a21be6..6f30c6c 100644 --- a/src/smorg/integrations/gcal/header.py +++ b/src/smorg/integrations/gcal/header.py @@ -13,10 +13,11 @@ def format_day_header( - days: tuple[date, ...], today: date, focused: date, column_width: int + days: tuple[date, ...], today: date, focused: date | None, 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.""" + icon's folded corner at its shoulder, `focused` underlined when it is a different day (None + when the view marks the focused day another way).""" initials = Text() numbers = Text() for day in days: diff --git a/src/smorg/integrations/gcal/panel.py b/src/smorg/integrations/gcal/panel.py index 91486ac..b69ab6d 100644 --- a/src/smorg/integrations/gcal/panel.py +++ b/src/smorg/integrations/gcal/panel.py @@ -20,6 +20,7 @@ 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.integrations.gcal.views.week import CalendarWeek from smorg.shell.animation import FrameClock from smorg.shell.view_host import HostedView, ViewHostPanel @@ -43,9 +44,14 @@ def __init__(self) -> None: def compose(self) -> ComposeResult: yield CalendarMenu(self) yield CalendarDay(self) + yield CalendarWeek(self) def view_classes(self) -> dict[CalendarView, type[HostedView]]: - return {CalendarView.MENU: CalendarMenu, CalendarView.DAY: CalendarDay} + return { + CalendarView.MENU: CalendarMenu, + CalendarView.DAY: CalendarDay, + CalendarView.WEEK: CalendarWeek, + } def on_mount(self) -> None: super().on_mount() @@ -160,4 +166,7 @@ def selected_item(self) -> Item | None: if self.active_view is CalendarView.DAY and self.is_mounted: day_view = self.query_one(CalendarDay) return day_view.selected_item() + if self.active_view is CalendarView.WEEK and self.is_mounted: + week_view = self.query_one(CalendarWeek) + return week_view.selected_item() return None diff --git a/src/smorg/integrations/gcal/views/day.py b/src/smorg/integrations/gcal/views/day.py index f14db38..796b1a0 100644 --- a/src/smorg/integrations/gcal/views/day.py +++ b/src/smorg/integrations/gcal/views/day.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING from rich.console import Group, RenderableType +from rich.style import Style from rich.text import Text from textual.app import ComposeResult from textual.binding import Binding @@ -15,14 +16,14 @@ 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.chips import FALLBACK_COLOR, format_chip, format_markers, pulsed_fill 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.palette import BREATH_FPS, 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.animation import FrameClock 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 @@ -57,24 +58,101 @@ def _chip_text(placed: Placed, row_offset: int) -> str: return "" -def _format_gutter(label: str, selected: bool) -> Text: +def _format_gutter(label: str, label_style: str) -> Text: gutter = Text() - if selected: - gutter.append(SELECTED_MARK, style="bold") - else: - gutter.append(" ") - gutter.append(f" {label:>5} ", style="dim") + gutter.append(f" {label:>5} ", style=label_style) 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) +def overlay_now_rule(row: Text, width: int) -> Text: + """`row` with the red now rule drawn over it: a dot and a rule across every cell, each chip's + fill kept underneath.""" + rule = "─" * max(0, width - 1) + overlaid = Text(f"●{rule}", style=row.style) + for span in row.spans: + if isinstance(span.style, str): + span_style = Style.parse(span.style) + else: + span_style = span.style + if span_style.bgcolor is None: + continue + fill_only = Style(bgcolor=span_style.bgcolor) + overlaid.stylize(fill_only, span.start, span.end) + overlaid.stylize(f"bold {RED}") + return overlaid + + +def format_lane_rows( + layout: Layout, + content_width: int, + colors: dict[str, str], + selected_id: str | None, + now: datetime, + background: str | None = None, + elapsed: float = 0.0, +) -> list[Text]: + """One `Text` per slot row: chips laid into lanes, no gutter, no now-line, every row exactly + `content_width` cells.""" + if background is None: + row_style = "" + else: + row_style = f"on {background}" + rows: list[Text] = [] + for row in layout.rows: + line = _format_lane_row( + layout, row.slot, content_width, colors, selected_id, now, elapsed, row_style + ) + rows.append(line) + return rows + + +def _format_lane_row( + layout: Layout, + slot: int, + content_width: int, + colors: dict[str, str], + selected_id: str | None, + now: datetime, + elapsed: float, + row_style: str, +) -> Text: + occupants = [ + placed + for placed in layout.placed + if placed.first_slot <= slot < placed.first_slot + placed.slot_count + ] + # A base style sits under the chip fills; stylize() afterwards would paint over them. + line = Text(style=row_style) + if not occupants: + line.append(" " * content_width) + return line + 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, slot - placed.first_slot) + if 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 + selected = placed.event.id == selected_id + if selected: + fill = pulsed_fill(color, elapsed) + else: + fill = None + lanes[placed.lane] = format_chip(text, lane_width - 1, color, selected, past, fill=fill) + for lane in lanes: + line.append_text(lane) + line.append(" ") + filled_width = lane_count * lane_width + remainder = content_width - filled_width + if remainder > 0: + line.append(" " * remainder) return line @@ -84,47 +162,27 @@ def format_ruler_rows( colors: dict[str, str], selected_id: str | None, now: datetime, + elapsed: float = 0.0, ) -> list[Text]: """The ruler's rows for one day at `width` columns, chips laid into lanes.""" content_width = max(1, width - RULER_GUTTER) + lane_rows = format_lane_rows(layout, content_width, colors, selected_id, now, elapsed=elapsed) 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(" ") + if layout.now_row == index: + label = now.strftime("%H:%M") + label_style = f"bold {RED}" + lane_row = overlay_now_rule(lane_rows[index], content_width) else: - line.append(" " * content_width) + if row.hour_rule: + label = row.time.strftime("%H:%M") + else: + label = "" + label_style = "dim" + lane_row = lane_rows[index] + line = _format_gutter(label, label_style) + line.append_text(lane_row) rows.append(line) - if layout.now_row == index: - rows.append(_format_now_line(now, width)) return rows @@ -142,15 +200,16 @@ def _format_all_day(events: tuple[Event, ...], colors: dict[str, str]) -> Text: return strip -class _Ruler(GutteredScroll): +class RulerScroll(GutteredScroll): can_focus = False - def __init__(self, draw: Callable[[], RenderableType], id: str) -> None: + def __init__(self, draw: Callable[[], RenderableType], id: str, body_id: str) -> None: super().__init__(id=id) self._draw = draw + self._body_id = body_id def compose_content(self) -> ComposeResult: - yield ViewBody(self._draw, id="day-ruler-body") + yield ViewBody(self._draw, id=self._body_id) class CalendarDay(Vertical, HostedView): @@ -158,10 +217,10 @@ class CalendarDay(Vertical, HostedView): 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("shift+up", "scroll_earlier", "scroll an hour", show=False), + Binding("shift+down", "scroll_later", "scroll an hour", show=False), + Binding("left", "previous_day", "change day", show=False), + Binding("right", "next_day", "change 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), @@ -180,10 +239,12 @@ def __init__(self, panel: CalendarPanel) -> None: self.panel = panel self.cursor = 0 self._cursor_day: date | None = None + self.elapsed = 0.0 + self.pulse_clock = FrameClock(self, BREATH_FPS, self._tick) def compose(self) -> ComposeResult: yield ViewBody(self._render_header, id="day-header") - yield _Ruler(self._render_ruler, id="day-ruler") + yield RulerScroll(self._render_ruler, id="day-ruler", body_id="day-ruler-body") yield ViewBody(self._render_footer, id="day-footer") def _timed(self) -> tuple[Event, ...]: @@ -275,7 +336,7 @@ def _render_ruler(self) -> RenderableType: else: selected_id = selected.id layout = self._layout_for(focused) - rows = format_ruler_rows(layout, width, colors, selected_id, now) + rows = format_ruler_rows(layout, width, colors, selected_id, now, elapsed=self.elapsed) return Group(*rows) def _render_footer(self) -> RenderableType: @@ -328,7 +389,7 @@ def _anchor_row(self) -> int: def _scroll_to_anchor(self) -> None: if not self.is_mounted: return - ruler = self.query_one("#day-ruler", _Ruler) + ruler = self.query_one("#day-ruler", RulerScroll) anchor_row = self._anchor_row() viewport_height = ruler.size.height third = viewport_height // 3 @@ -351,19 +412,26 @@ def _scroll_selection_into_view(self) -> None: 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) + region = Region(0, placed_selected.first_slot, 1, placed_selected.slot_count) + ruler = self.query_one("#day-ruler", RulerScroll) 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 _tick(self, elapsed: float) -> None: + self.elapsed = elapsed + if self.selected_item() is None: + return + self.query_one("#day-ruler-body", Static).refresh() + def on_show(self) -> None: self.call_after_refresh(self._scroll_to_anchor) + self.pulse_clock.start() + + def on_hide(self) -> None: + self.pulse_clock.stop() def _move(self, offset: int) -> None: timed = self._timed() @@ -380,11 +448,11 @@ def action_cursor_down(self) -> None: self._move(1) def action_scroll_earlier(self) -> None: - ruler = self.query_one("#day-ruler", _Ruler) + ruler = self.query_one("#day-ruler", RulerScroll) ruler.scroll_relative(y=-SCROLL_STEP_ROWS, animate=False) def action_scroll_later(self) -> None: - ruler = self.query_one("#day-ruler", _Ruler) + ruler = self.query_one("#day-ruler", RulerScroll) ruler.scroll_relative(y=SCROLL_STEP_ROWS, animate=False) def action_previous_day(self) -> None: diff --git a/src/smorg/integrations/gcal/views/week.py b/src/smorg/integrations/gcal/views/week.py new file mode 100644 index 0000000..0b4d4cc --- /dev/null +++ b/src/smorg/integrations/gcal/views/week.py @@ -0,0 +1,536 @@ +"""Several day columns sharing one scrolling ruler, adapting its column count to the width.""" + +from __future__ import annotations + +import webbrowser +from dataclasses import dataclass +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 +from smorg.integrations.gcal.footer import format_footer +from smorg.integrations.gcal.header import format_day_header +from smorg.integrations.gcal.palette import BREATH_FPS, RED +from smorg.integrations.gcal.ruler import Layout, lay_out +from smorg.integrations.gcal.source import Event +from smorg.integrations.gcal.views import CalendarView +from smorg.integrations.gcal.views.day import ( + DAYS_PER_WEEK, + RULER_GUTTER, + SCROLL_STEP_ROWS, + RulerScroll, + _format_gutter, + format_lane_rows, + overlay_now_rule, +) +from smorg.shell.animation import FrameClock +from smorg.shell.cursor import clamp_cursor, step_cursor +from smorg.shell.format import PLAIN_WIDTH, plain_lines +from smorg.shell.panel import PanelState, ViewBody +from smorg.shell.terminal_palette import lifted_background, widget_background +from smorg.shell.view_host import HostedView + +if TYPE_CHECKING: + from smorg.integrations.gcal.panel import CalendarPanel + +SLOTS_PER_DAY = 96 +_BORDERED_FOOTER_MIN_ROWS = 30 +_EMPTY_DAY_ANCHOR_SLOT = 32 +_SELECTION_SPACING = Spacing(3, 0, 3, 0) +_HINTS = "↑↓ event ⇧↑↓ scroll ←→ day [ ] week ⏎ details o open t today esc menu" + + +def column_count(width: int) -> int: + """How many day columns fit at `width` columns: 7, 5, 3, or 1.""" + if width >= 140: + return 7 + if width >= 110: + return 5 + if width >= 90: + return 3 + return 1 + + +def visible_days(focused: date, first: date, last: date, count: int) -> tuple[date, ...]: + """The `count` days to show: a Monday-based week or workweek at 5 or more columns (sliding to + end on `focused` when it falls past the last shown weekday), else a run centred on `focused`; + either way slid to stay inside `[first, last]`.""" + if count >= 5: + if focused.weekday() >= count: + start = focused - timedelta(days=count - 1) + else: + start = focused - timedelta(days=focused.weekday()) + else: + before = (count - 1) // 2 + start = focused - timedelta(days=before) + end = start + timedelta(days=count - 1) + if start < first: + shift = first - start + start += shift + end += shift + if end > last: + shift = end - last + start -= shift + end -= shift + return tuple(start + timedelta(days=offset) for offset in range(count)) + + +@dataclass(frozen=True) +class _Column: + day: date + width: int + layout: Layout + lane_rows: list[Text] + + +class CalendarWeek(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", show=False), + Binding("shift+down", "scroll_later", "scroll an hour", show=False), + Binding("left", "previous_day", "change day", show=False), + Binding("right", "next_day", "change day", show=False), + Binding("left_square_bracket", "previous_week", "change week", show=False), + Binding("right_square_bracket", "next_week", "change week", 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 = """ + CalendarWeek { width: 100%; height: 1fr; } + CalendarWeek > #week-header { dock: top; height: auto; } + CalendarWeek > #week-ruler { height: 1fr; } + CalendarWeek > #week-ruler > #week-ruler-body { height: auto; } + CalendarWeek > #week-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 + self.elapsed = 0.0 + self.pulse_clock = FrameClock(self, BREATH_FPS, self._tick) + + def compose(self) -> ComposeResult: + yield ViewBody(self._render_header, id="week-header") + yield RulerScroll(self._render_ruler, id="week-ruler", body_id="week-ruler-body") + yield ViewBody(self._render_footer, id="week-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: + 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 + index = clamp_cursor(self.cursor, len(timed)) + return timed[index] + + 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: + 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("#week-ruler-body", Static) + width = body.content_size.width + if width <= 0: + return PLAIN_WIDTH - 1 + return width + + def _shown_days(self, count: int) -> tuple[date, ...]: + focused = self.panel.focused() + first, last = self.panel.window() + return visible_days(focused, first, last, count) + + def _column_widths(self, width: int, count: int) -> list[int]: + separators = count - 1 + usable = width - RULER_GUTTER - separators + column_width = max(1, usable // count) + remainder = usable - column_width * count + widths = [column_width] * count + widths[-1] += remainder + return widths + + def _layout_for(self, day: date) -> Layout: + events = self.panel.events_on(day) + zone = self.panel.zone() + now = self.panel.now() + return lay_out(events, day, zone, now) + + def _bordered(self) -> bool: + if not self.is_mounted: + return False + return self.size.height >= _BORDERED_FOOTER_MIN_ROWS + + def _hidden_weekend_count(self, shown: tuple[date, ...]) -> int: + if len(shown) != 5: + return 0 + week_start = shown[0] + if week_start.weekday() != 0: + return 0 + saturday = week_start + timedelta(days=5) + sunday = week_start + timedelta(days=6) + saturday_events = self.panel.events_on(saturday) + sunday_events = self.panel.events_on(sunday) + hidden_count = len(saturday_events) + len(sunday_events) + return hidden_count + + def _format_all_day_cell(self, events: list[Event], width: int, colors: dict[str, str]) -> Text: + if not events: + return Text(" " * width) + first_event = events[0] + extra = len(events) - 1 + if extra > 0: + suffix = f" +{extra}" + else: + suffix = "" + chip_width = width - len(suffix) + color = colors.get(first_event.calendar_id, FALLBACK_COLOR) + chip = format_chip(first_event.title, chip_width, color, False, False) + cell = Text() + cell.append_text(chip) + if suffix: + cell.append(suffix, style="dim") + return cell + + def _format_all_day_row( + self, shown: tuple[date, ...], column_widths: list[int], focused: date, tint: str + ) -> Text: + colors = self._colors() + row = Text(" " * RULER_GUTTER) + for index, day in enumerate(shown): + if index: + row.append(" ") + events = self.panel.events_on(day) + all_day = [event for event in events if event.all_day] + cell = self._format_all_day_cell(all_day, column_widths[index], colors) + if day == focused: + cell.style = f"on {tint}" + row.append_text(cell) + return row + + def _render_header(self) -> RenderableType: + if self.panel.state is not PanelState.READY: + return self.panel.body_text() + ruler_width = self._ruler_width() + focused = self.panel.focused() + today = self.panel.today() + tint = self._tint() + count = column_count(ruler_width) + shown = self._shown_days(count) + column_widths = self._column_widths(ruler_width, count) + header_column_width = column_widths[0] + initials, numbers = format_day_header(shown, today, None, header_column_width + 1) + prefixed_initials = Text(" " * RULER_GUTTER) + prefixed_initials.append_text(initials) + prefixed_numbers = Text(" " * RULER_GUTTER) + prefixed_numbers.append_text(numbers) + focused_index = shown.index(focused) + focused_cell_start = RULER_GUTTER + focused_index * (header_column_width + 1) + focused_cell_end = focused_cell_start + header_column_width + prefixed_initials.stylize(f"on {tint}", focused_cell_start, focused_cell_end) + prefixed_numbers.stylize(f"on {tint}", focused_cell_start, focused_cell_end) + hidden_count = self._hidden_weekend_count(shown) + if hidden_count: + suffix = f" +{hidden_count}" + else: + suffix = "" + if suffix: + budget = self.body_width() - len(suffix) + prefixed_numbers.truncate(budget, overflow="ellipsis") + prefixed_numbers.append(suffix, style="dim") + all_day_row = self._format_all_day_row(shown, column_widths, focused, tint) + return Group(prefixed_initials, prefixed_numbers, all_day_row) + + def _tint(self) -> str: + background = widget_background(self) + return lifted_background(background) + + def _columns( + self, + shown: tuple[date, ...], + column_widths: list[int], + selected_id: str | None, + colors: dict[str, str], + now: datetime, + tint: str, + ) -> list[_Column]: + focused = self.panel.focused() + today = self.panel.today() + columns: list[_Column] = [] + for day, width in zip(shown, column_widths, strict=True): + layout = self._layout_for(day) + if day == focused: + column_selected_id = selected_id + column_background = tint + column_elapsed = self.elapsed + else: + column_selected_id = None + column_background = None + column_elapsed = 0.0 + lane_rows = format_lane_rows( + layout, + width, + colors, + column_selected_id, + now, + background=column_background, + elapsed=column_elapsed, + ) + if day == today and layout.now_row is not None: + now_row = lane_rows[layout.now_row] + lane_rows[layout.now_row] = overlay_now_rule(now_row, width) + columns.append(_Column(day=day, width=width, layout=layout, lane_rows=lane_rows)) + return columns + + def _render_ruler(self) -> RenderableType: + if self.panel.state is not PanelState.READY: + return Text() + width = self._ruler_width() + focused = self.panel.focused() + today = self.panel.today() + now = self.panel.now() + colors = self._colors() + tint = self._tint() + selected = self.selected_item() + if selected is None: + selected_id = None + else: + selected_id = selected.id + + count = column_count(width) + shown = self._shown_days(count) + column_widths = self._column_widths(width, count) + columns = self._columns(shown, column_widths, selected_id, colors, now, tint) + + focused_layout = None + today_layout = None + for column in columns: + if column.day == focused: + focused_layout = column.layout + if column.day == today: + today_layout = column.layout + assert focused_layout is not None + + rows: list[Text] = [] + for slot in range(SLOTS_PER_DAY): + row_meta = focused_layout.rows[slot] + if today_layout is not None and today_layout.now_row == slot: + label = now.strftime("%H:%M") + label_style = f"bold {RED}" + else: + if row_meta.hour_rule: + label = row_meta.time.strftime("%H:%M") + else: + label = "" + label_style = "dim" + line = _format_gutter(label, label_style) + for index, column in enumerate(columns): + if index: + line.append(" ") + line.append_text(column.lane_rows[slot]) + rows.append(line) + 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("#week-header", Static).refresh(layout=True) + self.query_one("#week-ruler-body", Static).refresh() + self.query_one("#week-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("#week-ruler", RulerScroll) + 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 + region = Region(0, placed_selected.first_slot, 1, placed_selected.slot_count) + ruler = self.query_one("#week-ruler", RulerScroll) + ruler.scroll_to_region(region, spacing=_SELECTION_SPACING, animate=False) + + @property + def ruler_scroll_y(self) -> float: + return self.query_one("#week-ruler", VerticalScroll).scroll_y + + def _tick(self, elapsed: float) -> None: + self.elapsed = elapsed + if self.selected_item() is None: + return + self.query_one("#week-ruler-body", Static).refresh() + + def on_show(self) -> None: + self.call_after_refresh(self._scroll_to_anchor) + self.pulse_clock.start() + + def on_hide(self) -> None: + self.pulse_clock.stop() + + 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("#week-ruler", RulerScroll) + ruler.scroll_relative(y=-SCROLL_STEP_ROWS, animate=False) + + def action_scroll_later(self) -> None: + ruler = self.query_one("#week-ruler", RulerScroll) + 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_previous_week(self) -> None: + focused = self.panel.focused() + target = focused - timedelta(days=DAYS_PER_WEEK) + self.panel.set_focused(target) + self.call_after_refresh(self._scroll_to_anchor) + + def action_next_week(self) -> None: + focused = self.panel.focused() + target = focused + timedelta(days=DAYS_PER_WEEK) + self.panel.set_focused(target) + 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.WEEK) + + 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/src/smorg/shell/terminal_palette.py b/src/smorg/shell/terminal_palette.py index f3fc5ca..fd3e5b2 100644 --- a/src/smorg/shell/terminal_palette.py +++ b/src/smorg/shell/terminal_palette.py @@ -115,6 +115,19 @@ def status_colors(background: RGB | None) -> StatusColors: return pick_for_background(_STATUS_DARK, _STATUS_LIGHT, background) +def lifted_background(background: RGB | None) -> str: + """A hex colour one lift step off the terminal background: lighter on dark terminals, + darker on light ones; unknown counts as black.""" + if background is None: + base = BLACK + else: + base = background + step = pick_for_background(_toward_white, _toward_black, base) + lifted = step(base) + hex_color = f"#{lifted[0]:02x}{lifted[1]:02x}{lifted[2]:02x}" + return hex_color + + def contrast_ratio(one: RGB, other: RGB) -> float: """W3C contrast ratio: 1.0 for two identical colors, 21.0 black on white.""" luminances = (relative_luminance(one), relative_luminance(other)) diff --git a/tests/integrations/gcal/helpers.py b/tests/integrations/gcal/helpers.py index 6e8d854..b2cdc3d 100644 --- a/tests/integrations/gcal/helpers.py +++ b/tests/integrations/gcal/helpers.py @@ -6,6 +6,7 @@ from zoneinfo import ZoneInfo from textual.app import App, ComposeResult +from textual.binding import Binding from smorg.core.contract import Item from smorg.core.state import SeenState @@ -19,6 +20,7 @@ EventKind, Response, ) +from smorg.shell.format import symbolize_key_display from smorg.shell.panel import PanelState PACIFIC = ZoneInfo("America/Los_Angeles") @@ -170,3 +172,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self._panel.focus() + + def get_key_display(self, binding: Binding) -> str: + default_display = super().get_key_display(binding) + return symbolize_key_display(default_display) diff --git a/tests/integrations/gcal/test_day.py b/tests/integrations/gcal/test_day.py index 2533821..f8e58c8 100644 --- a/tests/integrations/gcal/test_day.py +++ b/tests/integrations/gcal/test_day.py @@ -91,8 +91,6 @@ async def test_keys_move_the_selection_and_the_focused_day(): 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 diff --git a/tests/integrations/gcal/test_menu.py b/tests/integrations/gcal/test_menu.py index 3d238a9..6e881bc 100644 --- a/tests/integrations/gcal/test_menu.py +++ b/tests/integrations/gcal/test_menu.py @@ -46,8 +46,8 @@ async def test_the_landing_shows_the_date_icon_countdown_dots_and_destinations(m toasts: list[str] = [] monkeypatch.setattr(menu, "notify", lambda message, **kwargs: toasts.append(message)) await pilot.press("down", "enter") - assert panel.active_view is CalendarView.MENU - assert toasts == ["coming in the next milestone"] + assert panel.active_view is CalendarView.WEEK + assert toasts == [] @pytest.mark.asyncio diff --git a/tests/integrations/gcal/test_week.py b/tests/integrations/gcal/test_week.py new file mode 100644 index 0000000..2732d60 --- /dev/null +++ b/tests/integrations/gcal/test_week.py @@ -0,0 +1,206 @@ +from datetime import date + +import pytest +from rich.console import Group +from rich.style import Style +from rich.text import Text + +from smorg.integrations.gcal.ruler import lay_out +from smorg.integrations.gcal.views import CalendarView +from smorg.integrations.gcal.views.day import RULER_GUTTER, CalendarDay +from smorg.integrations.gcal.views.week import CalendarWeek, column_count, visible_days +from smorg.shell.app import _format_binding_rows +from smorg.shell.format import plain_lines + +from .helpers import PanelHarness, week_panel + + +def _text(view: CalendarWeek) -> str: + return "\n".join(view.content_lines()) + + +def test_column_count_steps_at_the_named_widths(): + widths = [80, 90, 110, 140, 200] + counts = [column_count(width) for width in widths] + assert counts == [1, 3, 5, 7, 7] + + +def test_visible_days_centres_on_the_focused_day_and_clamps_to_the_window(): + first = date(2026, 9, 14) + last = date(2026, 9, 27) + + centred = visible_days(date(2026, 9, 16), first, last, 3) + assert centred == (date(2026, 9, 15), date(2026, 9, 16), date(2026, 9, 17)) + + slid_forward = visible_days(date(2026, 9, 14), first, last, 3) + assert slid_forward == (date(2026, 9, 14), date(2026, 9, 15), date(2026, 9, 16)) + + workweek = visible_days(date(2026, 9, 18), first, last, 5) + assert workweek[0] == date(2026, 9, 14) + + full_week = visible_days(date(2026, 9, 23), first, last, 7) + assert full_week[0] == date(2026, 9, 21) + + sliding_saturday = visible_days(date(2026, 9, 19), first, last, 5) + assert sliding_saturday == ( + date(2026, 9, 15), + date(2026, 9, 16), + date(2026, 9, 17), + date(2026, 9, 18), + date(2026, 9, 19), + ) + + sliding_sunday = visible_days(date(2026, 9, 20), first, last, 5) + assert sliding_sunday[-1] == date(2026, 9, 20) + + +@pytest.mark.asyncio +async def test_the_week_view_draws_five_columns_with_a_hidden_weekend_indicator(): + panel = week_panel() + async with PanelHarness(panel).run_test(size=(120, 40)) as pilot: + await pilot.pause() + panel.show_view(CalendarView.WEEK) + await pilot.pause() + view = panel.query_one(CalendarWeek) + width = view._ruler_width() + assert column_count(width) == 5 + + text = _text(view) + assert "14◥" in text + # a ~21-column cell truncates "Migration review: pk swap" before "review" completes. + assert "Migration" in text + assert "+1" in text + assert "Farmers market" not in text + assert text.count("11:42") == 1 + + body_width = view.body_width() + assert all(len(line) <= body_width for line in view.content_lines()) + + header_lines = plain_lines(view._render_header(), body_width) + assert len(header_lines) == 3 + assert all(len(line) <= body_width for line in header_lines) + + selected = view.selected_item() + assert selected is not None + assert selected.id == "design" + + column_widths = view._column_widths(width, 5) + focused_start = RULER_GUTTER + next_start = focused_start + column_widths[0] + 1 + ruler_group = view._render_ruler() + assert isinstance(ruler_group, Group) + rows = ruler_group.renderables + row = rows[0] + assert isinstance(row, Text) + focused_tinted = any( + span.start == focused_start and Style.parse(span.style).bgcolor is not None + for span in row.spans + ) + neighbour_tinted = any( + span.start == next_start and Style.parse(span.style).bgcolor is not None + for span in row.spans + ) + assert focused_tinted + assert not neighbour_tinted + + now_rows = [row for row in rows if isinstance(row, Text) and "11:42" in row.plain] + assert len(now_rows) == 1 + now_row = now_rows[0] + assert len(rows) == 96 + assert now_row.plain[focused_start] == "●" + neighbour_chip_kept = any( + span.start == next_start and Style.parse(span.style).bgcolor is not None + for span in now_row.spans + ) + assert neighbour_chip_kept + + +@pytest.mark.asyncio +async def test_keys_move_the_selection_the_focused_day_and_the_week(): + panel = week_panel() + async with PanelHarness(panel).run_test(size=(120, 40)) as pilot: + await pilot.pause() + panel.show_view(CalendarView.WEEK) + await pilot.pause() + view = panel.query_one(CalendarWeek) + + 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 + ruler = view.query_one("#week-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") + await pilot.pause() + assert panel.focused().day == 15 + selected = view.selected_item() + assert selected is not None + assert selected.id == "standup-tue" + + for _ in range(4): + await pilot.press("right") + await pilot.pause() + assert panel.focused().day == 19 + + numbers_line = view.content_lines()[1] + assert "19" in numbers_line + assert "14" not in numbers_line + + await pilot.press("right_square_bracket") + await pilot.pause() + assert panel.focused().day == 26 + + await pilot.press("left_square_bracket") + await pilot.pause() + assert panel.focused().day == 19 + + await pilot.press("t") + await pilot.pause() + assert panel.focused().day == 14 + + await pilot.press("escape") + assert panel.active_view is CalendarView.MENU + + +@pytest.mark.asyncio +async def test_a_narrow_terminal_shows_one_column(): + panel = week_panel() + async with PanelHarness(panel).run_test(size=(80, 24)) as pilot: + await pilot.pause() + panel.show_view(CalendarView.WEEK) + await pilot.pause() + view = panel.query_one(CalendarWeek) + width = view._ruler_width() + assert column_count(width) == 1 + + text = _text(view) + assert "14◥" in text + + ruler = view.query_one("#week-ruler") + assert ruler.size.height >= 10 + + +@pytest.mark.asyncio +async def test_paired_bindings_merge_into_one_help_row(): + panel = week_panel() + async with PanelHarness(panel).run_test(size=(120, 40)) as pilot: + await pilot.pause() + week_rows = _format_binding_rows(pilot.app, CalendarWeek.BINDINGS) + day_rows = _format_binding_rows(pilot.app, CalendarDay.BINDINGS) + + assert ("←/→", "change day") in week_rows + assert ("[/]", "change week") in week_rows + assert ("⇧ + ↑/↓", "scroll an hour") in week_rows + assert ("←/→", "change day") in day_rows + assert ("⇧ + ↑/↓", "scroll an hour") in day_rows