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/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/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/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/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/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_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 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