element.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. from __future__ import annotations
  2. import inspect
  3. import re
  4. from copy import copy, deepcopy
  5. from pathlib import Path
  6. from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence, Union
  7. from typing_extensions import Self
  8. from nicegui import json
  9. from . import binding, events, globals, outbox, storage # pylint: disable=redefined-builtin
  10. from .dependencies import Component, Library, register_library, register_vue_component
  11. from .elements.mixins.visibility import Visibility
  12. from .event_listener import EventListener
  13. from .slot import Slot
  14. from .tailwind import Tailwind
  15. if TYPE_CHECKING:
  16. from .client import Client
  17. PROPS_PATTERN = re.compile(r'([:\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
  18. class Element(Visibility):
  19. component: Optional[Component] = None
  20. libraries: List[Library] = []
  21. extra_libraries: List[Library] = []
  22. exposed_libraries: List[Library] = []
  23. def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = None) -> None:
  24. """Generic Element
  25. This class is the base class for all other UI elements.
  26. But you can use it to create elements with arbitrary HTML tags.
  27. :param tag: HTML tag of the element
  28. :param _client: client for this element (for internal use only)
  29. """
  30. super().__init__()
  31. self.client = _client or globals.get_client()
  32. self.id = self.client.next_element_id
  33. self.client.next_element_id += 1
  34. self.tag = tag if tag else self.component.tag if self.component else 'div'
  35. self._classes: List[str] = []
  36. self._style: Dict[str, str] = {}
  37. self._props: Dict[str, Any] = {'key': self.id} # HACK: workaround for #600 and #898
  38. self._event_listeners: Dict[str, EventListener] = {}
  39. self._text: Optional[str] = None
  40. self.slots: Dict[str, Slot] = {}
  41. self.default_slot = self.add_slot('default')
  42. self.client.elements[self.id] = self
  43. self.parent_slot: Optional[Slot] = None
  44. slot_stack = globals.get_slot_stack()
  45. if slot_stack:
  46. self.parent_slot = slot_stack[-1]
  47. self.parent_slot.children.append(self)
  48. self.tailwind = Tailwind(self)
  49. outbox.enqueue_update(self)
  50. if self.parent_slot:
  51. outbox.enqueue_update(self.parent_slot.parent)
  52. def __init_subclass__(cls, *,
  53. component: Union[str, Path, None] = None,
  54. libraries: List[Union[str, Path]] = [],
  55. exposed_libraries: List[Union[str, Path]] = [],
  56. extra_libraries: List[Union[str, Path]] = [],
  57. ) -> None:
  58. super().__init_subclass__()
  59. base = Path(inspect.getfile(cls)).parent
  60. def glob_absolute_paths(file: Union[str, Path]) -> List[Path]:
  61. path = Path(file)
  62. if not path.is_absolute():
  63. path = base / path
  64. return sorted(path.parent.glob(path.name), key=lambda p: p.stem)
  65. cls.component = copy(cls.component)
  66. cls.libraries = copy(cls.libraries)
  67. cls.extra_libraries = copy(cls.extra_libraries)
  68. cls.exposed_libraries = copy(cls.exposed_libraries)
  69. if component:
  70. for path in glob_absolute_paths(component):
  71. cls.component = register_vue_component(path)
  72. for library in libraries:
  73. for path in glob_absolute_paths(library):
  74. cls.libraries.append(register_library(path))
  75. for library in extra_libraries:
  76. for path in glob_absolute_paths(library):
  77. cls.extra_libraries.append(register_library(path))
  78. for library in exposed_libraries:
  79. for path in glob_absolute_paths(library):
  80. cls.exposed_libraries.append(register_library(path, expose=True))
  81. def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
  82. """Add a slot to the element.
  83. :param name: name of the slot
  84. :param template: Vue template of the slot
  85. :return: the slot
  86. """
  87. self.slots[name] = Slot(self, name, template)
  88. return self.slots[name]
  89. def __enter__(self) -> Self:
  90. self.default_slot.__enter__()
  91. return self
  92. def __exit__(self, *_):
  93. self.default_slot.__exit__(*_)
  94. def __iter__(self) -> Iterator[Element]:
  95. for slot in self.slots.values():
  96. for child in slot:
  97. yield child
  98. def _collect_slot_dict(self) -> Dict[str, Any]:
  99. return {
  100. name: {'template': slot.template, 'ids': [child.id for child in slot]}
  101. for name, slot in self.slots.items()
  102. }
  103. def _to_dict(self) -> Dict[str, Any]:
  104. return {
  105. 'id': self.id,
  106. 'tag': self.tag,
  107. 'class': self._classes,
  108. 'style': self._style,
  109. 'props': self._props,
  110. 'text': self._text,
  111. 'slots': self._collect_slot_dict(),
  112. 'events': [listener.to_dict() for listener in self._event_listeners.values()],
  113. 'component': {
  114. 'key': self.component.key,
  115. 'name': self.component.name,
  116. 'tag': self.component.tag
  117. } if self.component else None,
  118. 'libraries': [
  119. {
  120. 'key': library.key,
  121. 'name': library.name,
  122. } for library in self.libraries
  123. ],
  124. }
  125. @staticmethod
  126. def _update_classes_list(
  127. classes: List[str],
  128. add: Optional[str] = None, remove: Optional[str] = None, replace: Optional[str] = None) -> List[str]:
  129. class_list = classes if replace is None else []
  130. class_list = [c for c in class_list if c not in (remove or '').split()]
  131. class_list += (add or '').split()
  132. class_list += (replace or '').split()
  133. return list(dict.fromkeys(class_list)) # NOTE: remove duplicates while preserving order
  134. def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
  135. -> Self:
  136. """Apply, remove, or replace HTML classes.
  137. This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
  138. Removing or replacing classes can be helpful if predefined classes are not desired.
  139. :param add: whitespace-delimited string of classes
  140. :param remove: whitespace-delimited string of classes to remove from the element
  141. :param replace: whitespace-delimited string of classes to use instead of existing ones
  142. """
  143. new_classes = self._update_classes_list(self._classes, add, remove, replace)
  144. if self._classes != new_classes:
  145. self._classes = new_classes
  146. self.update()
  147. return self
  148. @staticmethod
  149. def _parse_style(text: Optional[str]) -> Dict[str, str]:
  150. result = {}
  151. for word in (text or '').split(';'):
  152. word = word.strip()
  153. if word:
  154. key, value = word.split(':', 1)
  155. result[key.strip()] = value.strip()
  156. return result
  157. def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) -> Self:
  158. """Apply, remove, or replace CSS definitions.
  159. Removing or replacing styles can be helpful if the predefined style is not desired.
  160. :param add: semicolon-separated list of styles to add to the element
  161. :param remove: semicolon-separated list of styles to remove from the element
  162. :param replace: semicolon-separated list of styles to use instead of existing ones
  163. """
  164. style_dict = deepcopy(self._style) if replace is None else {}
  165. for key in self._parse_style(remove):
  166. style_dict.pop(key, None)
  167. style_dict.update(self._parse_style(add))
  168. style_dict.update(self._parse_style(replace))
  169. if self._style != style_dict:
  170. self._style = style_dict
  171. self.update()
  172. return self
  173. @staticmethod
  174. def _parse_props(text: Optional[str]) -> Dict[str, Any]:
  175. dictionary = {}
  176. for match in PROPS_PATTERN.finditer(text or ''):
  177. key = match.group(1)
  178. value = match.group(2) or match.group(3)
  179. if value and value.startswith('"') and value.endswith('"'):
  180. value = json.loads(value)
  181. dictionary[key] = value or True
  182. return dictionary
  183. def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
  184. """Add or remove props.
  185. This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
  186. Since props are simply applied as HTML attributes, they can be used with any HTML element.
  187. Boolean properties are assumed ``True`` if no value is specified.
  188. :param add: whitespace-delimited list of either boolean values or key=value pair to add
  189. :param remove: whitespace-delimited list of property keys to remove
  190. """
  191. needs_update = False
  192. for key in self._parse_props(remove):
  193. if key in self._props:
  194. needs_update = True
  195. del self._props[key]
  196. for key, value in self._parse_props(add).items():
  197. if self._props.get(key) != value:
  198. needs_update = True
  199. self._props[key] = value
  200. if needs_update:
  201. self.update()
  202. return self
  203. def tooltip(self, text: str) -> Self:
  204. """Add a tooltip to the element.
  205. :param text: text of the tooltip
  206. """
  207. with self:
  208. tooltip = Element('q-tooltip')
  209. tooltip._text = text # pylint: disable=protected-access
  210. return self
  211. def on(self,
  212. type: str, # pylint: disable=redefined-builtin
  213. handler: Optional[Callable[..., Any]] = None,
  214. args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None, *,
  215. throttle: float = 0.0,
  216. leading_events: bool = True,
  217. trailing_events: bool = True,
  218. ) -> Self:
  219. """Subscribe to an event.
  220. :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
  221. :param handler: callback that is called upon occurrence of the event
  222. :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
  223. :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
  224. :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
  225. :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
  226. """
  227. if handler:
  228. listener = EventListener(
  229. element_id=self.id,
  230. type=type,
  231. args=[args] if args and isinstance(args[0], str) else args, # type: ignore
  232. handler=handler,
  233. throttle=throttle,
  234. leading_events=leading_events,
  235. trailing_events=trailing_events,
  236. request=storage.request_contextvar.get(),
  237. )
  238. self._event_listeners[listener.id] = listener
  239. self.update()
  240. return self
  241. def _handle_event(self, msg: Dict) -> None:
  242. listener = self._event_listeners[msg['listener_id']]
  243. storage.request_contextvar.set(listener.request)
  244. args = events.GenericEventArguments(sender=self, client=self.client, args=msg['args'])
  245. events.handle_event(listener.handler, args)
  246. def update(self) -> None:
  247. """Update the element on the client side."""
  248. outbox.enqueue_update(self)
  249. def run_method(self, name: str, *args: Any) -> None:
  250. """Run a method on the client side.
  251. :param name: name of the method
  252. :param args: arguments to pass to the method
  253. """
  254. if not globals.loop:
  255. return
  256. data = {'id': self.id, 'name': name, 'args': args}
  257. target_id = globals._socket_id or self.client.id # pylint: disable=protected-access
  258. outbox.enqueue_message('run_method', data, target_id)
  259. def _collect_descendant_ids(self) -> List[int]:
  260. ids: List[int] = [self.id]
  261. for child in self:
  262. ids.extend(child._collect_descendant_ids()) # pylint: disable=protected-access
  263. return ids
  264. def clear(self) -> None:
  265. """Remove all child elements."""
  266. descendants = [self.client.elements[id] for id in self._collect_descendant_ids()[1:]]
  267. binding.remove(descendants, Element)
  268. for element in descendants:
  269. element.delete()
  270. del self.client.elements[element.id]
  271. for slot in self.slots.values():
  272. slot.children.clear()
  273. self.update()
  274. def move(self, target_container: Optional[Element] = None, target_index: int = -1):
  275. """Move the element to another container.
  276. :param target_container: container to move the element to (default: the parent container)
  277. :param target_index: index within the target slot (default: append to the end)
  278. """
  279. assert self.parent_slot is not None
  280. self.parent_slot.children.remove(self)
  281. self.parent_slot.parent.update()
  282. target_container = target_container or self.parent_slot.parent
  283. target_index = target_index if target_index >= 0 else len(target_container.default_slot.children)
  284. target_container.default_slot.children.insert(target_index, self)
  285. self.parent_slot = target_container.default_slot
  286. target_container.update()
  287. def remove(self, element: Union[Element, int]) -> None:
  288. """Remove a child element.
  289. :param element: either the element instance or its ID
  290. """
  291. if isinstance(element, int):
  292. children = list(self)
  293. element = children[element]
  294. binding.remove([element], Element)
  295. element.delete()
  296. del self.client.elements[element.id]
  297. for slot in self.slots.values():
  298. slot.children[:] = [e for e in slot if e.id != element.id]
  299. self.update()
  300. def delete(self) -> None:
  301. """Perform cleanup when the element is deleted."""
  302. outbox.enqueue_delete(self)