You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
365 lines
15 KiB
365 lines
15 KiB
from __future__ import annotations |
|
|
|
import inspect |
|
import re |
|
from copy import copy, deepcopy |
|
from pathlib import Path |
|
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence, Union |
|
|
|
from typing_extensions import Self |
|
|
|
from nicegui import json |
|
|
|
from . import events, globals, outbox, storage # pylint: disable=redefined-builtin |
|
from .dependencies import Component, Library, register_library, register_vue_component |
|
from .elements.mixins.visibility import Visibility |
|
from .event_listener import EventListener |
|
from .slot import Slot |
|
from .tailwind import Tailwind |
|
|
|
if TYPE_CHECKING: |
|
from .client import Client |
|
|
|
PROPS_PATTERN = re.compile(r'([:\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)') |
|
|
|
|
|
class Element(Visibility): |
|
component: Optional[Component] = None |
|
libraries: List[Library] = [] |
|
extra_libraries: List[Library] = [] |
|
exposed_libraries: List[Library] = [] |
|
|
|
def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = None) -> None: |
|
"""Generic Element |
|
|
|
This class is the base class for all other UI elements. |
|
But you can use it to create elements with arbitrary HTML tags. |
|
|
|
:param tag: HTML tag of the element |
|
:param _client: client for this element (for internal use only) |
|
""" |
|
super().__init__() |
|
self.client = _client or globals.get_client() |
|
self.id = self.client.next_element_id |
|
self.client.next_element_id += 1 |
|
self.tag = tag if tag else self.component.tag if self.component else 'div' |
|
self._classes: List[str] = [] |
|
self._style: Dict[str, str] = {} |
|
self._props: Dict[str, Any] = {'key': self.id} # HACK: workaround for #600 and #898 |
|
self._event_listeners: Dict[str, EventListener] = {} |
|
self._text: Optional[str] = None |
|
self.slots: Dict[str, Slot] = {} |
|
self.default_slot = self.add_slot('default') |
|
self._deleted: bool = False |
|
|
|
self.client.elements[self.id] = self |
|
self.parent_slot: Optional[Slot] = None |
|
slot_stack = globals.get_slot_stack() |
|
if slot_stack: |
|
self.parent_slot = slot_stack[-1] |
|
self.parent_slot.children.append(self) |
|
|
|
self.tailwind = Tailwind(self) |
|
|
|
outbox.enqueue_update(self) |
|
if self.parent_slot: |
|
outbox.enqueue_update(self.parent_slot.parent) |
|
|
|
def __init_subclass__(cls, *, |
|
component: Union[str, Path, None] = None, |
|
libraries: List[Union[str, Path]] = [], |
|
exposed_libraries: List[Union[str, Path]] = [], |
|
extra_libraries: List[Union[str, Path]] = [], |
|
) -> None: |
|
super().__init_subclass__() |
|
base = Path(inspect.getfile(cls)).parent |
|
|
|
def glob_absolute_paths(file: Union[str, Path]) -> List[Path]: |
|
path = Path(file) |
|
if not path.is_absolute(): |
|
path = base / path |
|
return sorted(path.parent.glob(path.name), key=lambda p: p.stem) |
|
|
|
cls.component = copy(cls.component) |
|
cls.libraries = copy(cls.libraries) |
|
cls.extra_libraries = copy(cls.extra_libraries) |
|
cls.exposed_libraries = copy(cls.exposed_libraries) |
|
if component: |
|
for path in glob_absolute_paths(component): |
|
cls.component = register_vue_component(path) |
|
for library in libraries: |
|
for path in glob_absolute_paths(library): |
|
cls.libraries.append(register_library(path)) |
|
for library in extra_libraries: |
|
for path in glob_absolute_paths(library): |
|
cls.extra_libraries.append(register_library(path)) |
|
for library in exposed_libraries: |
|
for path in glob_absolute_paths(library): |
|
cls.exposed_libraries.append(register_library(path, expose=True)) |
|
|
|
def add_slot(self, name: str, template: Optional[str] = None) -> Slot: |
|
"""Add a slot to the element. |
|
|
|
:param name: name of the slot |
|
:param template: Vue template of the slot |
|
:return: the slot |
|
""" |
|
self.slots[name] = Slot(self, name, template) |
|
return self.slots[name] |
|
|
|
def __enter__(self) -> Self: |
|
self.default_slot.__enter__() |
|
return self |
|
|
|
def __exit__(self, *_): |
|
self.default_slot.__exit__(*_) |
|
|
|
def __iter__(self) -> Iterator[Element]: |
|
for slot in self.slots.values(): |
|
for child in slot: |
|
yield child |
|
|
|
def _collect_slot_dict(self) -> Dict[str, Any]: |
|
return { |
|
name: {'template': slot.template, 'ids': [child.id for child in slot]} |
|
for name, slot in self.slots.items() |
|
} |
|
|
|
def _to_dict(self) -> Dict[str, Any]: |
|
return { |
|
'id': self.id, |
|
'tag': self.tag, |
|
'class': self._classes, |
|
'style': self._style, |
|
'props': self._props, |
|
'text': self._text, |
|
'slots': self._collect_slot_dict(), |
|
'events': [listener.to_dict() for listener in self._event_listeners.values()], |
|
'component': { |
|
'key': self.component.key, |
|
'name': self.component.name, |
|
'tag': self.component.tag |
|
} if self.component else None, |
|
'libraries': [ |
|
{ |
|
'key': library.key, |
|
'name': library.name, |
|
} for library in self.libraries |
|
], |
|
} |
|
|
|
@staticmethod |
|
def _update_classes_list( |
|
classes: List[str], |
|
add: Optional[str] = None, remove: Optional[str] = None, replace: Optional[str] = None) -> List[str]: |
|
class_list = classes if replace is None else [] |
|
class_list = [c for c in class_list if c not in (remove or '').split()] |
|
class_list += (add or '').split() |
|
class_list += (replace or '').split() |
|
return list(dict.fromkeys(class_list)) # NOTE: remove duplicates while preserving order |
|
|
|
def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \ |
|
-> Self: |
|
"""Apply, remove, or replace HTML classes. |
|
|
|
This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes. |
|
|
|
Removing or replacing classes can be helpful if predefined classes are not desired. |
|
|
|
:param add: whitespace-delimited string of classes |
|
:param remove: whitespace-delimited string of classes to remove from the element |
|
:param replace: whitespace-delimited string of classes to use instead of existing ones |
|
""" |
|
new_classes = self._update_classes_list(self._classes, add, remove, replace) |
|
if self._classes != new_classes: |
|
self._classes = new_classes |
|
self.update() |
|
return self |
|
|
|
@staticmethod |
|
def _parse_style(text: Optional[str]) -> Dict[str, str]: |
|
result = {} |
|
for word in (text or '').split(';'): |
|
word = word.strip() |
|
if word: |
|
key, value = word.split(':', 1) |
|
result[key.strip()] = value.strip() |
|
return result |
|
|
|
def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) -> Self: |
|
"""Apply, remove, or replace CSS definitions. |
|
|
|
Removing or replacing styles can be helpful if the predefined style is not desired. |
|
|
|
:param add: semicolon-separated list of styles to add to the element |
|
:param remove: semicolon-separated list of styles to remove from the element |
|
:param replace: semicolon-separated list of styles to use instead of existing ones |
|
""" |
|
style_dict = deepcopy(self._style) if replace is None else {} |
|
for key in self._parse_style(remove): |
|
style_dict.pop(key, None) |
|
style_dict.update(self._parse_style(add)) |
|
style_dict.update(self._parse_style(replace)) |
|
if self._style != style_dict: |
|
self._style = style_dict |
|
self.update() |
|
return self |
|
|
|
@staticmethod |
|
def _parse_props(text: Optional[str]) -> Dict[str, Any]: |
|
dictionary = {} |
|
for match in PROPS_PATTERN.finditer(text or ''): |
|
key = match.group(1) |
|
value = match.group(2) or match.group(3) |
|
if value and value.startswith('"') and value.endswith('"'): |
|
value = json.loads(value) |
|
dictionary[key] = value or True |
|
return dictionary |
|
|
|
def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self: |
|
"""Add or remove props. |
|
|
|
This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props. |
|
Since props are simply applied as HTML attributes, they can be used with any HTML element. |
|
|
|
Boolean properties are assumed ``True`` if no value is specified. |
|
|
|
:param add: whitespace-delimited list of either boolean values or key=value pair to add |
|
:param remove: whitespace-delimited list of property keys to remove |
|
""" |
|
needs_update = False |
|
for key in self._parse_props(remove): |
|
if key in self._props: |
|
needs_update = True |
|
del self._props[key] |
|
for key, value in self._parse_props(add).items(): |
|
if self._props.get(key) != value: |
|
needs_update = True |
|
self._props[key] = value |
|
if needs_update: |
|
self.update() |
|
return self |
|
|
|
def tooltip(self, text: str) -> Self: |
|
"""Add a tooltip to the element. |
|
|
|
:param text: text of the tooltip |
|
""" |
|
with self: |
|
tooltip = Element('q-tooltip') |
|
tooltip._text = text # pylint: disable=protected-access |
|
return self |
|
|
|
def on(self, |
|
type: str, # pylint: disable=redefined-builtin |
|
handler: Optional[Callable[..., Any]] = None, |
|
args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None, *, |
|
throttle: float = 0.0, |
|
leading_events: bool = True, |
|
trailing_events: bool = True, |
|
) -> Self: |
|
"""Subscribe to an event. |
|
|
|
:param type: name of the event (e.g. "click", "mousedown", or "update:model-value") |
|
:param handler: callback that is called upon occurrence of the event |
|
:param args: arguments included in the event message sent to the event handler (default: `None` meaning all) |
|
:param throttle: minimum time (in seconds) between event occurrences (default: 0.0) |
|
:param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`) |
|
:param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`) |
|
""" |
|
if handler: |
|
listener = EventListener( |
|
element_id=self.id, |
|
type=type, |
|
args=[args] if args and isinstance(args[0], str) else args, # type: ignore |
|
handler=handler, |
|
throttle=throttle, |
|
leading_events=leading_events, |
|
trailing_events=trailing_events, |
|
request=storage.request_contextvar.get(), |
|
) |
|
self._event_listeners[listener.id] = listener |
|
self.update() |
|
return self |
|
|
|
def _handle_event(self, msg: Dict) -> None: |
|
listener = self._event_listeners[msg['listener_id']] |
|
storage.request_contextvar.set(listener.request) |
|
args = events.GenericEventArguments(sender=self, client=self.client, args=msg['args']) |
|
events.handle_event(listener.handler, args) |
|
|
|
def update(self) -> None: |
|
"""Update the element on the client side.""" |
|
outbox.enqueue_update(self) |
|
|
|
def run_method(self, name: str, *args: Any) -> None: |
|
"""Run a method on the client side. |
|
|
|
:param name: name of the method |
|
:param args: arguments to pass to the method |
|
""" |
|
if not globals.loop: |
|
return |
|
data = {'id': self.id, 'name': name, 'args': args} |
|
target_id = globals._socket_id or self.client.id # pylint: disable=protected-access |
|
outbox.enqueue_message('run_method', data, target_id) |
|
|
|
def _collect_descendants(self, *, include_self: bool = False) -> List[Element]: |
|
elements: List[Element] = [self] if include_self else [] |
|
for child in self: |
|
elements.extend(child._collect_descendants(include_self=True)) # pylint: disable=protected-access |
|
return elements |
|
|
|
def clear(self) -> None: |
|
"""Remove all child elements.""" |
|
descendants = self._collect_descendants() |
|
self.client.remove_elements(descendants) |
|
for slot in self.slots.values(): |
|
slot.children.clear() |
|
self.update() |
|
|
|
def move(self, target_container: Optional[Element] = None, target_index: int = -1): |
|
"""Move the element to another container. |
|
|
|
:param target_container: container to move the element to (default: the parent container) |
|
:param target_index: index within the target slot (default: append to the end) |
|
""" |
|
assert self.parent_slot is not None |
|
self.parent_slot.children.remove(self) |
|
self.parent_slot.parent.update() |
|
target_container = target_container or self.parent_slot.parent |
|
target_index = target_index if target_index >= 0 else len(target_container.default_slot.children) |
|
target_container.default_slot.children.insert(target_index, self) |
|
self.parent_slot = target_container.default_slot |
|
target_container.update() |
|
|
|
def remove(self, element: Union[Element, int]) -> None: |
|
"""Remove a child element. |
|
|
|
:param element: either the element instance or its ID |
|
""" |
|
if isinstance(element, int): |
|
children = list(self) |
|
element = children[element] |
|
elements = element._collect_descendants(include_self=True) # pylint: disable=protected-access |
|
self.client.remove_elements(elements) |
|
assert element.parent_slot is not None |
|
element.parent_slot.children.remove(element) |
|
self.update() |
|
|
|
def delete(self) -> None: |
|
"""Delete the element.""" |
|
self.client.remove_elements([self]) |
|
assert self.parent_slot is not None |
|
self.parent_slot.children.remove(self) |
|
|
|
def _on_delete(self) -> None: |
|
"""Called when the element is deleted. |
|
|
|
This method can be overridden in subclasses to perform cleanup tasks. |
|
""" |
|
|
|
@property |
|
def is_deleted(self) -> bool: |
|
"""Whether the element has been deleted.""" |
|
return self._deleted
|
|
|