Skip to content

Selectors

DateSelect

DateSelectApp ────────────────────────────────────── March 2025 MonTueWedThuFriSatSun   1  2   3  4  5  6  7  8  9  10 11 12 13 14 15 16  17 18 19 20 21 22 23  24 25 26 27 28 29 30  31 ──────────────────────────────────────

textual_timepiece.pickers.DateSelect

Bases: BaseOverlayWidget

Date selection widget for selecting dates and date-ranges visually.

Supports mouse and keyboard navigation with arrow keys.

INFO

Control+Click/Enter will go back in scope with the top header.

PARAMETER DESCRIPTION
start

Initial start date for the widget.

TYPE: Date | None DEFAULT: None

end

Initial end date for the widget.

TYPE: Date | None DEFAULT: None

name

Name of the widget.

TYPE: str | None DEFAULT: None

id

Unique dom id for the widget

TYPE: str | None DEFAULT: None

classes

Any CSS classes that should be added to the widget.

TYPE: str | None DEFAULT: None

is_range

Whether the selection is a range. Automatically true if an 'end_date' or 'date_range' parameter is supplied.

TYPE: bool DEFAULT: False

disabled

Whether to disable the widget.

TYPE: bool DEFAULT: False

select_on_focus

Whether to place a keyboard cursor on widget focus.

TYPE: bool DEFAULT: True

date_range

Whether to restrict the dates to a certain range. Will automatically convert to absolute values.

TYPE: DateDelta | None DEFAULT: None

CLASS DESCRIPTION
DateChanged

Message sent when the start date changed.

EndDateChanged

Message sent when the end date changed.

METHOD DESCRIPTION
action_move_cursor

Move cursor to the next spot depending on direction.

action_select_cursor

Triggers the functionality for what is below the keyboard cursor.

is_day_in_range

Checks if a given date is within selected the date range(inclusive).

ATTRIBUTE DESCRIPTION
DEFAULT_CSS

Default CSS for the DateSelect widget.

TYPE: str

BINDINGS

All bindings for DateSelect

TYPE: list[BindingType]

COMPONENT_CLASSES

All component classes for DateSelect.

TYPE: set[str]

date

Start date. Bound to base dialog if using with a prebuilt picker.

date_range

Constant date range in between the start and end dates.

end_date

End date for date ranges.

scope

Scope of the current date picker view.

loc

Current location of the date picker for navigation.

data

Data for displaying date info.

header

Navigation date header is computed dynamically.

cursor_offset

Mouse cursor position for mouse navigation.

cursor

Keyboard cursor position.

