|
@@ -1,817 +1,32 @@
|
|
-import uuid
|
|
|
|
-
|
|
|
|
-from nicegui import app, events, optional_features, ui
|
|
|
|
-
|
|
|
|
-from . import demo
|
|
|
|
-from .tools import element_demo, heading, load_demo, subheading, text_demo
|
|
|
|
-
|
|
|
|
-CONSTANT_UUID = str(uuid.uuid4())
|
|
|
|
|
|
+from .sections import (action_events, audiovisual_elements, binding_properties, configuration_deployment, controls,
|
|
|
|
+ data_elements, page_layout, styling_appearance, text_elements)
|
|
|
|
+from .tools import heading
|
|
|
|
|
|
|
|
|
|
def create_full() -> None:
|
|
def create_full() -> None:
|
|
- heading('Basic Elements')
|
|
|
|
- load_demo(ui.label)
|
|
|
|
- load_demo(ui.icon)
|
|
|
|
- load_demo(ui.avatar)
|
|
|
|
- load_demo(ui.link)
|
|
|
|
- load_demo(ui.button)
|
|
|
|
- load_demo(ui.badge)
|
|
|
|
- load_demo(ui.toggle)
|
|
|
|
- load_demo(ui.radio)
|
|
|
|
- load_demo(ui.select)
|
|
|
|
- load_demo(ui.checkbox)
|
|
|
|
- load_demo(ui.switch)
|
|
|
|
- load_demo(ui.slider)
|
|
|
|
- load_demo(ui.joystick)
|
|
|
|
- load_demo(ui.input)
|
|
|
|
- load_demo(ui.textarea)
|
|
|
|
- load_demo(ui.number)
|
|
|
|
- load_demo(ui.knob)
|
|
|
|
- load_demo(ui.color_input)
|
|
|
|
- load_demo(ui.color_picker)
|
|
|
|
- load_demo(ui.date)
|
|
|
|
- load_demo(ui.time)
|
|
|
|
- load_demo(ui.upload)
|
|
|
|
- load_demo(ui.chat_message)
|
|
|
|
- load_demo(ui.element)
|
|
|
|
-
|
|
|
|
- heading('Markdown and HTML')
|
|
|
|
-
|
|
|
|
- load_demo(ui.markdown)
|
|
|
|
- load_demo(ui.mermaid)
|
|
|
|
- load_demo(ui.html)
|
|
|
|
-
|
|
|
|
- @text_demo('SVG',
|
|
|
|
- 'You can add Scalable Vector Graphics using the `ui.html` element.')
|
|
|
|
- def svg_demo():
|
|
|
|
- content = '''
|
|
|
|
- <svg viewBox="0 0 200 200" width="100" height="100" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
- <circle cx="100" cy="100" r="78" fill="#ffde34" stroke="black" stroke-width="3" />
|
|
|
|
- <circle cx="80" cy="85" r="8" />
|
|
|
|
- <circle cx="120" cy="85" r="8" />
|
|
|
|
- <path d="m60,120 C75,150 125,150 140,120" style="fill:none; stroke:black; stroke-width:8; stroke-linecap:round" />
|
|
|
|
- </svg>'''
|
|
|
|
- ui.html(content)
|
|
|
|
-
|
|
|
|
- heading('Images, Audio and Video')
|
|
|
|
-
|
|
|
|
- load_demo(ui.image)
|
|
|
|
-
|
|
|
|
- @text_demo('Captions and Overlays', '''
|
|
|
|
- By nesting elements inside a `ui.image` you can create augmentations.
|
|
|
|
|
|
+ heading('Text Elements')
|
|
|
|
+ text_elements.content()
|
|
|
|
|
|
- Use [Quasar classes](https://quasar.dev/vue-components/img) for positioning and styling captions.
|
|
|
|
- To overlay an SVG, make the `viewBox` exactly the size of the image and provide `100%` width/height to match the actual rendered size.
|
|
|
|
- ''')
|
|
|
|
- def captions_and_overlays_demo():
|
|
|
|
- with ui.image('https://picsum.photos/id/29/640/360'):
|
|
|
|
- ui.label('Nice!').classes('absolute-bottom text-subtitle2 text-center')
|
|
|
|
|
|
+ heading('Controls')
|
|
|
|
+ controls.content()
|
|
|
|
|
|
- with ui.image('https://cdn.stocksnap.io/img-thumbs/960w/airplane-sky_DYPWDEEILG.jpg'):
|
|
|
|
- ui.html('''
|
|
|
|
- <svg viewBox="0 0 960 638" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
- <circle cx="445" cy="300" r="100" fill="none" stroke="red" stroke-width="20" />
|
|
|
|
- </svg>
|
|
|
|
- ''').classes('bg-transparent')
|
|
|
|
-
|
|
|
|
- load_demo(ui.interactive_image)
|
|
|
|
- load_demo(ui.audio)
|
|
|
|
- load_demo(ui.video)
|
|
|
|
|
|
+ heading('Audiovisual Elements')
|
|
|
|
+ audiovisual_elements.content()
|
|
|
|
|
|
heading('Data Elements')
|
|
heading('Data Elements')
|
|
|
|
+ data_elements.content()
|
|
|
|
|
|
- load_demo(ui.table)
|
|
|
|
- load_demo(ui.aggrid)
|
|
|
|
- if optional_features.has('highcharts'):
|
|
|
|
- load_demo(ui.highchart)
|
|
|
|
- load_demo(ui.echart)
|
|
|
|
- if optional_features.has('matplotlib'):
|
|
|
|
- load_demo(ui.pyplot)
|
|
|
|
- load_demo(ui.line_plot)
|
|
|
|
- if optional_features.has('plotly'):
|
|
|
|
- load_demo(ui.plotly)
|
|
|
|
- load_demo(ui.linear_progress)
|
|
|
|
- load_demo(ui.circular_progress)
|
|
|
|
- load_demo(ui.spinner)
|
|
|
|
- load_demo(ui.scene)
|
|
|
|
- load_demo(ui.tree)
|
|
|
|
- load_demo(ui.log)
|
|
|
|
- load_demo(ui.editor)
|
|
|
|
- load_demo(ui.code)
|
|
|
|
- load_demo(ui.json_editor)
|
|
|
|
-
|
|
|
|
- heading('Layout')
|
|
|
|
-
|
|
|
|
- load_demo(ui.card)
|
|
|
|
- load_demo(ui.column)
|
|
|
|
- load_demo(ui.row)
|
|
|
|
- load_demo(ui.grid)
|
|
|
|
-
|
|
|
|
- @text_demo('Clear Containers', '''
|
|
|
|
- To remove all elements from a row, column or card container, use can call
|
|
|
|
- ```py
|
|
|
|
- container.clear()
|
|
|
|
- ```
|
|
|
|
-
|
|
|
|
- Alternatively, you can remove individual elements by calling
|
|
|
|
-
|
|
|
|
- - `container.remove(element: Element)`,
|
|
|
|
- - `container.remove(index: int)`, or
|
|
|
|
- - `element.delete()`.
|
|
|
|
- ''')
|
|
|
|
- def clear_containers_demo():
|
|
|
|
- container = ui.row()
|
|
|
|
-
|
|
|
|
- def add_face():
|
|
|
|
- with container:
|
|
|
|
- ui.icon('face')
|
|
|
|
- add_face()
|
|
|
|
-
|
|
|
|
- ui.button('Add', on_click=add_face)
|
|
|
|
- ui.button('Remove', on_click=lambda: container.remove(0) if list(container) else None)
|
|
|
|
- ui.button('Clear', on_click=container.clear)
|
|
|
|
-
|
|
|
|
- load_demo(ui.expansion)
|
|
|
|
- load_demo(ui.scroll_area)
|
|
|
|
- load_demo(ui.separator)
|
|
|
|
- load_demo(ui.splitter)
|
|
|
|
- load_demo('tabs')
|
|
|
|
- load_demo(ui.stepper)
|
|
|
|
- load_demo(ui.timeline)
|
|
|
|
- load_demo(ui.carousel)
|
|
|
|
- load_demo(ui.pagination)
|
|
|
|
- load_demo(ui.menu)
|
|
|
|
- load_demo(ui.context_menu)
|
|
|
|
-
|
|
|
|
- @text_demo('Tooltips', '''
|
|
|
|
- Simply call the `tooltip(text:str)` method on UI elements to provide a tooltip.
|
|
|
|
-
|
|
|
|
- For more artistic control you can nest tooltip elements and apply props, classes and styles.
|
|
|
|
- ''')
|
|
|
|
- def tooltips_demo():
|
|
|
|
- ui.label('Tooltips...').tooltip('...are shown on mouse over')
|
|
|
|
- with ui.button(icon='thumb_up'):
|
|
|
|
- ui.tooltip('I like this').classes('bg-green')
|
|
|
|
-
|
|
|
|
- load_demo(ui.notify)
|
|
|
|
- load_demo(ui.dialog)
|
|
|
|
-
|
|
|
|
- heading('Appearance')
|
|
|
|
-
|
|
|
|
- @text_demo('Styling', '''
|
|
|
|
- NiceGUI uses the [Quasar Framework](https://quasar.dev/) version 1.0 and hence has its full design power.
|
|
|
|
- Each NiceGUI element provides a `props` method whose content is passed [to the Quasar component](https://justpy.io/quasar_tutorial/introduction/#props-of-quasar-components):
|
|
|
|
- Have a look at [the Quasar documentation](https://quasar.dev/vue-components/button#design) for all styling props.
|
|
|
|
- Props with a leading `:` can contain JavaScript expressions that are evaluated on the client.
|
|
|
|
- You can also apply [Tailwind CSS](https://tailwindcss.com/) utility classes with the `classes` method.
|
|
|
|
-
|
|
|
|
- If you really need to apply CSS, you can use the `style` method. Here the delimiter is `;` instead of a blank space.
|
|
|
|
-
|
|
|
|
- All three functions also provide `remove` and `replace` parameters in case the predefined look is not wanted in a particular styling.
|
|
|
|
- ''')
|
|
|
|
- def design_demo():
|
|
|
|
- ui.radio(['x', 'y', 'z'], value='x').props('inline color=green')
|
|
|
|
- ui.button(icon='touch_app').props('outline round').classes('shadow-lg')
|
|
|
|
- ui.label('Stylish!').style('color: #6E93D6; font-size: 200%; font-weight: 300')
|
|
|
|
-
|
|
|
|
- subheading('Try styling NiceGUI elements!')
|
|
|
|
- ui.markdown('''
|
|
|
|
- Try out how
|
|
|
|
- [Tailwind CSS classes](https://tailwindcss.com/),
|
|
|
|
- [Quasar props](https://justpy.io/quasar_tutorial/introduction/#props-of-quasar-components),
|
|
|
|
- and CSS styles affect NiceGUI elements.
|
|
|
|
- ''').classes('bold-links arrow-links mb-[-1rem]')
|
|
|
|
- with ui.row():
|
|
|
|
- ui.label('Select an element from those available and start styling it!').classes('mx-auto my-auto')
|
|
|
|
- select_element = ui.select({
|
|
|
|
- ui.label: 'ui.label',
|
|
|
|
- ui.checkbox: 'ui.checkbox',
|
|
|
|
- ui.switch: 'ui.switch',
|
|
|
|
- ui.input: 'ui.input',
|
|
|
|
- ui.textarea: 'ui.textarea',
|
|
|
|
- ui.button: 'ui.button',
|
|
|
|
- }, value=ui.button, on_change=lambda: live_demo_ui.refresh()).props('dense')
|
|
|
|
-
|
|
|
|
- @ui.refreshable
|
|
|
|
- def live_demo_ui():
|
|
|
|
- with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
|
|
|
|
- with demo.python_window(classes='w-full max-w-[44rem]'):
|
|
|
|
- with ui.column().classes('w-full gap-4'):
|
|
|
|
- ui.markdown(f'''
|
|
|
|
- ```py
|
|
|
|
- from nicegui import ui
|
|
|
|
-
|
|
|
|
- element = {select_element.options[select_element.value]}('element')
|
|
|
|
- ```
|
|
|
|
- ''').classes('mb-[-0.25em]')
|
|
|
|
- with ui.row().classes('items-center gap-0 w-full px-2'):
|
|
|
|
- def handle_classes(e: events.ValueChangeEventArguments):
|
|
|
|
- try:
|
|
|
|
- element.classes(replace=e.value)
|
|
|
|
- except ValueError:
|
|
|
|
- pass
|
|
|
|
- ui.markdown("`element.classes('`")
|
|
|
|
- ui.input(on_change=handle_classes).classes('mt-[-0.5em] text-mono grow').props('dense')
|
|
|
|
- ui.markdown("`')`")
|
|
|
|
- with ui.row().classes('items-center gap-0 w-full px-2'):
|
|
|
|
- def handle_props(e: events.ValueChangeEventArguments):
|
|
|
|
- element._props = {'label': 'Button', 'color': 'primary'}
|
|
|
|
- try:
|
|
|
|
- element.props(e.value)
|
|
|
|
- except ValueError:
|
|
|
|
- pass
|
|
|
|
- element.update()
|
|
|
|
- ui.markdown("`element.props('`")
|
|
|
|
- ui.input(on_change=handle_props).classes('mt-[-0.5em] text-mono grow').props('dense')
|
|
|
|
- ui.markdown("`')`")
|
|
|
|
- with ui.row().classes('items-center gap-0 w-full px-2'):
|
|
|
|
- def handle_style(e: events.ValueChangeEventArguments):
|
|
|
|
- try:
|
|
|
|
- element.style(replace=e.value)
|
|
|
|
- except ValueError:
|
|
|
|
- pass
|
|
|
|
- ui.markdown("`element.style('`")
|
|
|
|
- ui.input(on_change=handle_style).classes('mt-[-0.5em] text-mono grow').props('dense')
|
|
|
|
- ui.markdown("`')`")
|
|
|
|
- ui.markdown('''
|
|
|
|
- ```py
|
|
|
|
- ui.run()
|
|
|
|
- ```
|
|
|
|
- ''')
|
|
|
|
- with demo.browser_window(classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window'):
|
|
|
|
- element: ui.element = select_element.value("element")
|
|
|
|
- live_demo_ui()
|
|
|
|
-
|
|
|
|
- @text_demo('Tailwind CSS', '''
|
|
|
|
- [Tailwind CSS](https://tailwindcss.com/) is a CSS framework for rapidly building custom user interfaces.
|
|
|
|
- NiceGUI provides a fluent, auto-complete friendly interface for adding Tailwind classes to UI elements.
|
|
|
|
-
|
|
|
|
- You can discover available classes by navigating the methods of the `tailwind` property.
|
|
|
|
- The builder pattern allows you to chain multiple classes together (as shown with "Label A").
|
|
|
|
- You can also call the `tailwind` property with a list of classes (as shown with "Label B").
|
|
|
|
-
|
|
|
|
- Although this is very similar to using the `classes` method, it is more convenient for Tailwind classes due to auto-completion.
|
|
|
|
-
|
|
|
|
- Last but not least, you can also predefine a style and apply it to multiple elements (labels C and D).
|
|
|
|
-
|
|
|
|
- Note that sometimes Tailwind is overruled by Quasar styles, e.g. when using `ui.button('Button').tailwind('bg-red-500')`.
|
|
|
|
- This is a known limitation and not fully in our control.
|
|
|
|
- But we try to provide solutions like the `color` parameter: `ui.button('Button', color='red-500')`.
|
|
|
|
- ''')
|
|
|
|
- def tailwind_demo():
|
|
|
|
- from nicegui import Tailwind
|
|
|
|
- ui.label('Label A').tailwind.font_weight('extrabold').text_color('blue-600').background_color('orange-200')
|
|
|
|
- ui.label('Label B').tailwind('drop-shadow', 'font-bold', 'text-green-600')
|
|
|
|
-
|
|
|
|
- red_style = Tailwind().text_color('red-600').font_weight('bold')
|
|
|
|
- label_c = ui.label('Label C')
|
|
|
|
- red_style.apply(label_c)
|
|
|
|
- ui.label('Label D').tailwind(red_style)
|
|
|
|
-
|
|
|
|
- load_demo(ui.query)
|
|
|
|
- load_demo(ui.colors)
|
|
|
|
- load_demo(ui.dark_mode)
|
|
|
|
-
|
|
|
|
- heading('Action')
|
|
|
|
-
|
|
|
|
- load_demo(ui.timer)
|
|
|
|
- load_demo(ui.keyboard)
|
|
|
|
- load_demo('bindings')
|
|
|
|
-
|
|
|
|
- @text_demo('UI Updates', '''
|
|
|
|
- NiceGUI tries to automatically synchronize the state of UI elements with the client,
|
|
|
|
- e.g. when a label text, an input value or style/classes/props of an element have changed.
|
|
|
|
- In other cases, you can explicitly call `element.update()` or `ui.update(*elements)` to update.
|
|
|
|
- The demo code shows both methods for a `ui.echart`, where it is difficult to automatically detect changes in the `options` dictionary.
|
|
|
|
- ''')
|
|
|
|
- def ui_updates_demo():
|
|
|
|
- from random import random
|
|
|
|
-
|
|
|
|
- chart = ui.echart({
|
|
|
|
- 'xAxis': {'type': 'value'},
|
|
|
|
- 'yAxis': {'type': 'value'},
|
|
|
|
- 'series': [{'type': 'line', 'data': [[0, 0], [1, 1]]}],
|
|
|
|
- })
|
|
|
|
-
|
|
|
|
- def add():
|
|
|
|
- chart.options['series'][0]['data'].append([random(), random()])
|
|
|
|
- chart.update()
|
|
|
|
-
|
|
|
|
- def clear():
|
|
|
|
- chart.options['series'][0]['data'].clear()
|
|
|
|
- ui.update(chart)
|
|
|
|
-
|
|
|
|
- with ui.row():
|
|
|
|
- ui.button('Add', on_click=add)
|
|
|
|
- ui.button('Clear', on_click=clear)
|
|
|
|
-
|
|
|
|
- load_demo(ui.refreshable)
|
|
|
|
-
|
|
|
|
- @text_demo('Async event handlers', '''
|
|
|
|
- Most elements also support asynchronous event handlers.
|
|
|
|
-
|
|
|
|
- Note: You can also pass a `functools.partial` into the `on_click` property to wrap async functions with parameters.
|
|
|
|
- ''')
|
|
|
|
- def async_handlers_demo():
|
|
|
|
- import asyncio
|
|
|
|
-
|
|
|
|
- async def async_task():
|
|
|
|
- ui.notify('Asynchronous task started')
|
|
|
|
- await asyncio.sleep(5)
|
|
|
|
- ui.notify('Asynchronous task finished')
|
|
|
|
-
|
|
|
|
- ui.button('start async task', on_click=async_task)
|
|
|
|
-
|
|
|
|
- @text_demo('Running CPU-bound tasks', '''
|
|
|
|
- NiceGUI provides a `cpu_bound` function for running CPU-bound tasks in a separate process.
|
|
|
|
- This is useful for long-running computations that would otherwise block the event loop and make the UI unresponsive.
|
|
|
|
- The function returns a future that can be awaited.
|
|
|
|
- ''')
|
|
|
|
- def cpu_bound_demo():
|
|
|
|
- import time
|
|
|
|
-
|
|
|
|
- from nicegui import run
|
|
|
|
-
|
|
|
|
- def compute_sum(a: float, b: float) -> float:
|
|
|
|
- time.sleep(1) # simulate a long-running computation
|
|
|
|
- return a + b
|
|
|
|
-
|
|
|
|
- async def handle_click():
|
|
|
|
- result = await run.cpu_bound(compute_sum, 1, 2)
|
|
|
|
- ui.notify(f'Sum is {result}')
|
|
|
|
-
|
|
|
|
- # ui.button('Compute', on_click=handle_click)
|
|
|
|
- # END OF DEMO
|
|
|
|
- async def mock_click():
|
|
|
|
- import asyncio
|
|
|
|
- await asyncio.sleep(1)
|
|
|
|
- ui.notify('Sum is 3')
|
|
|
|
- ui.button('Compute', on_click=mock_click)
|
|
|
|
-
|
|
|
|
- @text_demo('Running I/O-bound tasks', '''
|
|
|
|
- NiceGUI provides an `io_bound` function for running I/O-bound tasks in a separate thread.
|
|
|
|
- This is useful for long-running I/O operations that would otherwise block the event loop and make the UI unresponsive.
|
|
|
|
- The function returns a future that can be awaited.
|
|
|
|
- ''')
|
|
|
|
- def io_bound_demo():
|
|
|
|
- import requests
|
|
|
|
-
|
|
|
|
- from nicegui import run
|
|
|
|
-
|
|
|
|
- async def handle_click():
|
|
|
|
- URL = 'https://httpbin.org/delay/1'
|
|
|
|
- response = await run.io_bound(requests.get, URL, timeout=3)
|
|
|
|
- ui.notify(f'Downloaded {len(response.content)} bytes')
|
|
|
|
-
|
|
|
|
- ui.button('Download', on_click=handle_click)
|
|
|
|
-
|
|
|
|
- heading('Pages')
|
|
|
|
-
|
|
|
|
- load_demo(ui.page)
|
|
|
|
-
|
|
|
|
- @text_demo('Auto-index page', '''
|
|
|
|
- Pages created with the `@ui.page` decorator are "private".
|
|
|
|
- Their content is re-created for each client.
|
|
|
|
- Thus, in the demo to the right, the displayed ID on the private page changes when the browser reloads the page.
|
|
|
|
-
|
|
|
|
- UI elements that are not wrapped in a decorated page function are placed on an automatically generated index page at route "/".
|
|
|
|
- This auto-index page is created once on startup and *shared* across all clients that might connect.
|
|
|
|
- Thus, each connected client will see the *same* elements.
|
|
|
|
- In the demo to the right, the displayed ID on the auto-index page remains constant when the browser reloads the page.
|
|
|
|
- ''')
|
|
|
|
- def auto_index_page():
|
|
|
|
- from uuid import uuid4
|
|
|
|
-
|
|
|
|
- @ui.page('/private_page')
|
|
|
|
- async def private_page():
|
|
|
|
- ui.label(f'private page with ID {uuid4()}')
|
|
|
|
-
|
|
|
|
- # ui.label(f'shared auto-index page with ID {uuid4()}')
|
|
|
|
- # ui.link('private page', private_page)
|
|
|
|
- # END OF DEMO
|
|
|
|
- ui.label(f'shared auto-index page with ID {CONSTANT_UUID}')
|
|
|
|
- ui.link('private page', private_page)
|
|
|
|
-
|
|
|
|
- @text_demo('Page Layout', '''
|
|
|
|
- With `ui.header`, `ui.footer`, `ui.left_drawer` and `ui.right_drawer` you can add additional layout elements to a page.
|
|
|
|
- The `fixed` argument controls whether the element should scroll or stay fixed on the screen.
|
|
|
|
- The `top_corner` and `bottom_corner` arguments indicate whether a drawer should expand to the top or bottom of the page.
|
|
|
|
- See <https://quasar.dev/layout/header-and-footer> and <https://quasar.dev/layout/drawer> for more information about possible props.
|
|
|
|
- With `ui.page_sticky` you can place an element "sticky" on the screen.
|
|
|
|
- See <https://quasar.dev/layout/page-sticky> for more information.
|
|
|
|
- ''')
|
|
|
|
- def page_layout_demo():
|
|
|
|
- @ui.page('/page_layout')
|
|
|
|
- def page_layout():
|
|
|
|
- ui.label('CONTENT')
|
|
|
|
- [ui.label(f'Line {i}') for i in range(100)]
|
|
|
|
- with ui.header(elevated=True).style('background-color: #3874c8').classes('items-center justify-between'):
|
|
|
|
- ui.label('HEADER')
|
|
|
|
- ui.button(on_click=lambda: right_drawer.toggle(), icon='menu').props('flat color=white')
|
|
|
|
- with ui.left_drawer(top_corner=True, bottom_corner=True).style('background-color: #d7e3f4'):
|
|
|
|
- ui.label('LEFT DRAWER')
|
|
|
|
- with ui.right_drawer(fixed=False).style('background-color: #ebf1fa').props('bordered') as right_drawer:
|
|
|
|
- ui.label('RIGHT DRAWER')
|
|
|
|
- with ui.footer().style('background-color: #3874c8'):
|
|
|
|
- ui.label('FOOTER')
|
|
|
|
-
|
|
|
|
- ui.link('show page with fancy layout', page_layout)
|
|
|
|
-
|
|
|
|
- load_demo(ui.open)
|
|
|
|
- load_demo(ui.download)
|
|
|
|
-
|
|
|
|
- load_demo('storage')
|
|
|
|
-
|
|
|
|
- @text_demo('Parameter injection', '''
|
|
|
|
- Thanks to FastAPI, a page function accepts optional parameters to provide
|
|
|
|
- [path parameters](https://fastapi.tiangolo.com/tutorial/path-params/),
|
|
|
|
- [query parameters](https://fastapi.tiangolo.com/tutorial/query-params/) or the whole incoming
|
|
|
|
- [request](https://fastapi.tiangolo.com/advanced/using-request-directly/) for accessing
|
|
|
|
- the body payload, headers, cookies and more.
|
|
|
|
- ''')
|
|
|
|
- def parameter_demo():
|
|
|
|
- @ui.page('/icon/{icon}')
|
|
|
|
- def icons(icon: str, amount: int = 1):
|
|
|
|
- ui.label(icon).classes('text-h3')
|
|
|
|
- with ui.row():
|
|
|
|
- [ui.icon(icon).classes('text-h3') for _ in range(amount)]
|
|
|
|
- ui.link('Star', '/icon/star?amount=5')
|
|
|
|
- ui.link('Home', '/icon/home')
|
|
|
|
- ui.link('Water', '/icon/water_drop?amount=3')
|
|
|
|
-
|
|
|
|
- load_demo(ui.run_javascript)
|
|
|
|
-
|
|
|
|
- heading('Routes')
|
|
|
|
-
|
|
|
|
- subheading('Static files')
|
|
|
|
-
|
|
|
|
- @element_demo(app.add_static_files)
|
|
|
|
- def add_static_files_demo():
|
|
|
|
- from nicegui import app
|
|
|
|
-
|
|
|
|
- app.add_static_files('/examples', 'examples')
|
|
|
|
- ui.label('Some NiceGUI Examples').classes('text-h5')
|
|
|
|
- ui.link('AI interface', '/examples/ai_interface/main.py')
|
|
|
|
- ui.link('Custom FastAPI app', '/examples/fastapi/main.py')
|
|
|
|
- ui.link('Authentication', '/examples/authentication/main.py')
|
|
|
|
-
|
|
|
|
- subheading('Media files')
|
|
|
|
-
|
|
|
|
- @element_demo(app.add_media_files)
|
|
|
|
- def add_media_files_demo():
|
|
|
|
- from pathlib import Path
|
|
|
|
-
|
|
|
|
- import requests
|
|
|
|
-
|
|
|
|
- from nicegui import app
|
|
|
|
-
|
|
|
|
- media = Path('media')
|
|
|
|
- # media.mkdir(exist_ok=True)
|
|
|
|
- # r = requests.get('https://cdn.coverr.co/videos/coverr-cloudy-sky-2765/1080p.mp4')
|
|
|
|
- # (media / 'clouds.mp4').write_bytes(r.content)
|
|
|
|
- # app.add_media_files('/my_videos', media)
|
|
|
|
- # ui.video('/my_videos/clouds.mp4')
|
|
|
|
- # END OF DEMO
|
|
|
|
- ui.video('https://cdn.coverr.co/videos/coverr-cloudy-sky-2765/1080p.mp4')
|
|
|
|
-
|
|
|
|
- @text_demo('API Responses', '''
|
|
|
|
- NiceGUI is based on [FastAPI](https://fastapi.tiangolo.com/).
|
|
|
|
- This means you can use all of FastAPI's features.
|
|
|
|
- For example, you can implement a RESTful API in addition to your graphical user interface.
|
|
|
|
- You simply import the `app` object from `nicegui`.
|
|
|
|
- Or you can run NiceGUI on top of your own FastAPI app by using `ui.run_with(app)` instead of starting a server automatically with `ui.run()`.
|
|
|
|
-
|
|
|
|
- You can also return any other FastAPI response object inside a page function.
|
|
|
|
- For example, you can return a `RedirectResponse` to redirect the user to another page if certain conditions are met.
|
|
|
|
- This is used in our [authentication demo](https://github.com/zauberzeug/nicegui/tree/main/examples/authentication/main.py).
|
|
|
|
- ''')
|
|
|
|
- def fastapi_demo():
|
|
|
|
- import random
|
|
|
|
-
|
|
|
|
- from nicegui import app
|
|
|
|
-
|
|
|
|
- @app.get('/random/{max}')
|
|
|
|
- def generate_random_number(max: int):
|
|
|
|
- return {'min': 0, 'max': max, 'value': random.randint(0, max)}
|
|
|
|
-
|
|
|
|
- max = ui.number('max', value=100)
|
|
|
|
- ui.button('generate random number', on_click=lambda: ui.open(f'/random/{max.value:.0f}'))
|
|
|
|
-
|
|
|
|
- heading('Lifecycle')
|
|
|
|
-
|
|
|
|
- @text_demo('Events', '''
|
|
|
|
- You can register coroutines or functions to be called for the following events:
|
|
|
|
-
|
|
|
|
- - `app.on_startup`: called when NiceGUI is started or restarted
|
|
|
|
- - `app.on_shutdown`: called when NiceGUI is shut down or restarted
|
|
|
|
- - `app.on_connect`: called for each client which connects (optional argument: nicegui.Client)
|
|
|
|
- - `app.on_disconnect`: called for each client which disconnects (optional argument: nicegui.Client)
|
|
|
|
- - `app.on_exception`: called when an exception occurs (optional argument: exception)
|
|
|
|
-
|
|
|
|
- When NiceGUI is shut down or restarted, all tasks still in execution will be automatically canceled.
|
|
|
|
- ''')
|
|
|
|
- def lifecycle_demo():
|
|
|
|
- from datetime import datetime
|
|
|
|
-
|
|
|
|
- from nicegui import app
|
|
|
|
-
|
|
|
|
- # dt = datetime.now()
|
|
|
|
-
|
|
|
|
- def handle_connection():
|
|
|
|
- global dt
|
|
|
|
- dt = datetime.now()
|
|
|
|
- app.on_connect(handle_connection)
|
|
|
|
-
|
|
|
|
- label = ui.label()
|
|
|
|
- ui.timer(1, lambda: label.set_text(f'Last new connection: {dt:%H:%M:%S}'))
|
|
|
|
- # END OF DEMO
|
|
|
|
- global dt
|
|
|
|
- dt = datetime.now()
|
|
|
|
-
|
|
|
|
- subheading('Shutdown')
|
|
|
|
-
|
|
|
|
- @element_demo(app.shutdown)
|
|
|
|
- def shutdown_demo():
|
|
|
|
- from nicegui import app
|
|
|
|
-
|
|
|
|
- # ui.button('shutdown', on_click=app.shutdown)
|
|
|
|
- #
|
|
|
|
- # ui.run(reload=False)
|
|
|
|
- # END OF DEMO
|
|
|
|
- ui.button('shutdown', on_click=lambda: ui.notify(
|
|
|
|
- 'Nah. We do not actually shutdown the documentation server. Try it in your own app!'))
|
|
|
|
-
|
|
|
|
- @text_demo('URLs', '''
|
|
|
|
- You can access the list of all URLs on which the NiceGUI app is available via `app.urls`.
|
|
|
|
- The URLs are not available in `app.on_startup` because the server is not yet running.
|
|
|
|
- Instead, you can access them in a page function or register a callback with `app.urls.on_change`.
|
|
|
|
- ''')
|
|
|
|
- def urls_demo():
|
|
|
|
- from nicegui import app
|
|
|
|
-
|
|
|
|
- # @ui.page('/')
|
|
|
|
- # def index():
|
|
|
|
- # for url in app.urls:
|
|
|
|
- # ui.link(url, target=url)
|
|
|
|
- # END OF DEMO
|
|
|
|
- ui.link('https://nicegui.io', target='https://nicegui.io')
|
|
|
|
-
|
|
|
|
- heading('NiceGUI Fundamentals')
|
|
|
|
-
|
|
|
|
- @text_demo('Auto-context', '''
|
|
|
|
- In order to allow writing intuitive UI descriptions, NiceGUI automatically tracks the context in which elements are created.
|
|
|
|
- This means that there is no explicit `parent` parameter.
|
|
|
|
- Instead the parent context is defined using a `with` statement.
|
|
|
|
- It is also passed to event handlers and timers.
|
|
|
|
-
|
|
|
|
- In the demo, the label "Card content" is added to the card.
|
|
|
|
- And because the `ui.button` is also added to the card, the label "Click!" will also be created in this context.
|
|
|
|
- The label "Tick!", which is added once after one second, is also added to the card.
|
|
|
|
-
|
|
|
|
- This design decision allows for easily creating modular components that keep working after moving them around in the UI.
|
|
|
|
- For example, you can move label and button somewhere else, maybe wrap them in another container, and the code will still work.
|
|
|
|
- ''')
|
|
|
|
- def auto_context_demo():
|
|
|
|
- with ui.card():
|
|
|
|
- ui.label('Card content')
|
|
|
|
- ui.button('Add label', on_click=lambda: ui.label('Click!'))
|
|
|
|
- ui.timer(1.0, lambda: ui.label('Tick!'), once=True)
|
|
|
|
-
|
|
|
|
- load_demo('generic_events')
|
|
|
|
-
|
|
|
|
- heading('Configuration')
|
|
|
|
-
|
|
|
|
- load_demo(ui.run)
|
|
|
|
-
|
|
|
|
- @text_demo('Native Mode', '''
|
|
|
|
- You can enable native mode for NiceGUI by specifying `native=True` in the `ui.run` function.
|
|
|
|
- To customize the initial window size and display mode, use the `window_size` and `fullscreen` parameters respectively.
|
|
|
|
- Additionally, you can provide extra keyword arguments via `app.native.window_args` and `app.native.start_args`.
|
|
|
|
- Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/guide/api.html)
|
|
|
|
- for the `webview.create_window` and `webview.start` functions.
|
|
|
|
- Note that these keyword arguments will take precedence over the parameters defined in `ui.run`.
|
|
|
|
-
|
|
|
|
- In native mode the `app.native.main_window` object allows you to access the underlying window.
|
|
|
|
- It is an async version of [`Window` from pywebview](https://pywebview.flowrl.com/guide/api.html#window-object).
|
|
|
|
- ''', tab=lambda: ui.label('NiceGUI'))
|
|
|
|
- def native_mode_demo():
|
|
|
|
- from nicegui import app
|
|
|
|
-
|
|
|
|
- app.native.window_args['resizable'] = False
|
|
|
|
- app.native.start_args['debug'] = True
|
|
|
|
-
|
|
|
|
- ui.label('app running in native mode')
|
|
|
|
- # ui.button('enlarge', on_click=lambda: app.native.main_window.resize(1000, 700))
|
|
|
|
- #
|
|
|
|
- # ui.run(native=True, window_size=(400, 300), fullscreen=False)
|
|
|
|
- # END OF DEMO
|
|
|
|
- ui.button('enlarge', on_click=lambda: ui.notify('window will be set to 1000x700 in native mode'))
|
|
|
|
-
|
|
|
|
- # Show a helpful workaround until issue is fixed upstream.
|
|
|
|
- # For more info see: https://github.com/r0x0r/pywebview/issues/1078
|
|
|
|
- ui.markdown('''
|
|
|
|
- If webview has trouble finding required libraries, you may get an error relating to "WebView2Loader.dll".
|
|
|
|
- To work around this issue, try moving the DLL file up a directory, e.g.:
|
|
|
|
-
|
|
|
|
- * from `.venv/Lib/site-packages/webview/lib/x64/WebView2Loader.dll`
|
|
|
|
- * to `.venv/Lib/site-packages/webview/lib/WebView2Loader.dll`
|
|
|
|
- ''')
|
|
|
|
-
|
|
|
|
- @text_demo('Environment Variables', '''
|
|
|
|
- You can set the following environment variables to configure NiceGUI:
|
|
|
|
-
|
|
|
|
- - `MATPLOTLIB` (default: true) can be set to `false` to avoid the potentially costly import of Matplotlib.
|
|
|
|
- This will make `ui.pyplot` and `ui.line_plot` unavailable.
|
|
|
|
- - `NICEGUI_STORAGE_PATH` (default: local ".nicegui") can be set to change the location of the storage files.
|
|
|
|
- - `MARKDOWN_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of Markdown content snippets that are cached in memory.
|
|
|
|
- ''')
|
|
|
|
- def env_var_demo():
|
|
|
|
- from nicegui.elements import markdown
|
|
|
|
-
|
|
|
|
- ui.label(f'Markdown content cache size is {markdown.prepare_content.cache_info().maxsize}')
|
|
|
|
-
|
|
|
|
- heading('Deployment')
|
|
|
|
-
|
|
|
|
- subheading('Server Hosting')
|
|
|
|
-
|
|
|
|
- ui.markdown('''
|
|
|
|
- To deploy your NiceGUI app on a server, you will need to execute your `main.py` (or whichever file contains your `ui.run(...)`) on your cloud infrastructure.
|
|
|
|
- You can, for example, just install the [NiceGUI python package via pip](https://pypi.org/project/nicegui/) and use systemd or similar service to start the main script.
|
|
|
|
- In most cases, you will set the port to 80 (or 443 if you want to use HTTPS) with the `ui.run` command to make it easily accessible from the outside.
|
|
|
|
-
|
|
|
|
- A convenient alternative is the use of our [pre-built multi-arch Docker image](https://hub.docker.com/r/zauberzeug/nicegui) which contains all necessary dependencies.
|
|
|
|
- With this command you can launch the script `main.py` in the current directory on the public port 80:
|
|
|
|
- ''').classes('bold-links arrow-links')
|
|
|
|
- with demo.bash_window(classes='max-w-lg w-full h-44'):
|
|
|
|
- ui.markdown('''
|
|
|
|
- ```bash
|
|
|
|
- docker run -it --restart always \\
|
|
|
|
- -p 80:8080 \\
|
|
|
|
- -e PUID=$(id -u) \\
|
|
|
|
- -e PGID=$(id -g) \\
|
|
|
|
- -v $(pwd)/:/app/ \\
|
|
|
|
- zauberzeug/nicegui:latest
|
|
|
|
- ```
|
|
|
|
- ''')
|
|
|
|
- ui.markdown('''
|
|
|
|
- The demo assumes `main.py` uses the port 8080 in the `ui.run` command (which is the default).
|
|
|
|
- The `-d` tells docker to run in background and `--restart always` makes sure the container is restarted if the app crashes or the server reboots.
|
|
|
|
- Of course this can also be written in a Docker compose file:
|
|
|
|
- ''')
|
|
|
|
- with demo.python_window('docker-compose.yml', classes='max-w-lg w-full h-60'):
|
|
|
|
- ui.markdown('''
|
|
|
|
- ```yaml
|
|
|
|
- app:
|
|
|
|
- image: zauberzeug/nicegui:latest
|
|
|
|
- restart: always
|
|
|
|
- ports:
|
|
|
|
- - 80:8080
|
|
|
|
- environment:
|
|
|
|
- - PUID=1000 # change this to your user id
|
|
|
|
- - PGID=1000 # change this to your group id
|
|
|
|
- volumes:
|
|
|
|
- - ./:/app/
|
|
|
|
- ```
|
|
|
|
- ''')
|
|
|
|
- ui.markdown('''
|
|
|
|
- There are other handy features in the Docker image like non-root user execution and signal pass-through.
|
|
|
|
- For more details we recommend to have a look at our [Docker example](https://github.com/zauberzeug/nicegui/tree/main/examples/docker_image).
|
|
|
|
- ''').classes('bold-links arrow-links')
|
|
|
|
-
|
|
|
|
- ui.markdown('''
|
|
|
|
- You can provide SSL certificates directly using [FastAPI](https://fastapi.tiangolo.com/deployment/https/).
|
|
|
|
- In production we also like using reverse proxies like [Traefik](https://doc.traefik.io/traefik/) or [NGINX](https://www.nginx.com/) to handle these details for us.
|
|
|
|
- See our development [docker-compose.yml](https://github.com/zauberzeug/nicegui/blob/main/docker-compose.yml) as an example.
|
|
|
|
-
|
|
|
|
- You may also have a look at [our demo for using a custom FastAPI app](https://github.com/zauberzeug/nicegui/tree/main/examples/fastapi).
|
|
|
|
- This will allow you to do very flexible deployments as described in the [FastAPI documentation](https://fastapi.tiangolo.com/deployment/).
|
|
|
|
- Note that there are additional steps required to allow multiple workers.
|
|
|
|
- ''').classes('bold-links arrow-links')
|
|
|
|
-
|
|
|
|
- subheading('Package for Installation')
|
|
|
|
-
|
|
|
|
- ui.markdown('''
|
|
|
|
- NiceGUI apps can also be bundled into an executable with [PyInstaller](https://www.pyinstaller.org/).
|
|
|
|
- This allows you to distribute your app as a single file that can be executed on any computer.
|
|
|
|
-
|
|
|
|
- Just take care your `ui.run` command does not use the `reload` argument.
|
|
|
|
- Running the `build.py` below will create an executable `myapp` in the `dist` folder:
|
|
|
|
- ''').classes('bold-links arrow-links')
|
|
|
|
-
|
|
|
|
- with ui.row().classes('w-full items-stretch'):
|
|
|
|
- with demo.python_window(classes='max-w-lg w-full'):
|
|
|
|
- ui.markdown('''
|
|
|
|
- ```python
|
|
|
|
- from nicegui import native_mode, ui
|
|
|
|
-
|
|
|
|
- ui.label('Hello from PyInstaller')
|
|
|
|
-
|
|
|
|
- ui.run(reload=False, port=native_mode.find_open_port())
|
|
|
|
- ```
|
|
|
|
- ''')
|
|
|
|
- with demo.python_window('build.py', classes='max-w-lg w-full'):
|
|
|
|
- ui.markdown('''
|
|
|
|
- ```python
|
|
|
|
- import os
|
|
|
|
- import subprocess
|
|
|
|
- from pathlib import Path
|
|
|
|
- import nicegui
|
|
|
|
-
|
|
|
|
- cmd = [
|
|
|
|
- 'python',
|
|
|
|
- '-m', 'PyInstaller',
|
|
|
|
- 'main.py', # your main file with ui.run()
|
|
|
|
- '--name', 'myapp', # name of your app
|
|
|
|
- '--onefile',
|
|
|
|
- #'--windowed', # prevent console appearing, only use with ui.run(native=True, ...)
|
|
|
|
- '--add-data', f'{Path(nicegui.__file__).parent}{os.pathsep}nicegui'
|
|
|
|
- ]
|
|
|
|
- subprocess.call(cmd)
|
|
|
|
- ```
|
|
|
|
- ''')
|
|
|
|
-
|
|
|
|
- ui.markdown('''
|
|
|
|
- **Packaging Tips**
|
|
|
|
-
|
|
|
|
- - When building a PyInstaller app, your main script can use a native window (rather than a browser window) by
|
|
|
|
- using `ui.run(reload=False, native=True)`.
|
|
|
|
- The `native` parameter can be `True` or `False` depending on whether you want a native window or to launch a
|
|
|
|
- page in the user's browser - either will work in the PyInstaller generated app.
|
|
|
|
-
|
|
|
|
- - Specifying `--windowed` to PyInstaller will prevent a terminal console from appearing.
|
|
|
|
- However you should only use this option if you have also specified `native=True` in your `ui.run` command.
|
|
|
|
- Without a terminal console the user won't be able to exit the app by pressing Ctrl-C.
|
|
|
|
- With the `native=True` option, the app will automatically close when the window is closed, as expected.
|
|
|
|
-
|
|
|
|
- - Specifying `--windowed` to PyInstaller will create an `.app` file on Mac which may be more convenient to distribute.
|
|
|
|
- When you double-click the app to run it, it will not show any console output.
|
|
|
|
- You can also run the app from the command line with `./myapp.app/Contents/MacOS/myapp` to see the console output.
|
|
|
|
-
|
|
|
|
- - Specifying `--onefile` to PyInstaller will create a single executable file.
|
|
|
|
- Whilst convenient for distribution, it will be slower to start up.
|
|
|
|
- This is not NiceGUI's fault but just the way Pyinstaller zips things into a single file, then unzips everything
|
|
|
|
- into a temporary directory before running.
|
|
|
|
- You can mitigate this by removing `--onefile` from the PyInstaller command,
|
|
|
|
- and zip up the generated `dist` directory yourself, distribute it,
|
|
|
|
- and your end users can unzip once and be good to go,
|
|
|
|
- without the constant expansion of files due to the `--onefile` flag.
|
|
|
|
-
|
|
|
|
- - Summary of user experience for different options:
|
|
|
|
-
|
|
|
|
- | PyInstaller | `ui.run(...)` | Explanation |
|
|
|
|
- | :--- | :--- | :--- |
|
|
|
|
- | `onefile` | `native=False` | Single executable generated in `dist/`, runs in browser |
|
|
|
|
- | `onefile` | `native=True` | Single executable generated in `dist/`, runs in popup window |
|
|
|
|
- | `onefile` and `windowed` | `native=True` | Single executable generated in `dist/` (on Mac a proper `dist/myapp.app` generated incl. icon), runs in popup window, no console appears |
|
|
|
|
- | `onefile` and `windowed` | `native=False` | Avoid (no way to exit the app) |
|
|
|
|
- | Specify neither | | A `dist/myapp` directory created which can be zipped manually and distributed; run with `dist/myapp/myapp` |
|
|
|
|
-
|
|
|
|
- - If you are using a Python virtual environment, ensure you `pip install pyinstaller` within your virtual environment
|
|
|
|
- so that the correct PyInstaller is used, or you may get broken apps due to the wrong version of PyInstaller being picked up.
|
|
|
|
- That is why the build script invokes PyInstaller using `python -m PyInstaller` rather than just `pyinstaller`.
|
|
|
|
- ''').classes('bold-links arrow-links')
|
|
|
|
-
|
|
|
|
- with demo.bash_window(classes='max-w-lg w-full h-42 self-center'):
|
|
|
|
- ui.markdown('''
|
|
|
|
- ```bash
|
|
|
|
- python -m venv venv
|
|
|
|
- source venv/bin/activate
|
|
|
|
- pip install nicegui
|
|
|
|
- pip install pyinstaller
|
|
|
|
- ```
|
|
|
|
- ''')
|
|
|
|
-
|
|
|
|
- ui.markdown('''
|
|
|
|
- **Note:**
|
|
|
|
- If you're getting an error "TypeError: a bytes-like object is required, not 'str'", try adding the following lines to the top of your `main.py` file:
|
|
|
|
- ```py
|
|
|
|
- import sys
|
|
|
|
- sys.stdout = open('logs.txt', 'w')
|
|
|
|
- ```
|
|
|
|
- See <https://github.com/zauberzeug/nicegui/issues/681> for more information.
|
|
|
|
- ''').classes('bold-links arrow-links')
|
|
|
|
-
|
|
|
|
- subheading('NiceGUI On Air')
|
|
|
|
-
|
|
|
|
- ui.markdown('''
|
|
|
|
- By using `ui.run(on_air=True)` you can share your local app with others over the internet 🧞.
|
|
|
|
-
|
|
|
|
- When accessing the on-air URL, all libraries (like Vue, Quasar, ...) are loaded from our CDN.
|
|
|
|
- Thereby only the raw content and events need to be transmitted by your local app.
|
|
|
|
- This makes it blazing fast even if your app only has a poor internet connection (e.g. a mobile robot in the field).
|
|
|
|
|
|
+ heading('Binding Properties')
|
|
|
|
+ binding_properties.content()
|
|
|
|
|
|
- By setting `on_air=True` you will get a random URL which is valid for 1 hour.
|
|
|
|
- If you sign-up at <https://on-air.nicegui.io> you get a token which could be used to identify your device: `ui.run(on_air='<your token>'`).
|
|
|
|
- This will give you a fixed URL and the possibility to protect remote access with a passphrase.
|
|
|
|
|
|
+ heading('Page Layout')
|
|
|
|
+ page_layout.content()
|
|
|
|
|
|
- Currently On Air is available as a tech preview and can be used free of charge (for now).
|
|
|
|
- We will gradually improve stability, introduce payment options and extend the service with multi-device management, remote terminal access and more.
|
|
|
|
- Please let us know your feedback on [GitHub](https://github.com/zauberzeug/nicegui/discussions),
|
|
|
|
- [Reddit](https://www.reddit.com/r/nicegui/), or [Discord](https://discord.gg/TEpFeAaF4f).
|
|
|
|
|
|
+ heading('Styling and Appearance')
|
|
|
|
+ styling_appearance.content()
|
|
|
|
|
|
- **Data Privacy:**
|
|
|
|
- We take your privacy very serious.
|
|
|
|
- NiceGUI On Air does not log or store any content of the relayed data.
|
|
|
|
- ''').classes('bold-links arrow-links')
|
|
|
|
|
|
+ heading('Action Events')
|
|
|
|
+ action_events.content()
|
|
|
|
|
|
- ui.element('div').classes('h-32')
|
|
|
|
|
|
+ heading('Configuration and Deployment')
|
|
|
|
+ configuration_deployment.content()
|