Skip to content

Input

Note

All of the custom Input widgets have their Changed, Submitted and Blurred messages disabled by default.

Info

All inputs have an AbstractInput.action_adjust_time action which allows the user to adjust the value depending on the keyboard cursor location. This spinbox functionality can be accessed through clicking then dragging up/down, mouse wheel usage and up/down arrows.


textual_timepiece.pickers.DateInput

Bases: AbstractInput[Date]

Date picker for full dates.

PARAMETER DESCRIPTION
day

Initial value to set.

TYPE: Date | None DEFAULT: None

name

Name of the widget.

TYPE: str | None DEFAULT: None

id

Unique dom identifier value.

TYPE: str | None DEFAULT: None

classes

Any dom classes to add.

TYPE: str | None DEFAULT: None

tooltip

Tooltip to show when hovering the widget.

TYPE: str | None DEFAULT: 'YYYY-MM-DD Format.'

disabled

Whether to disable the widget.

TYPE: bool DEFAULT: False

select_on_focus

Whether to place the cursor on focus.

TYPE: bool DEFAULT: True

spinbox_sensitivity

Sensitivity setting for spinbox functionality.

TYPE: int DEFAULT: 1

CLASS DESCRIPTION
DateChanged

Message sent when the date changed.

METHOD DESCRIPTION
action_adjust_time

Adjust date with an offset depending on the text cursor position.

ATTRIBUTE DESCRIPTION
DEFAULT_CSS

TYPE: str

BINDINGS

All bindings for an AbstractInput.

TYPE: list[BindingType]

date

Date that is set. Bound if using within a picker.

Source code in src/textual_timepiece/pickers/_date_picker.py
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
class DateInput(AbstractInput[Date]):
    """Date picker for full dates.

    Params:
        day: Initial value to set.
        name: Name of the widget.
        id: Unique dom identifier value.
        classes: Any dom classes to add.
        tooltip: Tooltip to show when hovering the widget.
        disabled: Whether to disable the widget.
        select_on_focus: Whether to place the cursor on focus.
        spinbox_sensitivity: Sensitivity setting for spinbox functionality.
    """

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

        widget: DateInput
        date: Date | None

    PATTERN: ClassVar[str] = "0009-B9-99"
    DATE_FORMAT: ClassVar[str] = "%Y-%m-%d"
    ALIAS = "date"
    date = var[Date | None](None, init=False)
    """Date that is set. Bound if using within a picker."""

    def __init__(
        self,
        day: Date | None = None,
        name: str | None = None,
        id: str | None = None,
        classes: str | None = None,
        tooltip: str | None = "YYYY-MM-DD Format.",
        *,
        disabled: bool = False,
        select_on_focus: bool = True,
        spinbox_sensitivity: int = 1,
    ) -> None:
        super().__init__(
            value=day,
            name=name,
            id=id,
            classes=classes,
            disabled=disabled,
            tooltip=tooltip,
            select_on_focus=select_on_focus,
            spinbox_sensitivity=spinbox_sensitivity,
        )

    def watch_date(self, new: Date | None) -> None:
        # FIX: probably should prevent date changes
        with self.prevent(Input.Changed):
            self.value = (
                new.py_date().strftime(self.DATE_FORMAT) if new else ""
            )
        self.post_message(self.DateChanged(self, new))

    def _watch_value(self, value: str) -> None:
        if date := self.convert():
            self.date = date

    def action_adjust_time(self, offset: int) -> None:
        """Adjust date with an offset depending on the text cursor position."""

        try:
            if self.date is None:
                self.date = Date.today_in_system_tz()
            elif self._is_year_pos():
                self.date = self.date.add(years=offset)
            elif self._is_month_pos():
                self.date = self.date.add(months=offset)
            else:
                self.date = self.date.add(days=offset)
        except ValueError as err:
            self.log.debug(err)
            if not str(err).endswith("out of range"):
                raise

    def _is_year_pos(self) -> bool:
        return self.cursor_position < 4

    def _is_month_pos(self) -> bool:
        return 5 <= self.cursor_position < 7

    def _is_day_pos(self) -> bool:
        return 8 <= self.cursor_position

    def convert(self) -> Date | None:
        # NOTE: Pydate instead as I want to keep it open to standard formats.
        try:
            return Date.from_py_date(
                datetime.strptime(self.value, self.DATE_FORMAT).date()
            )
        except ValueError:
            return None

    def insert_text_at_cursor(self, text: str) -> None:
        if not text.isdigit():
            return

        # Extra Date Filtering
        if self.cursor_position == 6 and text not in "012":
            return

        if self.cursor_position == 5 and text not in "0123":
            return

        if (
            self.cursor_position == 6
            and self.value[5] == "3"
            and text not in "01"
        ):
            return

        super().insert_text_at_cursor(text)