Source code in src/textual_timepiece/pickers/_date_picker.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
class DateSelect(BaseOverlayWidget):
    """Date selection widget for selecting dates and date-ranges visually.

    Supports mouse and keyboard navigation with arrow keys.

    INFO:
        Control+Click/Enter will go back in scope with the top header.

    Params:
        start: Initial start date for the widget.
        end: Initial end date for the widget.
        name: Name of the widget.
        id: Unique dom id for the widget
        classes: Any CSS classes that should be added to the widget.
        is_range: Whether the selection is a range. Automatically true if an
            'end_date' or 'date_range' parameter is supplied.
        disabled: Whether to disable the widget.
        select_on_focus: Whether to place a keyboard cursor on widget focus.
        date_range: Whether to restrict the dates to a certain range.
            Will automatically convert to absolute values.
    """

    @dataclass
    class DateChanged(BaseMessage):
        """Message sent when the start date changed."""

        widget: DateSelect
        date: Date | None

    @dataclass
    class EndDateChanged(BaseMessage):
        """Message sent when the end date changed."""

        widget: DateSelect
        date: Date | None

    DEFAULT_CSS: ClassVar[str] = """
    DateSelect {
        background: $surface;
        width: auto;
        border: round $secondary;

        .dateselect--primary-date {
            color: $primary;
        }

        .dateselect--secondary-date {
            color: $secondary;
        }

        .dateselect--range-date {
            background: $panel-darken-3;
        }

        .dateselect--hovered-date {
            color: $accent;
            text-style: bold;
        }

        .dateselect--cursor-date {
            color: $accent;
            text-style: reverse bold;
        }

        .dateselect--start-date {
            color: $accent-lighten-3;
            text-style: italic;
        }

        .dateselect--end-date {
            color: $accent-lighten-3;
            text-style: italic;
        }
    }
    """
    """Default CSS for the `DateSelect` widget."""

    BINDING_GROUP_TITLE = "Date Select"

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding(
            "up",
            "move_cursor('up')",
            tooltip="Move the cursor up.",
        ),
        Binding(
            "right",
            "move_cursor('right')",
            tooltip="Move cursor to the right.",
        ),
        Binding(
            "down",
            "move_cursor('down')",
            tooltip="Move the cursor down.",
        ),
        Binding(
            "left",
            "move_cursor('left')",
            tooltip="Move the cursor to the left.",
        ),
        Binding(
            "enter",
            "select_cursor",
            tooltip="Navigate or select to the hovered part.",
        ),
        Binding(
            "ctrl+enter",
            "select_cursor(True)",
            tooltip="Reverse Navigate or select to the hovered part.",
        ),
    ]
    """All bindings for DateSelect

    | Key(s) | Description |
    | :- | :- |
    | up | Move the cursor up. |
    | right | Move cursor to the right. |
    | down | Move the cursor down. |
    | left | Move the cursor to the left. |
    | enter | Navigate or select to the hovered part. |
    | ctrl+enter | Reverse Navigate or select to the hovered part. |
    """

    COMPONENT_CLASSES: ClassVar[set[str]] = {
        "dateselect--start-date",
        "dateselect--end-date",
        "dateselect--cursor-date",
        "dateselect--hovered-date",  # NOTE: Only affects the foreground
        "dateselect--secondary-date",
        "dateselect--primary-date",
        "dateselect--range-date",  # NOTE: Only affects the background.
    }
    """All component classes for DateSelect.

    | Class | Description |
    | :- | :- |
    | `dateselect--cursor-date` | Color of label under the keyboard cursor. |
    | `dateselect--end-date` | Color of the selected end date if enabled. |
    | `dateselect--hovered-date` | Color of the mouse hovered date. |
    | `dateselect--primary-date` | Standard color of unselected dates. |
    | `dateselect--range-date` | Color of any dates if both end and start date\
            are selected |
    | `dateselect--secondary-date` | Color of weekdays labels in month view. |
    | `dateselect--start-date` | Color of selected start date. |
    """

    date = reactive[Date | None](None, init=False)
    """Start date. Bound to base dialog if using with a prebuilt picker."""

    date_range = var[DateDelta | None](None, init=False)
    """Constant date range in between the start and end dates."""

    end_date = reactive[Date | None](None, init=False)
    """End date for date ranges.

    Bound to base dialog if using with a prebuilt picker.
    """

    scope = var[DateScope](DateScope.MONTH)
    """Scope of the current date picker view."""

    loc = reactive[Date](Date.today_in_system_tz, init=False)
    """Current location of the date picker for navigation."""

    data = reactive[DisplayData](list, init=False, layout=True)
    """Data for displaying date info.

    Layout required as the size might differ between months.
    """

    header = reactive[str]("", init=False)
    """Navigation date header is computed dynamically."""

    cursor_offset = reactive[Offset | None](None, init=False)
    """Mouse cursor position for mouse navigation."""

    cursor = reactive[DateCursor | None](None, init=False)
    """Keyboard cursor position."""

    def __init__(
        self,
        start: Date | None = None,
        end: Date | None = None,
        name: str | None = None,
        id: str | None = None,
        classes: str | None = None,
        *,
        is_range: bool = False,
        disabled: bool = False,
        select_on_focus: bool = True,
        date_range: DateDelta | None = None,
    ) -> None:
        super().__init__(name=name, id=id, classes=classes, disabled=disabled)
        self._is_range = is_range or bool(end) or bool(date_range)

        self._select_on_focus = select_on_focus

        self.set_reactive(DateSelect.date, start)
        self.set_reactive(DateSelect.end_date, end)
        self.set_reactive(DateSelect.date_range, date_range)

    def _compute_header(self) -> str:
        if self.scope == DateScope.YEAR:
            return str(self.loc.year)

        elif self.scope == DateScope.DECADE:
            start = math.floor(self.loc.year / 10) * 10
            return f"{start} <-> {start + 9}"

        elif self.scope == DateScope.CENTURY:
            start = math.floor(self.loc.year / 100) * 100
            return f"{start} <-> {start + 99}"

        return f"{month_name[self.loc.month]} {self.loc.year}"

    def _validate_date_range(
        self,
        date_range: DateDelta | None,
    ) -> DateDelta | None:
        if date_range is None:
            return None
        return abs(date_range)

    def _watch_date_range(self, new: DateDelta | None) -> None:
        if new is None:
            return
        self._is_range = True
        if self.date:
            self.end_date = self.date.add(new)
        elif self.end_date:
            self.date = self.end_date.subtract(new)

    def _watch_scope(self, scope: DateScope) -> None:
        self.data = get_scope(scope, self.loc)
        if self.cursor:
            self._find_move()

    def _watch_date(self, date: Date | None) -> None:
        self.scope = DateScope.MONTH
        if date:
            if self.date_range:
                self.end_date = date.add(self.date_range)

            self.loc = date

    def _watch_loc(self, loc: Date) -> None:
        self.data = get_scope(self.scope, loc)

        if self.cursor:
            self.cursor = self.cursor.confine(self.data)

    async def _on_mouse_move(self, event: MouseMove) -> None:
        self.cursor_offset = event.offset

    def _on_leave(self, event: Leave) -> None:
        self.cursor_offset = None

    def _on_blur(self, event: Blur) -> None:
        self.cursor = None

    def _on_focus(self, event: Focus) -> None:
        if self._select_on_focus:
            self.cursor = DateCursor()

    def _on_date_select_date_changed(
        self,
        message: DateSelect.DateChanged,
    ) -> None:
        self.date = message.date
        if self.date_range and message.date:
            self.end_date = message.date.add(self.date_range)

    def _on_date_select_end_date_changed(
        self,
        message: DateSelect.EndDateChanged,
    ) -> None:
        self.end_date = message.date
        if self.date_range and message.date:
            self.date = message.date.subtract(self.date_range)

    async def _on_click(self, event: Click) -> None:
        target = self.get_line_offset(event.offset)
        self._navigate_picker(target, ctrl=event.ctrl)

    def action_move_cursor(self, direction: Directions) -> None:
        """Move cursor to the next spot depending on direction."""
        if self.cursor is None:
            self.log.debug("Cursor does not exist. Placing default location.")
            self.cursor = DateCursor()
        elif direction == "up":
            self._find_move(y=-1)
        elif direction == "right":
            self._find_move(x=1)
        elif direction == "down":
            self._find_move(y=1)
        elif direction == "left":
            self._find_move(x=-1)

    def _find_move(self, *, y: int = 0, x: int = 0) -> None:
        cursor = cast(DateCursor, self.cursor)
        if (new_y := cursor.y + y) == 0:
            new_x = cursor.x + x
            if cursor.y != 0:
                # NOTE: Making sure different row lengths align.
                new_x = math.ceil(((cursor.x) / len(self.data[0])) * 3)

            self.cursor = cursor.replace(y=new_y, x=new_x).confine(self.data)

        elif y and 0 <= new_y <= len(self.data):
            new_x = cursor.x
            if cursor.y == 0:
                # NOTE: Making sure different row lengths align.
                new_x = math.ceil(((cursor.x) / 3) * len(self.data[0]))

            self.cursor = cursor.replace(y=new_y, x=new_x).confine(self.data)

        elif x and 0 <= (new_x := cursor.x + x) < len(self.data[cursor.y - 1]):
            self.cursor = cursor.replace(x=new_x).confine(self.data)

    def _set_date(self, target: str | int, *, ctrl: bool) -> None:
        try:
            value = int(target)
            date = min(Date.MAX, max(Date.MIN, self.loc.replace(day=value)))
        except ValueError:
            return
        if ctrl:
            self.post_message(self.EndDateChanged(self, date))
        else:
            self.post_message(self.DateChanged(self, date))

    def _set_month(self, target: str) -> None:
        try:
            month_no = list(month_name).index(target)
        except IndexError:
            return
        else:
            self.set_reactive(
                DateSelect.loc,
                Date(self.loc.year, month_no, self.loc.day),
            )
            self.scope = DateScope.MONTH

    def _set_years(self, target: str | int) -> None:
        if self.scope == DateScope.CENTURY and isinstance(target, str):
            target = target.split("-")[0]
        try:
            value = max(1, min(int(target), 9999))
        except ValueError:
            return
        else:
            self.set_reactive(DateSelect.loc, self.loc.replace(year=value))
            self.scope = DateScope(self.scope.value - 1)

    def _set_target(self, target: str | int, *, ctrl: bool = False) -> None:
        if self.scope == DateScope.MONTH:
            self._set_date(target, ctrl=ctrl)
        elif self.scope == DateScope.YEAR:
            self._set_month(cast(str, target))
        else:
            self._set_years(target)

    def check_action(
        self, action: str, parameters: tuple[object, ...]
    ) -> bool | None:
        if action == "select_cursor":
            return self.cursor is not None

        return True

    def action_select_cursor(self, ctrl: bool = False) -> None:
        """Triggers the functionality for what is below the keyboard cursor."""
        cursor = cast(DateCursor, self.cursor)
        if cursor.y == 0:
            nav = (
                LEFT_ARROW,
                self.header,
                TARGET_ICON,
                RIGHT_ARROW,
            )
            self._navigate_picker(nav[cursor.x], ctrl=ctrl)
        else:
            self._navigate_picker(self.data[cursor.y - 1][cursor.x], ctrl=ctrl)

    def _navigate_picker(self, target: str | int, *, ctrl: bool) -> None:
        if target == LEFT_ARROW:
            self._crement_scope(-1)
        elif target == TARGET_ICON:
            self._set_current_scope()
        elif target == RIGHT_ARROW:
            self._crement_scope(1)
        elif target == self.header:
            if ctrl:
                self.scope = DateScope(max(self.scope.value - 1, 1))
            else:
                self.scope = DateScope(min(self.scope.value + 1, 4))
        elif target or isinstance(target, int):
            self._set_target(target, ctrl=ctrl and self._is_range)

    def _set_current_scope(self) -> None:
        self.scope = DateScope.MONTH
        self.loc = self.date or self.end_date or Date.today_in_system_tz()

    def _crement_scope(self, value: int) -> None:
        with suppress(ValueError):  # NOTE: Preventing out of range values.
            if self.scope == DateScope.MONTH:
                self.loc = self.loc.add(months=value)
            elif self.scope == DateScope.YEAR:
                self.loc = self.loc.add(years=value)
            elif self.scope == DateScope.DECADE:
                self.loc = self.loc.add(years=10 * value)
            else:
                self.loc = self.loc.add(years=100 * value)

    def _filter_style(
        self,
        y: int,
        x: range,
        date: Date | None = None,
        log_idx: DateCursor | None = None,
    ) -> Style:
        """Filters a rich style based on location data.

        Args:
            y: Current row being rendered.
            x: Range of indexes to target.
            date: If a date is being filtered.
            log_idx: Logical index for rendering the keyboard cursor.

        Returns:
            Combined style with all the properties that matched.
        """
        styles = [self.get_component_rich_style("dateselect--primary-date")]

        if date:
            if date == self.date:
                styles.append(
                    self.get_component_rich_style("dateselect--start-date")
                )
            elif date == self.end_date:
                styles.append(
                    self.get_component_rich_style("dateselect--end-date")
                )

            if self.is_day_in_range(date):
                styles.append(
                    self.get_component_rich_style(
                        "dateselect--range-date"
                    ).background_style
                )

        if (
            self.cursor_offset
            and self.cursor_offset.y == y
            and self.cursor_offset.x in x
        ):
            style = self.get_component_rich_style("dateselect--hovered-date")
            styles.append(style.from_color(style.color))

        if self.cursor and self.cursor == log_idx:
            styles.append(
                self.get_component_rich_style("dateselect--cursor-date")
            )

        return Style.combine(styles)

    def is_day_in_range(self, day: Date) -> bool:
        """Checks if a given date is within selected the date range(inclusive).

        Args:
            day: Date to check against.

        Returns:
            True if in the range else false.
        """
        return bool(
            self._is_range
            and self.date
            and self.end_date
            and self.date <= day <= self.end_date
        )

    def _render_header(self, y: int) -> list[Segment]:
        header_len = len(self.header)
        rem = self.size.width - (header_len + 10)
        blank, blank_extra = divmod(rem, 2)
        header_start = 5 + blank + blank_extra
        header_end = header_start + header_len
        right_nav_start = header_end + (blank - blank_extra) + len(TARGET_ICON)

        y += self._top_border_offset()
        return [
            Segment("   ", self.rich_style),
            Segment(
                LEFT_ARROW,
                self._filter_style(
                    y,
                    range(4, 5),
                    log_idx=DateCursor(0, 0),
                ),
            ),
            Segment(" " * (blank), self.rich_style),
            Segment(
                self.header,
                style=self._filter_style(
                    y,
                    range(header_start, header_end),
                    log_idx=DateCursor(0, 1),
                ),
            ),
            Segment("   ", self.rich_style),
            Segment(
                TARGET_ICON,
                style=self._filter_style(
                    y,
                    range(header_end + 1, header_end + 3),
                    log_idx=DateCursor(0, 2),
                ),
            ),
            Segment(" " * (blank - (3 - blank_extra)), self.rich_style),
            Segment(
                RIGHT_ARROW,
                style=self._filter_style(
                    y,
                    range(right_nav_start, right_nav_start + 2),
                    log_idx=DateCursor(0, 3),
                ),
            ),
        ]

    def _render_weekdays(self) -> list[Segment]:
        day_style = self.get_component_rich_style("dateselect--secondary-date")
        empty = Segment("  ", style=self.rich_style)
        segs = [Segment(" ", style=self.rich_style)]
        for i in range(7):
            segs.append(empty)
            segs.append(Segment(day_abbr[i], day_style))
        return segs

    def _render_month(self, y: int) -> list[Segment]:
        border_offset = self._top_border_offset()
        y += border_offset
        if y == (3 + border_offset):
            return self._render_weekdays()

        month = (y - (4 + border_offset)) // 2
        # NOTE: Removing nav header + weekdays

        date = None
        segments = [Segment(" ", style=self.rich_style)]
        subtotal = int(self.styles.border_left[0] != "")
        for i in range(7):
            segments.append(
                Segment(
                    "  ",
                    self._filter_style(
                        y,
                        range(subtotal, subtotal + 3),
                        date=date,
                    ),
                )
            )
            subtotal += 2
            if not (day := self.data[month][i]):
                segments.append(
                    Segment(
                        "   ",
                        style=self._filter_style(
                            y,
                            range(subtotal, subtotal + 4),
                            date=date,
                            log_idx=DateCursor(month + 1, i),
                        ),
                    )
                )
                date = None
            else:
                date = self.loc.replace(day=cast(int, day))
                segments.append(
                    Segment(
                        str(day).rjust(3),
                        style=self._filter_style(
                            y,
                            range(subtotal, subtotal + 4),
                            date=date,
                            log_idx=DateCursor(month + 1, i),
                        ),
                    )
                )
            subtotal += 3

        return segments

    def _render_year(self, y: int) -> list[Segment]:
        if (row := (y - 2) // 2) > 3:
            return []

        y += self._top_border_offset()

        values = self.data[row]
        value_max_width = self.size.width // len(values)

        segs = list[Segment]()
        for i, value in enumerate(values):
            if self.scope == DateScope.CENTURY:
                value = f"{value}-{cast(int, value) + 9}"
            else:
                value = str(value)
            n = len(value)
            start = (i * value_max_width) + (abs(value_max_width - n) // 2)
            end = start + n + 1

            value = value.center(value_max_width)
            segs.append(
                Segment(
                    value,
                    self._filter_style(
                        y,
                        range(start, end),
                        log_idx=DateCursor(row + 1, i),
                    ),
                )
            )

        return segs

    def render_line(self, y: int) -> Strip:
        if (y % 2 == 0) or (len(self.data) + 2) * 2 < y or not self.data:
            return Strip.blank(self.size.width)

        if y == 1:
            line = self._render_header(y)
        elif self.scope == DateScope.MONTH:
            line = self._render_month(y)
        else:
            line = self._render_year(y)

        return Strip(line)

    def get_content_height(
        self,
        container: Size,
        viewport: Size,
        width: int,
    ) -> int:
        total = 3 + len(self.data) * 2

        if self.scope == DateScope.MONTH:
            return total + 2

        return total

    def get_content_width(self, container: Size, viewport: Size) -> int:
        return 38

DEFAULT_CSS class-attribute

DateSelect {
    background: $surface;
    width: auto;
    border: round $secondary;

    .dateselect--primary-date {
        color: $primary;
    }

    .dateselect--secondary-date {
        color: $secondary;
    }

    .dateselect--range-date {
        background: $panel-darken-3;
    }

    .dateselect--hovered-date {
        color: $accent;
        text-style: bold;
    }

    .dateselect--cursor-date {
        color: $accent;
        text-style: reverse bold;
    }

    .dateselect--start-date {
        color: $accent-lighten-3;
        text-style: italic;
    }

    .dateselect--end-date {
        color: $accent-lighten-3;
        text-style: italic;
    }
}

Default CSS for the DateSelect widget.

BINDINGS class-attribute

BINDINGS: list[BindingType] = [
    Binding("up", "move_cursor('up')", tooltip="Move the cursor up."),
    Binding(
        "right", "move_cursor('right')", tooltip="Move cursor to the right."
    ),
    Binding("down", "move_cursor('down')", tooltip="Move the cursor down."),
    Binding(
        "left", "move_cursor('left')", tooltip="Move the cursor to the left."
    ),
    Binding(
        "enter",
        "select_cursor",
        tooltip="Navigate or select to the hovered part.",
    ),
    Binding(
        "ctrl+enter",
        "select_cursor(True)",
        tooltip="Reverse Navigate or select to the hovered part.",
    ),
]

All bindings for DateSelect

Key(s) Description
up Move the cursor up.
right Move cursor to the right.
down Move the cursor down.
left Move the cursor to the left.
enter Navigate or select to the hovered part.
ctrl+enter Reverse Navigate or select to the hovered part.

COMPONENT_CLASSES class-attribute

COMPONENT_CLASSES: set[str] = {
    "dateselect--start-date",
    "dateselect--end-date",
    "dateselect--cursor-date",
    "dateselect--hovered-date",
    "dateselect--secondary-date",
    "dateselect--primary-date",
    "dateselect--range-date",
}

All component classes for DateSelect.

Class Description
dateselect--cursor-date Color of label under the keyboard cursor.
dateselect--end-date Color of the selected end date if enabled.
dateselect--hovered-date Color of the mouse hovered date.
dateselect--primary-date Standard color of unselected dates.
dateselect--range-date Color of any dates if both end and start date are selected
dateselect--secondary-date Color of weekdays labels in month view.
dateselect--start-date Color of selected start date.

date class-attribute instance-attribute

date = reactive[Date | None](None, init=False)

Start date. Bound to base dialog if using with a prebuilt picker.

date_range class-attribute instance-attribute

date_range = var[DateDelta | None](None, init=False)

Constant date range in between the start and end dates.

end_date class-attribute instance-attribute

end_date = reactive[Date | None](None, init=False)

End date for date ranges.

Bound to base dialog if using with a prebuilt picker.

scope class-attribute instance-attribute

scope = var[DateScope](MONTH)

Scope of the current date picker view.

loc class-attribute instance-attribute

Current location of the date picker for navigation.

data class-attribute instance-attribute

data = reactive[DisplayData](list, init=False, layout=True)

Data for displaying date info.

Layout required as the size might differ between months.

header class-attribute instance-attribute

header = reactive[str]('', init=False)

Navigation date header is computed dynamically.

cursor_offset class-attribute instance-attribute

cursor_offset = reactive[Offset | None](None, init=False)

Mouse cursor position for mouse navigation.

cursor class-attribute instance-attribute

cursor = reactive[DateCursor | None](None, init=False)

Keyboard cursor position.

DateChanged dataclass

Bases: BaseMessage

Message sent when the start date changed.

Source code in src/textual_timepiece/pickers/_date_picker.py
104
105
106
107
108
109
@dataclass
class DateChanged(BaseMessage):
    """Message sent when the start date changed."""

    widget: DateSelect
    date: Date | None

EndDateChanged dataclass

Bases: BaseMessage

Message sent when the end date changed.

Source code in src/textual_timepiece/pickers/_date_picker.py
111
112
113
114
115
116
@dataclass
class EndDateChanged(BaseMessage):
    """Message sent when the end date changed."""

    widget: DateSelect
    date: Date | None

action_move_cursor

action_move_cursor(direction: Directions) -> None

Move cursor to the next spot depending on direction.

Source code in src/textual_timepiece/pickers/_date_picker.py
366
367
368
369
370
371
372
373
374
375
376
377
378
def action_move_cursor(self, direction: Directions) -> None:
    """Move cursor to the next spot depending on direction."""
    if self.cursor is None:
        self.log.debug("Cursor does not exist. Placing default location.")
        self.cursor = DateCursor()
    elif direction == "up":
        self._find_move(y=-1)
    elif direction == "right":
        self._find_move(x=1)
    elif direction == "down":
        self._find_move(y=1)
    elif direction == "left":
        self._find_move(x=-1)

action_select_cursor

action_select_cursor(ctrl: bool = False) -> None

Triggers the functionality for what is below the keyboard cursor.

Source code in src/textual_timepiece/pickers/_date_picker.py
451
452
453
454
455
456
457
458
459
460
461
462
463
def action_select_cursor(self, ctrl: bool = False) -> None:
    """Triggers the functionality for what is below the keyboard cursor."""
    cursor = cast(DateCursor, self.cursor)
    if cursor.y == 0:
        nav = (
            LEFT_ARROW,
            self.header,
            TARGET_ICON,
            RIGHT_ARROW,
        )
        self._navigate_picker(nav[cursor.x], ctrl=ctrl)
    else:
        self._navigate_picker(self.data[cursor.y - 1][cursor.x], ctrl=ctrl)

is_day_in_range

is_day_in_range(day: Date) -> bool

Checks if a given date is within selected the date range(inclusive).

PARAMETER DESCRIPTION
day

Date to check against.

TYPE: Date

RETURNS DESCRIPTION
bool

True if in the range else false.

Source code in src/textual_timepiece/pickers/_date_picker.py
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
def is_day_in_range(self, day: Date) -> bool:
    """Checks if a given date is within selected the date range(inclusive).

    Args:
        day: Date to check against.

    Returns:
        True if in the range else false.
    """
    return bool(
        self._is_range
        and self.date
        and self.end_date
        and self.date <= day <= self.end_date
    )

TimeSelect

TimeSelectApp 00:0000:3001:0001:30 02:0002:3003:0003:30 04:0004:3005:0005:30 06:0006:3007:0007:30 08:0008:3009:0009:30 10:0010:3011:0011:30 12:0012:3013:0013:30 14:0014:3015:0015:30 16:0016:3017:0017:30 18:0018:3019:0019:30 20:0020:3021:0021:30 22:0022:3023:0023:30

textual_timepiece.pickers.TimeSelect

Bases: BaseOverlayWidget

Time selection interface.

PARAMETER DESCRIPTION
name

Name of the widget.

TYPE: str | None DEFAULT: None

id

Unique dom id for the widget

TYPE: str | None DEFAULT: None

classes

Any CSS classes that should be added to the widget.

TYPE: str | None DEFAULT: None

disabled

Whether to disable the widget.

TYPE: bool DEFAULT: False

CLASS DESCRIPTION
TimeSelected

Message sent when a value is picked out of the time grid.

METHOD DESCRIPTION
action_focus_neighbor

Focus a nearby member. It will mirror back if going past an edge.

ATTRIBUTE DESCRIPTION
DEFAULT_CSS

Default CSS for the TimeSelect widget.

TYPE: str

BINDINGS

All bindings for TimeSelect

TYPE: list[BindingType]

Source code in src/textual_timepiece/pickers/_time_picker.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
class TimeSelect(BaseOverlayWidget):
    """Time selection interface.

    Params:
        name: Name of the widget.
        id: Unique dom id for the widget
        classes: Any CSS classes that should be added to the widget.
        disabled: Whether to disable the widget.
    """

    @dataclass
    class TimeSelected(BaseMessage):
        """Message sent when a value is picked out of the time grid."""

        widget: TimeSelect
        target: Time

    DEFAULT_CSS: ClassVar[str] = """
    TimeSelect {
        layout: grid !important;
        grid-size: 4;
        grid-gutter: 0;
        grid-rows: 1;
        & > Button {
            border: none;
            min-width: 5;
            width: 100%;
            text-style: italic;
            &.dual-hour {
                background: $panel;
            }
            &:focus {
                text-style: bold;
                color: $primary;
            }
            &:hover {
                border: none;
            }
        }
    }
    """
    """Default CSS for the `TimeSelect` widget."""

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding(
            "up",
            "focus_neighbor('up')",
            "Go Up",
            tooltip="Focus the neighbor above.",
            show=False,
        ),
        Binding(
            "right",
            "focus_neighbor('right')",
            "Go Right",
            tooltip="Focus the neighbor to the right.",
            show=False,
        ),
        Binding(
            "down",
            "focus_neighbor('down')",
            "Go Down",
            tooltip="Focus the neighbor below.",
            show=False,
        ),
        Binding(
            "left",
            "focus_neighbor('left')",
            "Go Left",
            tooltip="Focus the neighbor to the left.",
            show=False,
        ),
    ]
    """All bindings for TimeSelect

    | Key(s) | Description |
    | :- | :- |
    | up | Focus the neighbor above. |
    | right | Focus the neighbor to the right. |
    | down | Focus the neighbor below. |
    | left | Focus the neighbor to the left. |
    """

    def compose(self) -> ComposeResult:
        start = Time()
        interval = minutes(30)
        for time in range(48):
            yield Button(
                start.format_common_iso().removesuffix(":00"),
                id=f"time-{time}",
                classes="time icon",
            ).set_class(bool(time % 2), "dual-hour", update=False)
            start = add_time(start, interval)

    def _on_button_pressed(self, message: Button.Pressed) -> None:
        message.stop()
        time = Time.parse_common_iso(f"{message.button.label}:00")
        self.post_message(self.TimeSelected(self, time))

    def action_focus_neighbor(self, direction: Directions) -> None:
        """Focus a nearby member. It will mirror back if going past an edge."""
        if not self.has_focus_within:
            widget = self.query_one("#time-0", Button)
        else:
            # FIX: Subclass a button with a required id.
            focused_id = int(
                cast(str, cast(Button, self.app.focused).id).split("-")[-1]
            )

            row, col = divmod(focused_id, 4)
            if direction == "up":
                if row - 1 >= 0:
                    id = focused_id - 4
                else:
                    id = 43 + (col + 1)

            elif direction == "right":
                if col + 1 <= 3:
                    id = focused_id + 1
                else:
                    id = focused_id - col

            elif direction == "down":
                if row + 1 < 12:
                    id = focused_id + 4
                else:
                    id = col

            else:
                if col - 1 >= 0:
                    id = focused_id - 1
                else:
                    id = focused_id + (3 - col)

            widget = self.query_one(f"#time-{id}", Button)

        self.app.set_focus(widget)

DEFAULT_CSS class-attribute

TimeSelect {
    layout: grid !important;
    grid-size: 4;
    grid-gutter: 0;
    grid-rows: 1;
    & > Button {
        border: none;
        min-width: 5;
        width: 100%;
        text-style: italic;
        &.dual-hour {
            background: $panel;
        }
        &:focus {
            text-style: bold;
            color: $primary;
        }
        &:hover {
            border: none;
        }
    }
}

Default CSS for the TimeSelect widget.

BINDINGS class-attribute

BINDINGS: list[BindingType] = [
    Binding(
        "up",
        "focus_neighbor('up')",
        "Go Up",
        tooltip="Focus the neighbor above.",
        show=False,
    ),
    Binding(
        "right",
        "focus_neighbor('right')",
        "Go Right",
        tooltip="Focus the neighbor to the right.",
        show=False,
    ),
    Binding(
        "down",
        "focus_neighbor('down')",
        "Go Down",
        tooltip="Focus the neighbor below.",
        show=False,
    ),
    Binding(
        "left",
        "focus_neighbor('left')",
        "Go Left",
        tooltip="Focus the neighbor to the left.",
        show=False,
    ),
]

All bindings for TimeSelect

Key(s) Description
up Focus the neighbor above.
right Focus the neighbor to the right.
down Focus the neighbor below.
left Focus the neighbor to the left.

TimeSelected dataclass

Bases: BaseMessage

Message sent when a value is picked out of the time grid.

Source code in src/textual_timepiece/pickers/_time_picker.py
163
164
165
166
167
168
@dataclass
class TimeSelected(BaseMessage):
    """Message sent when a value is picked out of the time grid."""

    widget: TimeSelect
    target: Time

action_focus_neighbor

action_focus_neighbor(direction: Directions) -> None

Focus a nearby member. It will mirror back if going past an edge.

Source code in src/textual_timepiece/pickers/_time_picker.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def action_focus_neighbor(self, direction: Directions) -> None:
    """Focus a nearby member. It will mirror back if going past an edge."""
    if not self.has_focus_within:
        widget = self.query_one("#time-0", Button)
    else:
        # FIX: Subclass a button with a required id.
        focused_id = int(
            cast(str, cast(Button, self.app.focused).id).split("-")[-1]
        )

        row, col = divmod(focused_id, 4)
        if direction == "up":
            if row - 1 >= 0:
                id = focused_id - 4
            else:
                id = 43 + (col + 1)

        elif direction == "right":
            if col + 1 <= 3:
                id = focused_id + 1
            else:
                id = focused_id - col

        elif direction == "down":
            if row + 1 < 12:
                id = focused_id + 4
            else:
                id = col

        else:
            if col - 1 >= 0:
                id = focused_id - 1
            else:
                id = focused_id + (3 - col)

        widget = self.query_one(f"#time-{id}", Button)

    self.app.set_focus(widget)

DurationSelect

DurationSelectApp HoursMinutesSeconds +1+4+15+30+15+30 -1-4-15-30-15-30

textual_timepiece.pickers.DurationSelect

Bases: BaseOverlayWidget

Duration picker with various buttons in order to set time.

PARAMETER DESCRIPTION
name

Name of the widget.

TYPE: str | None DEFAULT: None

id

Unique dom id for the widget

TYPE: str | None DEFAULT: None

classes

Any CSS classes that should be added to the widget.

TYPE: str | None DEFAULT: None

disabled

Whether to disable the widget.

TYPE: bool DEFAULT: False

CLASS DESCRIPTION
DurationAdjusted

Message sent when duration is added or subtracted.

DurationRounded

Sent when one of the headers are clicked in order to round the

ATTRIBUTE DESCRIPTION
DEFAULT_CSS

Default CSS for the DurationSelect widget.

TYPE: str

Source code in src/textual_timepiece/pickers/_time_picker.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
class DurationSelect(BaseOverlayWidget):
    """Duration picker with various buttons in order to set time.

    Params:
        name: Name of the widget.
        id: Unique dom id for the widget
        classes: Any CSS classes that should be added to the widget.
        disabled: Whether to disable the widget.
    """

    @dataclass
    class DurationAdjusted(BaseMessage):
        """Message sent when duration is added or subtracted."""

        widget: DurationSelect
        delta: TimeDelta

    @dataclass
    class DurationRounded(BaseMessage):
        """Sent when one of the headers are clicked in order to round the
        value.
        """

        widget: DurationSelect
        value: int
        """Value used as a rounding factor."""
        scope: Literal["hours", "minutes", "seconds"]
        """Which subunit to round the duration to."""

    DEFAULT_CSS: ClassVar[str] = """
    DurationSelect {
        height: 3;
        layout: horizontal;
        width: 38;

        Grid {
            height: 3;
            grid-size: 2 3;
            grid-gutter: 0;
            grid-rows: 1;

            & > Button:first-of-type {
                column-span: 2;
                text-style: bold;
                background-tint: $background 50%;
            }

            & > Button {
                border: none;
                min-width: 5;
                width: 100%;
                text-style: italic;
                &:hover {
                    border: none;
                }
            }

        }
    }
    """
    """Default CSS for the `DurationSelect` widget."""

    GRID_TEMPLATE: ClassVar[dict[str, tuple[str, ...]]] = {
        "hour-grid": ("Hours", "+1", "+4", "-1", "-4"),
        "minute-grid": ("Minutes", "+15", "+30", "-15", "-30"),
        "second-grid": ("Seconds", "+15", "+30", "-15", "-30"),
    }

    def compose(self) -> ComposeResult:
        for grid, buttons in DurationSelect.GRID_TEMPLATE.items():
            with Grid(id=grid):
                for button in buttons:
                    yield Button(button, classes=grid)

    def _on_button_pressed(self, message: Button.Pressed) -> None:
        message.stop()
        label = str(message.button.label)
        try:
            value = int(label[1:])
            if label.startswith("-"):
                value *= -1
        except ValueError:
            value = None

        if message.button.has_class("hour-grid"):
            if value:
                self.post_message(
                    self.DurationAdjusted(self, TimeDelta(hours=value))
                )
            else:
                self.post_message(self.DurationRounded(self, 21600, "hours"))

        elif message.button.has_class("minute-grid"):
            if value:
                self.post_message(
                    self.DurationAdjusted(self, TimeDelta(minutes=value))
                )
            else:
                self.post_message(self.DurationRounded(self, 3600, "minutes"))
        elif message.button.has_class("second-grid"):
            if value:
                self.post_message(
                    self.DurationAdjusted(self, TimeDelta(seconds=value))
                )
            else:
                self.post_message(self.DurationRounded(self, 60, "seconds"))

DEFAULT_CSS class-attribute

DurationSelect {
    height: 3;
    layout: horizontal;
    width: 38;

    Grid {
        height: 3;
        grid-size: 2 3;
        grid-gutter: 0;
        grid-rows: 1;

        & > Button:first-of-type {
            column-span: 2;
            text-style: bold;
            background-tint: $background 50%;
        }

        & > Button {
            border: none;
            min-width: 5;
            width: 100%;
            text-style: italic;
            &:hover {
                border: none;
            }
        }

    }
}

Default CSS for the DurationSelect widget.

DurationAdjusted dataclass

Bases: BaseMessage

Message sent when duration is added or subtracted.

Source code in src/textual_timepiece/pickers/_time_picker.py
55
56
57
58
59
60
@dataclass
class DurationAdjusted(BaseMessage):
    """Message sent when duration is added or subtracted."""

    widget: DurationSelect
    delta: TimeDelta

DurationRounded dataclass

Bases: BaseMessage

Sent when one of the headers are clicked in order to round the value.

ATTRIBUTE DESCRIPTION
value

Value used as a rounding factor.

TYPE: int

scope

Which subunit to round the duration to.

TYPE: Literal['hours', 'minutes', 'seconds']

Source code in src/textual_timepiece/pickers/_time_picker.py
62
63
64
65
66
67
68
69
70
71
72
@dataclass
class DurationRounded(BaseMessage):
    """Sent when one of the headers are clicked in order to round the
    value.
    """

    widget: DurationSelect
    value: int
    """Value used as a rounding factor."""
    scope: Literal["hours", "minutes", "seconds"]
    """Which subunit to round the duration to."""

value instance-attribute

value: int

Value used as a rounding factor.

scope instance-attribute

scope: Literal['hours', 'minutes', 'seconds']

Which subunit to round the duration to.