فهرست منبع

Merge branch 'main' into v1.3

# Conflicts:
#	main.py
Falko Schindler 2 سال پیش
والد
کامیت
6185f679b7
100فایلهای تغییر یافته به همراه1476 افزوده شده و 320 حذف شده
  1. 1 1
      .github/workflows/publish.yml
  2. 2 2
      .github/workflows/test.yml
  3. 4 4
      CITATION.cff
  4. 6 1
      CONTRIBUTING.md
  5. 1 1
      DEPENDENCIES.md
  6. 14 8
      README.md
  7. 1 1
      development.dockerfile
  8. 3 1
      examples/authentication/main.py
  9. 22 19
      examples/chat_app/main.py
  10. 57 0
      examples/chat_with_ai/main.py
  11. 3 0
      examples/chat_with_ai/requirements.txt
  12. 2 2
      examples/local_file_picker/local_file_picker.py
  13. 4 4
      examples/nginx_subpath/app/main.py
  14. 1 1
      examples/nginx_subpath/docker-compose.yml
  15. 32 0
      examples/pandas_dataframe/main.py
  16. 1 0
      examples/sqlite_database/.gitignore
  17. 79 0
      examples/sqlite_database/main.py
  18. 65 0
      examples/todo_list/main.py
  19. 56 0
      examples/trello_cards/draganddrop.py
  20. 17 43
      examples/trello_cards/main.py
  21. 17 0
      fetch_dependencies.py
  22. 6 4
      fetch_tailwind.py
  23. 11 3
      fly.dockerfile
  24. 8 8
      fly.toml
  25. 120 89
      main.py
  26. 10 0
      nicegui/__init__.py
  27. 6 4
      nicegui/app.py
  28. 6 6
      nicegui/background_tasks.py
  29. 21 7
      nicegui/binding.py
  30. 18 7
      nicegui/client.py
  31. 3 2
      nicegui/dependencies.py
  32. 36 5
      nicegui/element.py
  33. 15 0
      nicegui/elements/aggrid.py
  34. 3 2
      nicegui/elements/avatar.py
  35. 11 2
      nicegui/elements/button.py
  36. 12 5
      nicegui/elements/chart.js
  37. 3 0
      nicegui/elements/chat_message.js
  38. 51 0
      nicegui/elements/chat_message.py
  39. 2 1
      nicegui/elements/checkbox.py
  40. 3 2
      nicegui/elements/color_input.py
  41. 21 0
      nicegui/elements/dark_mode.js
  42. 46 0
      nicegui/elements/dark_mode.py
  43. 3 2
      nicegui/elements/date.py
  44. 2 1
      nicegui/elements/expansion.py
  45. 24 0
      nicegui/elements/grid.py
  46. 27 3
      nicegui/elements/input.py
  47. 2 1
      nicegui/elements/knob.py
  48. 23 0
      nicegui/elements/link.js
  49. 4 1
      nicegui/elements/link.py
  50. 3 3
      nicegui/elements/log.py
  51. 1 0
      nicegui/elements/markdown.py
  52. 81 0
      nicegui/elements/mixins/disableable_element.py
  53. 1 1
      nicegui/elements/mixins/visibility.py
  54. 30 2
      nicegui/elements/number.py
  55. 2 1
      nicegui/elements/radio.py
  56. 8 4
      nicegui/elements/scene.py
  57. 2 1
      nicegui/elements/select.py
  58. 2 1
      nicegui/elements/slider.py
  59. 3 1
      nicegui/elements/splitter.py
  60. 2 1
      nicegui/elements/switch.py
  61. 5 5
      nicegui/elements/tabs.py
  62. 1 1
      nicegui/elements/textarea.py
  63. 2 1
      nicegui/elements/time.py
  64. 2 1
      nicegui/elements/toggle.py
  65. 27 0
      nicegui/elements/upload.js
  66. 6 3
      nicegui/elements/upload.py
  67. 5 0
      nicegui/elements/video.js
  68. 7 0
      nicegui/elements/video.py
  69. 7 1
      nicegui/event_listener.py
  70. 18 18
      nicegui/events.py
  71. 45 1
      nicegui/favicon.py
  72. 14 0
      nicegui/functions/download.py
  73. 3 0
      nicegui/functions/refreshable.js
  74. 67 0
      nicegui/functions/refreshable.py
  75. 2 0
      nicegui/globals.py
  76. 5 0
      nicegui/helpers.py
  77. 3 0
      nicegui/json/orjson_wrapper.py
  78. 66 0
      nicegui/language.py
  79. 3 1
      nicegui/native.py
  80. 26 13
      nicegui/native_mode.py
  81. 24 11
      nicegui/nicegui.py
  82. 11 1
      nicegui/page.py
  83. 9 4
      nicegui/run.py
  84. 5 1
      nicegui/run_with.py
  85. 18 0
      nicegui/static/nicegui.css
  86. 5 0
      nicegui/static/quasar.ar-TN.umd.prod.js
  87. 5 0
      nicegui/static/quasar.ar.umd.prod.js
  88. 5 0
      nicegui/static/quasar.az-Latn.umd.prod.js
  89. 5 0
      nicegui/static/quasar.bg.umd.prod.js
  90. 5 0
      nicegui/static/quasar.bn.umd.prod.js
  91. 5 0
      nicegui/static/quasar.ca.umd.prod.js
  92. 5 0
      nicegui/static/quasar.cs.umd.prod.js
  93. 5 0
      nicegui/static/quasar.da.umd.prod.js
  94. 5 0
      nicegui/static/quasar.de.umd.prod.js
  95. 5 0
      nicegui/static/quasar.el.umd.prod.js
  96. 5 0
      nicegui/static/quasar.en-GB.umd.prod.js
  97. 5 0
      nicegui/static/quasar.en-US.umd.prod.js
  98. 5 0
      nicegui/static/quasar.eo.umd.prod.js
  99. 5 0
      nicegui/static/quasar.es.umd.prod.js
  100. 5 0
      nicegui/static/quasar.et.umd.prod.js

+ 1 - 1
.github/workflows/publish.yml

@@ -137,7 +137,7 @@ jobs:
           git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
           git add CITATION.cff
           git commit -m "Update citation.cff"
-          git push
+          git push origin HEAD:main
 
   verify:
     needs: docker

+ 2 - 2
.github/workflows/test.yml

@@ -9,7 +9,7 @@ jobs:
         python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
       fail-fast: false
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 20
     steps:
       - uses: actions/checkout@v3
       - name: set up Python
@@ -25,7 +25,7 @@ jobs:
           poetry config virtualenvs.create false
           poetry install
           # install packages to run the examples
-          pip install opencv-python opencv-contrib-python-headless httpx replicate
+          pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai
           # try fix issue with importlib_resources
           pip install importlib-resources
       - name: test startup

+ 4 - 4
CITATION.cff

@@ -7,8 +7,8 @@ authors:
 - family-names: Trappe
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
-title: 'NiceGUI: Web-based interfaces with Python. The nice way.'
-version: v1.2.3
-date-released: '2023-03-30'
+title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
+version: v1.2.14
+date-released: '2023-05-14'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.7785517
+doi: 10.5281/zenodo.7933863

+ 6 - 1
CONTRIBUTING.md

@@ -130,10 +130,15 @@ Besides the documentation with interactive demos (see above) we collect useful,
 Each example should be about one concept.
 Please try to make them as minimal as possible to show what is needed to get some kind of functionality.
 We are happy to merge pull requests with new examples which show new concepts, ideas or interesting use cases.