DEFAULT_CSS class-attribute

AbstractInput {
    background: transparent;
    width: auto;
    border: none;
}

BINDINGS class-attribute

BINDINGS: list[BindingType] = [
    Binding("escape", "leave", "Defocus", tooltip="Defocus the input."),
    Binding(
        "up",
        "adjust_time(1)",
        "Increment",
        tooltip="Increment value depending on keyboard cursor location.",
        priority=True,
    ),
    Binding(
        "down",
        "adjust_time(-1)",
        "Decrement",
        tooltip="Decrement value depending on keyboard cursor location.",
        priority=True,
    ),
]

All bindings for an AbstractInput.

Key(s) Description
escape Defocus the input.
up Increment value depending on keyboard cursor location.
down Decrement value depending on keyboard cursor location.

date class-attribute instance-attribute

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

Date that is set. Bound if using within a picker.

DateChanged dataclass

Bases: BaseMessage

Message sent when the date changed.

Source code in src/textual_timepiece/pickers/_date_picker.py
862
863
864
865
866
867
@dataclass
class DateChanged(BaseMessage):
    """Message sent when the date changed."""

    widget: DateInput
    date: Date | None

action_adjust_time

action_adjust_time(offset: int) -> None

Adjust date with an offset depending on the text cursor position.

Source code in src/textual_timepiece/pickers/_date_picker.py
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
def action_adjust_time(self, offset: int) -> None:
    """Adjust date with an offset depending on the text cursor position."""

    try:
        if self.date is None:
            self.date = Date.today_in_system_tz()
        elif self._is_year_pos():
            self.date = self.date.add(years=offset)
        elif self._is_month_pos():
            self.date = self.date.add(months=offset)
        else:
            self.date = self.date.add(days=offset)
    except ValueError as err:
        self.log.debug(err)
        if not str(err).endswith("out of range"):
            raise

textual_timepiece.pickers.TimeInput

Bases: AbstractInput[Time]

Time input for a HH:MM:SS format

CLASS DESCRIPTION
TimeChanged

Message sent when the time is updated.

METHOD DESCRIPTION
action_adjust_time

Adjust time with an offset depending on the text cursor position.

ATTRIBUTE DESCRIPTION
DEFAULT_CSS

TYPE: str

BINDINGS

All bindings for an AbstractInput.

TYPE: list[BindingType]

time

Currently set time or none if its empty.

