Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
38e3fb6
feat: add month calendar
TomJGooding Nov 12, 2023
9c9d4c2
impoort future annotations
TomJGooding Nov 12, 2023
d4fb00c
update reactives and remove compute method
TomJGooding Nov 14, 2023
acdfc87
remove unused var import
TomJGooding Nov 14, 2023
073c3cd
add provisional watch methods for year and month
TomJGooding Nov 14, 2023
ab1aa6d
add show cursor
TomJGooding Nov 14, 2023
684b2be
add previous and next year methods
TomJGooding Nov 14, 2023
c0d44e8
add previous and next month methods
TomJGooding Nov 14, 2023
5cc2b85
add method to get date coordinate
TomJGooding Nov 15, 2023
055d788
add move cursor method
TomJGooding Nov 15, 2023
5bd8bc2
experiment with adding pauses to tests for ci
TomJGooding Nov 15, 2023
7939c54
Merge branch 'main' into feat-add-month-calendar
TomJGooding Nov 20, 2023
53b1ae6
precommit isort
TomJGooding Nov 20, 2023
7a7266e
remove old pauses in tests
TomJGooding Nov 20, 2023
56b380a
add is_current_month property
TomJGooding Nov 20, 2023
4e139c9
watch reactive first weekday change
TomJGooding Nov 21, 2023
d6cec28
add default cursor date
TomJGooding Nov 22, 2023
42de0f9
update cursor date on cell highlighted
TomJGooding Nov 23, 2023
726274b
add bindings to example
TomJGooding Nov 23, 2023
cfcac14
add basic default css
TomJGooding Nov 23, 2023
caea6eb
Merge branch 'main' into feat-add-month-calendar
TomJGooding Nov 28, 2023
334e771
update cursor date with relative delta
TomJGooding Nov 29, 2023
5a5eed2
Merge branch 'main' of github.com:Textualize/textual into feat-add-mo…
darrenburns Dec 12, 2023
9779e63
Merge branch 'main' into feat-add-month-calendar
TomJGooding Dec 29, 2023
c75950c
change to single date reactive
TomJGooding Dec 29, 2023
2d5e5c3
fix unnecessarily rebuilding table
TomJGooding Dec 29, 2023
0af9260
persist hover after table updates
TomJGooding Dec 29, 2023
66812c1
add option to show other months
TomJGooding Dec 29, 2023
d08c0b3
center example
TomJGooding Dec 29, 2023
5c14a92
add date selected message
TomJGooding Dec 30, 2023
8a8733f
make calendar dates property private
TomJGooding Jan 4, 2024
3d659dc
simplify calendar dates
TomJGooding Jan 4, 2024
20f0875
change calendar to six weeks
TomJGooding Jan 4, 2024
86640ef
update calendar dates docstring
TomJGooding Jan 5, 2024
cbde850
add date highlighted message
TomJGooding Jan 8, 2024
4298ae1
add pause to messages test
TomJGooding Jan 8, 2024
ea45e94
Merge branch 'main' into feat-add-month-calendar
TomJGooding Jan 9, 2024
f85e8b5
stop table header selected message
TomJGooding Jan 9, 2024
1a3d851
fix typing in test app
TomJGooding Jan 9, 2024
e16cb16
cache calendar dates
TomJGooding Jan 10, 2024
80a5f08
fix coordinate search when unnecessary
TomJGooding Jan 13, 2024
2cd2fd6
make cursor coord unpacking consistent
TomJGooding Jan 14, 2024
7f8e432
add binding to test app to toggle show other months
TomJGooding Jan 19, 2024
3c2153e
watch hover coordinate to hide cursor for blank cell
TomJGooding Jan 19, 2024
d690d86
hide hover cursor if blank cell clicked
TomJGooding Jan 19, 2024
ddbb109
check if cell selected is blank
TomJGooding Jan 19, 2024
a28279e
extend hide other months tests
TomJGooding Jan 20, 2024
ca82691
attempt to improve typing
TomJGooding Jan 20, 2024
87e956e
Merge branch 'main' into feat-add-month-calendar
TomJGooding Jan 30, 2024
9574f4c
Merge branch 'main' into feat-add-month-calendar
TomJGooding Feb 7, 2024
3da2661
Merge branch 'main' into feat-add-month-calendar
TomJGooding Mar 21, 2024
ec1fa19
add note about python-dateutil
TomJGooding Mar 21, 2024
6d56462
Merge branch 'main' into feat-add-month-calendar
TomJGooding May 24, 2024
f4549ca
fix performance issues using set_reactive
TomJGooding May 24, 2024
1e3678d
add custom month calendar table
TomJGooding May 24, 2024
fa9ad42
add bindings for previous/next month and year
TomJGooding May 24, 2024
5d90bfd
rename private table message handlers
TomJGooding May 24, 2024
1a826f4
add todo for handling blank cells
TomJGooding May 24, 2024
f4ecbd8
update calendar when up/down pressed on first/last row
TomJGooding May 24, 2024
1689d31
add cursor wrap around to previous/next date
TomJGooding Jun 9, 2024
1e1168a
refactor tests with reusable app class
TomJGooding Jun 10, 2024
dc253f3
improve tests for up/down pressed on first/last row
TomJGooding Jun 10, 2024
6a01d11
rename cursor key actions
TomJGooding Jun 11, 2024
a513aef
fix pageup/pagedown bindings
TomJGooding Jun 11, 2024
a2a7af4
Merge branch 'main' into feat-add-month-calendar
TomJGooding Jun 11, 2024
df43416
check show_other_months in prev/next month/year actions
TomJGooding Jun 11, 2024
8662cab
change show_other_months to styling only
TomJGooding Jul 13, 2024
4c8e701
Merge branch 'main' into feat-add-month-calendar
TomJGooding Jul 16, 2024
631cde7
tweak styling and add todo for component classes
TomJGooding Jul 16, 2024
07e8c5a
Merge branch 'main' into feat-add-month-calendar
TomJGooding Aug 10, 2024
5cb7b7e
change to keyword arguments except date
TomJGooding Aug 10, 2024
7e8b621
tidy ordering of methods
TomJGooding Aug 10, 2024
3f7d56f
replace relativedelta with timedelta for days and weeks
TomJGooding Aug 13, 2024
e72df0b
add tests that prev/next month methods account for leap years
TomJGooding Aug 13, 2024
6fa86ac
replace relativedelta in prev/next year methods
TomJGooding Aug 13, 2024
328bf84
add tests that prev/next month methods account for leap/non-leap years
TomJGooding Aug 13, 2024
d44e3e5
replace relativedelta in prev/next month methods
TomJGooding Aug 13, 2024
077bd63
replace remaining relativedelta instances and remove import
TomJGooding Aug 13, 2024
a7d13ab
tidy tests to help readability
TomJGooding Aug 13, 2024
530adec
refactor _get_calendar_dates method
TomJGooding Aug 14, 2024
aec97b0
rename calendar table to grid
TomJGooding Aug 15, 2024
6789b01
Merge branch 'main' into feat-add-month-calendar
TomJGooding Aug 20, 2024
c6efdde
Merge branch 'main' into feat-add-month-calendar
TomJGooding Sep 11, 2024
694a24d
change to relative imports
TomJGooding Sep 11, 2024
172fbbe
fix `today` by dropping the `()`
TomJGooding Sep 11, 2024
c9b7bd0
add class docstrings
TomJGooding Sep 13, 2024
ed0f127
Revert "change to relative imports"
TomJGooding Sep 13, 2024
e59d91c
add docstrings to messages
TomJGooding Sep 13, 2024
69a1b2a
add docstrings for methods
TomJGooding Sep 14, 2024
52e2f38
add docstrings for action methods
TomJGooding Sep 14, 2024
9a92dee
Merge branch 'main' into feat-add-month-calendar
TomJGooding Sep 14, 2024
de46d7a
fix AttributeError by restoring order in init
TomJGooding Sep 14, 2024
5c9c2be
Merge branch 'main' into feat-add-month-calendar
TomJGooding Sep 24, 2024
a6c38bb
update casing for binding descriptions
TomJGooding Sep 27, 2024
6bf3b0a
improve handling of clicking blank cells
TomJGooding Nov 7, 2024
773e8a0
fix crash when header clicked
TomJGooding Nov 18, 2024
cbf7540
Merge branch 'main' into feat-add-month-calendar
TomJGooding Nov 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/examples/widgets/month_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from textual.app import App, ComposeResult
from textual.widgets import MonthCalendar


