page.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import asyncio
  2. import inspect
  3. import time
  4. import uuid
  5. from dataclasses import dataclass
  6. from functools import wraps
  7. from typing import Callable, Optional
  8. import justpy as jp
  9. from addict import Dict
  10. from pygments.formatters import HtmlFormatter
  11. from starlette.requests import Request
  12. from ..globals import config, connect_handlers, disconnect_handlers, page_builders, view_stack
  13. from ..helpers import is_coroutine
  14. @dataclass
  15. class PageBuilder:
  16. function: Callable
  17. shared: bool
  18. class Page(jp.QuasarPage):
  19. def __init__(self,
  20. route: str,
  21. title: Optional[str] = None,
  22. *,
  23. favicon: Optional[str] = None,
  24. dark: Optional[bool] = ...,
  25. classes: str = 'q-ma-md column items-start',
  26. css: str = HtmlFormatter().get_style_defs('.codehilite'),
  27. on_connect: Optional[Callable] = None,
  28. on_page_ready: Optional[Callable] = None,
  29. on_disconnect: Optional[Callable] = None,
  30. shared: bool = False,
  31. ):
  32. super().__init__()
  33. self.route = route
  34. self.title = title or config.title
  35. self.favicon = favicon or config.favicon
  36. self.dark = dark if dark is not ... else config.dark
  37. self.tailwind = True # use Tailwind classes instead of Quasars
  38. self.css = css
  39. self.connect_handler = on_connect
  40. self.page_ready_handler = on_page_ready
  41. self.disconnect_handler = on_disconnect
  42. self.delete_flag = not shared
  43. self.waiting_javascript_commands: dict[str, str] = {}
  44. self.on('result_ready', self.handle_javascript_result)
  45. self.on('page_ready', self.handle_page_ready)
  46. self.view = jp.Div(a=self, classes=classes, style='row-gap: 1em', temp=False)
  47. self.view.add_page(self)
  48. async def _route_function(self, request: Request):
  49. for connect_handler in connect_handlers + ([self.connect_handler] if self.connect_handler else []):
  50. arg_count = len(inspect.signature(connect_handler).parameters)
  51. is_coro = is_coroutine(connect_handler)
  52. if arg_count == 1:
  53. await connect_handler(request) if is_coro else connect_handler(request)
  54. elif arg_count == 0:
  55. await connect_handler() if is_coro else connect_handler()
  56. else:
  57. raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
  58. return self
  59. async def handle_page_ready(self, msg: Dict) -> bool:
  60. if self.page_ready_handler:
  61. arg_count = len(inspect.signature(self.page_ready_handler).parameters)
  62. is_coro = is_coroutine(self.page_ready_handler)
  63. if arg_count == 1:
  64. await self.page_ready_handler(msg.websocket) if is_coro else self.page_ready_handler(msg.websocket)
  65. elif arg_count == 0:
  66. await self.page_ready_handler() if is_coro else self.page_ready_handler()
  67. else:
  68. raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
  69. return False
  70. async def on_disconnect(self, websocket=None) -> None:
  71. for disconnect_handler in ([self.disconnect_handler] if self.disconnect_handler else []) + disconnect_handlers:
  72. arg_count = len(inspect.signature(disconnect_handler).parameters)
  73. is_coro = is_coroutine(disconnect_handler)
  74. if arg_count == 1:
  75. await disconnect_handler(websocket) if is_coro else disconnect_handler(websocket)
  76. elif arg_count == 0:
  77. await disconnect_handler() if is_coro else disconnect_handler()
  78. else:
  79. raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
  80. await super().on_disconnect(websocket)
  81. async def await_javascript(self, code: str, check_interval: float = 0.01, timeout: float = 1.0) -> str:
  82. start_time = time.time()
  83. request_id = str(uuid.uuid4())
  84. await self.run_javascript(code, request_id=request_id)
  85. while request_id not in self.waiting_javascript_commands:
  86. if time.time() > start_time + timeout:
  87. raise TimeoutError('JavaScript did not respond in time')
  88. await asyncio.sleep(check_interval)
  89. return self.waiting_javascript_commands.pop(request_id)
  90. def handle_javascript_result(self, msg) -> bool:
  91. self.waiting_javascript_commands[msg.request_id] = msg.result
  92. return False
  93. def add_head_html(self, html: str) -> None:
  94. for page in get_current_view().pages.values():
  95. page.head_html += html
  96. def add_body_html(self, html: str) -> None:
  97. for page in get_current_view().pages.values():
  98. page.body_html += html
  99. def page(self,
  100. route: str,
  101. title: Optional[str] = None,
  102. *,
  103. favicon: Optional[str] = None,
  104. dark: Optional[bool] = ...,
  105. classes: str = 'q-ma-md column items-start',
  106. css: str = HtmlFormatter().get_style_defs('.codehilite'),
  107. on_connect: Optional[Callable] = None,
  108. on_page_ready: Optional[Callable] = None,
  109. on_disconnect: Optional[Callable] = None,
  110. shared: bool = False,
  111. ):
  112. """Page
  113. Creates a new page at the given route.
  114. :param route: route of the new page (path must start with '/')
  115. :param title: optional page title
  116. :param favicon: optional favicon
  117. :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
  118. :param classes: tailwind classes for the container div (default: `'q-ma-md column items-start'`)
  119. :param css: CSS definitions
  120. :param on_connect: optional function or coroutine which is called for each new client connection
  121. :param on_page_ready: optional function or coroutine which is called when the websocket is connected
  122. :param on_disconnect: optional function or coroutine which is called when a client disconnects
  123. :param shared: whether the page instance is shared between multiple clients (default: `False`)
  124. """
  125. def decorator(func):
  126. @wraps(func)
  127. async def decorated():
  128. page = Page(
  129. route=route,
  130. title=title,
  131. favicon=favicon,
  132. dark=dark,
  133. classes=classes,
  134. css=css,
  135. on_connect=on_connect,
  136. on_page_ready=on_page_ready,
  137. on_disconnect=on_disconnect,
  138. shared=shared,
  139. )
  140. view_stack.append(page.view)
  141. await func() if is_coroutine(func) else func()
  142. view_stack.pop()
  143. return page
  144. page_builders[route] = PageBuilder(decorated, shared)
  145. return decorated
  146. return decorator
  147. def get_current_view() -> jp.HTMLBaseComponent:
  148. if not view_stack:
  149. page = Page(route='/', title=config.title, dark=config.dark, classes=config.main_page_classes, shared=True)
  150. view_stack.append(page.view)
  151. jp.Route('/', page._route_function)
  152. return view_stack[-1]
  153. def error404() -> jp.QuasarPage:
  154. wp = jp.QuasarPage(title=config.title, favicon=config.favicon, dark=config.dark, tailwind=True)
  155. div = jp.Div(a=wp, classes='py-20 text-center')
  156. jp.Div(a=div, classes='text-8xl py-5', text='☹',
  157. style='font-family: "Arial Unicode MS", "Times New Roman", Times, serif;')
  158. jp.Div(a=div, classes='text-6xl py-5', text='404')
  159. jp.Div(a=div, classes='text-xl py-5', text='This page doesn\'t exist.')
  160. return wp