Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add toggle icon #6034

Merged
merged 15 commits into from
Dec 15, 2023
127 changes: 127 additions & 0 deletions examples/reference/widgets/ToggleIcon.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import panel as pn\n",
"pn.extension()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The ``ToggleIcon`` widget allows toggling a single condition between ``True``/``False`` states. This widget is interchangeable with the ``Checkbox`` widget.\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``** (boolean): Whether the icon is toggled on or off\n",
"* **`icon`** (str): The name of the icon to display from [tabler-icons.io](https://tabler-icons.io)/\n",
"* **`active_icon`** (str): The name of the icon to display when toggled from [tabler-icons.io](https://tabler-icons.io)/\n",
"\n",
"##### Display\n",
"\n",
"* **``disabled``** (boolean): Whether the widget is editable\n",
"* **``name``** (str): The title of the widget\n",
"\n",
"___"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"toggle = pn.widgets.ToggleIcon()\n",
"toggle"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"``Toggle.value`` is either True or False depending on whether the icon is toggled:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"toggle.value"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The `value` can be modified by clicking the icon, or setting it explicitly."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"toggle.value = True"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"By default, when `value` is `True`, the `active_icon` (`heart-filled`) is the filled version of the `icon` (`heart`).\n",
"\n",
"If you'd like this to be changed, manually set the `active_icon`."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"toggle = pn.widgets.ToggleIcon(icon=\"thumb-down\", active_icon=\"thumb-up\")\n",
"toggle"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Controls\n",
"\n",
"The `Toggle` widget exposes a number of options which can be changed from both Python and Javascript. Try out the effect of these parameters interactively:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"pn.Row(toggle.controls(jslink=True), toggle).show()"
]
}
],
"metadata": {
"language_info": {
"name": "python",
"pygments_lexer": "ipython3"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
192 changes: 45 additions & 147 deletions panel/chat/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,15 @@
from typing import ClassVar, List

import param
import requests

from ..io.cache import cache
from ..io.resources import CDN_DIST
from ..io.state import state
from ..pane.image import SVG
from ..layout import Column
from ..reactive import ReactiveHTML
from ..widgets.base import CompositeWidget
from ..widgets.icon import ToggleIcon

# if user cannot connect to internet
HEART_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-heart" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
</svg>
""" # noqa: E501

HEART_FILLED_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-heart-filled" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6.979 3.074a6 6 0 0 1 4.988 1.425l.037 .033l.034 -.03a6 6 0 0 1 4.733 -1.44l.246 .036a6 6 0 0 1 3.364 10.008l-.18 .185l-.048 .041l-7.45 7.379a1 1 0 0 1 -1.313 .082l-.094 -.082l-7.493 -7.422a6 6 0 0 1 3.176 -10.215z" stroke-width="0" fill="currentColor" />
</svg>
""" # noqa: E501

MISSING_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-help-square" width="15" height="15" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14z"></path>
<path d="M12 16v.01"></path>
<path d="M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"></path>
</svg>
""" # noqa: E501

MISSING_FILLED_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-help-square-filled" width="15" height="15" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19 2a3 3 0 0 1 2.995 2.824l.005 .176v14a3 3 0 0 1 -2.824 2.995l-.176 .005h-14a3 3 0 0 1 -2.995 -2.824l-.005 -.176v-14a3 3 0 0 1 2.824 -2.995l.176 -.005h14zm-7 13a1 1 0 0 0 -.993 .883l-.007 .117l.007 .127a1 1 0 0 0 1.986 0l.007 -.117l-.007 -.127a1 1 0 0 0 -.993 -.883zm1.368 -6.673a2.98 2.98 0 0 0 -3.631 .728a1 1 0 0 0 1.44 1.383l.171 -.18a.98 .98 0 0 1 1.11 -.15a1 1 0 0 1 -.34 1.886l-.232 .012a1 1 0 0 0 .111 1.994a3 3 0 0 0 1.371 -5.673z" stroke-width="0" fill="currentColor"></path>
</svg>
""" # noqa: E501


class ChatReactionIcons(ReactiveHTML):

class ChatReactionIcons(CompositeWidget):
"""
A widget to display reaction icons that can be clicked on.

Expand Down Expand Up @@ -81,127 +51,55 @@ class ChatReactionIcons(ReactiveHTML):

value = param.List(doc="The active reactions.")

_reactions = param.List(
doc="""
The list of reactions, which is the same as the keys of the options dict;
primarily needed as a workaround for quirks of ReactiveHTML."""
)
css_classes = param.List(default=["reaction-icons"], doc="The CSS classes of the widget.")

_svgs = param.List(
doc="""
The list of SVGs corresponding to the active reactions."""
)

_base_url = param.String(
default="https://tabler-icons.io/static/tabler-icons/icons/",
_rendered_icons = param.Dict(
default={},
doc="""
The base URL for the SVGs.""",
The rendered icons mapping reaction to icon.""",
)

_template = """
<div id="reaction-icons" class="reaction-icons">
{% for option in options %}
<span
type="button"
id="reaction-{{ loop.index0 }}"
onclick="${script('toggle_value')}"
style="cursor: pointer; width: ${model.width}px; height: ${model.height}px;"
title="{{ _reactions[loop.index0]|title }}"
>
${_svgs[{{ loop.index0 }}]}
</span>
{% endfor %}
</div>
"""

_scripts = {
"toggle_value": """
svg = event.target.shadowRoot.querySelector("svg");
const reaction = svg.getAttribute("alt");
const icon_name = data.options[reaction];
let src;
if (data.value.includes(reaction)) {
src = `${data._base_url}${icon_name}.svg`;
data.value = data.value.filter(r => r !== reaction);
} else {
src = reaction in data.active_icons
? `${data._base_url}${data.active_icons[reaction]}.svg`
: `${data._base_url}${icon_name}-filled.svg`;
data.value = [...data.value, reaction];
}
event.target.src = src;
"""
}

_stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_reaction_icons.css"]

def _get_label(self, active: bool, reaction: str, icon: str):
if active and reaction in self.active_icons:
icon_label = self.active_icons[reaction]
elif active:
icon_label = f"{icon}-filled"
else:
icon_label = icon
return icon_label

@cache
def _fetch_svg(self, icon_label: str):
if icon_label in ("heart", "heart-filled"):
return HEART_FILLED_SVG if "filled" in icon_label else HEART_SVG
elif icon_label in ("help-square", "help-square-filled"):
return MISSING_FILLED_SVG if "filled" in icon_label else MISSING_SVG

src = f"{self._base_url}{icon_label}.svg"
with requests.get(src) as response:
response.raise_for_status()
svg = response.text
return svg

def _stylize_svg(self, svg, reaction):
if b"dark" in state.session_args.get("theme", []):
svg = svg.replace('stroke="currentColor"', 'stroke="white"')
svg = svg.replace('fill="currentColor"', 'fill="white"')
if self.width:
svg = svg.replace('width="24"', f'width="{self.width}px"')
if self.height:
svg = svg.replace('height="24"', f'height="{self.height}px"')
svg = svg.replace("<svg", f'<svg alt="{reaction}"')
return svg

@param.depends(
"value",
"options",
"active_icons",
"width",
"height",
watch=True,
on_init=True,
)
def _update_icons(self):
self._reactions = list(self.options.keys())
svgs = []
for reaction, icon in self.options.items():
active = reaction in self.value
icon_label = self._get_label(active, reaction, icon)
try:
svg = self._fetch_svg(icon_label)
except Exception:
svg = MISSING_FILLED_SVG if active else MISSING_SVG
svg = self._stylize_svg(svg, reaction)
# important not to encode to keep the alt text!
svg_pane = SVG(
svg,
sizing_mode=None,
alt_text=reaction,
encode=False,
_composite_type = Column

def __init__(self, **params):
super().__init__(**params)
self._render_icons()

@param.depends("options", watch=True)
def _render_icons(self):
self._rendered_icons = {}
for option, icon in self.options.items():
active_icon = self.active_icons.get(option, "")
icon = ToggleIcon(
icon=icon,
active_icon=active_icon,
value=option in self.value,
name=option,
margin=0,
)
svgs.append(svg_pane)
self._svgs = svgs
icon.param.watch(self._update_value, "value")
self._rendered_icons[option] = icon
self._composite[:] = list(self._rendered_icons.values())

for reaction in self.value:
if reaction not in self._reactions:
self.value.remove(reaction)
@param.depends("value", watch=True)
def _update_icons(self):
for option, icon in self._rendered_icons.items():
icon.value = option in self.value

@param.depends("active_icons", watch=True)
def _update_active_icons(self):
for option, icon in self._rendered_icons.items():
icon.active_icon = self.active_icons.get(option, "")

def _update_value(self, event):
icon = event.obj.name
value = event.new
if value and icon not in self.value:
self.value.append(icon)
elif not value and icon in self.value:
self.value.remove(icon)


class ChatCopyIcon(ReactiveHTML):
Expand Down
11 changes: 5 additions & 6 deletions panel/chat/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,9 @@ class ChatMessage(PaneBase):
reactions = param.List(doc="""
Reactions to associate with the message.""")

reaction_icons = param.ClassSelector(class_=(ChatReactionIcons, dict), doc="""
reaction_icons = param.ClassSelector(class_=ChatReactionIcons, doc="""
A mapping of reactions to their reaction icons; if not provided
defaults to `{"favorite": "heart"}`.""",)
defaults to `{"favorite": "heart"}`.""", allow_refs=False)

timestamp = param.Date(doc="""
Timestamp of the message. Defaults to the creation time.""")
Expand Down Expand Up @@ -227,11 +227,10 @@ def __init__(self, object=None, **params):
elif state.browser_info.timezone:
tz = ZoneInfo(state.browser_info.timezone)
params["timestamp"] = datetime.datetime.now(tz=tz)
if params.get("reaction_icons") is None:
params["reaction_icons"] = {"favorite": "heart"}
if isinstance(params["reaction_icons"], dict):
reaction_icons = params.get("reaction_icons", {"favorite": "heart"})
if isinstance(reaction_icons, dict):
params["reaction_icons"] = ChatReactionIcons(
options=params["reaction_icons"], width=15, height=15
options=reaction_icons, width=15, height=15
)
super().__init__(object=object, **params)
self.reaction_icons.link(self, value="reactions", bidirectional=True)
Expand Down
10 changes: 10 additions & 0 deletions panel/dist/css/chat_message.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,13 @@
.avatar.rotating-placeholder {
animation: icon-rotation 1.28s infinite cubic-bezier(0.68, -0.55, 0.27, 1.55);
}

.reaction-icons {
display: flex;
flex-direction: column;
align-items: start;
justify-content: end;
width: fit-content;
margin-block: 0px;
margin-inline: 2px;
}
8 changes: 0 additions & 8 deletions panel/dist/css/chat_reaction_icons.css
Original file line number Diff line number Diff line change
@@ -1,8 +0,0 @@
.reaction-icons {
display: flex;
flex-direction: column;
align-items: start;
justify-content: end;
width: fit-content;
margin-inline: 2px;
}
Empty file added panel/dist/css/icon.css
Empty file.
Loading