from __future__ import annotations import inspect import re from copy import deepcopy from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union from typing_extensions import Self from nicegui import json from . import binding, events, globals, outbox, storage from .dependencies import JsComponent, 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[JsComponent] = 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.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) if cls.__base__ == Element: cls.component = None cls.libraries = [] cls.extra_libraries = [] 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 `_ or `Quasar `_ 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 `_ 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 return self def on(self, type: str, handler: Optional[Callable[..., Any]] = None, args: Optional[List[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, 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} outbox.enqueue_message('run_method', data, globals._socket_id or self.client.id) def _collect_descendant_ids(self) -> List[int]: ids: List[int] = [self.id] for child in self: ids.extend(child._collect_descendant_ids()) return ids def clear(self) -> None: """Remove all child elements.""" descendants = [self.client.elements[id] for id in self._collect_descendant_ids()[1:]] binding.remove(descendants, Element) for element in descendants: element.delete() del self.client.elements[element.id] 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] binding.remove([element], Element) element.delete() del self.client.elements[element.id] for slot in self.slots.values(): slot.children[:] = [e for e in slot if e.id != element.id] self.update() def delete(self) -> None: """Perform cleanup when the element is deleted.""" outbox.enqueue_delete(self)