Source code in src/textual_timepiece/pickers/_time_picker.py
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
class TimeInput(AbstractInput[Time]):
    """Time input for a HH:MM:SS format"""

    @dataclass
    class TimeChanged(BaseMessage):
        """Message sent when the time is updated."""

        widget: TimeInput
        new_time: Time | None

    PATTERN = "00:00:00"
    ALIAS = "time"

    time = var[Time | None](None, init=False)
    """Currently set time or none if its empty."""

    def _watch_time(self, time: Time | None) -> None:
        with self.prevent(Input.Changed), suppress(ValueError):
            if time:
                self.value = time.format_common_iso()
            else:
                self.value = ""

        self.post_message(self.TimeChanged(self, self.time))

    def _watch_value(self, value: str) -> None:
        if (ts := self.convert()) is not None and ts != self.time:
            self.time = ts
        super()._watch_value(value)

    def convert(self) -> Time | None:
        try:
            return Time.parse_common_iso(self.value)
        except ValueError:
            return None

    def insert_text_at_cursor(self, text: str) -> None:
        if self.cursor_position > 7:
            return

        if text not in digits:
            self.cursor_position += 1
            return

        if self.cursor_position == 0:
            self.value = text + self.value[1:]
        elif self.cursor_position == 7:
            self.value = self.value[:-1] + text
            return
        elif self.cursor_position in {4, 1} or (
            self.value[self.cursor_position] != ":" and text in "543210"
        ):
            self.value = (
                self.value[: self.cursor_position]
                + text
                + self.value[self.cursor_position + 1 :]
            )

        self.cursor_position += 1

    def action_delete_right(self) -> None:
        return

    def action_delete_left(self) -> None:
        if self.cursor_position < 0:
            return

        elif self.cursor_position == 8:
            self.value = self.value[:-1] + "0"

        elif self.cursor_position not in {2, 5}:
            self.value = (
                self.value[: self.cursor_position]
                + "0"
                + self.value[self.cursor_position + 1 :]
            )

        if self.cursor_position > 0:
            self.cursor_position -= 1

    def action_adjust_time(self, offset: int) -> None:
        """Adjust time with an offset depending on the text cursor position."""
        if 0 <= self.cursor_position < 2:
            self.time = add_time(self.time or Time(), hours(offset))
        elif 3 <= self.cursor_position < 5:
            self.time = add_time(self.time or Time(), minutes(offset))
        elif 6 <= self.cursor_position:
            self.time = add_time(self.time or Time(), seconds(offset))

DEFAULT_CSS class-attribute

AbstractInput {
    background: transparent;
    width: auto;
    border: none;
}

BINDINGS class-attribute

BINDINGS: list[BindingType] = [
    Binding("escape", "leave", "Defocus", tooltip="Defocus the input."),
    Binding(
        "up",
        "adjust_time(1)",
        "Increment",
        tooltip="Increment value depending on keyboard cursor location.",
        priority=True,
    ),
    Binding(
        "down",
        "adjust_time(-1)",
        "Decrement",
        tooltip="Decrement value depending on keyboard cursor location.",
        priority=True,
    ),
]

All bindings for an AbstractInput.

Key(s) Description
escape Defocus the input.
up Increment value depending on keyboard cursor location.
down Decrement value depending on keyboard cursor location.

time class-attribute instance-attribute

time = var[Time | None](None, init=False)

Currently set time or none if its empty.

TimeChanged dataclass

Bases: BaseMessage

Message sent when the time is updated.

Source code in src/textual_timepiece/pickers/_time_picker.py
496
497
498
499
500
501
@dataclass
class TimeChanged(BaseMessage):
    """Message sent when the time is updated."""

    widget: TimeInput
    new_time: Time | None

action_adjust_time

action_adjust_time(offset: int) -> None

Adjust time with an offset depending on the text cursor position.

Source code in src/textual_timepiece/pickers/_time_picker.py
573
574
575
576
577
578
579
580
def action_adjust_time(self, offset: int) -> None:
    """Adjust time with an offset depending on the text cursor position."""
    if 0 <= self.cursor_position < 2:
        self.time = add_time(self.time or Time(), hours(offset))
    elif 3 <= self.cursor_position < 5:
        self.time = add_time(self.time or Time(), minutes(offset))
    elif 6 <= self.cursor_position:
        self.time = add_time(self.time or Time(), seconds(offset))

textual_timepiece.pickers.DurationInput

