-
-
Notifications
You must be signed in to change notification settings - Fork 1k
feat: add month calendar #3667
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add month calendar #3667
Changes from 2 commits
38e3fb6
9c9d4c2
d4fb00c
acdfc87
073c3cd
ab1aa6d
684b2be
c0d44e8
5cc2b85
055d788
5bd8bc2
7939c54
53b1ae6
7a7266e
56b380a
4e139c9
d6cec28
42de0f9
726274b
cfcac14
caea6eb
334e771
5a5eed2
9779e63
c75950c
2d5e5c3
0af9260
66812c1
d08c0b3
5c14a92
8a8733f
3d659dc
20f0875
86640ef
cbde850
4298ae1
ea45e94
f85e8b5
1a3d851
e16cb16
80a5f08
2cd2fd6
7f8e432
3c2153e
d690d86
ddbb109
a28279e
ca82691
87e956e
9574f4c
3da2661
ec1fa19
6d56462
f4549ca
1e3678d
fa9ad42
5d90bfd
1a826f4
f4ecbd8
1689d31
1e1168a
dc253f3
6a01d11
a513aef
a2a7af4
df43416
8662cab
4c8e701
631cde7
07e8c5a
5cb7b7e
7e8b621
3f7d56f
e72df0b
6fa86ac
328bf84
d44e3e5
077bd63
a7d13ab
530adec
aec97b0
6789b01
c6efdde
694a24d
172fbbe
c9b7bd0
ed0f127
e59d91c
69a1b2a
52e2f38
9a92dee
de46d7a
5c9c2be
a6c38bb
6bf3b0a
773e8a0
cbf7540
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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): | ||
def compose(self) -> ComposeResult: | ||
yield MonthCalendar(year=2021, month=6) | ||
|
||
|
||
if __name__ == "__main__": | ||
app = MonthCalendarApp() | ||
app.run() |
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): | ||
darrenburns marked this conversation as resolved.
Show resolved
Hide resolved
|
||
year: Reactive[int | None] = Reactive[int | None](None) | ||
month: Reactive[int | None] = Reactive[int | None](None) | ||
first_weekday: Reactive[int] = Reactive(0) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm wondering about using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps this could be an |
||
_calendar: var[calendar.Calendar] = var(calendar.Calendar()) | ||
darrenburns marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
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() | ||
|
||
|
||
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 | ||
darrenburns marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from ._month_calendar import InvalidMonthNumber, InvalidWeekdayNumber | ||
|
||
__all__ = [ | ||
"InvalidMonthNumber", | ||
"InvalidWeekdayNumber", | ||
] |
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 |
There was a problem hiding this comment.
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).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 theDataTable
which can be arbitrarily large.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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)?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems sensible.
I think static for now.
There was a problem hiding this comment.
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 flaggedmin-width
as TODO as this might depend on locale?