diff --git a/examples/reference/widgets/TimePicker.ipynb b/examples/reference/widgets/TimePicker.ipynb new file mode 100644 index 0000000000..b4d0f66a14 --- /dev/null +++ b/examples/reference/widgets/TimePicker.ipynb @@ -0,0 +1,104 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import datetime as dt\n", + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``TimePicker`` widget allows entering a time value as text or `datetime.time`. \n", + "\n", + "Discover more on using widgets to add interactivity to your applications in the [how-to guides on interactivity](../how_to/interactivity/index.md). Alternatively, learn [how to set up callbacks and (JS-)links between parameters](../../how_to/links/index.md) or [how to use them as part of declarative UIs with Param](../../how_to/param/index.html).\n", + "\n", + "#### Parameters:\n", + "\n", + "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", + "\n", + "##### Core\n", + "\n", + "* **``value``** (str | datetime.time): The current value\n", + "* **``start``** (str | datetime.time): Inclusive lower bound of the allowed time selection\n", + "* **``end``** (str | datetime.time): Inclusive upper bound of the allowed time selection\n", + "\n", + " ```\n", + " +---+------------------------------------+------------+\n", + " | H | Hours (24 hours) | 00 to 23 |\n", + " | h | Hours | 1 to 12 |\n", + " | G | Hours, 2 digits with leading zeros | 1 to 12 |\n", + " | i | Minutes | 00 to 59 |\n", + " | S | Seconds, 2 digits | 00 to 59 |\n", + " | s | Seconds | 0, 1 to 59 |\n", + " | K | AM/PM | AM or PM |\n", + " +---+------------------------------------+------------+\n", + " ```\n", + " See also https://flatpickr.js.org/formatting/#date-formatting-tokens.\n", + "\n", + "\n", + "\n", + "##### Display\n", + "\n", + "* **``disabled``** (boolean): Whether the widget is editable\n", + "* **``name``** (str): The title of the widget\n", + "* **``format``** (str): Formatting specification for the display of the picked date.\n", + "* **``hour_increment``** (int): Defines the granularity of hour value increments in the UI. Default is 1.\n", + "* **``minute_increment``** (int): Defines the granularity of minute value increments in the UI. Default is 1.\n", + "* **``second_increment``** (int): Defines the granularity of second value increments in the UI. Default is 1.\n", + "* **``seconds``** (bool): Allows to select seconds. By default, only hours and minutes are selectable, and AM/PM depending on the `clock` option. Default is False.\n", + "* **``clock``** (bool): Whether to use 12 hour or 24 hour clock.\n", + "___" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `TimePicker` widget allows selecting a time of day." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "time_picker = pn.widgets.TimePicker(name='Time Picker', value=dt.datetime.now().time(), format='H:i K')\n", + "time_picker" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Either `datetime.time` or `str` can be used as input and `TimePicker` can be bounded by a start and end time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "time_picker = pn.widgets.TimePicker(name='Time Picker', value=\"08:28\", start='00:00', end='12:00')\n", + "time_picker" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/panel/models/__init__.py b/panel/models/__init__.py index c00045f5bd..a5616ffbfb 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -15,6 +15,7 @@ from .markup import HTML, JSON, PDF # noqa from .reactive_html import ReactiveHTML # noqa from .state import State # noqa +from .time_picker import TimePicker # noqa from .trend import TrendIndicator # noqa from .widgets import ( # noqa Audio, Button, CheckboxButtonGroup, CustomMultiSelect, CustomSelect, diff --git a/panel/models/index.ts b/panel/models/index.ts index ba9cf6e1fd..013635d924 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -45,6 +45,7 @@ export {Terminal} from "./terminal" export {TextAreaInput} from "./textarea_input" export {TextInput} from "./text_input" export {TextToSpeech} from "./text_to_speech" +export {TimePicker} from "./time_picker" export {ToggleIcon} from "./toggle_icon" export {TooltipIcon} from "./tooltip_icon" export {TrendIndicator} from "./trend" diff --git a/panel/models/time_picker.py b/panel/models/time_picker.py new file mode 100644 index 0000000000..d211dc5e61 --- /dev/null +++ b/panel/models/time_picker.py @@ -0,0 +1,7 @@ +from bokeh.models import TimePicker as BkTimePicker + + +class TimePicker(BkTimePicker): + """ + A custom Panel version of the Bokeh TimePicker model which fixes timezones. + """ diff --git a/panel/models/time_picker.ts b/panel/models/time_picker.ts new file mode 100644 index 0000000000..3ee91e473c --- /dev/null +++ b/panel/models/time_picker.ts @@ -0,0 +1,67 @@ +import {TimePicker as BkTimePicker, TimePickerView as BkTimePickerView} from "@bokehjs/models/widgets/time_picker" +import type * as p from "@bokehjs/core/properties" +import type flatpickr from "flatpickr" + +export class TimePickerView extends BkTimePickerView { + declare model: TimePicker + + private _offset_time(value: string | number): number { + const baseDate = new Date(value) + const timeZoneOffset = baseDate.getTimezoneOffset() * 60 * 1000 + return baseDate.getTime() + timeZoneOffset + } + + private _setDate(date: string | number): void { + date = this._offset_time(date) + this.picker.setDate(date) + } + + protected override get flatpickr_options(): flatpickr.Options.Options { + // on init + const options = super.flatpickr_options + if (options.defaultDate != null) { options.defaultDate = this._offset_time(options.defaultDate as string) } + return options + } + + override connect_signals(): void { + super.connect_signals() + + const {value} = this.model.properties + this.connect(value.change, () => { + const {value} = this.model + if (value != null && typeof value === "number") { + // we need to handle it when programmatically changed thru Python, e.g. + // time_picker.value = "4:08" or time_picker.value = "datetime.time(4, 8)" + // else, when changed in the UI, e.g. by typing in the input field + // no special handling is needed + this._setDate(value) + } + }) + } + +} + +export namespace TimePicker { + export type Attrs = p.AttrsOf + export type Props = BkTimePicker.Props & { + } +} + +export interface TimePicker extends TimePicker.Attrs { } + +export class TimePicker extends BkTimePicker { + declare properties: TimePicker.Props + + constructor(attrs?: Partial) { + super(attrs) + } + + static override __module__ = "panel.models.time_picker" + + static { + this.prototype.default_view = TimePickerView + + this.define(({ }) => ({ + })) + } +} diff --git a/panel/tests/ui/widgets/test_time_picker.py b/panel/tests/ui/widgets/test_time_picker.py new file mode 100644 index 0000000000..b8688958ae --- /dev/null +++ b/panel/tests/ui/widgets/test_time_picker.py @@ -0,0 +1,58 @@ +import datetime + +import pytest + +from panel.tests.util import serve_component, wait_until +from panel.widgets import TimePicker + +pytestmark = pytest.mark.ui + + +def test_time_picker(page): + + time_picker = TimePicker(value="18:08", format="H:i") + + serve_component(page, time_picker) + + # test init corrected timezone + locator = page.locator("#input") + assert locator.get_attribute("value") == "18:08:00" + + # test UI change + locator = page.locator("input.bk-input.form-control.input") + locator.click() + wait_until(lambda: page.locator("input.numInput.flatpickr-hour").is_visible()) + locator = page.locator("input.numInput.flatpickr-hour") + locator.press("ArrowDown") + locator.press("Enter") + wait_until(lambda: time_picker.value == datetime.time(17, 8)) + + # test str value change + time_picker.value = "04:08" + wait_until(lambda: time_picker.value == "04:08") + locator = page.locator("#input") + assert locator.get_attribute("value") == "04:08:00" + + # test datetime.time value change + time_picker.value = datetime.time(18, 8) + wait_until(lambda: time_picker.value == datetime.time(18, 8)) + locator = page.locator("#input") + assert locator.get_attribute("value") == "18:08:00" + + +@pytest.mark.parametrize("timezone_id", [ + "America/New_York", + "Europe/Berlin", + "UTC", +]) +def test_time_picker_timezone_different(page, timezone_id): + context = page.context.browser.new_context( + timezone_id=timezone_id, + ) + page = context.new_page() + + time_picker = TimePicker(value="18:08", format="H:i") + serve_component(page, time_picker) + + locator = page.locator("#input") + assert locator.get_attribute("value") == "18:08:00" diff --git a/panel/tests/widgets/test_input.py b/panel/tests/widgets/test_input.py index c36b0d03d3..54e1d9ebbe 100644 --- a/panel/tests/widgets/test_input.py +++ b/panel/tests/widgets/test_input.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, time as dt_time from pathlib import Path import numpy as np @@ -10,7 +10,7 @@ from panel.widgets import ( ArrayInput, Checkbox, DatePicker, DateRangePicker, DatetimeInput, DatetimePicker, DatetimeRangeInput, DatetimeRangePicker, FileInput, - FloatInput, IntInput, LiteralInput, StaticText, TextInput, + FloatInput, IntInput, LiteralInput, StaticText, TextInput, TimePicker, ) @@ -185,6 +185,22 @@ def test_datetime_range_picker(document, comm): datetime_range_picker._process_events({'value': '2018-09-10 00:00:01'}) +def test_time_picker(document, comm): + time_picker = TimePicker(name='Time Picker', value=dt_time(hour=18), format='H:i K') + assert time_picker.value == dt_time(hour=18) + assert time_picker.format == 'H:i K' + assert time_picker.start is None + assert time_picker.end is None + + +def test_time_picker_str(document, comm): + time_picker = TimePicker(name='Time Picker', value="08:28", start='00:00', end='12:00') + assert time_picker.value == "08:28" + assert time_picker.format == 'H:i' + assert time_picker.start == "00:00" + assert time_picker.end == "12:00" + + def test_file_input(document, comm): file_input = FileInput(accept='.txt') diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index 5a8c08d459..02d8180038 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -47,6 +47,7 @@ DatetimeInput, DatetimePicker, DatetimeRangeInput, DatetimeRangePicker, FileDropper, FileInput, FloatInput, IntInput, LiteralInput, NumberInput, PasswordInput, Spinner, StaticText, Switch, TextAreaInput, TextInput, + TimePicker, ) from .misc import FileDownload, JSONEditor, VideoStream # noqa from .player import DiscretePlayer, Player # noqa @@ -136,6 +137,7 @@ "TextEditor", "TextInput", "TextToSpeech", + "TimePicker", "Toggle", "ToggleGroup", "ToggleIcon", diff --git a/panel/widgets/input.py b/panel/widgets/input.py index f8c9f1032e..77b14c173c 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -8,7 +8,7 @@ import json from base64 import b64decode -from datetime import date, datetime +from datetime import date, datetime, time as dt_time from typing import ( TYPE_CHECKING, Any, ClassVar, Iterable, Mapping, Optional, ) @@ -31,7 +31,7 @@ from ..layout import Column, Panel from ..models import ( DatetimePicker as _bkDatetimePicker, TextAreaInput as _bkTextAreaInput, - TextInput as _BkTextInput, + TextInput as _BkTextInput, TimePicker as _BkTimePicker, ) from ..util import ( escape, lazy_load, param_reprs, try_datetime64_to_datetime, @@ -807,6 +807,77 @@ def _deserialize_value(self, value): return value +class _TimeCommon(Widget): + + hour_increment = param.Integer(default=1, doc=""" + Defines the granularity of hour value increments in the UI. + """) + + minute_increment = param.Integer(default=1, doc=""" + Defines the granularity of minute value increments in the UI. + """) + + second_increment = param.Integer(default=1, doc=""" + Defines the granularity of second value increments in the UI. + """) + + seconds = param.Boolean(default=False, doc=""" + Allows to select seconds. By default only hours and minutes are + selectable, and AM/PM depending on the `clock` option. + """) + + clock = param.String(default='12h', doc=""" + Whether to use 12 hour or 24 hour clock.""") + + __abstract = True + + +class TimePicker(_TimeCommon): + """ + The `TimePicker` allows selecting a `time` value using a text box + and a time-picking utility. + + Reference: https://panel.holoviz.org/reference/widgets/TimePicker.html + + :Example: + + >>> TimePicker( + ... value="12:59:31", start="09:00:00", end="18:00:00", name="Time" + ... ) + """ + + value = param.ClassSelector(default=None, class_=(dt_time, str), doc=""" + The current value""") + + start = param.ClassSelector(default=None, class_=(dt_time, str), doc=""" + Inclusive lower bound of the allowed time selection""") + + end = param.ClassSelector(default=None, class_=(dt_time, str), doc=""" + Inclusive upper bound of the allowed time selection""") + + format = param.String(default='H:i', doc=""" + Formatting specification for the display of the picked date. + + +---+------------------------------------+------------+ + | H | Hours (24 hours) | 00 to 23 | + | h | Hours | 1 to 12 | + | G | Hours, 2 digits with leading zeros | 1 to 12 | + | i | Minutes | 00 to 59 | + | S | Seconds, 2 digits | 00 to 59 | + | s | Seconds | 0, 1 to 59 | + | K | AM/PM | AM or PM | + +---+------------------------------------+------------+ + + See also https://flatpickr.js.org/formatting/#date-formatting-tokens. + """) + + _rename: ClassVar[Mapping[str, str | None]] = { + 'start': 'min_time', 'end': 'max_time', 'format': 'time_format' + } + + _widget_type: ClassVar[type[Model]] = _BkTimePicker + + class ColorPicker(Widget): """ The `ColorPicker` widget allows selecting a hexadecimal RGB color value