Bases: AbstractInput[TimeDelta]

Duration input for time deltas.

CLASS DESCRIPTION
DurationChanged

Message sent when the duration changes through input or spinbox.

METHOD DESCRIPTION
action_adjust_time

Adjust time with an offset depending on the text cursor position.

ATTRIBUTE DESCRIPTION
DEFAULT_CSS

TYPE: str

BINDINGS

All bindings for an AbstractInput.

TYPE: list[BindingType]

duration

Current duration in a TimeDelta or None if empty.

Source code in src/textual_timepiece/pickers/_time_picker.py
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
class DurationInput(AbstractInput[TimeDelta]):
    """Duration input for time deltas."""

    @dataclass
    class DurationChanged(BaseMessage):
        """Message sent when the duration changes through input or spinbox."""

        widget: DurationInput
        duration: TimeDelta | None

    ALIAS = "duration"
    MIN: Final[TimeDelta] = TimeDelta()
    MAX: Final[TimeDelta] = TimeDelta(hours=99, minutes=59, seconds=59)

    PATTERN = "99:99:99"

    duration = var[TimeDelta | None](None, init=False)
    """Current duration in a `TimeDelta` or None if empty.

    This value is capped at 99 hours, 59 minutes and 59 seconds.
    """

    def _validate_duration(
        self,
        duration: TimeDelta | None,
    ) -> TimeDelta | None:
        if duration is None:
            return None

        return max(self.MIN, min(self.MAX, duration))

    def _watch_duration(self, duration: TimeDelta | None) -> None:
        with self.prevent(Input.Changed), suppress(ValueError):
            if isinstance(duration, TimeDelta):
                self.value = format_seconds(int(duration.in_seconds()))
            else:
                self.value = ""

        self.post_message(self.DurationChanged(self, self.duration))

    def _watch_value(self, value: str) -> None:
        if dur := self.convert():
            self.duration = dur
        super()._watch_value(value)

    def convert(self) -> TimeDelta | None:
        try:
            hours, minutes, seconds = tuple(map(int, self.value.split(":")))
        except ValueError:
            return None
        if hours + minutes + seconds == 0:
            return TimeDelta()
        return TimeDelta(seconds=seconds, minutes=minutes, hours=hours)

    def action_adjust_time(self, offset: int) -> None:
        """Adjust time with an offset depending on the text cursor position."""
        if self.duration is None:
            self.duration = TimeDelta()
        elif 0 <= self.cursor_position < 2:
            self.duration += hours(offset)
        elif 3 <= self.cursor_position < 5:
            self.duration += minutes(offset)
        elif 6 <= self.cursor_position:
            self.duration += seconds(offset)

DEFAULT_CSS class-attribute

AbstractInput {
    background: transparent;
    width: auto;
    border: none;
}

BINDINGS class-attribute

BINDINGS: list[BindingType] = [
    Binding("escape", "leave", "Defocus", tooltip="Defocus the input."),
    Binding(
        "up",
        "adjust_time(1)",
        "Increment",
        tooltip="Increment value depending on keyboard cursor location.",
        priority=True,
    ),
    Binding(
        "down",
        "adjust_time(-1)",
        "Decrement",
        tooltip="Decrement value depending on keyboard cursor location.",
        priority=True,
    ),
]

All bindings for an AbstractInput.

Key(s) Description
escape Defocus the input.
up Increment value depending on keyboard cursor location.
down Decrement value depending on keyboard cursor location.

duration class-attribute instance-attribute

duration = var[TimeDelta | None](None, init=False)

Current duration in a TimeDelta or None if empty.

This value is capped at 99 hours, 59 minutes and 59 seconds.

DurationChanged dataclass

Bases: BaseMessage

Message sent when the duration changes through input or spinbox.

Source code in src/textual_timepiece/pickers/_time_picker.py
322
323
324
325
326
327
@dataclass
class DurationChanged(BaseMessage):
    """Message sent when the duration changes through input or spinbox."""

    widget: DurationInput
    duration: TimeDelta | None

