Skip to content
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

Draft
wants to merge 59 commits into
base: main
Choose a base branch
from

Conversation

TomJGooding
Copy link
Contributor

@TomJGooding TomJGooding commented Nov 12, 2023

Description

Add a MonthCalendar widget, intended to form part of a DatePicker widget.

Note

Currently very much a work in progress wanting feedback from Textual maintainers and users!

Related Issue

Checklist:

  • Docstrings on all new or modified functions / classes
  • Updated documentation
  • Updated CHANGELOG.md (where appropriate)

Copy link
Member

@darrenburns darrenburns left a comment

Choose a reason for hiding this comment

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

Early review with some comments and general out loud thinking. Good progress!

src/textual/widgets/_month_calendar.py Outdated Show resolved Hide resolved
src/textual/widgets/_month_calendar.py Outdated Show resolved Hide resolved
src/textual/widgets/_month_calendar.py Show resolved Hide resolved
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
Contributor Author

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
Contributor 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?

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
Contributor Author

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?

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
Contributor 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?

@TomJGooding
Copy link
Contributor Author

TomJGooding commented Nov 14, 2023

I'm struggling with handling the reactives for this widget, due to a class of problem explained this issue:

I'm afraid I'm blocked on this, unless there's a workaround that would include a cursor reactive. My current call_after_refresh is really a hacky workaround just so tests pass locally (though obviously not in CI!)

@darrenburns
Copy link
Member

Hi @TomJGooding, could you try adding a boolean @property called is_mounted to Widget which checks that self.mounted_event.is_set(), and then inside your watchers, only do things relating to the DOM (querying, updating, etc.) if the DOM is ready? Would that resolve the issue?

Let me know if I've misunderstood the issue!

@TomJGooding
Copy link
Contributor Author

Thanks Darren for your help, it looks like this works:

    def watch_month(self) -> None:
        if not self.is_mounted:
            return
        self._update_calendar_days()

Would it be better to add this new Widget.is_mounted property in a separate PR (linked to the issue #3011 mentioned above)?

@darrenburns
Copy link
Member

No problem! I think separate PR would be great for that.

@TomJGooding
Copy link
Contributor Author

TomJGooding commented Nov 29, 2023

My latest commit (334e771) will update the calendar cursor to the same highlighted day when the month changes.

Here's a quick demo recording, notice this accounts for when the new month doesn't have 31 days:

month-calendar-2023-11-29

This requires the python-dateutil package using relativedelta to add/subtract months. I was able to import this, but I'm not sure if only because it is required by one of the dev dependencies?

The problem with updating the cursor when the month changes is I'm not sure how to handle when the highlighted date is outside the month. For example in the demo recording, on the initial month the cursor could be highlighting the first Monday (31st). Where should the cursor highlight when the month changes? If changed to just the previous month, it probably should just highlight the same date, but what if the updated calendar is months or years different?

Perhaps if you don't mind (I appreciate how busy you are), this would a good time for a pre-review review? Tagging @darrenburns and @davep but any feedback would be welcome! Thanks again for your help so far.

(Also just to note, the failing test is a keyline snapshot mismatch and not related to this PR!)

@darrenburns darrenburns self-requested a review January 9, 2024 19:02
@TomJGooding
Copy link
Contributor Author

TomJGooding commented Jan 9, 2024

After some mashing of the arrow keys, there's definitely some issues with my current implementation of the calendar as it will start to freeze/lag and CPU usage climbs. Initially I thought just caching the calendar_dates matrix was the solution (which probably is a good idea anyway!), but there must be other expensive operations I've missed. Something else for me to look into, really only posted this as a reminder for myself.

@TomJGooding
Copy link
Contributor Author

datepicker-demo

@darrenburns
Copy link
Member

Looks like great progress :) did you manage to resolve the performance issues you mentioned previously or would you like some help in that area?

@TomJGooding
Copy link
Contributor Author

TomJGooding commented Jan 29, 2024

Unfortunately not. If you try mashing the arrow keys for a few seconds on the month_calendar.py example app, it will start to lag/freeze, possibly just due to an overload of messages but honestly I'm not really sure.

@willmcgugan
Copy link
Collaborator

Hi @TomJGooding Just had a quick look. I can't reproduce any performance problems. Seems to remain responsive for me. Are you on Windows?

@TomJGooding
Copy link
Contributor Author

Linux. You really do have to be quickly mashing the arrow keys!

@TomJGooding
Copy link
Contributor Author

I suspect the problem is that the CellHighlighted event will set the reactive date:

cursor_row, cursor_column = event.coordinate
highlighted_date = self._calendar_dates[cursor_row][cursor_column]
assert isinstance(highlighted_date, datetime.date)
self.date = highlighted_date

But the watch_date method may then set the table cursor_coordinate:

cursor_row, cursor_column = table.cursor_coordinate
if self._calendar_dates[cursor_row][cursor_column] != new_date:
date_coordinate = self._get_date_coordinate(self.date)
table.cursor_coordinate = date_coordinate

If you're really mashing the arrow keys, this will probably create a race condition which causes these performance issues?

I'm afraid I will need some help to resolve this, but obviously appreciate how busy you are at the moment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants