element.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. from __future__ import annotations
  2. import ast
  3. import inspect
  4. import re
  5. from copy import copy, deepcopy
  6. from pathlib import Path
  7. from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence, Union
  8. from typing_extensions import Self
  9. from . import events, globals, json, outbox, storage # pylint: disable=redefined-builtin
  10. from .awaitable_response import AwaitableResponse
  11. from .dependencies import Component, Library, register_library, register_vue_component
  12. from .elements.mixins.visibility import Visibility
  13. from .event_listener import EventListener
  14. from .slot import Slot
  15. from .tailwind import Tailwind
  16. if TYPE_CHECKING:
  17. from .client import Client
  18. PROPS_PATTERN = re.compile(r'''
  19. # Match a key-value pair optionally followed by whitespace or end of string
  20. ([:\w\-]+) # Capture group 1: Key
  21. (?: # Optional non-capturing group for value
  22. = # Match the equal sign
  23. (?: # Non-capturing group for value options
  24. ( # Capture group 2: Value enclosed in double quotes
  25. " # Match double quote
  26. [^"\\]* # Match any character except quotes or backslashes zero or more times
  27. (?:\\.[^"\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times
  28. " # Match the closing quote
  29. )
  30. |
  31. ( # Capture group 3: Value enclosed in single quotes
  32. ' # Match a single quote
  33. [^'\\]* # Match any character except quotes or backslashes zero or more times
  34. (?:\\.[^'\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times
  35. ' # Match the closing quote
  36. )
  37. | # Or
  38. ([\w\-.%:\/]+) # Capture group 4: Value without quotes
  39. )
  40. )? # End of optional non-capturing group for value
  41. (?:$|\s) # Match end of string or whitespace
  42. ''', re.VERBOSE)
  43. class Element(Visibility):
  44. component: Optional[Component] = None
  45. libraries: List[Library] = []
  46. extra_libraries: List[Library] = []
  47. exposed_libraries: List[Library] = []
  48. _default_props: Dict[str, Any] = {}
  49. _default_classes: List[str] = []
  50. _default_style: Dict[str, str] = {}
  51. def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = None) -> None:
  52. """Generic Element
  53. This class is the base class for all other UI elements.
  54. But you can use it to create elements with arbitrary HTML tags.
  55. :param tag: HTML tag of the element
  56. :param _client: client for this element (for internal use only)
  57. """
  58. super().__init__()
  59. self.client = _client or globals.get_client()
  60. self.id = self.client.next_element_id
  61. self.client.next_element_id += 1
  62. self.tag = tag if tag else self.component.tag if self.component else 'div'
  63. self._classes: List[str] = []
  64. self._classes.extend(self._default_classes)
  65. self._style: Dict[str, str] = {}
  66. self._style.update(self._default_style)
  67. self._props: Dict[str, Any] = {'key': self.id} # HACK: workaround for #600 and #898
  68. self._props.update(self._default_props)
  69. self._event_listeners: Dict[str, EventListener] = {}
  70. self._text: Optional[str] = None
  71. self.slots: Dict[str, Slot] = {}
  72. self.default_slot = self.add_slot('default')
  73. self._deleted: bool = False
  74. self.client.elements[self.id] = self
  75. self.parent_slot: Optional[Slot] = None
  76. slot_stack = globals.get_slot_stack()
  77. if slot_stack:
  78. self.parent_slot = slot_stack[-1]
  79. self.parent_slot.children.append(self)
  80. self.tailwind = Tailwind(self)
  81. outbox.enqueue_update(self)
  82. if self.parent_slot:
  83. outbox.enqueue_update(self.parent_slot.parent)
  84. def __init_subclass__(cls, *,
  85. component: Union[str, Path, None] = None,
  86. libraries: List[Union[str, Path]] = [],
  87. exposed_libraries: List[Union[str, Path]] = [],
  88. extra_libraries: List[Union[str, Path]] = [],
  89. ) -> None:
  90. super().__init_subclass__()
  91. base = Path(inspect.getfile(cls)).parent
  92. def glob_absolute_paths(file: Union[str, Path]) -> List[Path]:
  93. path = Path(file)
  94. if not path.is_absolute():
  95. path = base / path
  96. return sorted(path.parent.glob(path.name), key=lambda p: p.stem)
  97. cls.component = copy(cls.component)
  98. cls.libraries = copy(cls.libraries)
  99. cls.extra_libraries = copy(cls.extra_libraries)
  100. cls.exposed_libraries = copy(cls.exposed_libraries)
  101. if component:
  102. for path in glob_absolute_paths(component):
  103. cls.component = register_vue_component(path)
  104. for library in libraries:
  105. for path in glob_absolute_paths(library):
  106. cls.libraries.append(register_library(path))
  107. for library in extra_libraries:
  108. for path in glob_absolute_paths(library):
  109. cls.extra_libraries.append(register_library(path))
  110. for library in exposed_libraries:
  111. for path in glob_absolute_paths(library):
  112. cls.exposed_libraries.append(register_library(path, expose=True))
  113. cls._default_props = copy(cls._default_props)
  114. cls._default_classes = copy(cls._default_classes)
  115. cls._default_style = copy(cls._default_style)
  116. def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
  117. """Add a slot to the element.
  118. :param name: name of the slot
  119. :param template: Vue template of the slot
  120. :return: the slot
  121. """
  122. self.slots[name] = Slot(self, name, template)
  123. return self.slots[name]
  124. def __enter__(self) -> Self:
  125. self.default_slot.__enter__()
  126. return self
  127. def __exit__(self, *_):
  128. self.default_slot.__exit__(*_)
  129. def __iter__(self) -> Iterator[Element]:
  130. for slot in self.slots.values():
  131. for child in slot:
  132. yield child
  133. def _collect_slot_dict(self) -> Dict[str, Any]:
  134. return {
  135. name: {'template': slot.template, 'ids': [child.id for child in slot]}
  136. for name, slot in self.slots.items()
  137. }
  138. def _to_dict(self) -> Dict[str, Any]:
  139. return {
  140. 'id': self.id,
  141. 'tag': self.tag,
  142. 'class': self._classes,
  143. 'style': self._style,
  144. 'props': self._props,
  145. 'text': self._text,
  146. 'slots': self._collect_slot_dict(),
  147. 'events': [listener.to_dict() for listener in self._event_listeners.values()],
  148. 'component': {
  149. 'key': self.component.key,
  150. 'name': self.component.name,
  151. 'tag': self.component.tag
  152. } if self.component else None,
  153. 'libraries': [
  154. {
  155. 'key': library.key,
  156. 'name': library.name,
  157. } for library in self.libraries
  158. ],
  159. }
  160. @staticmethod
  161. def _update_classes_list(classes: List[str],
  162. add: Optional[str] = None,
  163. remove: Optional[str] = None,
  164. replace: Optional[str] = None) -> List[str]:
  165. class_list = classes if replace is None else []
  166. class_list = [c for c in class_list if c not in (remove or '').split()]
  167. class_list += (add or '').split()
  168. class_list += (replace or '').split()
  169. return list(dict.fromkeys(class_list)) # NOTE: remove duplicates while preserving order
  170. def classes(self,
  171. add: Optional[str] = None, *,
  172. remove: Optional[str] = None,
  173. replace: Optional[str] = None) -> Self:
  174. """Apply, remove, or replace HTML classes.
  175. This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
  176. Removing or replacing classes can be helpful if predefined classes are not desired.
  177. :param add: whitespace-delimited string of classes
  178. :param remove: whitespace-delimited string of classes to remove from the element
  179. :param replace: whitespace-delimited string of classes to use instead of existing ones
  180. """
  181. new_classes = self._update_classes_list(self._classes, add, remove, replace)
  182. if self._classes != new_classes:
  183. self._classes = new_classes
  184. self.update()
  185. return self
  186. @classmethod
  187. def default_classes(cls,
  188. add: Optional[str] = None, *,
  189. remove: Optional[str] = None,
  190. replace: Optional[str] = None) -> type[Self]:
  191. """Apply, remove, or replace default HTML classes.
  192. This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
  193. Removing or replacing classes can be helpful if predefined classes are not desired.
  194. All elements of this class will share these HTML classes.
  195. These must be defined before element instantiation.
  196. :param add: whitespace-delimited string of classes
  197. :param remove: whitespace-delimited string of classes to remove from the element
  198. :param replace: whitespace-delimited string of classes to use instead of existing ones
  199. """
  200. cls._default_classes = cls._update_classes_list(cls._default_classes, add, remove, replace)
  201. return cls
  202. @staticmethod
  203. def _parse_style(text: Optional[str]) -> Dict[str, str]:
  204. result = {}
  205. for word in (text or '').split(';'):
  206. word = word.strip()
  207. if word:
  208. key, value = word.split(':', 1)
  209. result[key.strip()] = value.strip()
  210. return result
  211. def style(self,
  212. add: Optional[str] = None, *,
  213. remove: Optional[str] = None,
  214. replace: Optional[str] = None) -> Self:
  215. """Apply, remove, or replace CSS definitions.
  216. Removing or replacing styles can be helpful if the predefined style is not desired.
  217. :param add: semicolon-separated list of styles to add to the element
  218. :param remove: semicolon-separated list of styles to remove from the element
  219. :param replace: semicolon-separated list of styles to use instead of existing ones
  220. """
  221. style_dict = deepcopy(self._style) if replace is None else {}
  222. for key in self._parse_style(remove):
  223. style_dict.pop(key, None)
  224. style_dict.update(self._parse_style(add))
  225. style_dict.update(self._parse_style(replace))
  226. if self._style != style_dict:
  227. self._style = style_dict
  228. self.update()
  229. return self
  230. @classmethod
  231. def default_style(cls,
  232. add: Optional[str] = None, *,
  233. remove: Optional[str] = None,
  234. replace: Optional[str] = None) -> type[Self]:
  235. """Apply, remove, or replace default CSS definitions.
  236. Removing or replacing styles can be helpful if the predefined style is not desired.
  237. All elements of this class will share these CSS definitions.
  238. These must be defined before element instantiation.
  239. :param add: semicolon-separated list of styles to add to the element
  240. :param remove: semicolon-separated list of styles to remove from the element
  241. :param replace: semicolon-separated list of styles to use instead of existing ones
  242. """
  243. if replace is not None:
  244. cls._default_style.clear()
  245. for key in cls._parse_style(remove):
  246. cls._default_style.pop(key, None)
  247. cls._default_style.update(cls._parse_style(add))
  248. cls._default_style.update(cls._parse_style(replace))
  249. return cls
  250. @staticmethod
  251. def _parse_props(text: Optional[str]) -> Dict[str, Any]:
  252. dictionary = {}
  253. for match in PROPS_PATTERN.finditer(text or ''):
  254. key = match.group(1)
  255. value = match.group(2) or match.group(3) or match.group(4)
  256. if value is None:
  257. dictionary[key] = True
  258. else:
  259. if (value.startswith("'") and value.endswith("'")) or (value.startswith('"') and value.endswith('"')):
  260. value = ast.literal_eval(value)
  261. dictionary[key] = value
  262. return dictionary
  263. def props(self,
  264. add: Optional[str] = None, *,
  265. remove: Optional[str] = None) -> Self:
  266. """Add or remove props.
  267. This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
  268. Since props are simply applied as HTML attributes, they can be used with any HTML element.
  269. Boolean properties are assumed ``True`` if no value is specified.
  270. :param add: whitespace-delimited list of either boolean values or key=value pair to add
  271. :param remove: whitespace-delimited list of property keys to remove
  272. """
  273. needs_update = False
  274. for key in self._parse_props(remove):
  275. if key in self._props:
  276. needs_update = True
  277. del self._props[key]
  278. for key, value in self._parse_props(add).items():
  279. if self._props.get(key) != value:
  280. needs_update = True
  281. self._props[key] = value
  282. if needs_update:
  283. self.update()
  284. return self
  285. @classmethod
  286. def default_props(cls,
  287. add: Optional[str] = None, *,
  288. remove: Optional[str] = None) -> type[Self]:
  289. """Add or remove default props.
  290. This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
  291. Since props are simply applied as HTML attributes, they can be used with any HTML element.
  292. All elements of this class will share these props.
  293. These must be defined before element instantiation.
  294. Boolean properties are assumed ``True`` if no value is specified.
  295. :param add: whitespace-delimited list of either boolean values or key=value pair to add
  296. :param remove: whitespace-delimited list of property keys to remove
  297. """
  298. for key in cls._parse_props(remove):
  299. if key in cls._default_props:
  300. del cls._default_props[key]
  301. for key, value in cls._parse_props(add).items():
  302. cls._default_props[key] = value
  303. return cls
  304. def tooltip(self, text: str) -> Self:
  305. """Add a tooltip to the element.
  306. :param text: text of the tooltip
  307. """
  308. with self:
  309. tooltip = Element('q-tooltip')
  310. tooltip._text = text # pylint: disable=protected-access
  311. return self
  312. def on(self,
  313. type: str, # pylint: disable=redefined-builtin
  314. handler: Optional[Callable[..., Any]] = None,
  315. args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None, *,
  316. throttle: float = 0.0,
  317. leading_events: bool = True,
  318. trailing_events: bool = True,
  319. ) -> Self:
  320. """Subscribe to an event.
  321. :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
  322. :param handler: callback that is called upon occurrence of the event
  323. :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
  324. :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
  325. :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
  326. :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
  327. """
  328. if handler:
  329. listener = EventListener(
  330. element_id=self.id,
  331. type=type,
  332. args=[args] if args and isinstance(args[0], str) else args, # type: ignore
  333. handler=handler,
  334. throttle=throttle,
  335. leading_events=leading_events,
  336. trailing_events=trailing_events,
  337. request=storage.request_contextvar.get(),
  338. )
  339. self._event_listeners[listener.id] = listener
  340. self.update()
  341. return self
  342. def _handle_event(self, msg: Dict) -> None:
  343. listener = self._event_listeners[msg['listener_id']]
  344. storage.request_contextvar.set(listener.request)
  345. args = events.GenericEventArguments(sender=self, client=self.client, args=msg['args'])
  346. events.handle_event(listener.handler, args)
  347. def update(self) -> None:
  348. """Update the element on the client side."""
  349. outbox.enqueue_update(self)
  350. def run_method(self, name: str, *args: Any) -> AwaitableResponse:
  351. """Run a method on the client side.
  352. :param name: name of the method
  353. :param args: arguments to pass to the method
  354. """
  355. if not globals.loop:
  356. return AwaitableResponse.none() # TODO: raise exception instead?
  357. args_string = json.dumps(args)
  358. return self.client.run_javascript(f'''
  359. const element = getElement("{self.id}");
  360. if (element === null || element === undefined) return;
  361. if ("{name}" in element) {{
  362. element["{name}"](...{args_string});
  363. }} else {{
  364. element.$refs.qRef["{name}"](...{args_string});
  365. }}
  366. ''') # TODO: consider globals._socket_id
  367. def _collect_descendants(self, *, include_self: bool = False) -> List[Element]:
  368. elements: List[Element] = [self] if include_self else []
  369. for child in self:
  370. elements.extend(child._collect_descendants(include_self=True)) # pylint: disable=protected-access
  371. return elements
  372. def clear(self) -> None:
  373. """Remove all child elements."""
  374. descendants = self._collect_descendants()
  375. self.client.remove_elements(descendants)
  376. for slot in self.slots.values():
  377. slot.children.clear()
  378. self.update()
  379. def move(self, target_container: Optional[Element] = None, target_index: int = -1):
  380. """Move the element to another container.
  381. :param target_container: container to move the element to (default: the parent container)
  382. :param target_index: index within the target slot (default: append to the end)
  383. """
  384. assert self.parent_slot is not None
  385. self.parent_slot.children.remove(self)
  386. self.parent_slot.parent.update()
  387. target_container = target_container or self.parent_slot.parent
  388. target_index = target_index if target_index >= 0 else len(target_container.default_slot.children)
  389. target_container.default_slot.children.insert(target_index, self)
  390. self.parent_slot = target_container.default_slot
  391. target_container.update()
  392. def remove(self, element: Union[Element, int]) -> None:
  393. """Remove a child element.
  394. :param element: either the element instance or its ID
  395. """
  396. if isinstance(element, int):
  397. children = list(self)
  398. element = children[element]
  399. elements = element._collect_descendants(include_self=True) # pylint: disable=protected-access
  400. self.client.remove_elements(elements)
  401. assert element.parent_slot is not None
  402. element.parent_slot.children.remove(element)
  403. self.update()
  404. def delete(self) -> None:
  405. """Delete the element."""
  406. self.client.remove_elements([self])
  407. assert self.parent_slot is not None
  408. self.parent_slot.children.remove(self)
  409. def _handle_delete(self) -> None:
  410. """Called when the element is deleted.
  411. This method can be overridden in subclasses to perform cleanup tasks.
  412. """
  413. @property
  414. def is_deleted(self) -> bool:
  415. """Whether the element has been deleted."""
  416. return self._deleted