action_adjust_time

action_adjust_time(offset: int) -> None

Adjust time with an offset depending on the text cursor position.

Source code in src/textual_timepiece/pickers/_time_picker.py
373
374
375
376
377
378
379
380
381
382
def action_adjust_time(self, offset: int) -> None:
    """Adjust time with an offset depending on the text cursor position."""
    if self.duration is None:
        self.duration = TimeDelta()
    elif 0 <= self.cursor_position < 2:
        self.duration += hours(offset)
    elif 3 <= self.cursor_position < 5:
        self.duration += minutes(offset)
    elif 6 <= self.cursor_position:
        self.duration += seconds(offset)

textual_timepiece.pickers.DateTimeInput

Bases: AbstractInput[LocalDateTime]

Input that combines both date and time into one.

CLASS DESCRIPTION
DateTimeChanged

Sent when the datetime is changed.

METHOD DESCRIPTION
action_adjust_time

Adjust date with an offset depending on the text cursor position.

ATTRIBUTE DESCRIPTION
DEFAULT_CSS

TYPE: str

BINDINGS

All bindings for an AbstractInput.

TYPE: list[BindingType]

datetime

Current datetime or none if nothing is set.

Source code in src/textual_timepiece/pickers/_datetime_picker.py
 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
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
class DateTimeInput(AbstractInput[LocalDateTime]):
    """Input that combines both date and time into one."""

    @dataclass
    class DateTimeChanged(BaseMessage):
        """Sent when the datetime is changed."""

        widget: DateTimeInput
        datetime: LocalDateTime | None

    PATTERN: ClassVar[str] = r"0009-B9-99 99:99:99"
    FORMAT: ClassVar[str] = r"%Y-%m-%d %H:%M:%S"
    ALIAS = "datetime"

    datetime = var[LocalDateTime | None](None, init=False)
    """Current datetime or none if nothing is set."""

    def __init__(
        self,
        value: LocalDateTime | None = None,
        name: str | None = None,
        id: str | None = None,
        classes: str | None = None,
        tooltip: str | None = None,
        *,
        disabled: bool = False,
        select_on_focus: bool = True,
        spinbox_sensitivity: int = 1,
    ) -> None:
        super().__init__(
            value=value,
            name=name,
            id=id,
            classes=classes,
            tooltip=tooltip,
            disabled=disabled,
            select_on_focus=select_on_focus,
            spinbox_sensitivity=spinbox_sensitivity,
        )

    def watch_datetime(self, value: LocalDateTime | None) -> None:
        with self.prevent(Input.Changed):
            if value:
                self.value = value.py_datetime().strftime(self.FORMAT)
            else:
                self.value = ""

        self.post_message(self.DateTimeChanged(self, self.datetime))

    def _watch_value(self, value: str) -> None:
        if (dt := self.convert()) is not None:
            self.datetime = dt

    def convert(self) -> LocalDateTime | None:
        try:
            return LocalDateTime.strptime(self.value, self.FORMAT)
        except ValueError:
            return None

    def action_adjust_time(self, offset: int) -> None:
        """Adjust date with an offset depending on the text cursor position."""

        try:
            if self.datetime is None:
                self.datetime = SystemDateTime.now().local()
            elif self.cursor_position < 4:
                self.datetime = self.datetime.add(
                    years=offset,
                    ignore_dst=True,
                )
            elif 5 <= self.cursor_position < 7:
                self.datetime = self.datetime.add(
                    months=offset,
                    ignore_dst=True,
                )
            elif 8 <= self.cursor_position < 10:
                self.datetime = self.datetime.add(
                    days=offset,
                    ignore_dst=True,
                )
            elif 11 <= self.cursor_position < 13:
                self.datetime = self.datetime.add(
                    hours=offset,
                    ignore_dst=True,
                )
            elif 14 <= self.cursor_position < 16:
                self.datetime = self.datetime.add(
                    minutes=offset,
                    ignore_dst=True,
                )
            else:
                self.datetime = self.datetime.add(
                    seconds=offset,
                    ignore_dst=True,
                )
        except ValueError as err:
            self.log.debug(err)
            if not str(err).endswith("out of range"):
                raise

    def insert_text_at_cursor(self, text: str) -> None:
        if not text.isdigit():
            return

        # Extra Date Filtering
        if self.cursor_position == 6 and text not in "012":
            return

        if self.cursor_position == 5 and text not in "0123":
            return

        if (
            self.cursor_position == 6
            and self.value[5] == "3"
            and text not in "01"
        ):
            return

        # Extra Time Filtering
        if self.cursor_position == 11:
            if (
                text == "2"
                and len(self.value) >= 12
                and self.value[12] not in "0123"
            ):
                self.value = self.value[:12] + "3" + self.value[13:]
            elif text not in "012":
                return

        if (
            self.cursor_position == 12
            and self.value[11] == "2"
            and text not in "0123"
        ):
            return

        if self.cursor_position in {14, 17} and text not in "012345":
            return

        super().insert_text_at_cursor(text)

