element.py 23 KB

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