mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-21 19:25:35 -05:00
582 lines
17 KiB
Python
582 lines
17 KiB
Python
try:
|
|
from typing import Any
|
|
except ImportError:
|
|
Any = "Any"
|
|
|
|
try:
|
|
import warnings
|
|
except ImportError:
|
|
# TODO: For now it probably means we are in MicroPython. We should figure
|
|
# out the "right" way to handle this. For now we just ignore the warning
|
|
# and logging to console
|
|
class warnings:
|
|
@staticmethod
|
|
def warn(*args, **kwargs):
|
|
print("WARNING: ", *args, **kwargs)
|
|
|
|
|
|
try:
|
|
from functools import cached_property
|
|
except ImportError:
|
|
# TODO: same comment about micropython as above
|
|
cached_property = property
|
|
|
|
try:
|
|
from pyodide.ffi import JsProxy
|
|
except ImportError:
|
|
# TODO: same comment about micropython as above
|
|
def JsProxy(obj):
|
|
return obj
|
|
|
|
|
|
from pyscript import display, document, window
|
|
|
|
alert = window.alert
|
|
|
|
|
|
class JSProperty:
|
|
"""JS property descriptor that directly maps to the property with the same
|
|
name in the underlying Shoelace JS component."""
|
|
|
|
|
|
def js_property(name: str, allow_nones: bool = False):
|
|
"""Create a property that maps to the property with the same name in the underlying
|
|
JS component.
|
|
|
|
Args:
|
|
name (str): The name of the property to map to.
|
|
allow_nones (bool): Whether to allow None values to be set. Defaults to False.
|
|
|
|
Example:
|
|
class Button(ElementBase):
|
|
tag = 'sl-button'
|
|
# the JS property is called 'variant'
|
|
variant = js_property('variant')
|
|
|
|
button = Button("Click me")
|
|
# This will set the property 'variant' on the underlying JS element to 'primary'
|
|
button.variant = 'primary'
|
|
|
|
Returns:
|
|
the property created
|
|
"""
|
|
|
|
class CustomProperty(JSProperty):
|
|
def __get__(self, obj, objtype=None):
|
|
return getattr(obj._js, name)
|
|
|
|
def __set__(self, obj, value):
|
|
if not allow_nones and value is None:
|
|
return
|
|
setattr(obj._js, name, value)
|
|
|
|
return CustomProperty()
|
|
|
|
|
|
class BaseElement:
|
|
def __init__(self, js_element):
|
|
self._js = js_element
|
|
self._parent = None
|
|
self.style = StyleProxy(self)
|
|
self._proxies = {}
|
|
|
|
def __eq__(self, obj):
|
|
"""Check if the element is the same as the other element by comparing
|
|
the underlying JS element"""
|
|
return isinstance(obj, BaseElement) and obj._js == self._js
|
|
|
|
@property
|
|
def parent(self):
|
|
if self._parent:
|
|
return self._parent
|
|
|
|
if self._js.parentElement:
|
|
self._parent = self.__class__(self._js.parentElement)
|
|
|
|
return self._parent
|
|
|
|
@property
|
|
def __class(self):
|
|
return self.__class__ if self.__class__ != PyDom else Element
|
|
|
|
def create(self, type_, is_child=True, classes=None, html=None, label=None):
|
|
js_el = document.createElement(type_)
|
|
element = self.__class(js_el)
|
|
|
|
if classes:
|
|
for class_ in classes:
|
|
element.add_class(class_)
|
|
|
|
if html is not None:
|
|
element.html = html
|
|
|
|
if label is not None:
|
|
element.label = label
|
|
|
|
if is_child:
|
|
self.append(element)
|
|
|
|
return element
|
|
|
|
def find(self, selector):
|
|
"""Return an ElementCollection representing all the child elements that
|
|
match the specified selector.
|
|
|
|
Args:
|
|
selector (str): A string containing a selector expression
|
|
|
|
Returns:
|
|
ElementCollection: A collection of elements matching the selector
|
|
"""
|
|
elements = self._js.querySelectorAll(selector)
|
|
if not elements:
|
|
return None
|
|
return ElementCollection([Element(el) for el in elements])
|
|
|
|
|
|
class Element(BaseElement):
|
|
@property
|
|
def children(self):
|
|
return [self.__class__(el) for el in self._js.children]
|
|
|
|
def append(self, child):
|
|
# TODO: this is Pyodide specific for now!!!!!!
|
|
# if we get passed a JSProxy Element directly we just map it to the
|
|
# higher level Python element
|
|
if isinstance(child, JsProxy):
|
|
return self.append(Element(child))
|
|
|
|
elif isinstance(child, Element):
|
|
self._js.appendChild(child._js)
|
|
|
|
return child
|
|
|
|
elif isinstance(child, ElementCollection):
|
|
for el in child:
|
|
self.append(el)
|
|
|
|
# -------- Pythonic Interface to Element -------- #
|
|
@property
|
|
def html(self):
|
|
return self._js.innerHTML
|
|
|
|
@html.setter
|
|
def html(self, value):
|
|
self._js.innerHTML = value
|
|
|
|
@property
|
|
def content(self):
|
|
# TODO: This breaks with with standard template elements. Define how to best
|
|
# handle this specifica use case. Just not support for now?
|
|
if self._js.tagName == "TEMPLATE":
|
|
warnings.warn(
|
|
"Content attribute not supported for template elements.", stacklevel=2
|
|
)
|
|
return None
|
|
return self._js.innerHTML
|
|
|
|
@content.setter
|
|
def content(self, value):
|
|
# TODO: (same comment as above)
|
|
if self._js.tagName == "TEMPLATE":
|
|
warnings.warn(
|
|
"Content attribute not supported for template elements.", stacklevel=2
|
|
)
|
|
return
|
|
|
|
display(value, target=self.id)
|
|
|
|
@property
|
|
def id(self):
|
|
return self._js.id
|
|
|
|
@id.setter
|
|
def id(self, value):
|
|
self._js.id = value
|
|
|
|
@property
|
|
def options(self):
|
|
if "options" in self._proxies:
|
|
return self._proxies["options"]
|
|
|
|
if not self._js.tagName.lower() in {"select", "datalist", "optgroup"}:
|
|
raise AttributeError(
|
|
f"Element {self._js.tagName} has no options attribute."
|
|
)
|
|
self._proxies["options"] = OptionsProxy(self)
|
|
return self._proxies["options"]
|
|
|
|
@property
|
|
def value(self):
|
|
return self._js.value
|
|
|
|
@value.setter
|
|
def value(self, value):
|
|
# in order to avoid confusion to the user, we don't allow setting the
|
|
# value of elements that don't have a value attribute
|
|
if not hasattr(self._js, "value"):
|
|
raise AttributeError(
|
|
f"Element {self._js.tagName} has no value attribute. If you want to "
|
|
"force a value attribute, set it directly using the `_js.value = <value>` "
|
|
"javascript API attribute instead."
|
|
)
|
|
self._js.value = value
|
|
|
|
@property
|
|
def selected(self):
|
|
return self._js.selected
|
|
|
|
@selected.setter
|
|
def selected(self, value):
|
|
# in order to avoid confusion to the user, we don't allow setting the
|
|
# value of elements that don't have a value attribute
|
|
if not hasattr(self._js, "selected"):
|
|
raise AttributeError(
|
|
f"Element {self._js.tagName} has no value attribute. If you want to "
|
|
"force a value attribute, set it directly using the `_js.value = <value>` "
|
|
"javascript API attribute instead."
|
|
)
|
|
self._js.selected = value
|
|
|
|
def clone(self, new_id=None):
|
|
clone = Element(self._js.cloneNode(True))
|
|
clone.id = new_id
|
|
|
|
return clone
|
|
|
|
def remove_class(self, classname):
|
|
classList = self._js.classList
|
|
if isinstance(classname, list):
|
|
classList.remove(*classname)
|
|
else:
|
|
classList.remove(classname)
|
|
return self
|
|
|
|
def add_class(self, classname):
|
|
classList = self._js.classList
|
|
if isinstance(classname, list):
|
|
classList.add(*classname)
|
|
else:
|
|
self._js.classList.add(classname)
|
|
return self
|
|
|
|
@property
|
|
def classes(self):
|
|
classes = self._js.classList.values()
|
|
return [x for x in classes]
|
|
|
|
def show_me(self):
|
|
self._js.scrollIntoView()
|
|
|
|
def snap(
|
|
self,
|
|
to: BaseElement | str = None,
|
|
width: int | None = None,
|
|
height: int | None = None,
|
|
):
|
|
"""
|
|
Captures a snapshot of a video element. (Only available for video elements)
|
|
|
|
Inputs:
|
|
|
|
* to: element where to save the snapshot of the video frame to
|
|
* width: width of the image
|
|
* height: height of the image
|
|
|
|
Output:
|
|
(Element) canvas element where the video frame snapshot was drawn into
|
|
"""
|
|
if self._js.tagName != "VIDEO":
|
|
raise AttributeError("Snap method is only available for video Elements")
|
|
|
|
if to is None:
|
|
canvas = self.create("canvas")
|
|
if width is None:
|
|
width = self._js.width
|
|
if height is None:
|
|
height = self._js.height
|
|
canvas._js.width = width
|
|
canvas._js.height = height
|
|
|
|
elif isistance(to, Element):
|
|
if to._js.tagName != "CANVAS":
|
|
raise TypeError("Element to snap to must a canvas.")
|
|
canvas = to
|
|
elif getattr(to, "tagName", "") == "CANVAS":
|
|
canvas = Element(to)
|
|
elif isinstance(to, str):
|
|
canvas = pydom[to][0]
|
|
if canvas._js.tagName != "CANVAS":
|
|
raise TypeError("Element to snap to must a be canvas.")
|
|
|
|
canvas.draw(self, width, height)
|
|
|
|
return canvas
|
|
|
|
def download(self, filename: str = "snapped.png") -> None:
|
|
"""Download the current element (only available for canvas elements) with the filename
|
|
provided in input.
|
|
|
|
Inputs:
|
|
* filename (str): name of the file being downloaded
|
|
|
|
Output:
|
|
None
|
|
"""
|
|
if self._js.tagName != "CANVAS":
|
|
raise AttributeError(
|
|
"The download method is only available for canvas Elements"
|
|
)
|
|
|
|
link = self.create("a")
|
|
link._js.download = filename
|
|
link._js.href = self._js.toDataURL()
|
|
link._js.click()
|
|
|
|
def draw(self, what, width, height):
|
|
"""Draw `what` on the current element (only available for canvas elements).
|
|
|
|
Inputs:
|
|
|
|
* what (canvas image source): An element to draw into the context. The specification permits any canvas
|
|
image source, specifically, an HTMLImageElement, an SVGImageElement, an HTMLVideoElement,
|
|
an HTMLCanvasElement, an ImageBitmap, an OffscreenCanvas, or a VideoFrame.
|
|
"""
|
|
if self._js.tagName != "CANVAS":
|
|
raise AttributeError(
|
|
"The draw method is only available for canvas Elements"
|
|
)
|
|
|
|
if isinstance(what, Element):
|
|
what = what._js
|
|
|
|
# https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
|
|
self._js.getContext("2d").drawImage(what, 0, 0, width, height)
|
|
|
|
|
|
class OptionsProxy:
|
|
"""This class represents the options of a select element. It
|
|
allows to access to add and remove options by using the `add` and `remove` methods.
|
|
"""
|
|
|
|
def __init__(self, element: Element) -> None:
|
|
self._element = element
|
|
if self._element._js.tagName.lower() != "select":
|
|
raise AttributeError(
|
|
f"Element {self._element._js.tagName} has no options attribute."
|
|
)
|
|
|
|
def add(
|
|
self,
|
|
value: Any = None,
|
|
html: str = None,
|
|
text: str = None,
|
|
before: Element | int = None,
|
|
**kws,
|
|
) -> None:
|
|
"""Add a new option to the select element"""
|
|
# create the option element and set the attributes
|
|
option = document.createElement("option")
|
|
if value is not None:
|
|
kws["value"] = value
|
|
if html is not None:
|
|
option.innerHTML = html
|
|
if text is not None:
|
|
kws["text"] = text
|
|
|
|
for key, value in kws.items():
|
|
option.setAttribute(key, value)
|
|
|
|
if before:
|
|
if isinstance(before, Element):
|
|
before = before._js
|
|
|
|
self._element._js.add(option, before)
|
|
|
|
def remove(self, item: int) -> None:
|
|
"""Remove the option at the specified index"""
|
|
self._element._js.remove(item)
|
|
|
|
def clear(self) -> None:
|
|
"""Remove all the options"""
|
|
for i in range(len(self)):
|
|
self.remove(0)
|
|
|
|
@property
|
|
def options(self):
|
|
"""Return the list of options"""
|
|
return [Element(opt) for opt in self._element._js.options]
|
|
|
|
@property
|
|
def selected(self):
|
|
"""Return the selected option"""
|
|
return self.options[self._element._js.selectedIndex]
|
|
|
|
def __iter__(self):
|
|
yield from self.options
|
|
|
|
def __len__(self):
|
|
return len(self.options)
|
|
|
|
def __repr__(self):
|
|
return f"{self.__class__.__name__} (length: {len(self)}) {self.options}"
|
|
|
|
def __getitem__(self, key):
|
|
return self.options[key]
|
|
|
|
|
|
class StyleProxy: # (dict):
|
|
def __init__(self, element: Element) -> None:
|
|
self._element = element
|
|
|
|
@cached_property
|
|
def _style(self):
|
|
return self._element._js.style
|
|
|
|
def __getitem__(self, key):
|
|
return self._style.getPropertyValue(key)
|
|
|
|
def __setitem__(self, key, value):
|
|
self._style.setProperty(key, value)
|
|
|
|
def remove(self, key):
|
|
self._style.removeProperty(key)
|
|
|
|
def set(self, **kws):
|
|
for k, v in kws.items():
|
|
self._element._js.style.setProperty(k, v)
|
|
|
|
# CSS Properties
|
|
# Reference: https://github.com/microsoft/TypeScript/blob/main/src/lib/dom.generated.d.ts#L3799C1-L5005C2
|
|
# Following prperties automatically generated from the above reference using
|
|
# tools/codegen_css_proxy.py
|
|
@property
|
|
def visible(self):
|
|
return self._element._js.style.visibility
|
|
|
|
@visible.setter
|
|
def visible(self, value):
|
|
self._element._js.style.visibility = value
|
|
|
|
|
|
class StyleCollection:
|
|
def __init__(self, collection: "ElementCollection") -> None:
|
|
self._collection = collection
|
|
|
|
def __get__(self, obj, objtype=None):
|
|
return obj._get_attribute("style")
|
|
|
|
def __getitem__(self, key):
|
|
return self._collection._get_attribute("style")[key]
|
|
|
|
def __setitem__(self, key, value):
|
|
for element in self._collection._elements:
|
|
element.style[key] = value
|
|
|
|
def remove(self, key):
|
|
for element in self._collection._elements:
|
|
element.style.remove(key)
|
|
|
|
|
|
class ElementCollection:
|
|
def __init__(self, elements: [Element]) -> None:
|
|
self._elements = elements
|
|
self.style = StyleCollection(self)
|
|
|
|
def __getitem__(self, key):
|
|
# If it's an integer we use it to access the elements in the collection
|
|
if isinstance(key, int):
|
|
return self._elements[key]
|
|
# If it's a slice we use it to support slice operations over the elements
|
|
# in the collection
|
|
elif isinstance(key, slice):
|
|
return ElementCollection(self._elements[key])
|
|
|
|
# If it's anything else (basically a string) we use it as a selector
|
|
# TODO: Write tests!
|
|
elements = self._element.querySelectorAll(key)
|
|
return ElementCollection([Element(el) for el in elements])
|
|
|
|
def __len__(self):
|
|
return len(self._elements)
|
|
|
|
def __eq__(self, obj):
|
|
"""Check if the element is the same as the other element by comparing
|
|
the underlying JS element"""
|
|
return isinstance(obj, ElementCollection) and obj._elements == self._elements
|
|
|
|
def _get_attribute(self, attr, index=None):
|
|
if index is None:
|
|
return [getattr(el, attr) for el in self._elements]
|
|
|
|
# As JQuery, when getting an attr, only return it for the first element
|
|
return getattr(self._elements[index], attr)
|
|
|
|
def _set_attribute(self, attr, value):
|
|
for el in self._elements:
|
|
setattr(el, attr, value)
|
|
|
|
@property
|
|
def html(self):
|
|
return self._get_attribute("html")
|
|
|
|
@html.setter
|
|
def html(self, value):
|
|
self._set_attribute("html", value)
|
|
|
|
@property
|
|
def value(self):
|
|
return self._get_attribute("value")
|
|
|
|
@value.setter
|
|
def value(self, value):
|
|
self._set_attribute("value", value)
|
|
|
|
@property
|
|
def children(self):
|
|
return self._elements
|
|
|
|
def __iter__(self):
|
|
yield from self._elements
|
|
|
|
def __repr__(self):
|
|
return f"{self.__class__.__name__} (length: {len(self._elements)}) {self._elements}"
|
|
|
|
|
|
class DomScope:
|
|
def __getattr__(self, __name: str):
|
|
element = document[f"#{__name}"]
|
|
if element:
|
|
return element[0]
|
|
|
|
|
|
class PyDom(BaseElement):
|
|
# Add objects we want to expose to the DOM namespace since this class instance is being
|
|
# remapped as "the module" itself
|
|
BaseElement = BaseElement
|
|
Element = Element
|
|
ElementCollection = ElementCollection
|
|
|
|
def __init__(self):
|
|
# PyDom is a special case of BaseElement where we don't want to create a new JS element
|
|
# and it really doesn't have a need for styleproxy or parent to to call to __init__
|
|
# (which actually fails in MP for some reason)
|
|
self._js = document
|
|
self._parent = None
|
|
self._proxies = {}
|
|
self.ids = DomScope()
|
|
self.body = Element(document.body)
|
|
self.head = Element(document.head)
|
|
|
|
def create(self, type_, classes=None, html=None):
|
|
return super().create(type_, is_child=False, classes=classes, html=html)
|
|
|
|
def __getitem__(self, key):
|
|
elements = self._js.querySelectorAll(key)
|
|
if not elements:
|
|
return None
|
|
return ElementCollection([Element(el) for el in elements])
|
|
|
|
|
|
dom = PyDom()
|