element.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. from __future__ import annotations
  2. import re
  3. import warnings
  4. from copy import deepcopy
  5. from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
  6. from typing_extensions import Self
  7. from nicegui import json
  8. from . import binding, events, globals, outbox
  9. from .elements.mixins.visibility import Visibility
  10. from .event_listener import EventListener
  11. from .slot import Slot
  12. from .tailwind import Tailwind
  13. if TYPE_CHECKING:
  14. from .client import Client
  15. PROPS_PATTERN = re.compile(r'([\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
  16. class Element(Visibility):
  17. def __init__(self, tag: str, *, _client: Optional[Client] = None) -> None:
  18. """Generic Element
  19. This class is the base class for all other UI elements.
  20. But you can use it to create elements with arbitrary HTML tags.
  21. :param tag: HTML tag of the element
  22. :param _client: client for this element (for internal use only)
  23. """
  24. super().__init__()
  25. self.client = _client or globals.get_client()
  26. self.id = self.client.next_element_id
  27. self.client.next_element_id += 1
  28. self.tag = tag
  29. self._classes: List[str] = []
  30. self._style: Dict[str, str] = {}
  31. self._props: Dict[str, Any] = {}
  32. self._event_listeners: Dict[str, EventListener] = {}
  33. self._text: Optional[str] = None
  34. self.slots: Dict[str, Slot] = {}
  35. self.default_slot = self.add_slot('default')
  36. self.client.elements[self.id] = self
  37. self.parent_slot: Optional[Slot] = None
  38. slot_stack = globals.get_slot_stack()
  39. if slot_stack:
  40. self.parent_slot = slot_stack[-1]
  41. self.parent_slot.children.append(self)
  42. self.tailwind = Tailwind(self)
  43. outbox.enqueue_update(self)
  44. if self.parent_slot:
  45. outbox.enqueue_update(self.parent_slot.parent)
  46. def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
  47. """Add a slot to the element.
  48. :param name: name of the slot
  49. :param template: Vue template of the slot
  50. :return: the slot
  51. """
  52. self.slots[name] = Slot(self, name, template)
  53. return self.slots[name]
  54. def __enter__(self) -> Self:
  55. self.default_slot.__enter__()
  56. return self
  57. def __exit__(self, *_):
  58. self.default_slot.__exit__(*_)
  59. def _collect_slot_dict(self) -> Dict[str, List[int]]:
  60. return {
  61. name: {'template': slot.template, 'ids': [child.id for child in slot.children]}
  62. for name, slot in self.slots.items()
  63. }
  64. def _to_dict(self) -> Dict[str, Any]:
  65. return {
  66. 'id': self.id,
  67. 'tag': self.tag,
  68. 'class': self._classes,
  69. 'style': self._style,
  70. 'props': self._props,
  71. 'text': self._text,
  72. 'slots': self._collect_slot_dict(),
  73. 'events': [listener.to_dict() for listener in self._event_listeners.values()],
  74. }
  75. @staticmethod
  76. def _update_classes_list(
  77. classes: List[str],
  78. add: Optional[str] = None, remove: Optional[str] = None, replace: Optional[str] = None) -> List[str]:
  79. class_list = classes if replace is None else []
  80. class_list = [c for c in class_list if c not in (remove or '').split()]
  81. class_list += (add or '').split()
  82. class_list += (replace or '').split()
  83. return list(dict.fromkeys(class_list)) # NOTE: remove duplicates while preserving order
  84. def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
  85. -> Self:
  86. """Apply, remove, or replace HTML classes.
  87. This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
  88. Removing or replacing classes can be helpful if predefined classes are not desired.
  89. :param add: whitespace-delimited string of classes
  90. :param remove: whitespace-delimited string of classes to remove from the element
  91. :param replace: whitespace-delimited string of classes to use instead of existing ones
  92. """
  93. new_classes = self._update_classes_list(self._classes, add, remove, replace)
  94. if self._classes != new_classes:
  95. self._classes = new_classes
  96. self.update()
  97. return self
  98. @staticmethod
  99. def _parse_style(text: Optional[str]) -> Dict[str, str]:
  100. result = {}
  101. for word in (text or '').split(';'):
  102. word = word.strip()
  103. if word:
  104. key, value = word.split(':', 1)
  105. result[key.strip()] = value.strip()
  106. return result
  107. def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) -> Self:
  108. """Apply, remove, or replace CSS definitions.
  109. Removing or replacing styles can be helpful if the predefined style is not desired.
  110. :param add: semicolon-separated list of styles to add to the element
  111. :param remove: semicolon-separated list of styles to remove from the element
  112. :param replace: semicolon-separated list of styles to use instead of existing ones
  113. """
  114. style_dict = deepcopy(self._style) if replace is None else {}
  115. for key in self._parse_style(remove):
  116. style_dict.pop(key, None)
  117. style_dict.update(self._parse_style(add))
  118. style_dict.update(self._parse_style(replace))
  119. if self._style != style_dict:
  120. self._style = style_dict
  121. self.update()
  122. return self
  123. @staticmethod
  124. def _parse_props(text: Optional[str]) -> Dict[str, Any]:
  125. dictionary = {}
  126. for match in PROPS_PATTERN.finditer(text or ''):
  127. key = match.group(1)
  128. value = match.group(2) or match.group(3)
  129. if value and value.startswith('"') and value.endswith('"'):
  130. value = json.loads(value)
  131. dictionary[key] = value or True
  132. return dictionary
  133. def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
  134. """Add or remove props.
  135. This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
  136. Since props are simply applied as HTML attributes, they can be used with any HTML element.
  137. Boolean properties are assumed ``True`` if no value is specified.
  138. :param add: whitespace-delimited list of either boolean values or key=value pair to add
  139. :param remove: whitespace-delimited list of property keys to remove
  140. """
  141. needs_update = False
  142. for key in self._parse_props(remove):
  143. if key in self._props:
  144. needs_update = True
  145. del self._props[key]
  146. for key, value in self._parse_props(add).items():
  147. if self._props.get(key) != value:
  148. needs_update = True
  149. self._props[key] = value
  150. if needs_update:
  151. self.update()
  152. return self
  153. def tooltip(self, text: str) -> Self:
  154. """Add a tooltip to the element.
  155. :param text: text of the tooltip
  156. """
  157. with self:
  158. tooltip = Element('q-tooltip')
  159. tooltip._text = text
  160. return self
  161. def on(self,
  162. type: str,
  163. handler: Optional[Callable],
  164. args: Optional[List[str]] = None, *,
  165. throttle: float = 0.0,
  166. leading_events: bool = True,
  167. trailing_events: bool = True,
  168. ) -> Self:
  169. """Subscribe to an event.
  170. :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
  171. :param handler: callback that is called upon occurrence of the event
  172. :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
  173. :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
  174. :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
  175. :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
  176. """
  177. if handler:
  178. if args and '*' in args:
  179. url = f'https://github.com/zauberzeug/nicegui/issues/644'
  180. warnings.warn(DeprecationWarning(f'Event args "*" is deprecated, omit this parameter instead ({url})'))
  181. args = None
  182. listener = EventListener(
  183. element_id=self.id,
  184. type=type,
  185. args=args,
  186. handler=handler,
  187. throttle=throttle,
  188. leading_events=leading_events,
  189. trailing_events=trailing_events,
  190. )
  191. self._event_listeners[listener.id] = listener
  192. return self
  193. def _handle_event(self, msg: Dict) -> None:
  194. listener = self._event_listeners[msg['listener_id']]
  195. events.handle_event(listener.handler, msg, sender=self)
  196. def update(self) -> None:
  197. """Update the element on the client side."""
  198. outbox.enqueue_update(self)
  199. def run_method(self, name: str, *args: Any) -> None:
  200. """Run a method on the client side.
  201. :param name: name of the method
  202. :param args: arguments to pass to the method
  203. """
  204. if not globals.loop:
  205. return
  206. data = {'id': self.id, 'name': name, 'args': args}
  207. outbox.enqueue_message('run_method', data, globals._socket_id or self.client.id)
  208. def _collect_descendant_ids(self) -> List[int]:
  209. ids: List[int] = [self.id]
  210. for slot in self.slots.values():
  211. for child in slot.children:
  212. ids.extend(child._collect_descendant_ids())
  213. return ids
  214. def clear(self) -> None:
  215. """Remove all child elements."""
  216. descendants = [self.client.elements[id] for id in self._collect_descendant_ids()[1:]]
  217. binding.remove(descendants, Element)
  218. for element in descendants:
  219. del self.client.elements[element.id]
  220. for slot in self.slots.values():
  221. slot.children.clear()
  222. self.update()
  223. def remove(self, element: Union[Element, int]) -> None:
  224. """Remove a child element.
  225. :param element: either the element instance or its ID
  226. """
  227. if isinstance(element, int):
  228. children = [child for slot in self.slots.values() for child in slot.children]
  229. element = children[element]
  230. binding.remove([element], Element)
  231. del self.client.elements[element.id]
  232. for slot in self.slots.values():
  233. slot.children[:] = [e for e in slot.children if e.id != element.id]
  234. self.update()
  235. def delete(self) -> None:
  236. """Called when the corresponding client is deleted.
  237. Can be overridden to perform cleanup.
  238. """