diff --git a/examples/reference/widgets/DiscretePlayer.ipynb b/examples/reference/widgets/DiscretePlayer.ipynb index 9323014579..bcc189b78b 100644 --- a/examples/reference/widgets/DiscretePlayer.ipynb +++ b/examples/reference/widgets/DiscretePlayer.ipynb @@ -38,6 +38,8 @@ "* **``disabled``** (boolean): Whether the widget is editable\n", "* **``name``** (str): The title of the widget\n", "* **``show_loop_controls``** (boolean): Whether radio buttons allowing to switch between loop policies options are shown\n", + "* **``show_value``** (boolean): Whether to display the value of the player\n", + "* **``value_align``** (str): Where to display the value; must be one of 'start', 'center', 'end'\n", "\n", "___" ] @@ -55,7 +57,8 @@ "metadata": {}, "outputs": [], "source": [ - "discrete_player = pn.widgets.DiscretePlayer(name='Discrete Player', options=[2, 4, 8, 16, 32, 64, 128], value=8, loop_policy='loop')\n", + "discrete_player = pn.widgets.DiscretePlayer(name='Discrete Player', options=[2, 4, 8, 16, 32, 64, 128],\n", + " value=8, loop_policy='loop', show_value=True, value_align='start')\n", "\n", "discrete_player" ] diff --git a/examples/reference/widgets/Player.ipynb b/examples/reference/widgets/Player.ipynb index 238b852a96..d210a01038 100644 --- a/examples/reference/widgets/Player.ipynb +++ b/examples/reference/widgets/Player.ipynb @@ -40,6 +40,8 @@ "* **``disabled``** (boolean): Whether the widget is editable\n", "* **``name``** (str): The title of the widget\n", "* **``show_loop_controls``** (boolean): Whether radio buttons allowing to switch between loop policies options are shown\n", + "* **``show_value``** (boolean): Whether to display the value of the player\n", + "* **``value_align``** (str): Where to display the value; must be one of 'start', 'center', 'end'\n", "\n", "___" ] @@ -57,7 +59,8 @@ "metadata": {}, "outputs": [], "source": [ - "player = pn.widgets.Player(name='Player', start=0, end=100, value=32, loop_policy='loop')\n", + "player = pn.widgets.Player(name='Player', start=0, end=100, value=32, loop_policy='loop',\n", + " show_value=True, value_align='start')\n", "\n", "player" ] diff --git a/panel/dist/css/player.css b/panel/dist/css/player.css new file mode 100644 index 0000000000..1b0bb61ad5 --- /dev/null +++ b/panel/dist/css/player.css @@ -0,0 +1,3 @@ +.pn-player-value { + font-weight: bold; +} diff --git a/panel/models/discrete_player.ts b/panel/models/discrete_player.ts new file mode 100644 index 0000000000..97cb31b3d1 --- /dev/null +++ b/panel/models/discrete_player.ts @@ -0,0 +1,46 @@ +import type * as p from "@bokehjs/core/properties" +import {PlayerView, Player} from "./player" +import {span} from "@bokehjs/core/dom" +import {to_string} from "@bokehjs/core/util/pretty" + +export class DiscretePlayerView extends PlayerView { + declare model: DiscretePlayer + + override append_value_to_title_el(): void { + let label = this.model.options[this.model.value] + if (typeof label !== "string") { + label = to_string(label) + } + this.titleEl.appendChild(span({class: "pn-player-value"}, label)) + } +} + +export namespace DiscretePlayer { + export type Attrs = p.AttrsOf + export type Props = Player.Props & { + options: p.Property + } +} + +export interface DiscretePlayer extends DiscretePlayer.Attrs { } + +export class DiscretePlayer extends Player { + + declare properties: DiscretePlayer.Props + + constructor(attrs?: Partial) { + super(attrs) + } + + static override __module__ = "panel.models.widgets" + + static { + this.prototype.default_view = DiscretePlayerView + + this.define(({List, Any}) => ({ + options: [List(Any), []], + })) + + this.override({width: 400}) + } +} diff --git a/panel/models/index.ts b/panel/models/index.ts index f6e656899c..ba9cf6e1fd 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -15,6 +15,7 @@ export {CustomMultiSelect} from "./multiselect" export {DataTabulator} from "./tabulator" export {DatetimePicker} from "./datetime_picker" export {DeckGLPlot} from "./deckgl" +export {DiscretePlayer} from "./discrete_player" export {ECharts} from "./echarts" export {Feed} from "./feed" export {FileDownload} from "./file_download" diff --git a/panel/models/player.ts b/panel/models/player.ts index 94eb0435c6..71d876ce3a 100644 --- a/panel/models/player.ts +++ b/panel/models/player.ts @@ -1,7 +1,8 @@ import {Enum} from "@bokehjs/core/kinds" import type * as p from "@bokehjs/core/properties" -import {div} from "@bokehjs/core/dom" +import {div, empty, span} from "@bokehjs/core/dom" import {Widget, WidgetView} from "@bokehjs/models/widgets/widget" +import {to_string} from "@bokehjs/core/util/pretty" const SVG_STRINGS = { slower: ' this.update_title_and_value()) + this.on_change(value_align, () => this.set_value_align()) this.on_change(direction, () => this.set_direction()) this.on_change(value, () => this.render()) this.on_change(loop_policy, () => this.set_loop_state(this.model.loop_policy)) @@ -92,6 +96,8 @@ export class PlayerView extends WidgetView { this.groupEl.removeChild(this.loop_state) } }) + this.on_change(show_value, () => this.update_title_and_value()) + } toggle_disable() { @@ -127,7 +133,13 @@ export class PlayerView extends WidgetView { this.groupEl = div() this.groupEl.style.display = "flex" this.groupEl.style.flexDirection = "column" - this.groupEl.style.alignItems = "center" + + // Display Value + this.titleEl = div() + this.titleEl.classList.add("pn-player-title") + this.titleEl.style.padding = "0 5px 0 5px" + this.update_title_and_value() + this.set_value_align() // Slider this.sliderEl = document.createElement("input") @@ -263,6 +275,7 @@ export class PlayerView extends WidgetView { this.loop_state.appendChild(reflect) this.loop_state.appendChild(reflect_label) + this.groupEl.appendChild(this.titleEl) this.groupEl.appendChild(this.sliderEl) this.groupEl.appendChild(button_div) if (this.model.show_loop_controls) { @@ -275,6 +288,7 @@ export class PlayerView extends WidgetView { set_frame(frame: number, throttled: boolean = true): void { this.model.value = frame + this.update_title_and_value() if (throttled) { this.model.value_throttled = frame } @@ -294,6 +308,56 @@ export class PlayerView extends WidgetView { return "once" } + update_title_and_value(): void { + empty(this.titleEl) + + const hide_header = this.model.title == null || (this.model.title.length == 0 && !this.model.show_value) + this.titleEl.style.display = hide_header ? "none" : "" + + if (!hide_header) { + this.titleEl.style.visibility = "visible" + const {title} = this.model + if (title != null && title.length > 0) { + if (this.contains_tex_string(title)) { + this.titleEl.innerHTML = `${this.process_tex(title)}` + if (this.model.show_value) { + this.titleEl.innerHTML += ": " + } + } else { + this.titleEl.textContent = `${title}` + if (this.model.show_value) { + this.titleEl.textContent += ": " + } + } + } + + if (this.model.show_value) { + this.append_value_to_title_el() + } + } else { + this.titleEl.style.visibility = "hidden" + } + } + + append_value_to_title_el(): void { + this.titleEl.appendChild(span({class: "pn-player-value"}, to_string(this.model.value))) + } + + set_value_align(): void { + switch (this.model.value_align) { + case "start": + this.titleEl.style.textAlign = "left" + break + case "center": + this.titleEl.style.textAlign = "center" + break + case "end": + this.titleEl.style.textAlign = "right" + console.log(this.titleEl) + break + } + } + set_loop_state(state: string): void { const button_group = this.loop_state.state for (let i = 0; i < button_group.length; i++) { @@ -429,9 +493,12 @@ export namespace Player { end: p.Property step: p.Property loop_policy: p.Property + title: p.Property value: p.Property + value_align: p.Property value_throttled: p.Property show_loop_controls: p.Property + show_value: p.Property } } @@ -450,16 +517,19 @@ export class Player extends Widget { static { this.prototype.default_view = PlayerView - this.define(({Bool, Int}) => ({ + this.define(({Bool, Int, Str}) => ({ direction: [Int, 0], interval: [Int, 500], start: [Int, 0], end: [Int, 10], step: [Int, 1], loop_policy: [LoopPolicy, "once"], + title: [Str, ""], value: [Int, 0], + value_align: [Str, "start"], value_throttled: [Int, 0], show_loop_controls: [Bool, true], + show_value: [Bool, true], })) this.override({width: 400}) diff --git a/panel/models/widgets.py b/panel/models/widgets.py index d35d9c298f..8504fcddda 100644 --- a/panel/models/widgets.py +++ b/panel/models/widgets.py @@ -31,6 +31,9 @@ class Player(Widget): """ The Player widget provides controls to play through a number of frames. """ + title = Nullable(String, default="", help=""" + The slider's label (supports :ref:`math text `). + """) start = Int(0, help="Lower bound of the Player slider") @@ -40,6 +43,9 @@ class Player(Widget): value_throttled = Int(0, help="Current throttled value of the player app") + value_align = String("start", help="""Location to display + the value of the slider ("start" "center", "end")""") + step = Int(1, help="Number of steps to advance the player by.") interval = Int(500, help="Interval between updates") @@ -53,11 +59,20 @@ class Player(Widget): show_loop_controls = Bool(True, help="""Whether the loop controls radio buttons are shown""") + show_value = Bool(True, help=""" + Whether to show the widget value""") + width = Override(default=400) height = Override(default=250) +class DiscretePlayer(Player): + + options = List(Any, help=""" + List of discrete options.""") + + class SingleSelect(InputWidget): ''' Single-select widget. diff --git a/panel/tests/ui/widgets/test_player.py b/panel/tests/ui/widgets/test_player.py new file mode 100644 index 0000000000..fde255d6a8 --- /dev/null +++ b/panel/tests/ui/widgets/test_player.py @@ -0,0 +1,55 @@ +import pytest + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +from panel.tests.util import serve_component, wait_until +from panel.widgets import Player + +pytestmark = pytest.mark.ui + + +def test_init(page): + player = Player() + serve_component(page, player) + + assert not page.is_visible('pn-player-value') + assert page.query_selector('.pn-player-value') is None + +def test_show_value(page): + player = Player(show_value=True) + serve_component(page, player) + + wait_until(lambda: page.query_selector('.pn-player-value') is not None) + assert page.query_selector('.pn-player-value') is not None + + +def test_name(page): + player = Player(name='test') + serve_component(page, player) + + assert page.is_visible('label') + assert page.query_selector('.pn-player-value') is None + + name = page.locator('.pn-player-title:has-text("test")') + expect(name).to_have_count(1) + + +def test_value_align(page): + player = Player(name='test', value_align='end') + serve_component(page, player) + + name = page.locator('.pn-player-title:has-text("test")') + expect(name).to_have_css("text-align", "right") + + +def test_name_and_show_value(page): + player = Player(name='test', show_value=True) + serve_component(page, player) + + assert page.is_visible('label') + assert page.query_selector('.pn-player-value') is not None + + name = page.locator('.pn-player-title:has-text("test")') + expect(name).to_have_count(1) diff --git a/panel/widgets/player.py b/panel/widgets/player.py index 817ead241e..565d6489da 100644 --- a/panel/widgets/player.py +++ b/panel/widgets/player.py @@ -8,7 +8,10 @@ import param from ..config import config -from ..models.widgets import Player as _BkPlayer +from ..io.resources import CDN_DIST +from ..models.widgets import ( + DiscretePlayer as _BkDiscretePlayer, Player as _BkPlayer, +) from ..util import indexOf, isIn from .base import Widget from .select import SelectBase @@ -34,19 +37,29 @@ class PlayerBase(Widget): show_loop_controls = param.Boolean(default=True, doc=""" Whether the loop controls radio buttons are shown""") + show_value = param.Boolean(default=False, doc=""" + Whether to show the widget value""") + step = param.Integer(default=1, doc=""" Number of frames to step forward and back by on each event.""") height = param.Integer(default=80) + value_align = param.ObjectSelector( + objects=["start", "center", "end"], doc=""" + Location to display the value of the slider + ("start", "center", "end")""") + width = param.Integer(default=510, allow_None=True, doc=""" Width of this component. If sizing_mode is set to stretch or scale mode this will merely be used as a suggestion.""") - _rename: ClassVar[Mapping[str, str | None]] = {'name': None} + _rename: ClassVar[Mapping[str, str | None]] = {'name': 'title'} _widget_type: ClassVar[type[Model]] = _BkPlayer + _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/player.css"] + __abstract = True def __init__(self, **params): @@ -76,7 +89,7 @@ class Player(PlayerBase): :Example: - >>> Player(name='Player', start=0, end=100, value=32, loop_policy='loop') + >>> Player(name='Player', start=0, end=100, value=32, loop_policy='loop', value_align='top_center') """ start = param.Integer(default=0, doc="Lower bound on the slider value") @@ -130,7 +143,8 @@ class DiscretePlayer(PlayerBase, SelectBase): >>> DiscretePlayer( ... name='Discrete Player', ... options=[2, 4, 8, 16, 32, 64, 128], value=32, - ... loop_policy='loop' + ... loop_policy='loop', + ... value_align='start' ... ) """ @@ -140,10 +154,12 @@ class DiscretePlayer(PlayerBase, SelectBase): value_throttled = param.Parameter(constant=True, doc="Current player value") - _rename: ClassVar[Mapping[str, str | None]] = {'name': None, 'options': None} + _rename: ClassVar[Mapping[str, str | None]] = {'name': 'title'} _source_transforms: ClassVar[Mapping[str, str | None]] = {'value': None, 'value_throttled': None} + _widget_type: ClassVar[type[Model]] = _BkDiscretePlayer + def _process_param_change(self, msg): values = self.values if 'options' in msg: