diff --git a/docs/examples/widgets/month_calendar.py b/docs/examples/widgets/month_calendar.py new file mode 100644 index 0000000000..878f3acd87 --- /dev/null +++ b/docs/examples/widgets/month_calendar.py @@ -0,0 +1,29 @@ +import datetime + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import MonthCalendar + + +class MonthCalendarApp(App): + CSS = """ + Screen { + align: center middle; + } + """ + + BINDINGS = [ + Binding("ctrl+s", "toggle_show_other_months"), + ] + + def compose(self) -> ComposeResult: + yield MonthCalendar(datetime.date(year=2021, month=6, day=3)) + + def action_toggle_show_other_months(self) -> None: + calendar = self.query_one(MonthCalendar) + calendar.show_other_months = not calendar.show_other_months + + +if __name__ == "__main__": + app = MonthCalendarApp() + app.run() diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 7036ca44f8..a1c0d1ab00 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -29,6 +29,7 @@ from textual.widgets._log import Log from textual.widgets._markdown import Markdown, MarkdownViewer from textual.widgets._masked_input import MaskedInput + from textual.widgets._month_calendar import MonthCalendar from textual.widgets._option_list import OptionList from textual.widgets._placeholder import Placeholder from textual.widgets._pretty import Pretty @@ -70,6 +71,7 @@ "Markdown", "MarkdownViewer", "MaskedInput", + "MonthCalendar", "OptionList", "Placeholder", "Pretty", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index aaf2e3b9f4..1be74d5041 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -19,6 +19,7 @@ from ._loading_indicator import LoadingIndicator as LoadingIndicator from ._log import Log as Log from ._markdown import Markdown as Markdown from ._markdown import MarkdownViewer as MarkdownViewer +from ._month_calendar import MonthCalendar as MonthCalendar from ._option_list import OptionList as OptionList from ._placeholder import Placeholder as Placeholder from ._pretty import Pretty as Pretty diff --git a/src/textual/widgets/_month_calendar.py b/src/textual/widgets/_month_calendar.py new file mode 100644 index 0000000000..98b6ba6455 --- /dev/null +++ b/src/textual/widgets/_month_calendar.py @@ -0,0 +1,520 @@ +from __future__ import annotations + +import calendar +import datetime +from typing import Sequence + +from rich.text import Text + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.coordinate import Coordinate +from textual.events import Mount +from textual.message import Message +from textual.reactive import Reactive +from textual.widget import Widget +from textual.widgets import DataTable +from textual.widgets.data_table import CellDoesNotExist + + +class InvalidWeekdayNumber(Exception): + """Exception raised if an invalid weekday number is supplied.""" + + +class CalendarGrid(DataTable, inherit_bindings=False): + """The grid used internally by the `MonthCalendar` widget for displaying + and navigating dates.""" + + # TODO: Ideally we want to hide that there's a DataTable underneath the + # `MonthCalendar` widget. Is there any mechanism so component classes could be + # defined in the parent and substitute the component styles in the child? + # For example, allow styling the header using `.month-calendar--header` + # rather than `.datatable--header`? + DEFAULT_CSS = """ + CalendarGrid { + height: auto; + width: auto; + + .datatable--header { + background: $surface; + } + } + """ + + +class MonthCalendar(Widget): + BINDINGS = [ + Binding("enter", "select_date", "Select date", show=False), + Binding("up", "previous_week", "Previous week", show=False), + Binding("down", "next_week", "Next week", show=False), + Binding("right", "next_day", "Next day", show=False), + Binding("left", "previous_day", "Previous day", show=False), + Binding("pageup", "previous_month", "Previous month", show=False), + Binding("pagedown", "next_month", "Next month", show=False), + Binding("ctrl+pageup", "previous_year", "Previous year", show=False), + Binding("ctrl+pagedown", "next_year", "Next year", show=False), + ] + """ + | Key(s) | Description | + | :- | :- | + | enter | Select the date under the cursor. | + | up | Move to the previous week. | + | down | Move to the next week. | + | right | Move to the next day.| + | left | Move to the previous day. | + | pageup | Move to the previous month. | + | pagedown | Move to the next month. | + | ctrl+pageup | Move to the previous year. | + | ctrl+pagedown | Move to the next year. | + """ + + COMPONENT_CLASSES = { + "month-calendar--outside-month", + } + """ + | Class | Description | + | :- | :- | + | `month-calendar--outside-month` | Target the dates outside the current calendar month. | + """ + + # TODO: min-width? + DEFAULT_CSS = """ + MonthCalendar { + height: auto; + width: auto; + min-height: 7; + + .month-calendar--outside-month { + color: gray; + } + } + """ + + date: Reactive[datetime.date] = Reactive(datetime.date.today) + """The date currently highlighted and sets the displayed month in the + calendar.""" + first_weekday: Reactive[int] = Reactive(0) + """The first day of the week in the calendar. Monday is 0 (the default), + Sunday is 6.""" + show_cursor: Reactive[bool] = Reactive(True) + """Whether to display a cursor for navigating dates within the calendar.""" + show_other_months: Reactive[bool] = Reactive(True) + """Whether to display dates from other months for a six-week calendar.""" + + class DateHighlighted(Message): + """Posted by the `MonthCalendar` widget when the cursor moves to + highlight a new date. + + Can be handled using `on_month_calendar_date_highlighted` in a subclass + of `MonthCalendar` or in a parent node in the DOM. + """ + + def __init__( + self, + month_calendar: MonthCalendar, + value: datetime.date, + ) -> None: + super().__init__() + self.month_calendar: MonthCalendar = month_calendar + """The `MonthCalendar` that sent this message.""" + self.value: datetime.date = value + """The highlighted date.""" + + @property + def control(self) -> MonthCalendar: + """Alias for the `MonthCalendar` that sent this message.""" + return self.month_calendar + + class DateSelected(Message): + """Posted by the `MonthCalendar` widget when a date is selected. + + Can be handled using `on_month_calendar_date_selected` in a subclass + of `MonthCalendar` or in a parent node in the DOM. + """ + + def __init__( + self, + month_calendar: MonthCalendar, + value: datetime.date, + ) -> None: + super().__init__() + self.month_calendar: MonthCalendar = month_calendar + """The `MonthCalendar` that sent this message.""" + self.value: datetime.date = value + """The selected date.""" + + @property + def control(self) -> MonthCalendar: + """Alias for the `MonthCalendar` that sent this message.""" + return self.month_calendar + + def __init__( + self, + date: datetime.date = datetime.date.today(), + *, + first_weekday: int = 0, + show_cursor: bool = True, + show_other_months: bool = True, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Initialize a MonthCalendar widget. + + Args: + date: The initial date to be highlighted (if `show_cursor` is True) + and sets the displayed month in the calendar. Defaults to today + if not supplied. + first_weekday: The first day of the week in the calendar. + Monday is 0 (the default), Sunday is 6. + show_cursor: Whether to display a cursor for navigating dates within + the calendar. + show_other_months: Whether to display dates from other months for + a six-week calendar. + name: The name of the widget. + id: The ID of the widget in the DOM. + classes: The CSS classes of the widget. + disabled: Whether the widget is disabled or not. + """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self.date = date + self.first_weekday = first_weekday + self._calendar = calendar.Calendar(first_weekday) + """A `Calendar` object from Python's `calendar` module used for + preparing the calendar data for this widget.""" + self.show_cursor = show_cursor + self.show_other_months = show_other_months + self._calendar_dates = self._get_calendar_dates() + """The matrix of `datetime.date` objects for this month calendar, where + each row represents a week. See the `_get_calendar_dates` method for + details.""" + + def compose(self) -> ComposeResult: + yield CalendarGrid(show_cursor=self.show_cursor) + + @on(CalendarGrid.CellHighlighted) + def _on_calendar_grid_cell_highlighted( + self, + event: CalendarGrid.CellHighlighted, + ) -> None: + """Post a `DateHighlighted` message when a date cell is highlighted in + the calendar grid.""" + event.stop() + if not self.show_other_months and event.value is None: + # TODO: This handling of blank cells is obviously a bit hacky. + # Instead this widget should prevent highlighting a blank cell + # altogether, either with the keyboard or mouse. + calendar_grid = self.query_one(CalendarGrid) + date_coordinate = self._get_date_coordinate(self.date) + with self.prevent(CalendarGrid.CellHighlighted): + calendar_grid.cursor_coordinate = date_coordinate + else: + cursor_row, cursor_column = event.coordinate + new_date = self._calendar_dates[cursor_row][cursor_column] + assert isinstance(new_date, datetime.date) + # Avoid possible race condition by setting the `date` reactive + # without invoking the watcher. When mashing the arrow keys, this + # otherwise would cause the app to lag or freeze entirely. + old_date = self.date + self.set_reactive(MonthCalendar.date, new_date) + if (new_date.month != old_date.month) or (new_date.year != old_date.year): + self._calendar_dates = self._get_calendar_dates() + self._update_calendar_grid(update_week_header=False) + + self.post_message(MonthCalendar.DateHighlighted(self, self.date)) + + @on(CalendarGrid.CellSelected) + def _on_calendar_grid_cell_selected( + self, + event: CalendarGrid.CellSelected, + ) -> None: + """Post a `DateSelected` message when a date cell is selected in + the calendar grid.""" + event.stop() + if not self.show_other_months and event.value is None: + calendar_grid = self.query_one(CalendarGrid) + calendar_grid._show_hover_cursor = False + return + # We cannot rely on the `event.coordinate` for the selected date, + # as selecting a date from the previous or next month will update the + # calendar to bring that entire month into view and the date at this + # co-ordinate will have changed! Thankfully it is safe to instead + # simply use the calendar `date`, as clicking a table cell will emit a + # `CellHighlighted` message *before* the `CellSelected` message. + self.post_message(MonthCalendar.DateSelected(self, self.date)) + + @on(CalendarGrid.HeaderSelected) + def _on_calendar_grid_header_selected( + self, + event: CalendarGrid.HeaderSelected, + ) -> None: + """Stop the propagation of the `HeaderSelected` message from the + underlying `DataTable`. Currently the `MonthCalendar` does not post its + own message when the header is selected as there doesn't seem any + practical purpose, but this could be easily added in future if needed.""" + event.stop() + pass + + def previous_month(self) -> None: + """Move to the previous month.""" + year = self.date.year + month = self.date.month - 1 + if month < 1: + year -= 1 + month += 12 + day = min(self.date.day, calendar.monthrange(year, month)[1]) + self.date = datetime.date(year, month, day) + + def next_month(self) -> None: + """Move to the next month.""" + year = self.date.year + month = self.date.month + 1 + if month > 12: + year += 1 + month -= 12 + day = min(self.date.day, calendar.monthrange(year, month)[1]) + self.date = datetime.date(year, month, day) + + def previous_year(self) -> None: + """Move to the previous year.""" + year = self.date.year - 1 + month = self.date.month + day = min(self.date.day, calendar.monthrange(year, month)[1]) + self.date = datetime.date(year, month, day) + + def next_year(self) -> None: + """Move to the next year.""" + year = self.date.year + 1 + month = self.date.month + day = min(self.date.day, calendar.monthrange(year, month)[1]) + self.date = datetime.date(year, month, day) + + def _on_mount(self, _: Mount) -> None: + self._update_calendar_grid(update_week_header=True) + + def hide_hover_cursor_if_blank_cell(hover_coordinate: Coordinate) -> None: + calendar_grid = self.query_one(CalendarGrid) + try: + hover_cell_value = calendar_grid.get_cell_at(hover_coordinate) + except CellDoesNotExist: + calendar_grid._set_hover_cursor(False) + return + if hover_cell_value is None: + calendar_grid._set_hover_cursor(False) + + self.watch( + self.query_one(CalendarGrid), + "hover_coordinate", + hide_hover_cursor_if_blank_cell, + ) + + def _update_calendar_grid(self, update_week_header: bool) -> None: + """Update the grid to display the dates of the current month calendar. + If specified, this will also update the weekday names in the header. + + Args: + update_week_header: Whether the weekday names in the header should + be updated (e.g. following a change to the `first_weekday`). + """ + calendar_grid = self.query_one(CalendarGrid) + old_hover_coordinate = calendar_grid.hover_coordinate + calendar_grid.clear() + + if update_week_header: + old_columns = calendar_grid.columns.copy() + for old_column in old_columns: + calendar_grid.remove_column(old_column) + + day_names = calendar.day_abbr + for day in self._calendar.iterweekdays(): + calendar_grid.add_column(day_names[day]) + + with self.prevent(CalendarGrid.CellHighlighted): + for week in self._calendar_dates: + calendar_grid.add_row( + *[ + self._format_day(date) if date is not None else None + for date in week + ] + ) + + date_coordinate = self._get_date_coordinate(self.date) + calendar_grid.cursor_coordinate = date_coordinate + + calendar_grid.hover_coordinate = old_hover_coordinate + + def _format_day(self, date: datetime.date) -> Text: + """Format a date for display in the calendar. + + Args: + date: The date to format for display in the calendar. + + Returns: + A Rich Text object containing the day. + """ + formatted_day = Text(str(date.day), justify="center") + if date.month != self.date.month: + outside_month_style = self.get_component_rich_style( + "month-calendar--outside-month", partial=True + ) + formatted_day.stylize(outside_month_style) + return formatted_day + + def _get_calendar_dates(self) -> list[Sequence[datetime.date | None]]: + """Returns a matrix of `datetime.date` objects for this month calendar, + where each row represents a week. If `show_other_months` is True, this + returns a six-week calendar including dates from the previous and next + month. If `show_other_months` is False, only weeks required for the + month are included and any dates outside the month are 'None'.""" + + month_weeks = self._calendar.monthdatescalendar(self.date.year, self.date.month) + calendar_dates: list[Sequence[datetime.date | None]] + + if not self.show_other_months: + calendar_dates = [ + [date if date.month == self.date.month else None for date in week] + for week in month_weeks + ] + return calendar_dates + + calendar_dates = [list(week) for week in month_weeks] + + if len(calendar_dates) < 6: + previous_month_weeks = self._get_previous_month_weeks() + next_month_weeks = self._get_next_month_weeks() + + current_first_date = calendar_dates[0][0] + assert isinstance(current_first_date, datetime.date) + current_last_date = calendar_dates[-1][6] + assert isinstance(current_last_date, datetime.date) + + if len(calendar_dates) == 4: + calendar_dates = ( + [previous_month_weeks[-1]] + calendar_dates + [next_month_weeks[0]] + ) + elif current_first_date.day == 1: + calendar_dates = [previous_month_weeks[-1]] + calendar_dates + elif current_last_date.month == self.date.month: + calendar_dates = calendar_dates + [next_month_weeks[0]] + else: + calendar_dates = calendar_dates + [next_month_weeks[1]] + + assert len(calendar_dates) == 6 + + return calendar_dates + + def _get_previous_month_weeks(self) -> list[list[datetime.date]]: + """Returns a matrix of `datetime.date` objects for the previous month, + used by the `_get_calendar_dates` method.""" + previous_month = self.date.month - 1 + previous_month_year = self.date.year + if previous_month < 1: + previous_month_year -= 1 + previous_month += 12 + return self._calendar.monthdatescalendar(previous_month_year, previous_month) + + def _get_next_month_weeks(self) -> list[list[datetime.date]]: + """Returns a matrix of `datetime.date` objects for the next month, + used by the `_get_calendar_dates` method.""" + next_month = self.date.month + 1 + next_month_year = self.date.year + if next_month > 12: + next_month_year += 1 + next_month -= 12 + return self._calendar.monthdatescalendar(next_month_year, next_month) + + def _get_date_coordinate(self, date: datetime.date) -> Coordinate: + """Get the coordinate in the calendar grid for a specified date. + + Args: + date: The date for which to find the corresponding coordinate. + + Returns: + The coordinate in the calendar grid for the specified date. + + Raises: + ValueError: If the date is out of range for the current month calendar. + """ + for week_index, week in enumerate(self._calendar_dates): + if date in week: + return Coordinate(week_index, week.index(date)) + + raise ValueError("Date is out of range for the current month calendar.") + + def validate_first_weekday(self, first_weekday: int) -> int: + if not 0 <= first_weekday <= 6: + raise InvalidWeekdayNumber( + "Weekday number must be between 0 (Monday) and 6 (Sunday)." + ) + return first_weekday + + def watch_date(self, old_date: datetime.date, new_date: datetime.date) -> None: + if not self.is_mounted: + return + if (new_date.month != old_date.month) or (new_date.year != old_date.year): + self._calendar_dates = self._get_calendar_dates() + self._update_calendar_grid(update_week_header=False) + else: + calendar_grid = self.query_one(CalendarGrid) + cursor_row, cursor_column = calendar_grid.cursor_coordinate + if self._calendar_dates[cursor_row][cursor_column] != new_date: + date_coordinate = self._get_date_coordinate(self.date) + calendar_grid.cursor_coordinate = date_coordinate + + def watch_first_weekday(self) -> None: + self._calendar = calendar.Calendar(self.first_weekday) + self._calendar_dates = self._get_calendar_dates() + if not self.is_mounted: + return + self._update_calendar_grid(update_week_header=True) + + def watch_show_cursor(self, show_cursor: bool) -> None: + if not self.is_mounted: + return + calendar_grid = self.query_one(CalendarGrid) + calendar_grid.show_cursor = show_cursor + + def watch_show_other_months(self) -> None: + self._calendar_dates = self._get_calendar_dates() + if not self.is_mounted: + return + self._update_calendar_grid(update_week_header=False) + + def action_select_date(self) -> None: + """Select the date under the cursor.""" + calendar_grid = self.query_one(CalendarGrid) + calendar_grid.action_select_cursor() + + def action_previous_day(self) -> None: + """Move to the previous day.""" + self.date -= datetime.timedelta(days=1) + + def action_next_day(self) -> None: + """Move to the next day.""" + self.date += datetime.timedelta(days=1) + + def action_previous_week(self) -> None: + """Move to the previous week.""" + self.date -= datetime.timedelta(weeks=1) + + def action_next_week(self) -> None: + """Move to the next week.""" + self.date += datetime.timedelta(weeks=1) + + def action_previous_month(self) -> None: + """Move to the previous month.""" + self.previous_month() + + def action_next_month(self) -> None: + """Move to the next month.""" + self.next_month() + + def action_previous_year(self) -> None: + """Move to the previous year.""" + self.previous_year() + + def action_next_year(self) -> None: + """Move to the next year.""" + self.next_year() diff --git a/src/textual/widgets/month_calendar.py b/src/textual/widgets/month_calendar.py new file mode 100644 index 0000000000..8c5fcb4967 --- /dev/null +++ b/src/textual/widgets/month_calendar.py @@ -0,0 +1,5 @@ +from ._month_calendar import InvalidWeekdayNumber + +__all__ = [ + "InvalidWeekdayNumber", +] diff --git a/tests/test_month_calendar.py b/tests/test_month_calendar.py new file mode 100644 index 0000000000..adadb7ca8f --- /dev/null +++ b/tests/test_month_calendar.py @@ -0,0 +1,562 @@ +from __future__ import annotations + +import datetime + +import pytest + +from textual import on +from textual.app import App, ComposeResult +from textual.coordinate import Coordinate +from textual.widgets import MonthCalendar +from textual.widgets._month_calendar import CalendarGrid +from textual.widgets.month_calendar import InvalidWeekdayNumber + + +def test_invalid_month_raises_exception(): + with pytest.raises(ValueError): + _ = MonthCalendar(datetime.date(year=2021, month=13, day=3)) + + +def test_invalid_day_raises_exception(): + with pytest.raises(ValueError): + _ = MonthCalendar(datetime.date(year=2021, month=6, day=32)) + + +def test_invalid_weekday_number_raises_exception(): + with pytest.raises(InvalidWeekdayNumber): + _ = MonthCalendar(first_weekday=7) + + +def test_calendar_dates_property(): + month_calendar = MonthCalendar(datetime.date(year=2021, month=6, day=3)) + first_monday = datetime.date(2021, 5, 31) + + expected_date = first_monday + for week in range(len(month_calendar._calendar_dates)): + for day in range(0, 7): + assert month_calendar._calendar_dates[week][day] == expected_date + expected_date += datetime.timedelta(days=1) + + +def test_get_date_coordinate(): + month_calendar = MonthCalendar(datetime.date(year=2021, month=6, day=3)) + target_date = datetime.date(2021, 6, 3) + + actual_coordinate = month_calendar._get_date_coordinate(target_date) + + expected_coordinate = Coordinate(0, 3) + assert actual_coordinate == expected_coordinate + + +def test_get_date_coordinate_when_out_of_range_raises_exception(): + month_calendar = MonthCalendar(datetime.date(year=2021, month=6, day=3)) + + with pytest.raises(ValueError): + month_calendar._get_date_coordinate(datetime.date(2021, 1, 1)) + + +async def test_calendar_defaults_to_today_if_no_date_provided(): + class TodayCalendarApp(App): + def compose(self) -> ComposeResult: + yield MonthCalendar() + + app = TodayCalendarApp() + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + today = datetime.date.today() + assert month_calendar.date == today + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == str( + today.day + ) + + +class MonthCalendarApp(App): + def __init__( + self, + date: datetime.date, + show_other_months: bool = True, + ) -> None: + super().__init__() + self._date = date + self._show_other_months = show_other_months + self.messages: list[tuple[str, datetime.date]] = [] + + def compose(self) -> ComposeResult: + yield MonthCalendar( + date=self._date, + show_other_months=self._show_other_months, + ) + + @on(MonthCalendar.DateHighlighted) + @on(MonthCalendar.DateSelected) + def record( + self, + event: MonthCalendar.DateHighlighted | MonthCalendar.DateSelected, + ) -> None: + self.messages.append((event.__class__.__name__, event.value)) + + +async def test_calendar_grid_week_header(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + actual_labels = [col.label.plain for col in calendar_grid.columns.values()] + expected_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + assert actual_labels == expected_labels + + +async def test_calendar_grid_days(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + for row, week in enumerate(month_calendar._calendar_dates): + for column, date in enumerate(week): + actual_day = calendar_grid.get_cell_at(Coordinate(row, column)).plain + assert isinstance(date, datetime.date) + expected_day = str(date.day) + assert actual_day == expected_day + + +async def test_calendar_grid_after_reactive_date_change_to_different_month(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + month_calendar.date = datetime.date(year=2022, month=10, day=2) + + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == "2" + expected_first_monday = datetime.date(2022, 9, 26) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "26" + + +async def test_calendar_grid_after_reactive_date_change_within_same_month(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + month_calendar.date = datetime.date(year=2021, month=6, day=19) + + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == "19" + expected_first_monday = datetime.date(2021, 5, 31) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "31" + + +async def test_calendar_grid_after_reactive_first_weekday_change(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + # Change first weekday to Sunday + month_calendar.first_weekday = 6 + + actual_labels = [col.label.plain for col in calendar_grid.columns.values()] + expected_labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + assert actual_labels == expected_labels + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == "3" + expected_first_sunday = datetime.date(2021, 5, 30) + actual_first_sunday = month_calendar._calendar_dates[0][0] + assert actual_first_sunday == expected_first_sunday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "30" + + +async def test_show_cursor(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + assert calendar_grid.show_cursor is True # Sanity check + + month_calendar.show_cursor = False + + assert calendar_grid.show_cursor is False + + +async def test_previous_year(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + month_calendar.previous_year() + + expected_date = datetime.date(2020, 6, 3) + assert month_calendar.date == expected_date + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == "3" + expected_first_monday = datetime.date(2020, 5, 25) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "25" + + +async def test_next_year(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + month_calendar.next_year() + + expected_date = datetime.date(2022, 6, 3) + assert month_calendar.date == expected_date + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == "3" + expected_first_monday = datetime.date(2022, 5, 30) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "30" + + +async def test_previous_year_accounts_for_leap_years(): + app = MonthCalendarApp(date=datetime.date(2024, 2, 29)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + + month_calendar.previous_year() + + expected_date = datetime.date(2023, 2, 28) + assert month_calendar.date == expected_date + + +async def test_next_year_accounts_for_leap_years(): + app = MonthCalendarApp(date=datetime.date(2024, 2, 29)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + + month_calendar.next_year() + + expected_date = datetime.date(2025, 2, 28) + assert month_calendar.date == expected_date + + +async def test_previous_month(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + month_calendar.previous_month() + + expected_date = datetime.date(2021, 5, 3) + assert month_calendar.date == expected_date + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == "3" + expected_first_monday = datetime.date(2021, 4, 26) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "26" + + +async def test_previous_month_when_month_is_january(): + app = MonthCalendarApp(date=datetime.date(2021, 1, 1)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + month_calendar.previous_month() + + expected_date = datetime.date(2020, 12, 1) + assert month_calendar.date == expected_date + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == "1" + expected_first_monday = datetime.date(2020, 11, 30) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "30" + + +async def test_previous_month_accounts_for_leap_years(): + app = MonthCalendarApp(date=datetime.date(2024, 3, 29)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + + month_calendar.previous_month() + + expected_date = datetime.date(2024, 2, 29) + assert month_calendar.date == expected_date + + +async def test_previous_month_accounts_for_nonleap_years(): + app = MonthCalendarApp(date=datetime.date(2021, 3, 29)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + + month_calendar.previous_month() + + expected_date = datetime.date(2021, 2, 28) + assert month_calendar.date == expected_date + + +async def test_next_month(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + month_calendar.next_month() + + expected_date = datetime.date(2021, 7, 3) + assert month_calendar.date == expected_date + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == "3" + expected_first_monday = datetime.date(2021, 6, 28) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "28" + + +async def test_next_month_when_month_is_december(): + app = MonthCalendarApp(date=datetime.date(2021, 12, 1)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + month_calendar.next_month() + + expected_date = datetime.date(2022, 1, 1) + assert month_calendar.date == expected_date + assert calendar_grid.get_cell_at(calendar_grid.cursor_coordinate).plain == "1" + expected_first_monday = datetime.date(2021, 12, 27) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "27" + + +async def test_next_month_accounts_for_leap_years(): + app = MonthCalendarApp(date=datetime.date(2024, 1, 29)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + + month_calendar.next_month() + + expected_date = datetime.date(2024, 2, 29) + assert month_calendar.date == expected_date + + +async def test_next_month_accounts_for_nonleap_years(): + app = MonthCalendarApp(date=datetime.date(2021, 1, 29)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + + month_calendar.next_month() + + expected_date = datetime.date(2021, 2, 28) + assert month_calendar.date == expected_date + + +async def test_cell_highlighted_updates_date(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + + await pilot.press("right") + expected_date = datetime.date(2021, 6, 4) + assert month_calendar.date == expected_date + + await pilot.press("down") + expected_date = datetime.date(2021, 6, 11) + assert month_calendar.date == expected_date + + await pilot.press("left") + expected_date = datetime.date(2021, 6, 10) + assert month_calendar.date == expected_date + + +async def test_hover_coordinate_persists_after_month_changes(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + await pilot.hover(MonthCalendar, offset=(3, 3)) + expected_coordinate = Coordinate(2, 0) + assert calendar_grid.hover_coordinate == expected_coordinate # Sanity check + + month_calendar.date = datetime.date(year=2022, month=10, day=2) + + assert calendar_grid.hover_coordinate == expected_coordinate + + +async def test_hover_coordinate_persists_after_first_weekday_changes(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + await pilot.hover(MonthCalendar, offset=(3, 3)) + expected_coordinate = Coordinate(2, 0) + assert calendar_grid.hover_coordinate == expected_coordinate # Sanity check + + month_calendar.first_weekday = 6 # Sunday + + assert calendar_grid.hover_coordinate == expected_coordinate + + +async def test_calendar_updates_when_up_key_pressed_on_first_row(): + """Pressing the `up` key when the cursor is on the first row should update + the date to the previous week and bring that month into view""" + + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + assert calendar_grid.cursor_coordinate == Coordinate(0, 3) # Sanity check + + await pilot.press("up") + + expected_date = datetime.date(2021, 5, 27) + assert month_calendar.date == expected_date + assert calendar_grid.cursor_coordinate == Coordinate(4, 3) + expected_first_monday = datetime.date(2021, 4, 26) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "26" + + +async def test_calendar_updates_when_down_key_pressed_on_last_row(): + """Pressing the `down` key when the cursor is on the last row should update + the date to the next week and bring that month into view""" + + app = MonthCalendarApp(date=datetime.date(2021, 5, 31)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + assert calendar_grid.cursor_coordinate == Coordinate(5, 0) # Sanity check + + await pilot.press("down") + + expected_date = datetime.date(2021, 6, 7) + assert month_calendar.date == expected_date + assert calendar_grid.cursor_coordinate == Coordinate(1, 0) + expected_first_monday = datetime.date(2021, 5, 31) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "31" + + +async def test_cursor_wraps_around_to_previous_or_next_date(): + """Pressing the `left`/`right` key when the cursor is on the first/last + column should wrap around the cursor to the previous/next date""" + + app = MonthCalendarApp(date=datetime.date(2021, 6, 6)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + assert calendar_grid.cursor_coordinate == Coordinate(0, 6) # Sanity check + + await pilot.press("right") + assert month_calendar.date == datetime.date(2021, 6, 7) + assert calendar_grid.cursor_coordinate == Coordinate(1, 0) + + await pilot.press("left") + assert month_calendar.date == datetime.date(2021, 6, 6) + assert calendar_grid.cursor_coordinate == Coordinate(0, 6) + + +async def test_calendar_updates_when_date_outside_month_highlighted(): + """Highlighting a date from the previous or next month should update the + calendar to bring that entire month into view""" + + app = MonthCalendarApp(date=datetime.date(2021, 6, 1)) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + # Sanity checks + assert calendar_grid.cursor_coordinate == Coordinate(0, 1) + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "31" + + await pilot.press("left") + + expected_date = datetime.date(2021, 5, 31) + assert month_calendar.date == expected_date + assert calendar_grid.cursor_coordinate == Coordinate(5, 0) + expected_first_monday = datetime.date(2021, 4, 26) + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)).plain == "26" + + +async def test_calendar_if_show_other_months_is_false(): + """If `show_other_months` is False, only dates from the current month + should be displayed and other blank cells should not be selectable""" + + app = MonthCalendarApp( + date=datetime.date(2021, 6, 1), + show_other_months=False, + ) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + expected_messages = [("DateHighlighted", datetime.date(2021, 6, 1))] + expected_coordinate = Coordinate(0, 1) + expected_date = datetime.date(2021, 6, 1) + expected_first_monday = None + actual_first_monday = month_calendar._calendar_dates[0][0] + + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)) == expected_first_monday + assert month_calendar.date == expected_date + assert calendar_grid.cursor_coordinate == expected_coordinate + assert app.messages == expected_messages + + await pilot.click(MonthCalendar, offset=(3, 1)) + assert calendar_grid.cursor_coordinate == expected_coordinate + assert app.messages == expected_messages + assert month_calendar.date == expected_date + + await pilot.hover(MonthCalendar, offset=(3, 1)) + assert calendar_grid._show_hover_cursor is False + + +async def test_calendar_after_reactive_show_other_months_change(): + app = MonthCalendarApp( + date=datetime.date(2021, 6, 1), + show_other_months=True, + ) + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + calendar_grid = month_calendar.query_one(CalendarGrid) + + month_calendar.show_other_months = False + + expected_first_monday = None + actual_first_monday = month_calendar._calendar_dates[0][0] + assert actual_first_monday == expected_first_monday + assert calendar_grid.get_cell_at(Coordinate(0, 0)) is None + + +async def test_month_calendar_message_emission(): + app = MonthCalendarApp(date=datetime.date(2021, 6, 3)) + expected_messages = [] + async with app.run_test() as pilot: + month_calendar = pilot.app.query_one(MonthCalendar) + + expected_messages.append(("DateHighlighted", datetime.date(2021, 6, 3))) + assert app.messages == expected_messages + + await pilot.press("enter") + expected_messages.append(("DateSelected", datetime.date(2021, 6, 3))) + assert app.messages == expected_messages + + await pilot.press("right") + expected_messages.append(("DateHighlighted", datetime.date(2021, 6, 4))) + assert app.messages == expected_messages + + await pilot.click(MonthCalendar, offset=(2, 1)) + expected_messages.append(("DateHighlighted", datetime.date(2021, 5, 31))) + expected_messages.append(("DateSelected", datetime.date(2021, 5, 31))) + # TODO: This probably shouldn't emit another DateHighlighted message? + expected_messages.append(("DateHighlighted", datetime.date(2021, 5, 31))) + assert app.messages == expected_messages + + month_calendar.previous_month() + await pilot.pause() + expected_messages.append(("DateHighlighted", datetime.date(2021, 4, 30))) + assert app.messages == expected_messages