element.py 12 KB

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