DEFAULT_CSS class-attribute

AbstractInput {
    background: transparent;
    width: auto;
    border: none;
}

BINDINGS class-attribute

BINDINGS: list[BindingType] = [
    Binding("escape", "leave", "Defocus", tooltip="Defocus the input."),
    Binding(
        "up",
        "adjust_time(1)",
        "Increment",
        tooltip="Increment value depending on keyboard cursor location.",
        priority=True,
    ),
    Binding(
        "down",
        "adjust_time(-1)",
        "Decrement",
        tooltip="Decrement value depending on keyboard cursor location.",
        priority=True,
    ),
]

All bindings for an AbstractInput.

Key(s) Description
escape Defocus the input.
up Increment value depending on keyboard cursor location.
down Decrement value depending on keyboard cursor location.

datetime class-attribute instance-attribute

datetime = var[LocalDateTime | None](None, init=False)

Current datetime or none if nothing is set.

DateTimeChanged dataclass

Bases: BaseMessage

Sent when the datetime is changed.

Source code in src/textual_timepiece/pickers/_datetime_picker.py
73
74
75
76
77
78
@dataclass
class DateTimeChanged(BaseMessage):
    """Sent when the datetime is changed."""

    widget: DateTimeInput
    datetime: LocalDateTime | None

action_adjust_time

action_adjust_time(offset: int) -> None

Adjust date with an offset depending on the text cursor position.

Source code in src/textual_timepiece/pickers/_datetime_picker.py
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
def action_adjust_time(self, offset: int) -> None:
    """Adjust date with an offset depending on the text cursor position."""

    try:
        if self.datetime is None:
            self.datetime = SystemDateTime.now().local()
        elif self.cursor_position < 4:
            self.datetime = self.datetime.add(
                years=offset,
                ignore_dst=True,
            )
        elif 5 <= self.cursor_position < 7:
            self.datetime = self.datetime.add(
                months=offset,
                ignore_dst=True,
            )
        elif 8 <= self.cursor_position < 10:
            self.datetime = self.datetime.add(
                days=offset,
                ignore_dst=True,
            )
        elif 11 <= self.cursor_position < 13:
            self.datetime = self.datetime.add(
                hours=offset,
                ignore_dst=True,
            )
        elif 14 <= self.cursor_position < 16:
            self.datetime = self.datetime.add(
                minutes=offset,
                ignore_dst=True,
            )
        else:
            self.datetime = self.datetime.add(
                seconds=offset,
                ignore_dst=True,
            )
    except ValueError as err:
        self.log.debug(err)
        if not str(err).endswith("out of range"):
            raise