element.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  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, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, overload
  8. from typing_extensions import Self
  9. from . import context, core, events, helpers, json, storage
  10. from .awaitable_response import AwaitableResponse, NullResponse
  11. from .dependencies import Component, Library, register_library, register_resource, 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. from .version import __version__
  17. if TYPE_CHECKING:
  18. from .client import Client
  19. PROPS_PATTERN = re.compile(r'''
  20. # Match a key-value pair optionally followed by whitespace or end of string
  21. ([:\w\-]+) # Capture group 1: Key
  22. (?: # Optional non-capturing group for value
  23. = # Match the equal sign
  24. (?: # Non-capturing group for value options
  25. ( # Capture group 2: Value enclosed in double quotes
  26. " # Match double quote
  27. [^"\\]* # Match any character except quotes or backslashes zero or more times
  28. (?:\\.[^"\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times
  29. " # Match the closing quote
  30. )
  31. |
  32. ( # Capture group 3: Value enclosed in single quotes
  33. ' # Match a single quote
  34. [^'\\]* # Match any character except quotes or backslashes zero or more times
  35. (?:\\.[^'\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times
  36. ' # Match the closing quote
  37. )
  38. | # Or
  39. ([\w\-.%:\/]+) # Capture group 4: Value without quotes
  40. )
  41. )? # End of optional non-capturing group for value
  42. (?:$|\s) # Match end of string or whitespace
  43. ''', re.VERBOSE)
  44. # https://www.w3.org/TR/xml/#sec-common-syn
  45. TAG_START_CHAR = r':|[A-Z]|_|[a-z]|[\u00C0-\u00D6]|[\u00D8-\u00F6]|[\u00F8-\u02FF]|[\u0370-\u037D]|[\u037F-\u1FFF]|[\u200C-\u200D]|[\u2070-\u218F]|[\u2C00-\u2FEF]|[\u3001-\uD7FF]|[\uF900-\uFDCF]|[\uFDF0-\uFFFD]|[\U00010000-\U000EFFFF]'
  46. TAG_CHAR = TAG_START_CHAR + r'|-|\.|[0-9]|\u00B7|[\u0300-\u036F]|[\u203F-\u2040]'
  47. TAG_PATTERN = re.compile(fr'^({TAG_START_CHAR})({TAG_CHAR})*$')
  48. class Element(Visibility):
  49. component: Optional[Component] = None
  50. libraries: ClassVar[List[Library]] = []
  51. extra_libraries: ClassVar[List[Library]] = []
  52. exposed_libraries: ClassVar[List[Library]] = []
  53. _default_props: ClassVar[Dict[str, Any]] = {}
  54. _default_classes: ClassVar[List[str]] = []
  55. _default_style: ClassVar[Dict[str, str]] = {}
  56. def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = None) -> None:
  57. """Generic Element
  58. This class is the base class for all other UI elements.
  59. But you can use it to create elements with arbitrary HTML tags.
  60. :param tag: HTML tag of the element
  61. :param _client: client for this element (for internal use only)
  62. """
  63. super().__init__()
  64. self.client = _client or context.get_client()
  65. self.id = self.client.next_element_id
  66. self.client.next_element_id += 1
  67. self.tag = tag if tag else self.component.tag if self.component else 'div'
  68. if not TAG_PATTERN.match(self.tag):
  69. raise ValueError(f'Invalid HTML tag: {self.tag}')
  70. self._classes: List[str] = []
  71. self._classes.extend(self._default_classes)
  72. self._style: Dict[str, str] = {}
  73. self._style.update(self._default_style)
  74. self._props: Dict[str, Any] = {}
  75. self._props.update(self._default_props)
  76. self._event_listeners: Dict[str, EventListener] = {}
  77. self._text: Optional[str] = None
  78. self.slots: Dict[str, Slot] = {}
  79. self.default_slot = self.add_slot('default')
  80. self._deleted: bool = False
  81. self.client.elements[self.id] = self
  82. self.parent_slot: Optional[Slot] = None
  83. slot_stack = context.get_slot_stack()
  84. if slot_stack:
  85. self.parent_slot = slot_stack[-1]
  86. self.parent_slot.children.append(self)
  87. self.tailwind = Tailwind(self)
  88. self.client.outbox.enqueue_update(self)
  89. if self.parent_slot:
  90. self.client.outbox.enqueue_update(self.parent_slot.parent)
  91. def __init_subclass__(cls, *,
  92. component: Union[str, Path, None] = None,
  93. libraries: List[Union[str, Path]] = [], # noqa: B006
  94. exposed_libraries: List[Union[str, Path]] = [], # noqa: B006
  95. extra_libraries: List[Union[str, Path]] = [], # noqa: B006
  96. ) -> None:
  97. super().__init_subclass__()
  98. base = Path(inspect.getfile(cls)).parent
  99. def glob_absolute_paths(file: Union[str, Path]) -> List[Path]:
  100. path = Path(file)
  101. if not path.is_absolute():
  102. path = base / path
  103. return sorted(path.parent.glob(path.name), key=lambda p: p.stem)
  104. cls.component = copy(cls.component)
  105. cls.libraries = copy(cls.libraries)
  106. cls.extra_libraries = copy(cls.extra_libraries)
  107. cls.exposed_libraries = copy(cls.exposed_libraries)
  108. if component:
  109. for path in glob_absolute_paths(component):
  110. cls.component = register_vue_component(path)
  111. for library in libraries:
  112. for path in glob_absolute_paths(library):
  113. cls.libraries.append(register_library(path))
  114. for library in extra_libraries:
  115. for path in glob_absolute_paths(library):
  116. cls.extra_libraries.append(register_library(path))
  117. for library in exposed_libraries:
  118. for path in glob_absolute_paths(library):
  119. cls.exposed_libraries.append(register_library(path, expose=True))
  120. cls._default_props = copy(cls._default_props)
  121. cls._default_classes = copy(cls._default_classes)
  122. cls._default_style = copy(cls._default_style)
  123. def add_resource(self, path: Union[str, Path]) -> None:
  124. """Add a resource to the element.
  125. :param path: path to the resource (e.g. folder with CSS and JavaScript files)
  126. """
  127. resource = register_resource(Path(path))
  128. self._props['resource_path'] = f'/_nicegui/{__version__}/resources/{resource.key}'
  129. def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
  130. """Add a slot to the element.
  131. NiceGUI is using the slot concept from Vue:
  132. Elements can have multiple slots, each possibly with a number of children.
  133. Most elements only have one slot, e.g. a `ui.card` (QCard) only has a default slot.
  134. But more complex elements like `ui.table` (QTable) can have more slots like "header", "body" and so on.
  135. If you nest NiceGUI elements via with `ui.row(): ...` you place new elements inside of the row's default slot.
  136. But if you use with `table.add_slot(...): ...`, you enter a different slot.
  137. The slot stack helps NiceGUI to keep track of which slot is currently used for new elements.
  138. The `parent` field holds a reference to its element.
  139. Whenever an element is entered via a `with` expression, its default slot is automatically entered as well.
  140. :param name: name of the slot
  141. :param template: Vue template of the slot
  142. :return: the slot
  143. """
  144. self.slots[name] = Slot(self, name, template)
  145. return self.slots[name]
  146. def __enter__(self) -> Self:
  147. self.default_slot.__enter__()
  148. return self
  149. def __exit__(self, *_) -> None:
  150. self.default_slot.__exit__(*_)
  151. def __iter__(self) -> Iterator[Element]:
  152. for slot in self.slots.values():
  153. yield from slot
  154. def _collect_slot_dict(self) -> Dict[str, Any]:
  155. return {
  156. name: {
  157. 'ids': [child.id for child in slot],
  158. **({'template': slot.template} if slot.template is not None else {}),
  159. }
  160. for name, slot in self.slots.items()
  161. if slot != self.default_slot
  162. }
  163. def _to_dict(self) -> Dict[str, Any]:
  164. return {
  165. 'tag': self.tag,
  166. **({'text': self._text} if self._text is not None else {}),
  167. **{
  168. key: value
  169. for key, value in {
  170. 'class': self._classes,
  171. 'style': self._style,
  172. 'props': self._props,
  173. 'slots': self._collect_slot_dict(),
  174. 'children': [child.id for child in self.default_slot.children],
  175. 'events': [listener.to_dict() for listener in self._event_listeners.values()],
  176. 'component': {
  177. 'key': self.component.key,
  178. 'name': self.component.name,
  179. 'tag': self.component.tag
  180. } if self.component else None,
  181. 'libraries': [
  182. {
  183. 'key': library.key,
  184. 'name': library.name,
  185. } for library in self.libraries
  186. ],
  187. }.items()
  188. if value
  189. },
  190. }
  191. @staticmethod
  192. def _update_classes_list(classes: List[str],
  193. add: Optional[str] = None,
  194. remove: Optional[str] = None,
  195. replace: Optional[str] = None) -> List[str]:
  196. class_list = classes if replace is None else []
  197. class_list = [c for c in class_list if c not in (remove or '').split()]
  198. class_list += (add or '').split()
  199. class_list += (replace or '').split()
  200. return list(dict.fromkeys(class_list)) # NOTE: remove duplicates while preserving order
  201. def classes(self,
  202. add: Optional[str] = None, *,
  203. remove: Optional[str] = None,
  204. replace: Optional[str] = None) -> Self:
  205. """Apply, remove, or replace HTML classes.
  206. This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
  207. Removing or replacing classes can be helpful if predefined classes are not desired.
  208. :param add: whitespace-delimited string of classes
  209. :param remove: whitespace-delimited string of classes to remove from the element
  210. :param replace: whitespace-delimited string of classes to use instead of existing ones
  211. """
  212. new_classes = self._update_classes_list(self._classes, add, remove, replace)
  213. if self._classes != new_classes:
  214. self._classes = new_classes
  215. self.update()
  216. return self
  217. @classmethod
  218. def default_classes(cls,
  219. add: Optional[str] = None, *,
  220. remove: Optional[str] = None,
  221. replace: Optional[str] = None) -> type[Self]:
  222. """Apply, remove, or replace default HTML classes.
  223. This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
  224. Removing or replacing classes can be helpful if predefined classes are not desired.
  225. All elements of this class will share these HTML classes.
  226. These must be defined before element instantiation.
  227. :param add: whitespace-delimited string of classes
  228. :param remove: whitespace-delimited string of classes to remove from the element
  229. :param replace: whitespace-delimited string of classes to use instead of existing ones
  230. """
  231. cls._default_classes = cls._update_classes_list(cls._default_classes, add, remove, replace)
  232. return cls
  233. @staticmethod
  234. def _parse_style(text: Optional[str]) -> Dict[str, str]:
  235. result = {}
  236. for word in (text or '').split(';'):
  237. word = word.strip() # noqa: PLW2901
  238. if word:
  239. key, value = word.split(':', 1)
  240. result[key.strip()] = value.strip()
  241. return result
  242. def style(self,
  243. add: Optional[str] = None, *,
  244. remove: Optional[str] = None,
  245. replace: Optional[str] = None) -> Self:
  246. """Apply, remove, or replace CSS definitions.
  247. Removing or replacing styles can be helpful if the predefined style is not desired.
  248. :param add: semicolon-separated list of styles to add to the element
  249. :param remove: semicolon-separated list of styles to remove from the element
  250. :param replace: semicolon-separated list of styles to use instead of existing ones
  251. """
  252. style_dict = deepcopy(self._style) if replace is None else {}
  253. for key in self._parse_style(remove):
  254. style_dict.pop(key, None)
  255. style_dict.update(self._parse_style(add))
  256. style_dict.update(self._parse_style(replace))
  257. if self._style != style_dict:
  258. self._style = style_dict
  259. self.update()
  260. return self
  261. @classmethod
  262. def default_style(cls,
  263. add: Optional[str] = None, *,
  264. remove: Optional[str] = None,
  265. replace: Optional[str] = None) -> type[Self]:
  266. """Apply, remove, or replace default CSS definitions.
  267. Removing or replacing styles can be helpful if the predefined style is not desired.
  268. All elements of this class will share these CSS definitions.
  269. These must be defined before element instantiation.
  270. :param add: semicolon-separated list of styles to add to the element
  271. :param remove: semicolon-separated list of styles to remove from the element
  272. :param replace: semicolon-separated list of styles to use instead of existing ones
  273. """
  274. if replace is not None:
  275. cls._default_style.clear()
  276. for key in cls._parse_style(remove):
  277. cls._default_style.pop(key, None)
  278. cls._default_style.update(cls._parse_style(add))
  279. cls._default_style.update(cls._parse_style(replace))
  280. return cls
  281. @staticmethod
  282. def _parse_props(text: Optional[str]) -> Dict[str, Any]:
  283. dictionary = {}
  284. for match in PROPS_PATTERN.finditer(text or ''):
  285. key = match.group(1)
  286. value = match.group(2) or match.group(3) or match.group(4)
  287. if value is None:
  288. dictionary[key] = True
  289. else:
  290. if (value.startswith("'") and value.endswith("'")) or (value.startswith('"') and value.endswith('"')):
  291. value = ast.literal_eval(value)
  292. dictionary[key] = value
  293. return dictionary
  294. def props(self,
  295. add: Optional[str] = None, *,
  296. remove: Optional[str] = None) -> Self:
  297. """Add or remove props.
  298. This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
  299. Since props are simply applied as HTML attributes, they can be used with any HTML element.
  300. Boolean properties are assumed ``True`` if no value is specified.
  301. :param add: whitespace-delimited list of either boolean values or key=value pair to add
  302. :param remove: whitespace-delimited list of property keys to remove
  303. """
  304. needs_update = False
  305. for key in self._parse_props(remove):
  306. if key in self._props:
  307. needs_update = True
  308. del self._props[key]
  309. for key, value in self._parse_props(add).items():
  310. if self._props.get(key) != value:
  311. needs_update = True
  312. self._props[key] = value
  313. if needs_update:
  314. self.update()
  315. return self
  316. @classmethod
  317. def default_props(cls,
  318. add: Optional[str] = None, *,
  319. remove: Optional[str] = None) -> type[Self]:
  320. """Add or remove default props.
  321. This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
  322. Since props are simply applied as HTML attributes, they can be used with any HTML element.
  323. All elements of this class will share these props.
  324. These must be defined before element instantiation.
  325. Boolean properties are assumed ``True`` if no value is specified.
  326. :param add: whitespace-delimited list of either boolean values or key=value pair to add
  327. :param remove: whitespace-delimited list of property keys to remove
  328. """
  329. for key in cls._parse_props(remove):
  330. if key in cls._default_props:
  331. del cls._default_props[key]
  332. for key, value in cls._parse_props(add).items():
  333. cls._default_props[key] = value
  334. return cls
  335. def tooltip(self, text: str) -> Self:
  336. """Add a tooltip to the element.
  337. :param text: text of the tooltip
  338. """
  339. from .elements.tooltip import Tooltip # pylint: disable=import-outside-toplevel, cyclic-import
  340. with self:
  341. Tooltip(text)
  342. return self
  343. @overload
  344. def on(self,
  345. type: str, # pylint: disable=redefined-builtin
  346. *,
  347. js_handler: Optional[str] = None,
  348. ) -> Self:
  349. ...
  350. @overload
  351. def on(self,
  352. type: str, # pylint: disable=redefined-builtin
  353. handler: Optional[Callable[..., Any]] = None,
  354. args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None,
  355. *,
  356. throttle: float = 0.0,
  357. leading_events: bool = True,
  358. trailing_events: bool = True,
  359. ) -> Self:
  360. ...
  361. def on(self,
  362. type: str, # pylint: disable=redefined-builtin
  363. handler: Optional[Callable[..., Any]] = None,
  364. args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None,
  365. *,
  366. throttle: float = 0.0,
  367. leading_events: bool = True,
  368. trailing_events: bool = True,
  369. js_handler: Optional[str] = None,
  370. ) -> Self:
  371. """Subscribe to an event.
  372. :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
  373. :param handler: callback that is called upon occurrence of the event
  374. :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
  375. :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
  376. :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
  377. :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
  378. :param js_handler: JavaScript code that is executed upon occurrence of the event, e.g. `(evt) => alert(evt)` (default: `None`)
  379. """
  380. if handler and js_handler:
  381. raise ValueError('Either handler or js_handler can be specified, but not both')
  382. if handler or js_handler:
  383. listener = EventListener(
  384. element_id=self.id,
  385. type=helpers.kebab_to_camel_case(type),
  386. args=[args] if args and isinstance(args[0], str) else args, # type: ignore
  387. handler=handler,
  388. js_handler=js_handler,
  389. throttle=throttle,
  390. leading_events=leading_events,
  391. trailing_events=trailing_events,
  392. request=storage.request_contextvar.get(),
  393. )
  394. self._event_listeners[listener.id] = listener
  395. self.update()
  396. return self
  397. def _handle_event(self, msg: Dict) -> None:
  398. listener = self._event_listeners[msg['listener_id']]
  399. storage.request_contextvar.set(listener.request)
  400. args = events.GenericEventArguments(sender=self, client=self.client, args=msg['args'])
  401. events.handle_event(listener.handler, args)
  402. def update(self) -> None:
  403. """Update the element on the client side."""
  404. if self.is_deleted:
  405. return
  406. self.client.outbox.enqueue_update(self)
  407. def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
  408. """Run a method on the client side.
  409. If the function is awaited, the result of the method call is returned.
  410. Otherwise, the method is executed without waiting for a response.
  411. :param name: name of the method
  412. :param args: arguments to pass to the method
  413. :param timeout: maximum time to wait for a response (default: 1 second)
  414. """
  415. if not core.loop:
  416. return NullResponse()
  417. return self.client.run_javascript(f'return runMethod({self.id}, "{name}", {json.dumps(args)})',
  418. timeout=timeout, check_interval=check_interval)
  419. def _collect_descendants(self, *, include_self: bool = False) -> List[Element]:
  420. elements: List[Element] = [self] if include_self else []
  421. for child in self:
  422. elements.extend(child._collect_descendants(include_self=True)) # pylint: disable=protected-access
  423. return elements
  424. def clear(self) -> None:
  425. """Remove all child elements."""
  426. descendants = self._collect_descendants()
  427. self.client.remove_elements(descendants)
  428. for slot in self.slots.values():
  429. slot.children.clear()
  430. self.update()
  431. def move(self, target_container: Optional[Element] = None, target_index: int = -1):
  432. """Move the element to another container.
  433. :param target_container: container to move the element to (default: the parent container)
  434. :param target_index: index within the target slot (default: append to the end)
  435. """
  436. assert self.parent_slot is not None
  437. self.parent_slot.children.remove(self)
  438. self.parent_slot.parent.update()
  439. target_container = target_container or self.parent_slot.parent
  440. target_index = target_index if target_index >= 0 else len(target_container.default_slot.children)
  441. target_container.default_slot.children.insert(target_index, self)
  442. self.parent_slot = target_container.default_slot
  443. target_container.update()
  444. def remove(self, element: Union[Element, int]) -> None:
  445. """Remove a child element.
  446. :param element: either the element instance or its ID
  447. """
  448. if isinstance(element, int):
  449. children = list(self)
  450. element = children[element]
  451. elements = element._collect_descendants(include_self=True) # pylint: disable=protected-access
  452. self.client.remove_elements(elements)
  453. assert element.parent_slot is not None
  454. element.parent_slot.children.remove(element)
  455. self.update()
  456. def delete(self) -> None:
  457. """Delete the element and all its children."""
  458. assert self.parent_slot is not None
  459. self.parent_slot.parent.remove(self)
  460. def _handle_delete(self) -> None:
  461. """Called when the element is deleted.
  462. This method can be overridden in subclasses to perform cleanup tasks.
  463. """
  464. @property
  465. def is_deleted(self) -> bool:
  466. """Whether the element has been deleted."""
  467. return self._deleted