class MonthCalendarApp(App):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a CSS variable containing the following CSS to this app yields better results. I reckon we should add the equivalent CSS to the widget itself to override the default DataTable behaviour (unless you can think of a reason not to).

    MonthCalendar { height: auto; width: auto; }
    MonthCalendar > DataTable { height: auto; width: auto; }

We don't do this in the DataTable itself because it's intended to scroll, but this widget has a very constrained height compared to the DataTable which can be arbitrarily large.

Copy link
Collaborator Author

@TomJGooding TomJGooding Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I should have mentioned, I haven't started looking at the DEFAULT_CSS yet as I wanted to focus on the core functionality first. Do you think the table/columns widths should just be static, or allow the developer to specify the column width (or perhaps even grow dynamically according to the widget width)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't started looking at the DEFAULT_CSS yet as I wanted to focus on the core functionality first.

Seems sensible.

Do you think the table/columns widths should just be static, or allow the developer to specify the column width (or perhaps even grow dynamically according to the widget width)?

I think static for now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've now added some basic DEFAULT_CSS in cfcac14. I've flagged min-width as TODO as this might depend on locale?

def compose(self) -> ComposeResult:
yield MonthCalendar(year=2021, month=6)


if __name__ == "__main__":
app = MonthCalendarApp()
app.run()
2 changes: 2 additions & 0 deletions src/textual/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from ._loading_indicator import LoadingIndicator
from ._log import Log
from ._markdown import Markdown, MarkdownViewer
from ._month_calendar import MonthCalendar
from ._option_list import OptionList
from ._placeholder import Placeholder
from ._pretty import Pretty
Expand Down Expand Up @@ -64,6 +65,7 @@
"Log",
"Markdown",
"MarkdownViewer",
"MonthCalendar",
"OptionList",
"Placeholder",
"Pretty",
Expand Down
1 change: 1 addition & 0 deletions src/textual/widgets/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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
Expand Down
107 changes: 107 additions & 0 deletions src/textual/widgets/_month_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from __future__ import annotations