+To list your addition on the website itself, you can use the `example_link` function below the
+["In-depth examples" section heading](https://github.com/zauberzeug/nicegui/blob/8a86d2064f8f4464f3819ac5c6763a2cb2d0e990/main.py#L242).
+The title should match the example folder name when [snake case converted](https://github.com/zauberzeug/nicegui/blob/8a86d2064f8f4464f3819ac5c6763a2cb2d0e990/website/style.py#L31).
 
 ## Pull requests
 
-To get started, fork the repository on GitHub, make your changes, and open a pull request (PR) with a detailed description of the changes you've made.
+To get started, fork the repository on GitHub, clone it somewhere on your filesystem, commit and push your changes,
+and then open a pull request (PR) with a detailed description of the changes you've made
+(the PR button is shown on the GitHub website of your forked repository).
 
 When submitting a PR, please make sure that the code follows the existing coding style and that all tests are passing.
 If you're adding a new feature, please include tests that cover the new functionality.

+ 1 - 1
DEPENDENCIES.md

@@ -1,6 +1,6 @@
 # Included Web Dependencies
 
-- Quasar: 2.11.8
+- Quasar: 2.11.10
 - Vue: 3.2.47
 - Socket.io: 4.6.1
 - Tailwind CSS: 3.2.6

+ 14 - 8
README.md

@@ -11,21 +11,24 @@ You can create buttons, dialogs, Markdown, 3D scenes, plots and much more.
 It is great for micro web apps, dashboards, robotics projects, smart home solutions and similar use cases.
 You can also use it in development, for example when tweaking/configuring a machine learning algorithm or tuning motor controllers.
 
-NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Docker image](https://hub.docker.com/r/zauberzeug/nicegui) and on [GitHub](https://github.com/zauberzeug/nicegui).
-
-[![PyPI version](https://badge.fury.io/py/nicegui.svg)](https://pypi.org/project/nicegui/)
-[![PyPI - Downloads](https://img.shields.io/pypi/dm/nicegui)](https://pypi.org/project/nicegui/)
-[![Docker Pulls](https://img.shields.io/docker/pulls/zauberzeug/nicegui)](https://hub.docker.com/r/zauberzeug/nicegui)<br />
+NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Docker image](https://hub.docker.com/r/zauberzeug/nicegui) and on [conda-forge](https://anaconda.org/conda-forge/nicegui) as well as [GitHub](https://github.com/zauberzeug/nicegui).
+
+[![PyPI](https://img.shields.io/pypi/v/nicegui?color=dark-green)](https://pypi.org/project/nicegui/)
+[![PyPI downloads](https://img.shields.io/pypi/dm/nicegui?color=dark-green)](https://pypi.org/project/nicegui/)
+[![Conda version](https://img.shields.io/conda/v/conda-forge/nicegui?color=green&label=conda-forge)](https://anaconda.org/conda-forge/nicegui)
+[![Conda downloads](https://img.shields.io/conda/dn/conda-forge/nicegui?color=green&label=downloads)](https://anaconda.org/conda-forge/nicegui)
+[![Docker pulls](https://img.shields.io/docker/pulls/zauberzeug/nicegui)](https://hub.docker.com/r/zauberzeug/nicegui)<br />
+[![GitHub license](https://img.shields.io/github/license/zauberzeug/nicegui?color=orange)](https://github.com/zauberzeug/nicegui/blob/main/LICENSE)
 [![GitHub commit activity](https://img.shields.io/github/commit-activity/m/zauberzeug/nicegui)](https://github.com/zauberzeug/nicegui/graphs/commit-activity)
-[![GitHub issues](https://img.shields.io/github/issues/zauberzeug/nicegui)](https://github.com/zauberzeug/nicegui/issues)
+[![GitHub issues](https://img.shields.io/github/issues/zauberzeug/nicegui?color=blue)](https://github.com/zauberzeug/nicegui/issues)
 [![GitHub forks](https://img.shields.io/github/forks/zauberzeug/nicegui)](https://github.com/zauberzeug/nicegui/network)
 [![GitHub stars](https://img.shields.io/github/stars/zauberzeug/nicegui)](https://github.com/zauberzeug/nicegui/stargazers)
-[![GitHub license](https://img.shields.io/github/license/zauberzeug/nicegui)](https://github.com/zauberzeug/nicegui/blob/main/LICENSE)
 
 ## Features
 
 - browser-based graphical user interface
 - implicit reload on code change
+- acts as webserver (accessed by the browser) or in native mode (eg. desktop window)
 - standard GUI elements like label, button, checkbox, switch, slider, input, file upload, ...
 - simple grouping with rows, columns, cards and dialogs
 - general-purpose HTML and Markdown elements
@@ -37,13 +40,16 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
   - interact with tables
   - navigate foldable tree structures
 - built-in timer to refresh data in intervals (even every 10 ms)
-- straight-forward data binding to write even less code
+- straight-forward data binding and refreshable functions to write even less code
 - notifications, dialogs and menus to provide state of the art user interaction
 - shared and individual web pages
 - ability to add custom routes and data responses
 - capture keyboard input for global shortcuts etc.
 - customize look by defining primary, secondary and accent colors
 - live-cycle events and session data
+- runs in Jupyter Notebooks and allows Python's interactive mode
+- auto-complete support for Tailwind CSS
+- SVG, Base64 and emoji favicon support
 
 ## Installation
 

+ 1 - 1
development.dockerfile

@@ -1,4 +1,4 @@
-FROM python:3.7-slim
+FROM python:3.7.16-slim
 
 RUN apt update && apt install curl -y
 

+ 3 - 1
examples/authentication/main.py

@@ -7,6 +7,7 @@ use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlet
 Here we just demonstrate the NiceGUI integration.
 '''
 
+import os
 import uuid
 from typing import Dict
 
@@ -16,7 +17,8 @@ from starlette.middleware.sessions import SessionMiddleware
 
 from nicegui import app, ui
 
-app.add_middleware(SessionMiddleware, secret_key='some_random_string')  # use your own secret key here
+# put your your own secret key in an environment variable MY_SECRET_KEY
+app.add_middleware(SessionMiddleware, secret_key=os.environ.get('MY_SECRET_KEY', ''))
 
 # in reality users and session_info would be persistent (e.g. database, file, ...) and passwords obviously hashed
 users = [('user1', 'pass1'), ('user2', 'pass2')]

+ 22 - 19
examples/chat_app/main.py

@@ -1,41 +1,44 @@
 #!/usr/bin/env python3
-import asyncio
+from datetime import datetime
 from typing import List, Tuple
+from uuid import uuid4
 
 from nicegui import Client, ui
 
-messages: List[Tuple[str, str]] = []
-contents: List[ui.column] = []
+messages: List[Tuple[str, str, str, str]] = []
 
 
-async def update(content: ui.column) -> None:
-    content.clear()
-    with content:  # use the context of each client to update their ui
-        for name, text in messages:
-            ui.markdown(f'**{name or "someone"}:** {text}').classes('text-lg m-2')
-        await ui.run_javascript(f'window.scrollTo(0, document.body.scrollHeight)', respond=False)
+@ui.refreshable
+async def chat_messages(own_id: str) -> None:
+    for user_id, avatar, text, stamp in messages:
+        ui.chat_message(text=text, stamp=stamp, avatar=avatar, sent=own_id == user_id)
+    await ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)', respond=False)
 
 
 @ui.page('/')
 async def main(client: Client):
-    async def send() -> None:
-        messages.append((name.value, text.value))
+    def send() -> None:
+        stamp = datetime.utcnow().strftime('%X')
+        messages.append((user_id, avatar, text.value, stamp))
         text.value = ''
-        await asyncio.gather(*[update(content) for content in contents])  # run updates concurrently
+        chat_messages.refresh()
+
+    user_id = str(uuid4())
+    avatar = f'https://robohash.org/{user_id}?bgset=bg2'
 
     anchor_style = r'a:link, a:visited {color: inherit !important; text-decoration: none; font-weight: 500}'
     ui.add_head_html(f'<style>{anchor_style}</style>')
     with ui.footer().classes('bg-white'), ui.column().classes('w-full max-w-3xl mx-auto my-6'):
         with ui.row().classes('w-full no-wrap items-center'):
-            name = ui.input(placeholder='name').props('rounded outlined autofocus input-class=mx-3')
-            text = ui.input(placeholder='message').props('rounded outlined input-class=mx-3') \
-                .classes('w-full self-center').on('keydown.enter', send)
+            with ui.avatar().on('click', lambda: ui.open(main)):
+                ui.image(avatar)
+            text = ui.input(placeholder='message').on('keydown.enter', send) \
+                .props('rounded outlined input-class=mx-3').classes('flex-grow')
         ui.markdown('simple chat app built with [NiceGUI](https://nicegui.io)') \
             .classes('text-xs self-end mr-8 m-[-1em] text-primary')
 
-    await client.connected()  # update(...) uses run_javascript which is only possible after connecting
-    contents.append(ui.column().classes('w-full max-w-2xl mx-auto'))  # save ui context for updates
-    await update(contents[-1])  # ensure all messages are shown after connecting
-
+    await client.connected()  # chat_messages(...) uses run_javascript which is only possible after connecting
+    with ui.column().classes('w-full max-w-2xl mx-auto items-stretch'):
+        await chat_messages(user_id)
 
 ui.run()

+ 57 - 0
examples/chat_with_ai/main.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+from typing import List, Tuple
+
+from langchain.chains import ConversationChain
+from langchain.chat_models import ChatOpenAI
+
+from nicegui import Client, ui
+
+OPENAI_API_KEY = 'not-set'  # TODO: set your OpenAI API key here
+
+llm = ConversationChain(llm=ChatOpenAI(model_name='gpt-3.5-turbo', openai_api_key=OPENAI_API_KEY))
+
+messages: List[Tuple[str, str, str]] = []
+thinking: bool = False
+
+
+@ui.refreshable
+async def chat_messages() -> None:
+    for name, text in messages:
+        ui.chat_message(text=text, name=name, sent=name == 'You')
+    if thinking:
+        ui.spinner(size='3rem').classes('self-center')
+    await ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)', respond=False)
+
+
+@ui.page('/')
+async def main(client: Client):
+    async def send() -> None:
+        global thinking
+        message = text.value
+        messages.append(('You', text.value))
+        thinking = True
+        text.value = ''
+        chat_messages.refresh()
+
+        response = await llm.arun(message)
+        messages.append(('Bot', response))
+        thinking = False
+        chat_messages.refresh()
+
+    anchor_style = r'a:link, a:visited {color: inherit !important; text-decoration: none; font-weight: 500}'
+    ui.add_head_html(f'<style>{anchor_style}</style>')
+    await client.connected()
+
+    with ui.column().classes('w-full max-w-2xl mx-auto items-stretch'):
+        await chat_messages()
+
+    with ui.footer().classes('bg-white'), ui.column().classes('w-full max-w-3xl mx-auto my-6'):
+        with ui.row().classes('w-full no-wrap items-center'):
+            placeholder = 'message' if OPENAI_API_KEY != 'not-set' else \
+                'Please provide your OPENAI key in the Python script first!'
+            text = ui.input(placeholder=placeholder).props('rounded outlined input-class=mx-3') \
+                .classes('w-full self-center').on('keydown.enter', send)
+        ui.markdown('simple chat app built with [NiceGUI](https://nicegui.io)') \
+            .classes('text-xs self-end mr-8 m-[-1em] text-primary')
+
+ui.run(title='Chat with GPT-3 (example)')

+ 3 - 0
examples/chat_with_ai/requirements.txt

@@ -0,0 +1,3 @@
+langchain>=0.0.142
+nicegui
+openai

+ 2 - 2
examples/local_file_picker/local_file_picker.py

@@ -1,5 +1,5 @@
 from pathlib import Path
-from typing import Optional
+from typing import Dict, Optional
 
 from nicegui import ui
 
@@ -58,7 +58,7 @@ class local_file_picker(ui.dialog):
             })
         self.grid.update()
 
-    async def handle_double_click(self, msg: dict) -> None:
+    async def handle_double_click(self, msg: Dict) -> None:
         self.path = Path(msg['args']['data']['path'])
         if self.path.is_dir():
             self.update_grid()

+ 4 - 4
examples/nginx_subpath/app/main.py

@@ -3,14 +3,14 @@ from nicegui import ui
 
 @ui.page('/subpage')
 def subpage():
-    ui.label('This is a subpage').classes('text-h5 mx-auto mt-12')
-    # TODO: this is not working properly yet
-    # ui.button('back', on_click=lambda: ui.open('/')).classes('mx-auto')
+    ui.label('This is a subpage').classes('text-h5 mx-auto mt-24')
+    ui.link('Navigate to the index page.', '/').classes('text-lg mx-auto')
+    ui.button('back', on_click=lambda: ui.open('/')).classes('mx-auto')
 
 
 @ui.page('/')
 def index():
-    with ui.card().classes('mx-auto px-24 pt-12 pb-24 items-center text-center'):
+    with ui.card().classes('mx-auto p-24 items-center text-center'):
         ui.label('This demonstrates hosting of a NiceGUI app on a subpath.').classes('text-h5')
         ui.label('As you can see the entire app is available below "/nicegui".').classes('text-lg')
         ui.label('But the code here does not need to know that.').classes('text-lg')

+ 1 - 1
examples/nginx_subpath/docker-compose.yml

@@ -1,7 +1,7 @@
 version: "3.9"
 services:
   app:
-    image: zauberzeug/nicegui:latest
+    image: zauberzeug/nicegui:1.2.7
     volumes:
       - ./app:/app # mount local app directory
   proxy:

+ 32 - 0
examples/pandas_dataframe/main.py

@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+import pandas as pd
+from pandas.api.types import is_bool_dtype, is_numeric_dtype
+
+from nicegui import ui
+
+df = pd.DataFrame(data={
+    'col1': [x for x in range(4)],
+    'col2': ['This', 'column', 'contains', 'strings.'],
+    'col3': [x / 4 for x in range(4)],
+    'col4': [True, False, True, False],
+})
+
+
+def update(*, df: pd.DataFrame, r: int, c: int, value):
+    df.iat[r, c] = value
+    ui.notify(f'Set ({r}, {c}) to {value}')
+
+
+with ui.grid(rows=len(df.index)+1).classes('grid-flow-col'):
+    for c, col in enumerate(df.columns):
+        ui.label(col).classes('font-bold')
+        for r, row in enumerate(df.loc[:, col]):
+            if is_bool_dtype(df[col].dtype):
+                cls = ui.checkbox
+            elif is_numeric_dtype(df[col].dtype):
+                cls = ui.number
+            else:
+                cls = ui.input
+            cls(value=row, on_change=lambda event, r=r, c=c: update(df=df, r=r, c=c, value=event.value))
+
+ui.run()

+ 1 - 0
examples/sqlite_database/.gitignore

@@ -0,0 +1 @@
+*.db

+ 79 - 0
examples/sqlite_database/main.py

@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+import sqlite3
+from pathlib import Path
+from typing import Any, Dict
+
+from nicegui import ui
+
+DB_FILE = Path(__file__).parent / 'users.db'
+DB_FILE.touch()
+conn = sqlite3.connect(DB_FILE, check_same_thread=False)
+cursor = conn.cursor()
+cursor.execute('CREATE TABLE IF NOT EXISTS users (id integer primary key AUTOINCREMENT, name text, age integer)')
+conn.commit()
+
+
+@ui.refreshable
+def users_ui() -> None:
+    cursor.execute('SELECT * FROM users')
+    for row in cursor.fetchall():
+        user = {'id': row[0], 'name': row[1], 'age': row[2]}
+        with ui.card():
+            with ui.row().classes('justify-between w-full'):
+                ui.label(user['id'])
+                ui.label(user['name'])
+                ui.label(user['age'])
+            with ui.row():
+                ui.button('edit', on_click=lambda _, user=user: open_dialog(user))
+                ui.button('delete', on_click=lambda _, user=user: delete(user), color='red')
+
+
+def create() -> None:
+    cursor.execute('INSERT INTO users (name, age) VALUES (?, ?)', (name.value, age.value))
+    conn.commit()
+    ui.notify(f'Created new user {name.value}')
+    name.value = ''
+    age.value = None
+    users_ui.refresh()
+
+
+def update() -> None:
+    query = 'UPDATE users SET name=?, age=? WHERE id=?'
+    cursor.execute(query, (dialog_name.value, dialog_age.value, dialog_id))
+    conn.commit()
+    ui.notify(f'Updated user {dialog_name.value}')
+    dialog.close()
+    users_ui.refresh()
+
+
+def delete(user: Dict[str, Any]) -> None:
+    cursor.execute('DELETE from users WHERE id=?', (user['id'],))
+    conn.commit()
+    ui.notify(f'Deleted user {user["name"]}')
+    users_ui.refresh()
+
+
+def open_dialog(user: Dict[str, Any]) -> None:
+    global dialog_id
+    dialog_id = user['id']
+    dialog_name.value = user['name']
+    dialog_age.value = user['age']
+    dialog.open()
+
+
+name = ui.input(label='Name')
+age = ui.number(label='Age', format='%.0f')
+ui.button('Add new user', on_click=create)
+
+users_ui()
+
+with ui.dialog() as dialog:
+    with ui.card():
+        dialog_id = None
+        dialog_name = ui.input('Name')
+        dialog_age = ui.number('Age', format='%.0f')
+        with ui.row():
+            ui.button('Save', on_click=update)
+            ui.button('Close', on_click=dialog.close).props('outline')
+
+ui.run()

+ 65 - 0
examples/todo_list/main.py

@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+from dataclasses import dataclass
+from typing import List
+
+from nicegui import ui
+
+
+@dataclass
+class TodoItem:
+    name: str
+    done: bool = False
+
+
+items: List[TodoItem] = [
+    TodoItem('Buy milk', done=True),
+    TodoItem('Clean the house'),
+    TodoItem('Call mom'),
+]
+
+
+def add(name: str) -> None:
+    items.append(TodoItem(name))
+    add_input.value = None
+    render_list.refresh()
+
+
+def remove(item: TodoItem) -> None:
+    items.remove(item)
+    render_list.refresh()
+
+
+def toggle(item: TodoItem) -> None:
+    item.done = not item.done
+    render_list.refresh()
+
+
+def rename(item: TodoItem, name: str) -> None:
+    item.name = name
+    render_list.refresh()
+
+
+@ui.refreshable
+def render_list():
+    if not items:
+        ui.label('List is empty.')
+        return
+    ui.linear_progress(sum(item.done for item in items) / len(items), show_value=False)
+    with ui.row().classes('justify-center w-full'):
+        ui.label(f'Completed: {sum(item.done for item in items)}')
+        ui.label(f'Remaining: {sum(not item.done for item in items)}')
+    for item in items:
+        with ui.row().classes('items-center'):
+            ui.checkbox(value=item.done, on_change=lambda _, item=item: toggle(item))
+            input = ui.input(value=item.name).classes('flex-grow')
+            input.on('keydown.enter', lambda _, item=item, input=input: rename(item, input.value))
+            ui.button(on_click=lambda _, item=item: remove(item)).props('flat fab-mini icon=delete color=grey')
+
+
+with ui.card().classes('w-80 items-stretch'):
+    ui.label('Todo list').classes('text-semibold text-2xl')
+    render_list()
+    add_input = ui.input('New item').classes('mx-12')
+    add_input.on('keydown.enter', lambda: add(add_input.value))
+
+ui.run()

+ 56 - 0
examples/trello_cards/draganddrop.py

@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from typing import Callable, Optional
+
+from typing_extensions import Protocol
+
+from nicegui import ui
+
+
+class Item(Protocol):
+    title: str
+
+
+dragged: Optional[card] = None
+
+
+class column(ui.column):
+
+    def __init__(self, name: str, on_drop: Optional[Callable[[Item, str], None]] = None) -> None:
+        super().__init__()
+        with self.classes('bg-blue-grey-2 w-60 p-4 rounded shadow-2'):
+            ui.label(name).classes('text-bold ml-1')
+        self.name = name
+        self.on('dragover.prevent', self.highlight)
+        self.on('dragleave', self.unhighlight)
+        self.on('drop', self.move_card)
+        self.on_drop = on_drop
+
+    def highlight(self) -> None:
+        self.classes(remove='bg-blue-grey-2', add='bg-blue-grey-3')
+
+    def unhighlight(self) -> None:
+        self.classes(remove='bg-blue-grey-3', add='bg-blue-grey-2')
+
+    def move_card(self) -> None:
+        global dragged
+        self.unhighlight()
+        dragged.parent_slot.parent.remove(dragged)
+        with self:
+            card(dragged.item)
+        self.on_drop(dragged.item, self.name)
+        dragged = None
+
+
+class card(ui.card):
+
+    def __init__(self, item: Item) -> None:
+        super().__init__()
+        self.item = item
+        with self.props('draggable').classes('w-full cursor-pointer bg-grey-1'):
+            ui.label(item.title)
+        self.on('dragstart', self.handle_dragstart)
+
+    def handle_dragstart(self) -> None:
+        global dragged
+        dragged = self

+ 17 - 43
examples/trello_cards/main.py

@@ -1,56 +1,30 @@
 #!/usr/bin/env python3
-from __future__ import annotations
+from dataclasses import dataclass
 
-from typing import Optional
+import draganddrop as dnd
 
 from nicegui import ui
 
 
-class Column(ui.column):
+@dataclass
+class ToDo:
+    title: str
 
-    def __init__(self, name: str) -> None:
-        super().__init__()
-        with self.classes('bg-gray-200 w-48 p-4 rounded shadow'):
-            ui.label(name).classes('text-bold')
-        self.on('dragover.prevent', self.highlight)
-        self.on('dragleave', self.unhighlight)
-        self.on('drop', self.move_card)
 
-    def highlight(self) -> None:
-        self.classes(add='bg-gray-400')
-
-    def unhighlight(self) -> None:
-        self.classes(remove='bg-gray-400')
-
-    def move_card(self) -> None:
-        self.unhighlight()
-        Card.dragged.parent_slot.parent.remove(Card.dragged)
-        with self:
-            Card(Card.dragged.text)
-
-
-class Card(ui.card):
-    dragged: Optional[Card] = None
-
-    def __init__(self, text: str) -> None:
-        super().__init__()
-        self.text = text
-        with self.props('draggable').classes('w-full cursor-pointer'):
-            ui.label(self.text)
-        self.on('dragstart', self.handle_dragstart)
-
-    def handle_dragstart(self) -> None:
-        Card.dragged = self
+def handle_drop(todo: ToDo, location: str):
+    ui.notify(f'"{todo.title}" is now in {location}')
 
 
 with ui.row():
-    with Column('Next'):
-        Card('Clean up the kitchen')
-        Card('Do the laundry')
-        Card('Go to the gym')
-    with Column('Doing'):
-        Card('Make dinner')
-    with Column('Done'):
-        Card('Buy groceries')
+    with dnd.column('Next', on_drop=handle_drop):
+        dnd.card(ToDo('Simplify Layouting'))
+        dnd.card(ToDo('Provide Deployment'))
+    with dnd.column('Doing', on_drop=handle_drop):
+        dnd.card(ToDo('Improve Documentation'))
+    with dnd.column('Done', on_drop=handle_drop):
+        dnd.card(ToDo('Invent NiceGUI'))
+        dnd.card(ToDo('Test in own Projects'))
+        dnd.card(ToDo('Publish as Open Source'))
+        dnd.card(ToDo('Release Native-Mode'))
 
 ui.run()

+ 17 - 0
fetch_dependencies.py

@@ -56,6 +56,23 @@ css = request_buffered_str(url)
 Path('nicegui/static/quasar.prod.css').write_text(css)
 print('Quasar:', version)
 
+# Quasar language packs
+url = 'https://cdn.jsdelivr.net/npm/quasar@2/dist/lang/'
+html = request_buffered_str(url)
+soup = BeautifulSoup(html, 'html.parser')
+languages = []
+for link in soup.find_all('a', href=re.compile(r'\.umd\.prod\.js$')):
+    name = link.get('href').split('/')[-1]
+    languages.append(name.split('.')[0])
+    js = request_buffered_str(url + name)
+    Path(f'nicegui/static/quasar.{name}').write_text(js)
+with open(Path(__file__).parent / 'nicegui' / 'language.py', 'w') as f:
+    f.write(f'from typing_extensions import Literal\n\n')
+    f.write(f'Language = Literal[\n')
+    for language in languages:
+        f.write(f"    '{language}',\n")
+    f.write(f']\n')
+
 # vue.js
 url = 'https://unpkg.com/vue@3/anything'
 info = request_buffered_str(url)

+ 6 - 4
fetch_tailwind.py

@@ -2,17 +2,19 @@
 import re
 from dataclasses import dataclass, field
 from pathlib import Path
+from typing import List
 
 import requests
 from bs4 import BeautifulSoup
+from secure import SecurePath
 
 
 @dataclass
 class Property:
     title: str
     description: str
-    members: list[str]
-    short_members: list[str] = field(init=False)
+    members: List[str]
+    short_members: List[str] = field(init=False)
     common_prefix: str = field(init=False)
 
     def __post_init__(self) -> None:
@@ -47,7 +49,7 @@ class Property:
         return '_'.join(word.lower() for word in re.sub(r'[-/ &]', ' ', self.title).split())
 
 
-properties: list[Property] = []
+properties: List[Property] = []
 
 
 def get_soup(url: str) -> BeautifulSoup:
@@ -81,7 +83,7 @@ for file in (Path(__file__).parent / 'nicegui' / 'tailwind_types').glob('*.py'):
 for property in properties:
     if not property.members:
         continue
-    with open(Path(__file__).parent / 'nicegui' / 'tailwind_types' / f'{property.snake_title}.py', 'w') as f:
+    with SecurePath(open(Path(__file__).parent / 'nicegui' / 'tailwind_types' / f'{property.snake_title}.py', 'w')) as f:
         f.write('from typing_extensions import Literal\n')
         f.write('\n')
         f.write(f'{property.pascal_title} = Literal[\n')

+ 11 - 3
fly.dockerfile

@@ -1,12 +1,20 @@
-FROM python:3.11-slim
+FROM python:3.11.3-slim
 
-LABEL maintainer="Zauberzeug GmbH <info@zauberzeug.com>"
+LABEL maintainer="Zauberzeug GmbH <nicegui@zauberzeug.com>"
+
+RUN pip install itsdangerous prometheus_client isort docutils pandas
 
 WORKDIR /app
 
 ADD . .
+
+# ensure unique version to not serve cached and hence potentially wrong static files
+ARG VERSION=unknown
+RUN if [ "$VERSION" = "unknown" ]; then echo "Error: VERSION build argument is required. Use: fly deploy --build-arg VERSION=$(git describe --abbrev=0 --tags --match 'v*' 2>/dev/null | sed 's/^v//' || echo '0.0.0')" && exit 1; fi
+RUN sed -i "/\[tool.poetry\]/,/]/s/version = .*/version = \"$VERSION\"/" pyproject.toml
+
+RUN cat pyproject.toml
 RUN pip install .
-RUN pip install itsdangerous prometheus_client isort docutils
 
 EXPOSE 8080
 EXPOSE 9062

+ 8 - 8
fly.toml

@@ -12,7 +12,7 @@ processes = []
 [deploy]
 # boot a single, new VM with the new release, verify its health, then
 # One by one, each running VM is taken down and replaced by the new release VM
-strategy = "canary" 
+strategy = "rolling" 
 
 
 [experimental]
@@ -39,19 +39,19 @@ strategy = "canary"
     port = 443
 
   [[services.tcp_checks]]
-    grace_period = "10s"
-    interval = "5s"
-    restart_limit = 2
-    timeout = "1s"
+    interval = "10s"
+    grace_period = "2m"
+    restart_limit = 3
+    timeout = "5s"
 
   [[services.http_checks]]
     interval = "20s"
-    grace_period = "2m"
+    grace_period = "4m"
     method = "get"
     path = "/"
     protocol = "http"
-    restart_limit = 2
-    timeout = "5s"
+    restart_limit = 3
+    timeout = "10s"
     tls_skip_verify = false
     [services.http_checks.headers]
 

+ 120 - 89
main.py

@@ -10,9 +10,10 @@ if True:
 
 import os
 from pathlib import Path
+from typing import Awaitable, Callable, Optional
 
 from fastapi import Request
-from fastapi.responses import FileResponse, RedirectResponse
+from fastapi.responses import FileResponse, RedirectResponse, Response
 from starlette.middleware.sessions import SessionMiddleware
 
 import prometheus
@@ -28,24 +29,25 @@ from website.style import example_link, features, heading, link_target, section_
 prometheus.start_monitor(app)
 
 # session middleware is required for demo in documentation and prometheus
-app.add_middleware(SessionMiddleware, secret_key='NiceGUI is awesome!')
+app.add_middleware(SessionMiddleware, secret_key=os.environ.get('NICEGUI_SECRET_KEY', ''))
 
 app.add_static_files('/favicon', str(Path(__file__).parent / 'website' / 'favicon'))
 app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
 
 
 @app.get('/logo.png')
-def logo():
+def logo() -> FileResponse:
     return FileResponse(svg.PATH / 'logo.png', media_type='image/png')
 
 
 @app.get('/logo_square.png')
-def logo():
+def logo_square() -> FileResponse:
     return FileResponse(svg.PATH / 'logo_square.png', media_type='image/png')
 
 
 @app.middleware('http')
-async def redirect_reference_to_documentation(request: Request, call_next):
+async def redirect_reference_to_documentation(request: Request,
+                                              call_next: Callable[[Request], Awaitable[Response]]) -> Response:
     if request.url.path == '/reference':
         return RedirectResponse('/documentation')
     return await call_next(request)
@@ -61,10 +63,10 @@ def add_head_html() -> None:
     ui.add_head_html(f"<style>{(Path(__file__).parent / 'website' / 'static' / 'style.css').read_text()}</style>")
 
 
-def add_header() -> None:
+def add_header(menu: Optional[ui.left_drawer] = None) -> None:
     menu_items = {
-        'Features': '/#features',
         'Installation': '/#installation',
+        'Features': '/#features',
         'Demos': '/#demos',
         'Documentation': '/documentation',
         'Examples': '/#examples',
@@ -73,6 +75,8 @@ def add_header() -> None:
     with ui.header() \
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
+        if menu:
+            ui.button(on_click=menu.toggle).props('flat color=white icon=menu round').classes('lg:hidden')
         with ui.link(target=index_page).classes('row gap-4 items-center no-wrap mr-auto'):
             svg.face().classes('w-8 stroke-white stroke-2')
             svg.word().classes('w-24')
@@ -83,15 +87,22 @@ def add_header() -> None:
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
                 ui.link(title, target).classes(replace='text-lg text-white')
-        with ui.link(target='https://discord.gg/TEpFeAaF4f'):
+        with ui.link(target='https://discord.gg/TEpFeAaF4f').classes('max-[435px]:hidden').tooltip('Discord'):
             svg.discord().classes('fill-white scale-125 m-1')
-        with ui.link(target='https://github.com/zauberzeug/nicegui/'):
+        with ui.link(target='https://www.reddit.com/r/nicegui/').classes('max-[385px]:hidden').tooltip('Reddit'):
+            svg.reddit().classes('fill-white scale-125 m-1')
+        with ui.link(target='https://github.com/zauberzeug/nicegui/').tooltip('GitHub'):
             svg.github().classes('fill-white scale-125 m-1')
-        add_star()
+        add_star().classes('max-[480px]:hidden')
+        with ui.row().classes('lg:hidden'):
+            with ui.button().props('flat color=white icon=more_vert round'):
+                with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):
+                    for title, target in menu_items.items():
+                        ui.menu_item(title, on_click=lambda _, target=target: ui.open(target))
 
 
 @ui.page('/')
-async def index_page(client: Client):
+async def index_page(client: Client) -> None:
     client.content.classes('p-0 gap-0')
     add_head_html()
     add_header()
@@ -114,19 +125,71 @@ async def index_page(client: Client):
         with ui.column().classes('text-white max-w-4xl'):
             heading('Interact with Python through buttons, dialogs, 3D&nbsp;scenes, plots and much more.')
             with ui.column().classes('gap-2 bold-links arrow-links text-lg'):
-                ui.markdown(
-                    'NiceGUI handles all the web development details for you. '
-                    'So you can focus on writing Python code. '
-                    'Anything from short scripts and dashboards to full robotics projects, IoT solutions, '
-                    'smart home automations and machine learning projects can benefit from having all code in one place.'
-                )
-                ui.markdown(
-                    'Available as '
-                    '[PyPI package](https://pypi.org/project/nicegui/), '
-                    '[Docker image](https://hub.docker.com/r/zauberzeug/nicegui) and on '
-                    '[GitHub](https://github.com/zauberzeug/nicegui).')
+                ui.markdown('''
+                    NiceGUI manages web development details, letting you focus on Python code for diverse applications,
+                    including robotics, IoT solutions, smart home automation, and machine learning.
+                    Designed to work smoothly with connected peripherals like webcams and GPIO pins in IoT setups,
+                    NiceGUI streamlines the management of all your code in one place.
+                    <br><br>
+                    With a gentle learning curve, NiceGUI is user-friendly for beginners
+                    and offers advanced customization for experienced users,
+                    ensuring simplicity for basic tasks and feasibility for complex projects.
+                    <br><br><br>
+                    Available as
+                    [PyPI package](https://pypi.org/project/nicegui/),
+                    [Docker image](https://hub.docker.com/r/zauberzeug/nicegui) and on
+                    [GitHub](https://github.com/zauberzeug/nicegui).
+                ''')
         example_card.create()
 
+    with ui.column().classes('w-full text-lg p-8 lg:p-16 max-w-[1600px] mx-auto'):
+        link_target('installation', '-50px')
+        section_heading('Installation', 'Get *started*')
+        with ui.row().classes('w-full text-lg leading-tight grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8'):
+            with ui.column().classes('w-full max-w-md gap-2'):
+                ui.html('<em>1.</em>').classes('text-3xl font-bold')
+                ui.markdown('Create __main.py__').classes('text-lg')
+                with python_window(classes='w-full h-52'):
+                    ui.markdown('''
+                        ```python\n
+                        from nicegui import ui
+
+                        ui.label('Hello NiceGUI!')
+
+                        ui.run()
+                        ```
+                    ''')
+            with ui.column().classes('w-full max-w-md gap-2'):
+                ui.html('<em>2.</em>').classes('text-3xl font-bold')
+                ui.markdown('Install and launch').classes('text-lg')
+                with bash_window(classes='w-full h-52'):
+                    ui.markdown('''
+                        ```bash
+                        pip3 install nicegui
+                        python3 main.py
+                        ```
+                    ''')
+            with ui.column().classes('w-full max-w-md gap-2'):
+                ui.html('<em>3.</em>').classes('text-3xl font-bold')
+                ui.markdown('Enjoy!').classes('text-lg')
+                with browser_window(classes='w-full h-52'):
+                    ui.label('Hello NiceGUI!')
+        with ui.expansion('...or use Docker to run your main.py').classes('w-full gap-2 bold-links arrow-links'):
+            with ui.row().classes('mt-8 w-full justify-center items-center gap-8'):
+                ui.markdown('''
+                    With our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) 
+                    you can start the server without installing any packages.
+
+                    The command searches for `main.py` in in your current directory and makes the app available at http://localhost:8888.
+                ''').classes('max-w-xl')
+                with bash_window(classes='max-w-lg w-full h-52'):
+                    ui.markdown('''
+                        ```bash
+                        docker run -it --rm -p 8888:8080 \\
+                            -v "$PWD":/app zauberzeug/nicegui
+                        ```
+                    ''')
+
     with ui.column().classes('w-full p-8 lg:p-16 bold-links arrow-links max-w-[1600px] mx-auto'):
         link_target('features', '-50px')
         section_heading('Features', 'Code *nicely*')
@@ -134,12 +197,12 @@ async def index_page(client: Client):
             features('swap_horiz', 'Interaction', [
                 'buttons, switches, sliders, inputs, ...',
                 'notifications, dialogs and menus',
-                'keyboard input',
-                'on-screen joystick',
+                'interactive images with SVG overlays',
+                'web pages and native window apps',
             ])
             features('space_dashboard', 'Layout', [
                 'navigation bars, tabs, panels, ...',
-                'grouping with rows, columns and cards',
+                'grouping with rows, columns, grids and cards',
                 'HTML and Markdown elements',
                 'flex layout by default',
             ])
@@ -156,10 +219,10 @@ async def index_page(client: Client):
                 '[Tailwind CSS](https://tailwindcss.com/) auto-completion',
             ])
             features('source', 'Coding', [
-                'live-cycle events',
-                'implicit reload on code change',
+                'routing for multiple pages',
+                'auto-reload on code change',
                 'straight-forward data binding',
-                'execute javascript from Python',
+                'Jupyter notebook compatibility',
             ])
             features('anchor', 'Foundation', [
                 'generic [Vue](https://vuejs.org/) to Python bridge',
@@ -168,44 +231,6 @@ async def index_page(client: Client):
                 'Python 3.7+',
             ])
 
-    with ui.column().classes('w-full text-lg p-8 lg:p-16 max-w-[1600px] mx-auto'):
-        link_target('installation', '-50px')
-        section_heading('Installation', 'Get *started*')
-        with ui.row().classes('w-full text-lg leading-tight grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8'):
-            with ui.column().classes('w-full max-w-md gap-2'):
-                ui.html('<em>1.</em>').classes('text-3xl font-bold')
-                ui.markdown('Create __main.py__').classes('text-lg')
-                with python_window(classes='w-full h-52'):
-                    ui.markdown('''```python\n
-from nicegui import ui
-
-ui.label('Hello NiceGUI!')
-
-ui.run()
-```''')
-            with ui.column().classes('w-full max-w-md gap-2'):
-                ui.html('<em>2.</em>').classes('text-3xl font-bold')
-                ui.markdown('Install and launch').classes('text-lg')
-                with bash_window(classes='w-full h-52'):
-                    ui.markdown('```bash\npip3 install nicegui\npython3 main.py\n```')
-            with ui.column().classes('w-full max-w-md gap-2'):
-                ui.html('<em>3.</em>').classes('text-3xl font-bold')
-                ui.markdown('Enjoy!').classes('text-lg')
-                with browser_window(classes='w-full h-52'):
-                    ui.label('Hello NiceGUI!')
-        with ui.expansion('...or use Docker to run your main.py').classes('w-full gap-2 bold-links arrow-links'):
-            with ui.row().classes('mt-8 w-full justify-center items-center gap-8'):
-                ui.markdown('''
-With our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) 
-you can start the server without installing any packages.
-
-The command searches for `main.py` in in your current directory and makes the app available at http://localhost:8888.
-''').classes('max-w-xl')
-                with bash_window(classes='max-w-lg w-full h-52'):
-                    ui.markdown('```bash\n'
-                                'docker run -it --rm -p 8888:8080 \\\n -v "$PWD":/app zauberzeug/nicegui\n'
-                                '```')
-
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1600px] mx-auto'):
         link_target('demos', '-50px')
         section_heading('Demos', 'Try *this*')
@@ -228,18 +253,15 @@ The command searches for `main.py` in in your current directory and makes the ap
         with ui.row().classes('w-full text-lg leading-tight grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4'):
             example_link('Slideshow', 'implements a keyboard-controlled image slideshow')
             example_link('Authentication', 'shows how to use sessions to build a login screen')
-            example_link(
-                'Modularization',
-                'provides an example of how to modularize your application into multiple files and reuse code')
-            example_link(
-                'FastAPI',
-                'illustrates the integration of NiceGUI with an existing FastAPI application')
-            example_link(
-                'Map',
-                'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) to display a map at specific locations')
-            example_link(
-                'AI Interface',
-                'utilizes the [replicate](https://replicate.com) library to perform voice-to-text transcription and generate images from prompts with Stable Diffusion')
+            example_link('Modularization',
+                         'provides an example of how to modularize your application into multiple files and reuse code')
+            example_link('FastAPI', 'illustrates the integration of NiceGUI with an existing FastAPI application')
+            example_link('Map',
+                         'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) '
+                         'to display a map at specific locations')
+            example_link('AI Interface',
+                         'utilizes the [replicate](https://replicate.com) library to perform voice-to-text '
+                         'transcription and generate images from prompts with Stable Diffusion')
             example_link('3D Scene', 'creates a webGL view and loads an STL mesh illuminated with a spotlight')
             example_link('Custom Vue Component', 'shows how to write and integrate a custom Vue component')
             example_link('Image Mask Overlay', 'shows how to overlay an image with a mask')
@@ -252,11 +274,15 @@ The command searches for `main.py` in in your current directory and makes the ap
             example_link('Local File Picker', 'demonstrates a dialog for selecting files locally on the server')
             example_link('Search as you type', 'using public API of thecocktaildb.com to search for cocktails')
             example_link('Menu and Tabs', 'uses Quasar to create foldable menu and tabs inside a header bar')
+            example_link('Todo list', 'shows a simple todo list with checkboxes and text input')
             example_link('Trello Cards', 'shows Trello-like cards that can be dragged and dropped into columns')
             example_link('Slots', 'shows how to use scoped slots to customize Quasar elements')
             example_link('Table and slots', 'shows how to use component slots in a table')
             example_link('Single Page App', 'navigate without reloading the page')
             example_link('Chat App', 'a simple chat app')
+            example_link('Chat with AI', 'a simple chat app with AI')
+            example_link('SQLite Database', 'CRUD operations on a SQLite database')
+            example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')
@@ -297,28 +323,32 @@ The command searches for `main.py` in in your current directory and makes the ap
 
 
 @ui.page('/documentation')
-def documentation_page():
+def documentation_page() -> None:
     add_head_html()
-    add_header()
-    side_menu()
+    menu = side_menu()
+    add_header(menu)
     ui.add_head_html('<style>html {scroll-behavior: auto;}</style>')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
         section_heading('Reference, Demos and more', '*NiceGUI* Documentation')
-        ui.markdown(
-            'This is the documentation for NiceGUI >= 1.0. '
-            'Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).'
-        ).classes('bold-links arrow-links')
+        ui.markdown('''
+            This is the documentation for NiceGUI >= 1.0.
+            Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).
+        ''').classes('bold-links arrow-links')
         documentation.create_full()
 
 
 @ui.page('/documentation/{name}')
-def documentation_page_more(name: str):
+async def documentation_page_more(name: str, client: Client) -> None:
     if not hasattr(ui, name):
         name = name.replace('_', '')  # NOTE: "AG Grid" leads to anchor name "ag_grid", but class is `ui.aggrid`
     module = importlib.import_module(f'website.more_documentation.{name}_documentation')
-    api = getattr(ui, name)
     more = getattr(module, 'more', None)
-    back_link_target = str(api.__doc__ or api.__init__.__doc__).splitlines()[0].strip()
+    if hasattr(ui, name):
+        api = getattr(ui, name)
+        back_link_target = str(api.__doc__ or api.__init__.__doc__).splitlines()[0].strip()
+    else:
+        api = name
+        back_link_target = name
 
     add_head_html()
     add_header()
@@ -336,6 +366,7 @@ def documentation_page_more(name: str):
                 ui.markdown('**Reference**').classes('mt-4')
             ui.markdown('## Reference').classes('mt-16')
             generate_class_doc(api)
-
+    await client.connected()
+    await ui.run_javascript(f'document.title = "{name} • NiceGUI";', respond=False)
 
 ui.run(uvicorn_reload_includes='*.py, *.css, *.html')

+ 10 - 0
nicegui/__init__.py

@@ -9,3 +9,13 @@ from . import elements, globals, ui
 from .client import Client
 from .nicegui import app
 from .tailwind import Tailwind
+
+__all__ = [
+    'app',
+    'Client',
+    'elements',
+    'globals',
+    'Tailwind',
+    'ui',
+    '__version__',
+]

+ 6 - 4
nicegui/app.py

@@ -60,7 +60,7 @@ class App(FastAPI):
             raise Exception('calling shutdown() is not supported when auto-reload is enabled')
         globals.server.should_exit = True
 
-    def add_static_files(self, path: str, directory: str) -> None:
+    def add_static_files(self, url_path: str, local_directory: str) -> None:
         """Add static files.
 
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
@@ -68,10 +68,12 @@ class App(FastAPI):
         Otherwise the browser would not be able to access the files.
         Do only put non-security-critical files in there, as they are accessible to everyone.
 
-        :param path: string that starts with a slash "/"
-        :param directory: folder with static files to serve under the given path
+        :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
+        :param local_directory: local folder with files to serve as static content
         """
-        globals.app.mount(path, StaticFiles(directory=directory))
+        if url_path == '/':
+            raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
+        globals.app.mount(url_path, StaticFiles(directory=local_directory))
 
     def remove_route(self, path: str) -> None:
         """Remove routes with the given path."""

+ 6 - 6
nicegui/background_tasks.py

@@ -1,4 +1,4 @@
-'''inspired from https://quantlane.com/blog/ensure-asyncio-task-exceptions-get-logged/'''
+"""inspired from https://quantlane.com/blog/ensure-asyncio-task-exceptions-get-logged/"""
 import asyncio
 import sys
 from typing import Awaitable, Dict, Set, TypeVar
@@ -15,12 +15,12 @@ lazy_tasks_waiting: Dict[str, Awaitable[T]] = {}
 
 
 def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.Task[T]':
-    '''Wraps a loop.create_task call and ensures there is an exception handler added to the task.
+    """Wraps a loop.create_task call and ensures there is an exception handler added to the task.
 
     If the task raises an exception, it is logged and handled by the global exception handlers.
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
-    '''
+    """
     task = globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
     task.add_done_callback(_handle_task_result)
     running_tasks.add(task)
@@ -28,11 +28,11 @@ def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.T
     return task
 
 
-def create_lazy(coroutine: Awaitable[T], *, name: str) -> 'asyncio.Task[T]':
-    '''Wraps a create call and ensures a second task with the same name is delayed until the first one is done.
+def create_lazy(coroutine: Awaitable[T], *, name: str) -> None:
+    """Wraps a create call and ensures a second task with the same name is delayed until the first one is done.
 
     If a third task with the same name is created while the first one is still running, the second one is discarded.
-    '''
+    """
     if name in lazy_tasks_running:
         lazy_tasks_waiting[name] = coroutine
         return

+ 21 - 7
nicegui/binding.py

@@ -2,7 +2,7 @@ import asyncio
 import logging
 import time
 from collections import defaultdict
-from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type
+from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
 
 from . import globals
 
@@ -11,15 +11,29 @@ bindable_properties: Dict[Tuple[int, str], Any] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable]] = []
 
 
+def get_attribute(obj: Union[object, Dict], name: str) -> Any:
+    if isinstance(obj, dict):
+        return obj[name]
+    else:
+        return getattr(obj, name)
+
+
+def set_attribute(obj: Union[object, Dict], name: str, value: Any) -> None:
+    if isinstance(obj, dict):
+        obj[name] = value
+    else:
+        setattr(obj, name, value)
+
+
 async def loop():
     while True:
         visited: Set[Tuple[int, str]] = set()
         t = time.time()
         for link in active_links:
             (source_obj, source_name, target_obj, target_name, transform) = link
-            value = transform(getattr(source_obj, source_name))
-            if getattr(target_obj, target_name) != value:
-                setattr(target_obj, target_name, value)
+            value = transform(get_attribute(source_obj, source_name))
+            if get_attribute(target_obj, target_name) != value:
+                set_attribute(target_obj, target_name, value)
                 propagate(target_obj, target_name, visited)
             del link, source_obj, target_obj
         if time.time() - t > 0.01:
@@ -34,9 +48,9 @@ def propagate(source_obj: Any, source_name: str, visited: Optional[Set[Tuple[int
     for _, target_obj, target_name, transform in bindings.get((id(source_obj), source_name), []):
         if (id(target_obj), target_name) in visited:
             continue
-        target_value = transform(getattr(source_obj, source_name))
-        if getattr(target_obj, target_name) != target_value:
-            setattr(target_obj, target_name, target_value)
+        target_value = transform(get_attribute(source_obj, source_name))
+        if get_attribute(target_obj, target_name) != target_value:
+            set_attribute(target_obj, target_name, target_value)
             propagate(target_obj, target_name, visited)
 
 

+ 18 - 7
nicegui/client.py

@@ -52,10 +52,12 @@ class Client:
 
     @property
     def ip(self) -> Optional[str]:
-        return self.environ.get('REMOTE_ADDR') if self.environ else None
+        """Return the IP address of the client, or None if the client is not connected."""
+        return self.environ['asgi.scope']['client'][0] if self.environ else None
 
     @property
     def has_socket_connection(self) -> bool:
+        """Return True if the client is connected, False otherwise."""
         return self.environ is not None
 
     def __enter__(self):
@@ -66,7 +68,7 @@ class Client:
         self.content.__exit__()
 
     def build_response(self, request: Request, status_code: int = 200) -> Response:
-        prefix = request.headers.get('X-Forwarded-Prefix', '')
+        prefix = request.headers.get('X-Forwarded-Prefix', request.scope.get('root_path', ''))
         vue_html, vue_styles, vue_scripts = generate_vue_content()
         elements = json.dumps({id: element._to_dict() for id, element in self.elements.items()})
         return templates.TemplateResponse('index.html', {
@@ -82,13 +84,14 @@ class Client:
             'viewport': self.page.resolve_viewport(),
             'favicon_url': get_favicon_url(self.page, prefix),
             'dark': str(self.page.resolve_dark()),
+            'language': self.page.resolve_language(),
             'prefix': prefix,
             'tailwind': globals.tailwind,
             'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
         }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
 
     async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
-        '''Blocks execution until the client is connected.'''
+        """Block execution until the client is connected."""
         self.is_waiting_for_connection = True
         deadline = time.time() + timeout
         while not self.environ:
@@ -98,7 +101,7 @@ class Client:
         self.is_waiting_for_connection = False
 
     async def disconnected(self, check_interval: float = 0.1) -> None:
-        '''Blocks execution until the client disconnects.'''
+        """Block execution until the client disconnects."""
         self.is_waiting_for_disconnect = True
         while self.id in globals.clients:
             await asyncio.sleep(check_interval)
@@ -106,11 +109,12 @@ class Client:
 
     async def run_javascript(self, code: str, *,
                              respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
-        '''Allows execution of javascript on the client.
+        """Execute JavaScript on the client.
 
         The client connection must be established before this method is called.
-        You can do this by `await client.connected()` or register a callback with `client.on_connected(...)`.
-        If respond is True, the javascript code must return a string.'''
+        You can do this by `await client.connected()` or register a callback with `client.on_connect(...)`.
+        If respond is True, the javascript code must return a string.
+        """
         request_id = str(uuid.uuid4())
         command = {
             'code': code,
@@ -127,11 +131,18 @@ class Client:
         return self.waiting_javascript_commands.pop(request_id)
 
     def open(self, target: Union[Callable, str]) -> None:
+        """Open a new page in the client."""
         path = target if isinstance(target, str) else globals.page_routes[target]
         outbox.enqueue_message('open', path, self.id)
 
+    def download(self, url: str, filename: Optional[str] = None) -> None:
+        """Download a file from the given URL."""
+        outbox.enqueue_message('download', {'url': url, 'filename': filename}, self.id)
+
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
+        """Register a callback to be called when the client connects."""
         self.connect_handlers.append(handler)
 
     def on_disconnect(self, handler: Union[Callable, Awaitable]) -> None:
+        """Register a callback to be called when the client disconnects."""
         self.disconnect_handlers.append(handler)

+ 3 - 2
nicegui/dependencies.py

@@ -5,10 +5,11 @@ from typing import Dict, List, Set, Tuple
 import vbuild
 
 from . import __version__, globals
+from .helpers import KWONLY_SLOTS
 from .ids import IncrementingStringIds
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class Component:
     name: str
     path: Path
@@ -18,7 +19,7 @@ class Component:
         return f'/_nicegui/{__version__}/components/{self.name}'
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class Dependency:
     id: int
     path: Path

+ 36 - 5
nicegui/element.py

@@ -39,9 +39,9 @@ class Element(Visibility):
         self.tag = tag
         self._classes: List[str] = []
         self._style: Dict[str, str] = {}
-        self._props: Dict[str, Any] = {}
+        self._props: Dict[str, Any] = {'key': self.id}  # HACK: workaround for #600 and #898
         self._event_listeners: Dict[str, EventListener] = {}
-        self._text: str = ''
+        self._text: Optional[str] = None
         self.slots: Dict[str, Slot] = {}
         self.default_slot = self.add_slot('default')
 
@@ -195,22 +195,39 @@ class Element(Visibility):
             tooltip._text = text
         return self
 
-    def on(self, type: str, handler: Optional[Callable], args: Optional[List[str]] = None, *, throttle: float = 0.0) \
-            -> Self:
+    def on(self,
+           type: str,
+           handler: Optional[Callable],
+           args: Optional[List[str]] = None, *,
+           throttle: float = 0.0,
+           leading_events: bool = True,
+           trailing_events: bool = True,
+           ) -> Self:
         """Subscribe to an event.
 
         :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
         :param handler: callback that is called upon occurrence of the event
         :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
         :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
+        :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
+        :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
         """
         if handler:
             if args and '*' in args:
                 url = f'https://github.com/zauberzeug/nicegui/issues/644'
                 warnings.warn(DeprecationWarning(f'Event args "*" is deprecated, omit this parameter instead ({url})'))
                 args = None
-            listener = EventListener(element_id=self.id, type=type, args=args, handler=handler, throttle=throttle)
+            listener = EventListener(
+                element_id=self.id,
+                type=type,
+                args=args,
+                handler=handler,
+                throttle=throttle,
+                leading_events=leading_events,
+                trailing_events=trailing_events,
+            )
             self._event_listeners[listener.id] = listener
+            self.update()
         return self
 
     def _handle_event(self, msg: Dict) -> None:
@@ -249,6 +266,20 @@ class Element(Visibility):
             slot.children.clear()
         self.update()
 
+    def move(self, target_container: Optional[Element] = None, target_index: int = -1):
+        """Move the element to another container.
+
+        :param target_container: container to move the element to (default: the parent container)
+        :param target_index: index within the target slot (default: append to the end)
+        """
+        self.parent_slot.children.remove(self)
+        self.parent_slot.parent.update()
+        target_container = target_container or self.parent_slot.parent
+        target_index = target_index if target_index >= 0 else len(target_container.default_slot.children)
+        target_container.default_slot.children.insert(target_index, self)
+        self.parent_slot = target_container.default_slot
+        target_container.update()
+
     def remove(self, element: Union[Element, int]) -> None:
         """Remove a child element.
 

+ 15 - 0
nicegui/elements/aggrid.py

@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from typing import Dict, List, Optional
 
 from ..dependencies import register_component
@@ -25,6 +27,19 @@ class AgGrid(Element):
         self._props['html_columns'] = html_columns
         self._classes = ['nicegui-aggrid', f'ag-theme-{theme}']
 
+    @staticmethod
+    def from_pandas(df: 'pandas.DataFrame', *, theme: str = 'balham') -> AgGrid:
+        """Create an AG Grid from a Pandas DataFrame.
+
+        :param df: Pandas DataFrame
+        :param theme: AG Grid theme (default: 'balham')
+        :return: AG Grid
+        """
+        return AgGrid({
+            'columnDefs': [{'field': col} for col in df.columns],
+            'rowData': df.to_dict('records'),
+        }, theme=theme)
+
     @property
     def options(self) -> Dict:
         return self._props['options']

+ 3 - 2
nicegui/elements/avatar.py

@@ -7,7 +7,7 @@ from ..element import Element
 class Avatar(Element):
 
     def __init__(self,
-                 icon: str = 'none', *,
+                 icon: Optional[str] = None, *,
                  color: Optional[str] = 'primary',
                  text_color: Optional[str] = None,
                  size: Optional[str] = None,
@@ -30,7 +30,8 @@ class Avatar(Element):
         """
         super().__init__('q-avatar')
 
-        self._props['icon'] = icon
+        if icon is not None:
+            self._props['icon'] = icon
         self._props['square'] = square
         self._props['rounded'] = rounded
 

+ 11 - 2
nicegui/elements/button.py

@@ -1,11 +1,13 @@
+import asyncio
 from typing import Callable, Optional
 
 from ..colors import set_background_color
 from ..events import ClickEventArguments, handle_event
+from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 
 
-class Button(TextElement):
+class Button(TextElement, DisableableElement):
 
     def __init__(self,
                  text: str = '', *,
@@ -16,7 +18,7 @@ class Button(TextElement):
 
         This element is based on Quasar's `QBtn <https://quasar.dev/vue-components/button>`_ component.
 
-        The ``color`` parameter excepts a Quasar color, a Tailwind color, or a CSS color.
+        The ``color`` parameter accepts a Quasar color, a Tailwind color, or a CSS color.
         If a Quasar color is used, the button will be styled according to the Quasar theme including the color of the text.
         Note that there are colors like "red" being both a Quasar color and a CSS color.
         In such cases the Quasar color will be used.
@@ -33,3 +35,10 @@ class Button(TextElement):
 
     def _text_to_model_text(self, text: str) -> None:
         self._props['label'] = text
+
+    async def clicked(self) -> None:
+        """Wait until the button is clicked."""
+        event = asyncio.Event()
+        self.on('click', event.set)
+        await self.client.connected()
+        await event.wait()

+ 12 - 5
nicegui/elements/chart.js

@@ -4,6 +4,7 @@ export default {
     setTimeout(() => {
       const imports = this.extras.map((extra) => import(window.path_prefix + extra));
       Promise.allSettled(imports).then(() => {
+        this.seriesCount = this.options.series ? this.options.series.length : 0;
         this.chart = Highcharts[this.type](this.$el, this.options);
         this.chart.reflow();
       });
@@ -18,16 +19,22 @@ export default {
   methods: {
     update_chart() {
       if (this.chart) {
-        while (this.chart.series.length > this.options.series.length) this.chart.series[0].remove();
-        while (this.chart.series.length < this.options.series.length) this.chart.addSeries({}, false);
+        while (this.seriesCount > this.options.series.length) {
+          this.chart.series[0].remove();
+          this.seriesCount--;
+        }
+        while (this.seriesCount < this.options.series.length) {
+          this.chart.addSeries({}, false);
+          this.seriesCount++;
+        }
         this.chart.update(this.options);
       }
     },
-    destroyChart () {
+    destroyChart() {
       if (this.chart) {
-        this.chart.destroy()
+        this.chart.destroy();
       }
-    }
+    },
   },
   props: {
     type: String,

+ 3 - 0
nicegui/elements/chat_message.js

@@ -0,0 +1,3 @@
+export default {
+  template: `<q-chat-message v-bind="$attrs" />`,
+};

+ 51 - 0
nicegui/elements/chat_message.py

@@ -0,0 +1,51 @@
+import html
+from typing import List, Optional, Union
+
+from ..dependencies import register_component
+from ..element import Element
+
+register_component('chat_message', __file__, 'chat_message.js')
+
+
+class ChatMessage(Element):
+
+    def __init__(self,
+                 text: Union[str, List[str]], *,
+                 name: Optional[str] = None,
+                 label: Optional[str] = None,
+                 stamp: Optional[str] = None,
+                 avatar: Optional[str] = None,
+                 sent: bool = False,
+                 text_html: bool = False,
+                 ) -> None:
+        """Chat Message
+
+        Based on Quasar's `Chat Message <https://quasar.dev/vue-components/chat/>`_ component.
+
+        :param text: the message body (can be a list of strings for multiple message parts)
+        :param name: the name of the message author
+        :param label: renders a label header/section only
+        :param stamp: timestamp of the message
+        :param avatar: URL to an avatar
+        :param sent: render as a sent message (so from current user) (default: False)
+        :param text_html: render text as HTML (default: False)
+        """
+        super().__init__('chat_message')
+
+        if isinstance(text, str):
+            text = [text]
+        if not text_html:
+            text = [html.escape(part) for part in text]
+            text = [part.replace('\n', '<br />') for part in text]
+        self._props['text'] = text
+        self._props['text-html'] = True
+
+        if name is not None:
+            self._props['name'] = name
+        if label is not None:
+            self._props['label'] = label
+        if stamp is not None:
+            self._props['stamp'] = stamp
+        if avatar is not None:
+            self._props['avatar'] = avatar
+        self._props['sent'] = sent

+ 2 - 1
nicegui/elements/checkbox.py

@@ -1,10 +1,11 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 from .mixins.value_element import ValueElement
 
 
-class Checkbox(TextElement, ValueElement):
+class Checkbox(TextElement, ValueElement, DisableableElement):
 
     def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable] = None) -> None:
         """Checkbox

+ 3 - 2
nicegui/elements/color_input.py

@@ -3,10 +3,11 @@ from typing import Callable, Optional
 from nicegui import ui
 
 from .color_picker import ColorPicker
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class ColorInput(ValueElement):
+class ColorInput(ValueElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self, label: Optional[str] = None, *,
@@ -16,7 +17,7 @@ class ColorInput(ValueElement):
         :param label: displayed label for the color input
         :param placeholder: text to show if no color is selected
         :param value: the current color value
-        :param on_change: callback to execute when the input is confirmed by leaving the focus
+        :param on_change: callback to execute when the value changes
         """
         super().__init__(tag='q-input', value=value, on_value_change=on_change)
         if label is not None:

+ 21 - 0
nicegui/elements/dark_mode.js

@@ -0,0 +1,21 @@
+export default {
+  mounted() {
+    this.update();
+  },
+  updated() {
+    this.update();
+  },
+  methods: {
+    update() {
+      Quasar.Dark.set(this.value === null ? "auto" : this.value);
+      if (window.tailwind) {
+        tailwind.config.darkMode = this.auto ? "media" : "class";
+        if (this.value) document.body.classList.add("dark");
+        else document.body.classList.remove("dark");
+      }
+    },
+  },
+  props: {
+    value: Boolean,
+  },
+};

+ 46 - 0
nicegui/elements/dark_mode.py

@@ -0,0 +1,46 @@
+from typing import Optional
+
+from ..dependencies import register_component
+from .mixins.value_element import ValueElement
+
+register_component('dark_mode', __file__, 'dark_mode.js')
+
+
+class DarkMode(ValueElement):
+    VALUE_PROP = 'value'
+
+    def __init__(self, value: Optional[bool] = False) -> None:
+        """Dark mode
+
+        You can use this element to enable, disable or toggle dark mode on the page.
+        The value `None` represents auto mode, which uses the client's system preference.
+
+        Note that this element overrides the `dark` parameter of the `ui.run` function and page decorators.
+
+        :param value: Whether dark mode is enabled. If None, dark mode is set to auto.
+        """
+        super().__init__(tag='dark_mode', value=value, on_value_change=None)
+
+    def enable(self) -> None:
+        """Enable dark mode."""
+        self.value = True
+
+    def disable(self) -> None:
+        """Disable dark mode."""
+        self.value = False
+
+    def toggle(self) -> None:
+        """Toggle dark mode.
+
+        This method will raise a ValueError if dark mode is set to auto.
+        """
+        if self.value is None:
+            raise ValueError('Cannot toggle dark mode when it is set to auto.')
+        self.value = not self.value
+
+    def auto(self) -> None:
+        """Set dark mode to auto.
+
+        This will use the client's system preference.
+        """
+        self.value = None

+ 3 - 2
nicegui/elements/date.py

@@ -1,10 +1,11 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Date(ValueElement):
-    EVENT_ARGS = ['*']
+class Date(ValueElement, DisableableElement):
+    EVENT_ARGS = None
 
     def __init__(self,
                  value: Optional[str] = None,

+ 2 - 1
nicegui/elements/expansion.py

@@ -1,9 +1,10 @@
 from typing import Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Expansion(ValueElement):
+class Expansion(ValueElement, DisableableElement):
 
     def __init__(self, text: Optional[str] = None, *, icon: Optional[str] = None, value: bool = False) -> None:
         '''Expansion Element

+ 24 - 0
nicegui/elements/grid.py

@@ -0,0 +1,24 @@
+from typing import Optional
+
+from ..element import Element
+
+
+class Grid(Element):
+
+    def __init__(self,
+                 rows: Optional[int] = None,
+                 columns: Optional[int] = None,
+                 ) -> None:
+        '''Grid Element
+
+        Provides a container which arranges its child in a grid.
+
+        :param rows: number of rows in the grid
+        :param columns: number of columns in the grid
+        '''
+        super().__init__('div')
+        self._classes = ['nicegui-grid']
+        if rows is not None:
+            self._style['grid-template-rows'] = f'repeat({rows}, minmax(0, 1fr))'
+        if columns is not None:
+            self._style['grid-template-columns'] = f'repeat({columns}, minmax(0, 1fr))'

+ 27 - 3
nicegui/elements/input.py

@@ -1,10 +1,11 @@
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 from .icon import Icon
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Input(ValueElement):
+class Input(ValueElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -14,6 +15,7 @@ class Input(ValueElement):
                  password: bool = False,
                  password_toggle_button: bool = False,
                  on_change: Optional[Callable] = None,
+                 autocomplete: Optional[List[str]] = None,
                  validation: Dict[str, Callable] = {}) -> None:
         """Text Input
 
@@ -31,7 +33,8 @@ class Input(ValueElement):
         :param value: the current value of the text input
         :param password: whether to hide the input (default: False)
         :param password_toggle_button: whether to show a button to toggle the password visibility (default: False)
-        :param on_change: callback to execute when the input is confirmed by leaving the focus
+        :param on_change: callback to execute when the value changes
+        :param autocomplete: optional list of strings for autocompletion
         :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
         """
         super().__init__(tag='q-input', value=value, on_value_change=on_change)
@@ -51,6 +54,27 @@ class Input(ValueElement):
 
         self.validation = validation
 
+        if autocomplete:
+            def find_autocompletion() -> Optional[str]:
+                if self.value:
+                    needle = str(self.value).casefold()
+                    for item in autocomplete:
+                        if item.casefold().startswith(needle):
+                            return item
+
+            def autocomplete_input() -> None:
+                match = find_autocompletion() or ''
+                self.props(f'shadow-text="{match[len(self.value):]}"')
+
+            def complete_input() -> None:
+                match = find_autocompletion()
+                if match:
+                    self.set_value(match)
+                self.props(f'shadow-text=""')
+
+            self.on('keyup', autocomplete_input)
+            self.on('keydown.tab', complete_input)
+
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)
         for message, check in self.validation.items():

+ 2 - 1
nicegui/elements/knob.py

@@ -2,10 +2,11 @@ from typing import Optional
 
 from ..colors import set_text_color
 from .label import Label
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Knob(ValueElement):
+class Knob(ValueElement, DisableableElement):
 
     def __init__(self,
                  value: float = 0.0,

+ 23 - 0
nicegui/elements/link.js

@@ -0,0 +1,23 @@
+export default {
+  template: `<a :href="computed_href" :target="target"><slot></slot></a>`,
+  mounted() {
+    setTimeout(this.compute_href, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_href();
+  },
+  methods: {
+    compute_href() {
+      this.computed_href = (this.href.startsWith("/") ? window.path_prefix : "") + this.href;
+    },
+  },
+  props: {
+    href: String,
+    target: String,
+  },
+  data: function () {
+    return {
+      computed_href: this.href,
+    };
+  },
+};

+ 4 - 1
nicegui/elements/link.py

@@ -1,9 +1,12 @@
 from typing import Callable, Union
 
 from .. import globals
+from ..dependencies import register_component
 from ..element import Element
 from .mixins.text_element import TextElement
 
+register_component('link', __file__, 'link.js')
+
 
 class Link(TextElement):
 
@@ -19,7 +22,7 @@ class Link(TextElement):
         :param target: page function or string that is a an absolute URL or relative path from base URL
         :param new_tab: open link in new tab (default: False)
         """
-        super().__init__(tag='a', text=text)
+        super().__init__(tag='link', text=text)
         self._props['href'] = target if isinstance(target, str) else globals.page_routes[target]
         self._props['target'] = '_blank' if new_tab else '_self'
         self._classes = ['nicegui-link']

+ 3 - 3
nicegui/elements/log.py

@@ -1,3 +1,4 @@
+import urllib.parse
 from collections import deque
 from typing import Any, Optional
 
@@ -23,10 +24,9 @@ class Log(Element):
         self.lines: deque[str] = deque(maxlen=max_lines)
 
     def push(self, line: Any) -> None:
-        line = str(line)
-        self.lines.extend(line.splitlines())
+        self.lines.extend(map(urllib.parse.quote, str(line).splitlines()))
         self._props['lines'] = '\n'.join(self.lines)
-        self.run_method('push', line)
+        self.run_method('push', urllib.parse.quote(str(line)))
 
     def clear(self) -> None:
         """Clear the log"""

+ 1 - 0
nicegui/elements/markdown.py

@@ -24,6 +24,7 @@ class Markdown(ContentElement):
         """
         self.extras = extras
         super().__init__(tag='markdown', content=content)
+        self._classes = ['nicegui-markdown']
         self._props['codehilite_css'] = HtmlFormatter(nobackground=True).get_style_defs('.codehilite')
 
     def on_content_change(self, content: str) -> None:

+ 81 - 0
nicegui/elements/mixins/disableable_element.py

@@ -0,0 +1,81 @@
+from typing import Any, Callable
+
+from typing_extensions import Self
+
+from ...binding import BindableProperty, bind, bind_from, bind_to
+from ...element import Element
+
+
+class DisableableElement(Element):
+    enabled = BindableProperty(on_change=lambda sender, value: sender.on_enabled_change(value))
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self.enabled = True
+
+    def enable(self) -> None:
+        """Enable the element."""
+        self.enabled = True
+
+    def disable(self) -> None:
+        """Disable the element."""
+        self.enabled = False
+
+    def bind_enabled_to(self,
+                        target_object: Any,
+                        target_name: str = 'enabled',
+                        forward: Callable = lambda x: x) -> Self:
+        """Bind the enabled state of this element to the target object's target_name property.
+
+        The binding works one way only, from this element to the target.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        """
+        bind_to(self, 'enabled', target_object, target_name, forward)
+        return self
+
+    def bind_enabled_from(self,
+                          target_object: Any,
+                          target_name: str = 'enabled',
+                          backward: Callable = lambda x: x) -> Self:
+        """Bind the enabled state of this element from the target object's target_name property.
+
+        The binding works one way only, from the target to this element.
+
+        :param target_object: The object to bind from.
+        :param target_name: The name of the property to bind from.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind_from(self, 'enabled', target_object, target_name, backward)
+        return self
+
+    def bind_enabled(self,
+                     target_object: Any,
+                     target_name: str = 'enabled', *,
+                     forward: Callable = lambda x: x,
+                     backward: Callable = lambda x: x) -> Self:
+        """Bind the enabled state of this element to the target object's target_name property.
+
+        The binding works both ways, from this element to the target and from the target to this element.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind(self, 'enabled', target_object, target_name, forward=forward, backward=backward)
+        return self
+
+    def set_enabled(self, value: bool) -> None:
+        """Set the enabled state of the element."""
+        self.enabled = value
+
+    def on_enabled_change(self, enabled: bool) -> None:
+        """Called when the element is enabled or disabled.
+
+        :param enabled: The new state.
+        """
+        self._props['disable'] = not enabled
+        self.update()

+ 1 - 1
nicegui/elements/mixins/visibility.py

@@ -70,7 +70,7 @@ class Visibility:
         bind(self, 'visible', target_object, target_name, forward=forward, backward=backward)
         return self
 
-    def set_visibility(self, visible: str) -> None:
+    def set_visibility(self, visible: bool) -> None:
         """Set the visibility of this element.
 
         :param visible: Whether the element should be visible.

+ 30 - 2
nicegui/elements/number.py

@@ -1,15 +1,21 @@
 from typing import Any, Callable, Dict, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Number(ValueElement):
+class Number(ValueElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
                  label: Optional[str] = None, *,
                  placeholder: Optional[str] = None,
                  value: Optional[float] = None,
+                 min: Optional[float] = None,
+                 max: Optional[float] = None,
+                 step: Optional[float] = None,
+                 prefix: Optional[str] = None,
+                 suffix: Optional[str] = None,
                  format: Optional[str] = None,
                  on_change: Optional[Callable] = None,
                  validation: Dict[str, Callable] = {}) -> None:
@@ -23,8 +29,13 @@ class Number(ValueElement):
         :param label: displayed name for the number input
         :param placeholder: text to show if no value is entered
         :param value: the initial value of the field
+        :param min: the minimum value allowed
+        :param max: the maximum value allowed
+        :param step: the step size for the stepper buttons
+        :param prefix: a prefix to prepend to the displayed value
+        :param suffix: a suffix to append to the displayed value
         :param format: a string like "%.2f" to format the displayed value
-        :param on_change: callback to execute when the input is confirmed by leaving the focus
+        :param on_change: callback to execute when the value changes
         :param validation: dictionary of validation rules, e.g. ``{'Too small!': lambda value: value < 3}``
         """
         self.format = format
@@ -34,7 +45,24 @@ class Number(ValueElement):
             self._props['label'] = label
         if placeholder is not None:
             self._props['placeholder'] = placeholder
+        if min is not None:
+            self._props['min'] = min
+        if max is not None:
+            self._props['max'] = max
+        if step is not None:
+            self._props['step'] = step
+        if prefix is not None:
+            self._props['prefix'] = prefix
+        if suffix is not None:
+            self._props['suffix'] = suffix
         self.validation = validation
+        self.on('blur', self.sanitize)
+
+    def sanitize(self) -> None:
+        value = float(self.value or 0)
+        value = max(value, self._props.get('min', -float('inf')))
+        value = min(value, self._props.get('max', float('inf')))
+        self.set_value(float(self.format % value) if self.format else value)
 
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)

+ 2 - 1
nicegui/elements/radio.py

@@ -1,9 +1,10 @@
 from typing import Any, Callable, Dict, List, Optional, Union
 
 from .choice_element import ChoiceElement
+from .mixins.disableable_element import DisableableElement
 
 
-class Radio(ChoiceElement):
+class Radio(ChoiceElement, DisableableElement):
 
     def __init__(self, options: Union[List, Dict], *, value: Any = None, on_change: Optional[Callable] = None):
         """Radio Selection

+ 8 - 4
nicegui/elements/scene.py

@@ -5,8 +5,8 @@ from .. import binding, globals
 from ..dependencies import register_component
 from ..element import Element
 from ..events import SceneClickEventArguments, SceneClickHit, handle_event
+from ..helpers import KWONLY_SLOTS
 from .scene_object3d import Object3D
-from .scene_objects import Scene as SceneObject
 
 register_component('scene', __file__, 'scene.js', [
     'lib/three.min.js',
@@ -18,7 +18,7 @@ register_component('scene', __file__, 'scene.js', [
 ])
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class SceneCamera:
     x: float = 0
     y: float = -3
@@ -31,7 +31,7 @@ class SceneCamera:
     up_z: float = 1
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class SceneObject:
     id: str = 'scene'
 
@@ -53,7 +53,11 @@ class Scene(Element):
     from .scene_objects import Text3d as text3d
     from .scene_objects import Texture as texture
 
-    def __init__(self, width: int = 400, height: int = 300, grid: bool = True, on_click: Optional[Callable] = None) -> None:
+    def __init__(self,
+                 width: int = 400,
+                 height: int = 300,
+                 grid: bool = True,
+                 on_click: Optional[Callable] = None) -> None:
         """3D Scene
 
         Display a 3d scene using `three.js <https://threejs.org/>`_.

+ 2 - 1
nicegui/elements/select.py

@@ -5,11 +5,12 @@ from typing import Any, Callable, Dict, List, Optional, Union
 from nicegui.dependencies import register_component
 
 from .choice_element import ChoiceElement
+from .mixins.disableable_element import DisableableElement
 
 register_component('select', __file__, 'select.js')
 
 
-class Select(ChoiceElement):
+class Select(ChoiceElement, DisableableElement):
 
     def __init__(self, options: Union[List, Dict], *,
                  label: Optional[str] = None,

+ 2 - 1
nicegui/elements/slider.py

@@ -1,9 +1,10 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Slider(ValueElement):
+class Slider(ValueElement, DisableableElement):
 
     def __init__(self, *,
                  min: float,

+ 3 - 1
nicegui/elements/splitter.py

@@ -1,9 +1,11 @@
 from typing import Callable, Optional, Tuple
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Splitter(ValueElement):
+class Splitter(ValueElement, DisableableElement):
+
     def __init__(self, *,
                  horizontal: Optional[bool] = False,
                  reverse: Optional[bool] = False,

+ 2 - 1
nicegui/elements/switch.py

@@ -1,10 +1,11 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 from .mixins.value_element import ValueElement
 
 
-class Switch(TextElement, ValueElement):
+class Switch(TextElement, ValueElement, DisableableElement):
 
     def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable] = None) -> None:
         """Switch

+ 5 - 5
nicegui/elements/tabs.py

@@ -1,7 +1,7 @@
 from typing import Any, Callable, Optional
 
 from .. import globals
-from ..element import Element
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
@@ -22,7 +22,7 @@ class Tabs(ValueElement):
         self.panels: Optional[TabPanels] = None
 
 
-class Tab(Element):
+class Tab(DisableableElement):
 
     def __init__(self, name: str, label: Optional[str] = None, icon: Optional[str] = None) -> None:
         """Tab
@@ -34,7 +34,7 @@ class Tab(Element):
         :param label: label of the tab (default: `None`, meaning the same as `name`)
         :param icon: icon of the tab (default: `None`)
         """
-        super().__init__('q-tab')
+        super().__init__(tag='q-tab')
         self._props['name'] = name
         self._props['label'] = label if label is not None else name
         if icon:
@@ -65,7 +65,7 @@ class TabPanels(ValueElement):
         self._props['animated'] = animated
 
 
-class TabPanel(Element):
+class TabPanel(DisableableElement):
 
     def __init__(self, name: str) -> None:
         """Tab Panel
@@ -75,5 +75,5 @@ class TabPanel(Element):
 
         :param name: name of the tab panel (the value of the `TabPanels` element)
         """
-        super().__init__('q-tab-panel')
+        super().__init__(tag='q-tab-panel')
         self._props['name'] = name

+ 1 - 1
nicegui/elements/textarea.py

@@ -19,7 +19,7 @@ class Textarea(Input):
         :param label: displayed name for the textarea
         :param placeholder: text to show if no value is entered
         :param value: the initial value of the field
-        :param on_change: callback to execute when the input is confirmed by leaving the focus
+        :param on_change: callback to execute when the value changes
         :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
         """
         super().__init__(label, placeholder=placeholder, value=value, on_change=on_change, validation=validation)

+ 2 - 1
nicegui/elements/time.py

@@ -1,9 +1,10 @@
 from typing import Callable, Optional
 
+from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Time(ValueElement):
+class Time(ValueElement, DisableableElement):
 
     def __init__(self,
                  value: Optional[str] = None,

+ 2 - 1
nicegui/elements/toggle.py

@@ -1,9 +1,10 @@
 from typing import Any, Callable, Dict, List, Optional, Union
 
 from .choice_element import ChoiceElement
+from .mixins.disableable_element import DisableableElement
 
 
-class Toggle(ChoiceElement):
+class Toggle(ChoiceElement, DisableableElement):
 
     def __init__(self, options: Union[List, Dict], *, value: Any = None, on_change: Optional[Callable] = None) -> None:
         """Toggle

+ 27 - 0
nicegui/elements/upload.js

@@ -0,0 +1,27 @@
+export default {
+  template: `
+    <q-uploader ref="uploader" :url="computed_url">
+        <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+            <slot :name="slot" v-bind="slotProps || {}" />
+        </template>
+    </q-uploader>
+  `,
+  mounted() {
+    setTimeout(() => {
+      this.computed_url = (window.path_prefix || "") + this.url;
+    }, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  methods: {
+    reset() {
+      this.$refs.uploader.reset();
+    },
+  },
+  props: {
+    url: String,
+  },
+  data: function () {
+    return {
+      computed_url: this.url,
+    };
+  },
+};

+ 6 - 3
nicegui/elements/upload.py

@@ -2,12 +2,15 @@ from typing import Callable, Optional
 
 from fastapi import Request, Response
 
-from ..element import Element
+from ..dependencies import register_component
 from ..events import EventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
+from .mixins.disableable_element import DisableableElement
 
+register_component('upload', __file__, 'upload.js')
 
-class Upload(Element):
+
+class Upload(DisableableElement):
 
     def __init__(self, *,
                  multiple: bool = False,
@@ -32,7 +35,7 @@ class Upload(Element):
         :param label: label for the uploader (default: `''`)
         :param auto_upload: automatically upload files when they are selected (default: `False`)
         """
-        super().__init__('q-uploader')
+        super().__init__(tag='upload')
         self._props['multiple'] = multiple
         self._props['label'] = label
         self._props['auto-upload'] = auto_upload

+ 5 - 0
nicegui/elements/video.js

@@ -1,5 +1,10 @@
 export default {
   template: `<video :controls="controls" :autoplay="autoplay" :muted="muted" :src="src" />`,
+  methods: {
+    seek(seconds) {
+      this.$el.currentTime = seconds;
+    },
+  },
   props: {
     controls: Boolean,
     autoplay: Boolean,

+ 7 - 0
nicegui/elements/video.py

@@ -36,3 +36,10 @@ class Video(Element):
         if type:
             url = f'https://github.com/zauberzeug/nicegui/pull/624'
             warnings.warn(DeprecationWarning(f'The type parameter for ui.video is deprecated and ineffective ({url}).'))
+
+    def seek(self, seconds: float) -> None:
+        """Seek to a specific position in the video.
+
+        :param seconds: the position in seconds
+        """
+        self.run_method('seek', seconds)

+ 7 - 1
nicegui/event_listener.py

@@ -2,8 +2,10 @@ import uuid
 from dataclasses import dataclass, field
 from typing import Any, Callable, Dict, List
 
+from .helpers import KWONLY_SLOTS
 
-@dataclass
+
+@dataclass(**KWONLY_SLOTS)
 class EventListener:
     id: str = field(init=False)
     element_id: int
@@ -11,6 +13,8 @@ class EventListener:
     args: List[str]
     handler: Callable
     throttle: float
+    leading_events: bool
+    trailing_events: bool
 
     def __post_init__(self) -> None:
         self.id = str(uuid.uuid4())
@@ -29,4 +33,6 @@ class EventListener:
             'keys': keys,
             'args': self.args,
             'throttle': self.throttle,
+            'leading_events': self.leading_events,
+            'trailing_events': self.trailing_events,
         }

+ 18 - 18
nicegui/events.py

@@ -1,27 +1,27 @@
 from dataclasses import dataclass
 from inspect import signature
-from typing import TYPE_CHECKING, Any, BinaryIO, Callable, List, Optional, Union
+from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
 
 from . import background_tasks, globals
-from .helpers import is_coroutine
+from .helpers import KWONLY_SLOTS, is_coroutine
 
 if TYPE_CHECKING:
     from .client import Client
     from .element import Element
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class EventArguments:
     sender: 'Element'
     client: 'Client'
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class ClickEventArguments(EventArguments):
     pass
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class SceneClickHit:
     object_id: str
     object_name: str
@@ -30,7 +30,7 @@ class SceneClickHit:
     z: float
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class SceneClickEventArguments(ClickEventArguments):
     click_type: str
     button: int
@@ -41,12 +41,12 @@ class SceneClickEventArguments(ClickEventArguments):
     hits: List[SceneClickHit]
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class ColorPickEventArguments(EventArguments):
     color: str
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class MouseEventArguments(EventArguments):
     type: str
     image_x: float
@@ -59,38 +59,38 @@ class MouseEventArguments(EventArguments):
     shift: bool
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class JoystickEventArguments(EventArguments):
     action: str
     x: Optional[float] = None
     y: Optional[float] = None
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class UploadEventArguments(EventArguments):
     content: BinaryIO
     name: str
     type: str
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class ValueChangeEventArguments(EventArguments):
     value: Any
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class TableSelectionEventArguments(EventArguments):
     selection: List[Any]
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class KeyboardAction:
     keydown: bool
     keyup: bool
     repeat: bool
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class KeyboardModifiers:
     alt: bool
     ctrl: bool
@@ -98,7 +98,7 @@ class KeyboardModifiers:
     shift: bool
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class KeyboardKey:
     name: str
     code: str
@@ -261,7 +261,7 @@ class KeyboardKey:
         return self.name == 'F12'
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class KeyEventArguments(EventArguments):
     action: KeyboardAction
     key: KeyboardKey
@@ -269,14 +269,14 @@ class KeyEventArguments(EventArguments):
 
 
 def handle_event(handler: Optional[Callable],
-                 arguments: Union[EventArguments, dict], *,
+                 arguments: Union[EventArguments, Dict], *,
                  sender: Optional['Element'] = None) -> None:
     try:
         if handler is None:
             return
         no_arguments = not signature(handler).parameters
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
-        assert sender.parent_slot is not None
+        assert sender is not None and sender.parent_slot is not None
         with sender.parent_slot:
             result = handler() if no_arguments else handler(arguments)
         if is_coroutine(handler):

+ 45 - 1
nicegui/favicon.py

@@ -1,3 +1,4 @@
+import urllib.parse
 from pathlib import Path
 from typing import TYPE_CHECKING, Optional
 
@@ -10,7 +11,7 @@ if TYPE_CHECKING:
 
 
 def create_favicon_route(path: str, favicon: Optional[str]) -> None:
-    if favicon and is_remote_url(favicon):
+    if favicon and (is_remote_url(favicon) or is_char(favicon)):
         return
     fallback = Path(__file__).parent / 'static' / 'favicon.ico'
     path = f'{"" if path == "/" else path}/favicon.ico'
@@ -24,6 +25,12 @@ def get_favicon_url(page: 'page', prefix: str) -> str:
         return favicon
     elif not favicon:
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
+    elif is_data_url(favicon):
+        return favicon
+    elif is_svg(favicon):
+        return svg_to_data_url(favicon)
+    elif is_char(favicon):
+        return char_to_data_url(favicon)
     elif page.path == '/':
         return f'{prefix}/favicon.ico'
     else:
@@ -32,3 +39,40 @@ def get_favicon_url(page: 'page', prefix: str) -> str:
 
 def is_remote_url(favicon: str) -> bool:
     return favicon.startswith('http://') or favicon.startswith('https://')
+
+
+def is_char(favicon: str) -> bool:
+    return len(favicon) == 1
+
+
+def is_svg(favicon: str) -> bool:
+    return favicon.strip().startswith('<svg')
+
+
+def is_data_url(favicon: str) -> bool:
+    return favicon.startswith('data:')
+
+
+def char_to_data_url(char: str) -> str:
+    svg = f'''
+        <svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
+            <style>
+                @supports (-moz-appearance:none) {{
+                    text {{
+                        font-size: 100px;
+                        transform: translateY(0.1em);
+                    }}
+                }}
+                text {{
+                    font-family: Arial, sans-serif;
+                }}
+            </style>
+            <text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
+        </svg>
+    '''
+    return svg_to_data_url(svg)
+
+
+def svg_to_data_url(svg: str) -> str:
+    svg_urlencoded = urllib.parse.quote(svg)
+    return f'data:image/svg+xml,{svg_urlencoded}'

+ 14 - 0
nicegui/functions/download.py

@@ -0,0 +1,14 @@
+from typing import Optional
+
+from .. import globals
+
+
+def download(url: str, filename: Optional[str] = None) -> None:
+    """Download
+
+    Function to trigger the download of a file.
+
+    :param url: target URL of the file to download
+    :param filename: name of the file to download (default: name of the file on the server)
+    """
+    globals.get_client().download(url, filename)

+ 3 - 0
nicegui/functions/refreshable.js

@@ -0,0 +1,3 @@
+export default {
+  template: `<slot></slot>`,
+};

+ 67 - 0
nicegui/functions/refreshable.py

@@ -0,0 +1,67 @@
+from typing import Any, Callable, Dict, List, Tuple
+
+from typing_extensions import Self
+
+from .. import background_tasks, globals
+from ..dependencies import register_component
+from ..element import Element
+from ..helpers import is_coroutine
+
+register_component('refreshable', __file__, 'refreshable.js')
+
+
+class refreshable:
+
+    def __init__(self, func: Callable) -> None:
+        """Refreshable UI functions
+
+        The `@ui.refreshable` decorator allows you to create functions that have a `refresh` method.
+        This method will automatically delete all elements created by the function and recreate them.
+        """
+        self.func = func
+        self.instance = None
+        self.containers: List[Tuple[Element, List[Any], Dict[str, Any]]] = []
+
+    def __get__(self, instance, _) -> Self:
+        self.instance = instance
+        return self
+
+    def __call__(self, *args, **kwargs) -> None:
+        self.prune()
+        with Element('refreshable') as container:
+            self.containers.append((container, args, kwargs))
+        return self._run_in_container(container, *args, **kwargs)
+
+    def refresh(self) -> None:
+        self.prune()
+        for container, args, kwargs in self.containers:
+            container.clear()
+            result = self._run_in_container(container, *args, **kwargs)
+            if is_coroutine(self.func):
+                if globals.loop and globals.loop.is_running():
+                    background_tasks.create(result)
+                else:
+                    globals.app.on_startup(result)
+
+    def prune(self) -> None:
+        self.containers = [
+            (container, args, kwargs)
+            for container, args, kwargs in self.containers
+            if container.client.id in globals.clients
+        ]
+
+    def _run_in_container(self, container: Element, *args, **kwargs) -> None:
+        if is_coroutine(self.func):
+            async def wait_for_result() -> None:
+                with container:
+                    if self.instance is None:
+                        await self.func(*args, **kwargs)
+                    else:
+                        await self.func(self.instance, *args, **kwargs)
+            return wait_for_result()
+        else:
+            with container:
+                if self.instance is None:
+                    self.func(*args, **kwargs)
+                else:
+                    self.func(self.instance, *args, **kwargs)

+ 2 - 0
nicegui/globals.py

@@ -10,6 +10,7 @@ from uvicorn import Server
 
 from . import background_tasks
 from .app import App
+from .language import Language
 
 if TYPE_CHECKING:
     from .client import Client
@@ -36,6 +37,7 @@ title: str
 viewport: str
 favicon: Optional[str]
 dark: Optional[bool]
+language: Language
 binding_refresh_interval: float
 excludes: List[str]
 tailwind: bool

+ 5 - 0
nicegui/helpers.py

@@ -2,6 +2,7 @@ import asyncio
 import functools
 import inspect
 import socket
+import sys
 import threading
 import time
 import webbrowser
@@ -13,6 +14,8 @@ from . import background_tasks, globals
 if TYPE_CHECKING:
     from .client import Client
 
+KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}
+
 
 def is_coroutine(object: Any) -> bool:
     while isinstance(object, functools.partial):
@@ -46,6 +49,8 @@ def is_port_open(host: str, port: int) -> bool:
         sock.connect((host, port))
     except (ConnectionRefusedError, TimeoutError):
         return False
+    except Exception:
+        return False
     else:
         return True
     finally:

+ 3 - 0
nicegui/json/orjson_wrapper.py

@@ -1,3 +1,4 @@
+from decimal import Decimal
 from typing import Any, Optional, Tuple
 
 import numpy as np
@@ -42,6 +43,8 @@ def _orjson_converter(obj):
     """Custom serializer/converter, e.g. for numpy object arrays."""
     if isinstance(obj, np.ndarray) and obj.dtype == np.object_:
         return obj.tolist()
+    if isinstance(obj, Decimal):
+        return float(obj)
 
 
 class NiceGUIJSONResponse(Response):

+ 66 - 0
nicegui/language.py

@@ -0,0 +1,66 @@
+from typing_extensions import Literal
+
+Language = Literal[
+    'ar',
+    'ar-TN',
+    'az-Latn',
+    'bg',
+    'bn',
+    'ca',
+    'cs',
+    'da',
+    'de',
+    'el',
+    'en-GB',
+    'en-US',
+    'eo',
+    'es',
+    'et',
+    'eu',
+    'fa',
+    'fa-IR',
+    'fi',
+    'fr',
+    'gn',
+    'he',
+    'hr',
+    'hu',
+    'id',
+    'is',
+    'it',
+    'ja',
+    'kk',
+    'km',
+    'ko-KR',
+    'kur-CKB',
+    'lt',
+    'lu',
+    'lv',
+    'ml',
+    'mm',
+    'ms',
+    'my',
+    'nb-NO',
+    'nl',
+    'pl',
+    'pt',
+    'pt-BR',
+    'ro',
+    'ru',
+    'sk',
+    'sl',
+    'sm',
+    'sr',
+    'sr-CYR',
+    'sv',
+    'ta',
+    'th',
+    'tr',
+    'ug',
+    'uk',
+    'uz-Cyrl',
+    'uz-Latn',
+    'vi',
+    'zh-CN',
+    'zh-TW',
+]

+ 3 - 1
nicegui/native.py

@@ -1,8 +1,10 @@
 from dataclasses import dataclass, field
 from typing import Any, Dict
 
+from .helpers import KWONLY_SLOTS
 
-@dataclass
+
+@dataclass(**KWONLY_SLOTS)
 class Native:
     start_args: Dict[str, Any] = field(default_factory=dict)
     window_args: Dict[str, Any] = field(default_factory=dict)

+ 26 - 13
nicegui/native_mode.py

@@ -1,27 +1,39 @@
 import _thread
 import multiprocessing
 import socket
+import sys
 import tempfile
 import time
 import warnings
 from threading import Thread
 
-from . import globals
+from . import globals, helpers
 
-with warnings.catch_warnings():
-    # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
-    warnings.filterwarnings('ignore', category=DeprecationWarning)
-    import webview
+try:
+    with warnings.catch_warnings():
+        # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
+        warnings.filterwarnings('ignore', category=DeprecationWarning)
+        import webview
+except ModuleNotFoundError:
+    pass
 
 
-def open_window(url: str, title: str, width: int, height: int, fullscreen: bool) -> None:
-    window_kwargs = dict(url=url, title=title, width=width, height=height, fullscreen=fullscreen)
+def open_window(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
+    while not helpers.is_port_open(host, port):
+        time.sleep(0.1)
+
+    window_kwargs = dict(url=f'http://{host}:{port}', title=title, width=width, height=height, fullscreen=fullscreen)
     window_kwargs.update(globals.app.native.window_args)
-    webview.create_window(**window_kwargs)
-    webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
+
+    try:
+        webview.create_window(**window_kwargs)
+        webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
+    except NameError:
+        print('Native mode is not supported in this configuration. Please install pywebview to use it.')
+        sys.exit(1)
 
 
-def activate(url: str, title: str, width: int, height: int, fullscreen: bool) -> None:
+def activate(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
     def check_shutdown() -> None:
         while process.is_alive():
             time.sleep(0.1)
@@ -31,17 +43,18 @@ def activate(url: str, title: str, width: int, height: int, fullscreen: bool) ->
         _thread.interrupt_main()
 
     multiprocessing.freeze_support()
-    process = multiprocessing.Process(target=open_window, args=(url, title, width, height, fullscreen), daemon=False)
+    process = multiprocessing.Process(target=open_window, args=(host, port, title, width, height, fullscreen),
+                                      daemon=False)
     process.start()
     Thread(target=check_shutdown, daemon=True).start()
 
 
 def find_open_port(start_port: int = 8000, end_port: int = 8999) -> int:
-    '''Reliably find an open port in a given range.
+    """Reliably find an open port in a given range.
 
     This function will actually try to open the port to ensure no firewall blocks it.
     This is better than, e.g., passing port=0 to uvicorn.
-    '''
+    """
     for port in range(start_port, end_port + 1):
         try:
             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:

+ 24 - 11
nicegui/nicegui.py

@@ -1,5 +1,6 @@
 import asyncio
 import os
+import socket
 import time
 import urllib.parse
 from pathlib import Path
@@ -8,6 +9,7 @@ from typing import Dict, Optional
 from fastapi import HTTPException, Request
 from fastapi.middleware.gzip import GZipMiddleware
 from fastapi.responses import FileResponse, Response
+from fastapi.staticfiles import StaticFiles
 from fastapi_socketio import SocketManager
 
 from nicegui import json
@@ -28,6 +30,11 @@ socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=jso
 globals.sio = sio = app.sio
 
 app.add_middleware(GZipMiddleware)
+static_files = StaticFiles(
+    directory=(Path(__file__).parent / 'static').resolve(),
+    follow_symlink=True,
+)
+app.mount(f'/_nicegui/{__version__}/static', static_files, name='static')
 
 globals.index_client = Client(page('/'), shared=True).__enter__()
 
@@ -37,16 +44,6 @@ def index(request: Request) -> Response:
     return globals.index_client.build_response(request)
 
 
-@app.get(f'/_nicegui/{__version__}' + '/static/{name}')
-def get_static(name: str):
-    return FileResponse(Path(__file__).parent / 'static' / name)
-
-
-@app.get(f'/_nicegui/{__version__}' + '/static/fonts/{name}')
-def get_static(name: str):
-    return FileResponse(Path(__file__).parent / 'static' / 'fonts' / name)
-
-
 @app.get(f'/_nicegui/{__version__}' + '/dependencies/{id}/{name}')
 def get_dependencies(id: int, name: str):
     if id in js_dependencies and js_dependencies[id].path.exists() and js_dependencies[id].path.name == name:
@@ -80,7 +77,23 @@ def handle_startup(with_welcome_message: bool = True) -> None:
     background_tasks.create(prune_slot_stacks())
     globals.state = globals.State.STARTED
     if with_welcome_message:
-        print(f'NiceGUI ready to go on {os.environ["NICEGUI_URL"]}')
+        print_welcome_message()
+
+
+def print_welcome_message():
+    host = os.environ['NICEGUI_HOST']
+    port = os.environ['NICEGUI_PORT']
+    ips = set()
+    if host == '0.0.0.0':
+        try:
+            ips.update(set(info[4][0] for info in socket.getaddrinfo(socket.gethostname(), None) if len(info[4]) == 2))
+        except Exception:
+            pass  # NOTE: if we can't get the host's IP, we'll just use localhost
+    ips.discard('127.0.0.1')
+    addresses = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
+    if len(addresses) >= 2:
+        addresses[-1] = 'and ' + addresses[-1]
+    print(f'NiceGUI ready to go on {", ".join(addresses)}')
 
 
 @app.on_event('shutdown')

+ 11 - 1
nicegui/page.py

@@ -8,6 +8,7 @@ from fastapi import Request, Response
 from . import background_tasks, globals
 from .client import Client
 from .favicon import create_favicon_route
+from .language import Language
 
 
 class page:
@@ -18,7 +19,9 @@ class page:
                  viewport: Optional[str] = None,
                  favicon: Optional[str] = None,
                  dark: Optional[bool] = ...,
+                 language: Language = ...,
                  response_timeout: float = 3.0,
+                 **kwargs,
                  ) -> None:
         """Page
 
@@ -32,14 +35,18 @@ class page:
         :param viewport: optional viewport meta tag content
         :param favicon: optional relative filepath or absolute URL to a favicon (default: `None`, NiceGUI icon will be used)
         :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
+        :param language: language of the page (defaults to `language` argument of `run` command)
         :param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
+        :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
         """
         self.path = path
         self.title = title
         self.viewport = viewport
         self.favicon = favicon
         self.dark = dark
+        self.language = language
         self.response_timeout = response_timeout
+        self.kwargs = kwargs
 
         create_favicon_route(self.path, favicon)
 
@@ -52,6 +59,9 @@ class page:
     def resolve_dark(self) -> Optional[bool]:
         return self.dark if self.dark is not ... else globals.dark
 
+    def resolve_language(self) -> Optional[str]:
+        return self.language if self.language is not ... else globals.language
+
     def __call__(self, func: Callable) -> Callable:
         globals.app.remove_route(self.path)  # NOTE make sure only the latest route definition is used
         parameters_of_decorated_func = list(inspect.signature(func).parameters.keys())
@@ -86,6 +96,6 @@ class page:
             parameters.insert(0, request)
         decorated.__signature__ = inspect.Signature(parameters)
 
-        globals.app.get(self.path)(decorated)
+        globals.app.get(self.path, **self.kwargs)(decorated)
         globals.page_routes[func] = self.path
         return func

+ 9 - 4
nicegui/run.py

@@ -10,6 +10,7 @@ from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
 
 from . import globals, helpers, native_mode
+from .language import Language
 
 
 def run(*,
@@ -19,6 +20,7 @@ def run(*,
         viewport: str = 'width=device-width, initial-scale=1',
         favicon: Optional[str] = None,
         dark: Optional[bool] = False,
+        language: Language = 'en-US',
         binding_refresh_interval: float = 0.1,
         show: bool = True,
         native: bool = False,
@@ -41,8 +43,9 @@ def run(*,
     :param port: use this port (default: `8080`)
     :param title: page title (default: `'NiceGUI'`, can be overwritten per page)
     :param viewport: page meta viewport content (default: `'width=device-width, initial-scale=1'`, can be overwritten per page)
-    :param favicon: relative filepath or absolute URL to a favicon (default: `None`, NiceGUI icon will be used)
+    :param favicon: relative filepath, absolute URL to a favicon (default: `None`, NiceGUI icon will be used) or emoji (e.g. `'🚀'`, works for most browsers)
     :param dark: whether to use Quasar's dark mode (default: `False`, use `None` for "auto" mode)
+    :param language: language for Quasar elements (default: `'en-US'`)
     :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
     :param show: automatically open the UI in a browser tab (default: `True`)
     :param native: open the UI in a native window of size 800x600 (default: `False`, deactivates `show`, automatically finds an open port)
@@ -64,6 +67,7 @@ def run(*,
     globals.viewport = viewport
     globals.favicon = favicon
     globals.dark = dark
+    globals.language = language
     globals.binding_refresh_interval = binding_refresh_interval
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.tailwind = tailwind
@@ -84,12 +88,13 @@ def run(*,
         host = host or '127.0.0.1'
         port = native_mode.find_open_port()
         width, height = window_size or (800, 600)
-        native_mode.activate(f'http://{host}:{port}', title, width, height, fullscreen)
+        native_mode.activate(host, port, title, width, height, fullscreen)
     else:
         host = host or '0.0.0.0'
 
-    # NOTE: We save the URL in an environment variable so the subprocess started in reload mode can access it.
-    os.environ['NICEGUI_URL'] = f'http://{host}:{port}'
+    # NOTE: We save host and port in environment variables so the subprocess started in reload mode can access them.
+    os.environ['NICEGUI_HOST'] = host
+    os.environ['NICEGUI_PORT'] = str(port)
 
     if show:
         helpers.schedule_browser(host, port)

+ 5 - 1
nicegui/run_with.py

@@ -3,6 +3,7 @@ from typing import Optional
 from fastapi import FastAPI
 
 from nicegui import globals
+from nicegui.language import Language
 from nicegui.nicegui import handle_shutdown, handle_startup
 
 
@@ -12,14 +13,17 @@ def run_with(
     viewport: str = 'width=device-width, initial-scale=1',
     favicon: Optional[str] = None,
     dark: Optional[bool] = False,
+    language: Language = 'en-US',
     binding_refresh_interval: float = 0.1,
     exclude: str = '',
+    mount_path: str = '/',
 ) -> None:
     globals.ui_run_has_been_called = True
     globals.title = title
     globals.viewport = viewport
     globals.favicon = favicon
     globals.dark = dark
+    globals.language = language
     globals.binding_refresh_interval = binding_refresh_interval
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.tailwind = True
@@ -27,4 +31,4 @@ def run_with(
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))
     app.on_event('shutdown')(lambda: handle_shutdown())
 
-    app.mount('/', globals.app)
+    app.mount(mount_path, globals.app)

+ 18 - 0
nicegui/static/nicegui.css

@@ -36,6 +36,10 @@
   align-items: flex-start;
   gap: 1rem;
 }
+.nicegui-grid {
+  display: grid;
+  gap: 1rem;
+}
 .nicegui-card {
   display: flex;
   flex-direction: column;
@@ -61,12 +65,26 @@
   height: 16rem;
 }
 .nicegui-log {
+  padding: 0.25rem;
   border-width: 1px;
   white-space: pre;
   font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
   opacity: 1 !important;
   cursor: text !important;
 }
+.nicegui-markdown blockquote {
+  border-left: 0.25rem solid #8884;
+  padding: 1rem 1rem 0.5rem 1rem;
+  margin: 1rem 0;
+}
+.nicegui-markdown th {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
+.nicegui-markdown td {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
 
 #popup {
   position: fixed;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.ar-TN.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.ar.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.az-Latn.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.bg.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.bn.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.ca.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.cs.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.da.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.de.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.el.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.en-GB.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.en-US.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.eo.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.es.umd.prod.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
nicegui/static/quasar.et.umd.prod.js


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است