import calendar
import datetime

from rich.text import Text
from textual.app import ComposeResult
from textual.reactive import Reactive, var
from textual.widget import Widget
from textual.widgets import DataTable


class InvalidMonthNumber(Exception):
pass


class InvalidWeekdayNumber(Exception):
pass


class MonthCalendar(Widget):
year: Reactive[int | None] = Reactive[int | None](None)
month: Reactive[int | None] = Reactive[int | None](None)
first_weekday: Reactive[int] = Reactive(0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about using Literal["monday", "tuesday", ...] here to remove any second-guessing about how days are indexed. Will leave it as an open thought here if you or anyone else wants to chip in.

Copy link
Collaborator Author

@TomJGooding TomJGooding Nov 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this could be an IntEnum similar to the calendar module in 3.12?

_calendar: var[calendar.Calendar] = var(calendar.Calendar())

def __init__(
self,
year: int | None = None,
month: int | None = None,
first_weekday: int = 0,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.year = year
self.month = month
self.first_weekday = first_weekday

def compose(self) -> ComposeResult:
yield DataTable()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open thoughts: I wonder if we should display the month name above the DataTable. I wonder if it should be something that can be toggled.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming this would simply display "November 2023" for example, and then if developers wanted something different they would be expected to create a custom widget and set MonthCalendar.month_header to False?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure - I guess the month name would be defined by locale too? @willmcgugan any thoughts?


def on_mount(self) -> None:
self._update_week_header()
self._update_calendar_days()

def _update_week_header(self) -> None:
table = self.query_one(DataTable)
day_names = calendar.day_abbr
for day in self._calendar.iterweekdays():
table.add_column(day_names[day])

def _update_calendar_days(self) -> None:
table = self.query_one(DataTable)
table.clear()
for week in self.calendar_dates:
table.add_row(*[self._format_day(date) for date in week])

@property
def calendar_dates(self) -> list[list[datetime.date]]:
"""
A matrix of `datetime.date` objects for this month calendar. Each row
represents a week, including dates before the start of the month
or after the end of the month that are required to get a complete week.
"""
assert self.year is not None and self.month is not None
dates = list(self._calendar.itermonthdates(self.year, self.month))
return [dates[i : i + 7] for i in range(0, len(dates), 7)]

def _format_day(self, date: datetime.date) -> Text:
formatted_day = Text(str(date.day), justify="center")
if date.month != self.month:
formatted_day.style = "grey37"
return formatted_day

def _compute__calendar(self) -> calendar.Calendar:
return calendar.Calendar(self.first_weekday)

def validate_year(self, year: int | None) -> int:
if year is None:
current_year = datetime.date.today().year
return current_year
return year

def validate_month(self, month: int | None) -> int:
if month is None:
current_month = datetime.date.today().month
return current_month
if not 1 <= month <= 12:
raise InvalidMonthNumber("Month number must be 1-12.")
return month

def validate_first_weekday(self, first_weekday: int) -> int:
if not 0 <= first_weekday <= 6:
raise InvalidWeekdayNumber(
"Weekday number must be 0 (Monday) to 6 (Sunday)."
)
return first_weekday

# def watch_year(self) -> None:
# self._update_calendar_days()

# def _watch_month(self) -> None:
# self._update_calendar_days()
6 changes: 6 additions & 0 deletions src/textual/widgets/month_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from ._month_calendar import InvalidMonthNumber, InvalidWeekdayNumber

__all__ = [
"InvalidMonthNumber",
"InvalidWeekdayNumber",
]
103 changes: 103 additions & 0 deletions tests/test_month_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import datetime

import pytest
from textual.app import App, ComposeResult
from textual.coordinate import Coordinate
from textual.widgets import DataTable, MonthCalendar
from textual.widgets.month_calendar import InvalidMonthNumber, InvalidWeekdayNumber


def test_year_defaults_to_current_year_if_none_provided():
month_calendar = MonthCalendar()
current_year = datetime.date.today().year
assert month_calendar.year == current_year


def test_month_defaults_to_current_month_if_none_provided():
month_calendar = MonthCalendar()
current_month = datetime.date.today().month
assert month_calendar.month == current_month


def test_invalid_month_number_raises_exception():
with pytest.raises(InvalidMonthNumber):
month_calendar = MonthCalendar(month=13)


def test_invalid_weekday_number_raises_exception():
with pytest.raises(InvalidWeekdayNumber):
month_calendar = MonthCalendar(first_weekday=7)


def test_first_weekday_computes_calendar_object():
month_calendar = MonthCalendar(first_weekday=6)
assert month_calendar._calendar.firstweekday == 6
month_calendar.first_weekday = 0
assert month_calendar._calendar.firstweekday == 0


def test_calendar_dates_property():
month_calendar = MonthCalendar(year=2021, month=6)
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)


class MonthCalendarApp(App):
def compose(self) -> ComposeResult:
yield MonthCalendar(year=2021, month=6)


async def test_calendar_table_week_header():
app = MonthCalendarApp()
async with app.run_test() as pilot:
month_calendar = pilot.app.query_one(MonthCalendar)
table = month_calendar.query_one(DataTable)
actual_labels = [col.label.plain for col in table.columns.values()]
expected_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
assert actual_labels == expected_labels


async def test_calendar_table_days():
app = MonthCalendarApp()
async with app.run_test() as pilot:
month_calendar = pilot.app.query_one(MonthCalendar)
table = month_calendar.query_one(DataTable)
for row, week in enumerate(month_calendar.calendar_dates):
for column, date in enumerate(week):
actual_day = table.get_cell_at(Coordinate(row, column)).plain
expected_day = str(date.day)
assert actual_day == expected_day


# async def test_calendar_table_after_reactive_year_change():
# app = MonthCalendarApp()
# async with app.run_test() as pilot:
# month_calendar = pilot.app.query_one(MonthCalendar)
#
# month_calendar.year = 2023
#
# table = month_calendar.query_one(DataTable)
# for row, week in enumerate(month_calendar.calendar_dates):
# for column, date in enumerate(week):
# actual_day = table.get_cell_at(Coordinate(row, column)).plain
# expected_day = str(date.day)
# assert actual_day == expected_day
#
#
# async def test_calendar_table_after_reactive_month_change():
# app = MonthCalendarApp()
# async with app.run_test() as pilot:
# month_calendar = pilot.app.query_one(MonthCalendar)
#
# month_calendar.month = 7
#
# table = month_calendar.query_one(DataTable)
# for row, week in enumerate(month_calendar.calendar_dates):
# for column, date in enumerate(week):
# actual_day = table.get_cell_at(Coordinate(row, column)).plain
# expected_day = str(date.day)
# assert actual_day == expected_day