Forráskód Böngészése

Merge branch 'main' into feature/dependencies

# Conflicts:
#	nicegui/client.py
#	nicegui/dependencies.py
#	nicegui/elements/chart.py
#	nicegui/elements/chat_message.py
#	nicegui/elements/colors.py
#	nicegui/functions/refreshable.py
#	nicegui/templates/index.html
#	pyproject.toml
Dominique CLAUSE 2 éve
szülő
commit
f0a8b9093b
100 módosított fájl, 757 hozzáadás és 150 törlés
  1. 1 1
      .github/workflows/test.yml
  2. 4 4
      CITATION.cff
  3. 12 8
      README.md
  4. 1 1
      development.dockerfile
  5. 3 1
      examples/authentication/main.py
  6. 15 13
      examples/chat_app/main.py
  7. 57 0
      examples/chat_with_ai/main.py
  8. 3 0
      examples/chat_with_ai/requirements.txt
  9. 2 2
      examples/local_file_picker/local_file_picker.py
  10. 32 0
      examples/pandas_dataframe/main.py
  11. 1 0
      examples/sqlite_database/.gitignore
  12. 79 0
      examples/sqlite_database/main.py
  13. 17 0
      fetch_dependencies.py
  14. 6 4
      fetch_tailwind.py
  15. 1 1
      fly.dockerfile
  16. 39 27
      main.py
  17. 5 5
      nicegui/app.py
  18. 4 3
      nicegui/client.py
  19. 2 2
      nicegui/dependencies.py
  20. 15 1
      nicegui/element.py
  21. 0 1
      nicegui/elements/aggrid.py
  22. 3 2
      nicegui/elements/avatar.py
  23. 9 1
      nicegui/elements/button.py
  24. 0 1
      nicegui/elements/chart.py
  25. 1 18
      nicegui/elements/chat_message.js
  26. 14 2
      nicegui/elements/chat_message.py
  27. 1 1
      nicegui/elements/color_input.py
  28. 0 1
      nicegui/elements/colors.py
  29. 2 1
      nicegui/elements/input.py
  30. 0 1
      nicegui/elements/link.py
  31. 3 4
      nicegui/elements/log.py
  32. 1 1
      nicegui/elements/mixins/visibility.py
  33. 1 1
      nicegui/elements/number.py
  34. 3 3
      nicegui/elements/scene.py
  35. 1 1
      nicegui/elements/textarea.py
  36. 4 2
      nicegui/elements/upload.py
  37. 3 1
      nicegui/event_listener.py
  38. 17 17
      nicegui/events.py
  39. 19 14
      nicegui/functions/refreshable.py
  40. 2 0
      nicegui/globals.py
  41. 3 0
      nicegui/helpers.py
  42. 3 0
      nicegui/json/orjson_wrapper.py
  43. 66 0
      nicegui/language.py
  44. 3 1
      nicegui/native.py
  45. 18 1
      nicegui/nicegui.py
  46. 7 0
      nicegui/page.py
  47. 7 2
      nicegui/run.py
  48. 3 0
      nicegui/run_with.py
  49. 9 0
      nicegui/static/nicegui.css
  50. 5 0
      nicegui/static/quasar.ar-TN.umd.prod.js
  51. 5 0
      nicegui/static/quasar.ar.umd.prod.js
  52. 5 0
      nicegui/static/quasar.az-Latn.umd.prod.js
  53. 5 0
      nicegui/static/quasar.bg.umd.prod.js
  54. 5 0
      nicegui/static/quasar.bn.umd.prod.js
  55. 5 0
      nicegui/static/quasar.ca.umd.prod.js
  56. 5 0
      nicegui/static/quasar.cs.umd.prod.js
  57. 5 0
      nicegui/static/quasar.da.umd.prod.js
  58. 5 0
      nicegui/static/quasar.de.umd.prod.js
  59. 5 0
      nicegui/static/quasar.el.umd.prod.js
  60. 5 0
      nicegui/static/quasar.en-GB.umd.prod.js
  61. 5 0
      nicegui/static/quasar.en-US.umd.prod.js
  62. 5 0
      nicegui/static/quasar.eo.umd.prod.js
  63. 5 0
      nicegui/static/quasar.es.umd.prod.js
  64. 5 0
      nicegui/static/quasar.et.umd.prod.js
  65. 5 0
      nicegui/static/quasar.eu.umd.prod.js
  66. 5 0
      nicegui/static/quasar.fa-IR.umd.prod.js
  67. 5 0
      nicegui/static/quasar.fa.umd.prod.js
  68. 5 0
      nicegui/static/quasar.fi.umd.prod.js
  69. 5 0
      nicegui/static/quasar.fr.umd.prod.js
  70. 5 0
      nicegui/static/quasar.gn.umd.prod.js
  71. 5 0
      nicegui/static/quasar.he.umd.prod.js
  72. 5 0
      nicegui/static/quasar.hr.umd.prod.js
  73. 5 0
      nicegui/static/quasar.hu.umd.prod.js
  74. 5 0
      nicegui/static/quasar.id.umd.prod.js
  75. 5 0
      nicegui/static/quasar.is.umd.prod.js
  76. 5 0
      nicegui/static/quasar.it.umd.prod.js
  77. 5 0
      nicegui/static/quasar.ja.umd.prod.js
  78. 5 0
      nicegui/static/quasar.kk.umd.prod.js
  79. 5 0
      nicegui/static/quasar.km.umd.prod.js
  80. 5 0
      nicegui/static/quasar.ko-KR.umd.prod.js
  81. 5 0
      nicegui/static/quasar.kur-CKB.umd.prod.js
  82. 5 0
      nicegui/static/quasar.lt.umd.prod.js
  83. 5 0
      nicegui/static/quasar.lu.umd.prod.js
  84. 5 0
      nicegui/static/quasar.lv.umd.prod.js
  85. 5 0
      nicegui/static/quasar.ml.umd.prod.js
  86. 5 0
      nicegui/static/quasar.mm.umd.prod.js
  87. 5 0
      nicegui/static/quasar.ms.umd.prod.js
  88. 5 0
      nicegui/static/quasar.my.umd.prod.js
  89. 5 0
      nicegui/static/quasar.nb-NO.umd.prod.js
  90. 5 0
      nicegui/static/quasar.nl.umd.prod.js
  91. 5 0
      nicegui/static/quasar.pl.umd.prod.js
  92. 5 0
      nicegui/static/quasar.pt-BR.umd.prod.js
  93. 5 0
      nicegui/static/quasar.pt.umd.prod.js
  94. 5 0
      nicegui/static/quasar.ro.umd.prod.js
  95. 5 0
      nicegui/static/quasar.ru.umd.prod.js
  96. 5 0
      nicegui/static/quasar.sk.umd.prod.js
  97. 5 0
      nicegui/static/quasar.sl.umd.prod.js
  98. 5 0
      nicegui/static/quasar.sm.umd.prod.js
  99. 5 0
      nicegui/static/quasar.sr-CYR.umd.prod.js
  100. 5 0
      nicegui/static/quasar.sr.umd.prod.js

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

@@ -25,7 +25,7 @@ jobs:
           poetry config virtualenvs.create false
           poetry config virtualenvs.create false
           poetry install
           poetry install
           # install packages to run the examples
           # 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
           # try fix issue with importlib_resources
           pip install importlib-resources
           pip install importlib-resources
       - name: test startup
       - name: test startup

+ 4 - 4
CITATION.cff

@@ -7,8 +7,8 @@ authors:
 - family-names: Trappe
 - family-names: Trappe
   given-names: Rodja
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
   orcid: https://orcid.org/0009-0009-4735-6227
-title: 'NiceGUI: Web-based interfaces with Python. The nice way.'
-version: v1.2.10
-date-released: '2023-04-27'
+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
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.7871621
+doi: 10.5281/zenodo.7933863

+ 12 - 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.
 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.
 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 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 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 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
 ## Features
 
 
 - browser-based graphical user interface
 - browser-based graphical user interface
 - implicit reload on code change
 - 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, ...
 - standard GUI elements like label, button, checkbox, switch, slider, input, file upload, ...
 - simple grouping with rows, columns, cards and dialogs
 - simple grouping with rows, columns, cards and dialogs
 - general-purpose HTML and Markdown elements
 - general-purpose HTML and Markdown elements
@@ -37,7 +40,7 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
   - interact with tables
   - interact with tables
   - navigate foldable tree structures
   - navigate foldable tree structures
 - built-in timer to refresh data in intervals (even every 10 ms)
 - 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
 - notifications, dialogs and menus to provide state of the art user interaction
 - shared and individual web pages
 - shared and individual web pages
 - ability to add custom routes and data responses
 - ability to add custom routes and data responses
@@ -46,6 +49,7 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
 - live-cycle events and session data
 - live-cycle events and session data
 - runs in Jupyter Notebooks and allows Python's interactive mode
 - runs in Jupyter Notebooks and allows Python's interactive mode
 - auto-complete support for Tailwind CSS
 - auto-complete support for Tailwind CSS
+- SVG, Base64 and emoji favicon support
 
 
 ## Installation
 ## 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
 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.
 Here we just demonstrate the NiceGUI integration.
 '''
 '''
 
 
+import os
 import uuid
 import uuid
 from typing import Dict
 from typing import Dict
 
 
@@ -16,7 +17,8 @@ from starlette.middleware.sessions import SessionMiddleware
 
 
 from nicegui import app, ui
 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
 # in reality users and session_info would be persistent (e.g. database, file, ...) and passwords obviously hashed
 users = [('user1', 'pass1'), ('user2', 'pass2')]
 users = [('user1', 'pass1'), ('user2', 'pass2')]

+ 15 - 13
examples/chat_app/main.py

@@ -1,42 +1,44 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 from datetime import datetime
 from datetime import datetime
 from typing import List, Tuple
 from typing import List, Tuple
+from uuid import uuid4
 
 
 from nicegui import Client, ui
 from nicegui import Client, ui
 
 
-messages: List[Tuple[str, str]] = []
+messages: List[Tuple[str, str, str, str]] = []
 
 
 
 
 @ui.refreshable
 @ui.refreshable
-async def chat_messages(name_input: ui.input) -> None:
-    for name, text in messages:
-        ui.chat_message(text=text,
-                        name=name,
-                        stamp=datetime.utcnow().strftime('%X'),
-                        avatar=f'https://robohash.org/{name or "anonymous"}?bgset=bg2',
-                        sent=name == name_input.value)
+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)
     await ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)', respond=False)
 
 
 
 
 @ui.page('/')
 @ui.page('/')
 async def main(client: Client):
 async def main(client: Client):
     def send() -> None:
     def send() -> None:
-        messages.append((name.value, text.value))
+        stamp = datetime.utcnow().strftime('%X')
+        messages.append((user_id, avatar, text.value, stamp))
         text.value = ''
         text.value = ''
         chat_messages.refresh()
         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}'
     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>')
     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.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'):
         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)') \
         ui.markdown('simple chat app built with [NiceGUI](https://nicegui.io)') \
             .classes('text-xs self-end mr-8 m-[-1em] text-primary')
             .classes('text-xs self-end mr-8 m-[-1em] text-primary')
 
 
     await client.connected()  # chat_messages(...) uses run_javascript which is only possible 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'):
     with ui.column().classes('w-full max-w-2xl mx-auto items-stretch'):
-        await chat_messages(name_input=name)
+        await chat_messages(user_id)
 
 
 ui.run()
 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 pathlib import Path
-from typing import Optional
+from typing import Dict, Optional
 
 
 from nicegui import ui
 from nicegui import ui
 
 
@@ -58,7 +58,7 @@ class local_file_picker(ui.dialog):
             })
             })
         self.grid.update()
         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'])
         self.path = Path(msg['args']['data']['path'])
         if self.path.is_dir():
         if self.path.is_dir():
             self.update_grid()
             self.update_grid()

+ 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()

+ 17 - 0
fetch_dependencies.py

@@ -56,6 +56,23 @@ css = request_buffered_str(url)
 Path('nicegui/static/quasar.prod.css').write_text(css)
 Path('nicegui/static/quasar.prod.css').write_text(css)
 print('Quasar:', version)
 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
 # vue.js
 url = 'https://unpkg.com/vue@3/anything'
 url = 'https://unpkg.com/vue@3/anything'
 info = request_buffered_str(url)
 info = request_buffered_str(url)

+ 6 - 4
fetch_tailwind.py

@@ -2,17 +2,19 @@
 import re
 import re
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from pathlib import Path
 from pathlib import Path
+from typing import List
 
 
 import requests
 import requests
 from bs4 import BeautifulSoup
 from bs4 import BeautifulSoup
+from secure import SecurePath
 
 
 
 
 @dataclass
 @dataclass
 class Property:
 class Property:
     title: str
     title: str
     description: 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)
     common_prefix: str = field(init=False)
 
 
     def __post_init__(self) -> None:
     def __post_init__(self) -> None:
@@ -47,7 +49,7 @@ class Property:
         return '_'.join(word.lower() for word in re.sub(r'[-/ &]', ' ', self.title).split())
         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:
 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:
 for property in properties:
     if not property.members:
     if not property.members:
         continue
         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('from typing_extensions import Literal\n')
         f.write('\n')
         f.write('\n')
         f.write(f'{property.pascal_title} = Literal[\n')
         f.write(f'{property.pascal_title} = Literal[\n')

+ 1 - 1
fly.dockerfile

@@ -1,4 +1,4 @@
-FROM python:3.11-slim
+FROM python:3.11.3-slim
 
 
 LABEL maintainer="Zauberzeug GmbH <nicegui@zauberzeug.com>"
 LABEL maintainer="Zauberzeug GmbH <nicegui@zauberzeug.com>"
 
 

+ 39 - 27
main.py

@@ -29,7 +29,7 @@ from website.style import example_link, features, heading, link_target, section_
 prometheus.start_monitor(app)
 prometheus.start_monitor(app)
 
 
 # session middleware is required for demo in documentation and prometheus
 # 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('/favicon', str(Path(__file__).parent / 'website' / 'favicon'))
 app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
 app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
@@ -75,19 +75,20 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
         if menu:
         if menu:
-            ui.button(on_click=menu.toggle).props('flat color=white icon=menu round') \
-                .classes('max-[405px]:hidden lg:hidden')
+            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'):
         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.face().classes('w-8 stroke-white stroke-2')
             svg.word().classes('w-24')
             svg.word().classes('w-24')
         with ui.row().classes('max-lg:hidden'):
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
             for title, target in menu_items.items():
                 ui.link(title, target).classes(replace='text-lg text-white')
                 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'):
             svg.discord().classes('fill-white scale-125 m-1')
             svg.discord().classes('fill-white scale-125 m-1')
+        with ui.link(target='https://www.reddit.com/r/nicegui/').classes('max-[385px]:hidden'):
+            svg.reddit().classes('fill-white scale-125 m-1')
         with ui.link(target='https://github.com/zauberzeug/nicegui/'):
         with ui.link(target='https://github.com/zauberzeug/nicegui/'):
             svg.github().classes('fill-white scale-125 m-1')
             svg.github().classes('fill-white scale-125 m-1')
-        add_star().classes('max-[460px]:hidden')
+        add_star().classes('max-[480px]:hidden')
         with ui.row().classes('lg:hidden'):
         with ui.row().classes('lg:hidden'):
             with ui.button().props('flat color=white icon=more_vert round'):
             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'):
                 with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):
@@ -119,17 +120,21 @@ async def index_page(client: Client):
         with ui.column().classes('text-white max-w-4xl'):
         with ui.column().classes('text-white max-w-4xl'):
             heading('Interact with Python through buttons, dialogs, 3D&nbsp;scenes, plots and much more.')
             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'):
             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()
         example_card.create()
 
 
     with ui.column().classes('w-full text-lg p-8 lg:p-16 max-w-[1600px] mx-auto'):
     with ui.column().classes('w-full text-lg p-8 lg:p-16 max-w-[1600px] mx-auto'):
@@ -187,12 +192,12 @@ async def index_page(client: Client):
             features('swap_horiz', 'Interaction', [
             features('swap_horiz', 'Interaction', [
                 'buttons, switches, sliders, inputs, ...',
                 'buttons, switches, sliders, inputs, ...',
                 'notifications, dialogs and menus',
                 'notifications, dialogs and menus',
-                'keyboard input',
-                'on-screen joystick',
+                'interactive images with SVG overlays',
+                'web pages and native window apps',
             ])
             ])
             features('space_dashboard', 'Layout', [
             features('space_dashboard', 'Layout', [
                 'navigation bars, tabs, panels, ...',
                 'navigation bars, tabs, panels, ...',
-                'grouping with rows, columns and cards',
+                'grouping with rows, columns, grids and cards',
                 'HTML and Markdown elements',
                 'HTML and Markdown elements',
                 'flex layout by default',
                 'flex layout by default',
             ])
             ])
@@ -209,8 +214,8 @@ async def index_page(client: Client):
                 '[Tailwind CSS](https://tailwindcss.com/) auto-completion',
                 '[Tailwind CSS](https://tailwindcss.com/) auto-completion',
             ])
             ])
             features('source', 'Coding', [
             features('source', 'Coding', [
-                'live-cycle events',
-                'implicit reload on code change',
+                'routing for multiple pages',
+                'auto-reload on code change',
                 'straight-forward data binding',
                 'straight-forward data binding',
                 'Jupyter notebook compatibility',
                 'Jupyter notebook compatibility',
             ])
             ])
@@ -245,8 +250,7 @@ async def index_page(client: Client):
             example_link('Authentication', 'shows how to use sessions to build a login screen')
             example_link('Authentication', 'shows how to use sessions to build a login screen')
             example_link('Modularization',
             example_link('Modularization',
                          'provides an example of how to modularize your application into multiple files and reuse code')
                          '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('FastAPI', 'illustrates the integration of NiceGUI with an existing FastAPI application')
             example_link('Map',
             example_link('Map',
                          'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) '
                          'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) '
                          'to display a map at specific locations')
                          'to display a map at specific locations')
@@ -270,6 +274,9 @@ async def index_page(client: Client):
             example_link('Table and slots', 'shows how to use component slots in a table')
             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('Single Page App', 'navigate without reloading the page')
             example_link('Chat App', 'a simple chat app')
             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'):
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')
         link_target('why')
@@ -325,13 +332,17 @@ def documentation_page():
 
 
 
 
 @ui.page('/documentation/{name}')
 @ui.page('/documentation/{name}')
-def documentation_page_more(name: str):
+async def documentation_page_more(name: str, client: Client):
     if not hasattr(ui, name):
     if not hasattr(ui, name):
         name = name.replace('_', '')  # NOTE: "AG Grid" leads to anchor name "ag_grid", but class is `ui.aggrid`
         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')
     module = importlib.import_module(f'website.more_documentation.{name}_documentation')
-    api = getattr(ui, name)
     more = getattr(module, 'more', None)
     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_head_html()
     add_header()
     add_header()
@@ -349,6 +360,7 @@ def documentation_page_more(name: str):
                 ui.markdown('**Reference**').classes('mt-4')
                 ui.markdown('**Reference**').classes('mt-4')
             ui.markdown('## Reference').classes('mt-16')
             ui.markdown('## Reference').classes('mt-16')
             generate_class_doc(api)
             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')
 ui.run(uvicorn_reload_includes='*.py, *.css, *.html')

+ 5 - 5
nicegui/app.py

@@ -60,7 +60,7 @@ class App(FastAPI):
             raise Exception('calling shutdown() is not supported when auto-reload is enabled')
             raise Exception('calling shutdown() is not supported when auto-reload is enabled')
         globals.server.should_exit = True
         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.
 
 
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
@@ -68,12 +68,12 @@ class App(FastAPI):
         Otherwise the browser would not be able to access the files.
         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.
         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
         """
         """
-        if path == '/':
+        if url_path == '/':
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
-        globals.app.mount(path, StaticFiles(directory=directory))
+        globals.app.mount(url_path, StaticFiles(directory=local_directory))
 
 
     def remove_route(self, path: str) -> None:
     def remove_route(self, path: str) -> None:
         """Remove routes with the given path."""
         """Remove routes with the given path."""

+ 4 - 3
nicegui/client.py

@@ -53,7 +53,7 @@ class Client:
     @property
     @property
     def ip(self) -> Optional[str]:
     def ip(self) -> Optional[str]:
         """Return the IP address of the client, or None if the client is not connected."""
         """Return the IP address of the client, or None if the client is not connected."""
-        return self.environ.get('REMOTE_ADDR') if self.environ else None
+        return self.environ['asgi.scope']['client'][0] if self.environ else None
 
 
     @property
     @property
     def has_socket_connection(self) -> bool:
     def has_socket_connection(self) -> bool:
@@ -68,7 +68,7 @@ class Client:
         self.content.__exit__()
         self.content.__exit__()
 
 
     def build_response(self, request: Request, status_code: int = 200) -> Response:
     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', ''))
         elements = json.dumps({id: element._to_dict() for id, element in self.elements.items()})
         elements = json.dumps({id: element._to_dict() for id, element in self.elements.items()})
         vue_html, vue_styles, vue_scripts, import_maps, js_imports = generate_resources(prefix, self.elements.values())
         vue_html, vue_styles, vue_scripts, import_maps, js_imports = generate_resources(prefix, self.elements.values())
         return templates.TemplateResponse('index.html', {
         return templates.TemplateResponse('index.html', {
@@ -85,6 +85,7 @@ class Client:
             'viewport': self.page.resolve_viewport(),
             'viewport': self.page.resolve_viewport(),
             'favicon_url': get_favicon_url(self.page, prefix),
             'favicon_url': get_favicon_url(self.page, prefix),
             'dark': str(self.page.resolve_dark()),
             'dark': str(self.page.resolve_dark()),
+            'language': self.page.resolve_language(),
             'prefix': prefix,
             'prefix': prefix,
             'tailwind': globals.tailwind,
             'tailwind': globals.tailwind,
             'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
             'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
@@ -112,7 +113,7 @@ class Client:
         """Execute JavaScript on the client.
         """Execute JavaScript on the client.
 
 
         The client connection must be established before this method is called.
         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(...)`.
+        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.
         If respond is True, the javascript code must return a string.
         """
         """
         request_id = str(uuid.uuid4())
         request_id = str(uuid.uuid4())

+ 2 - 2
nicegui/dependencies.py

@@ -5,11 +5,11 @@ from typing import Any, Dict, List, Set, Tuple
 
 
 import vbuild
 import vbuild
 
 
-from . import __version__
 from .element import Element
 from .element import Element
+from . import __version__
 
 
 
 
-class Legacy():
+class Legacy:
     """ @todo remove when register_component is removed. """
     """ @todo remove when register_component is removed. """
     components: List[str] = []
     components: List[str] = []
     libraries: List[str] = []
     libraries: List[str] = []

+ 15 - 1
nicegui/element.py

@@ -39,7 +39,7 @@ class Element(Visibility):
         self.tag = tag
         self.tag = tag
         self._classes: List[str] = []
         self._classes: List[str] = []
         self._style: Dict[str, 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._event_listeners: Dict[str, EventListener] = {}
         self._text: Optional[str] = None
         self._text: Optional[str] = None
         self.components: List[str] = []
         self.components: List[str] = []
@@ -270,6 +270,20 @@ class Element(Visibility):
             slot.children.clear()
             slot.children.clear()
         self.update()
         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:
     def remove(self, element: Union[Element, int]) -> None:
         """Remove a child element.
         """Remove a child element.
 
 

+ 0 - 1
nicegui/elements/aggrid.py

@@ -27,7 +27,6 @@ class AgGrid(Element):
         super().__init__('aggrid')
         super().__init__('aggrid')
         self._props['options'] = options
         self._props['options'] = options
         self._props['html_columns'] = html_columns
         self._props['html_columns'] = html_columns
-        self._props['key'] = self.id  # HACK: workaround for #600
         self._classes = ['nicegui-aggrid', f'ag-theme-{theme}']
         self._classes = ['nicegui-aggrid', f'ag-theme-{theme}']
         self.use_component('aggrid').use_library('aggrid')
         self.use_component('aggrid').use_library('aggrid')
 
 

+ 3 - 2
nicegui/elements/avatar.py

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

+ 9 - 1
nicegui/elements/button.py

@@ -1,3 +1,4 @@
+import asyncio
 from typing import Callable, Optional
 from typing import Callable, Optional
 
 
 from ..colors import set_background_color
 from ..colors import set_background_color
@@ -17,7 +18,7 @@ class Button(TextElement, DisableableElement):
 
 
         This element is based on Quasar's `QBtn <https://quasar.dev/vue-components/button>`_ component.
         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.
         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.
         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.
         In such cases the Quasar color will be used.
@@ -34,3 +35,10 @@ class Button(TextElement, DisableableElement):
 
 
     def _text_to_model_text(self, text: str) -> None:
     def _text_to_model_text(self, text: str) -> None:
         self._props['label'] = text
         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()

+ 0 - 1
nicegui/elements/chart.py

@@ -113,7 +113,6 @@ class Chart(Element):
         self.use_library('highcharts-3d')
         self.use_library('highcharts-3d')
         for extra in extras:
         for extra in extras:
             self.use_library(extra)
             self.use_library(extra)
-        self._props['key'] = self.id  # HACK: workaround for #600
 
 
     @property
     @property
     def options(self) -> Dict:
     def options(self) -> Dict:

+ 1 - 18
nicegui/elements/chat_message.js

@@ -1,20 +1,3 @@
 export default {
 export default {
-  template: `
-    <q-chat-message
-      :text="[text]"
-      :name="name"
-      :label="label"
-      :stamp="stamp"
-      :avatar="avatar"
-      :sent=sent
-    />
-  `,
-  props: {
-    text: String,
-    name: String,
-    label: String,
-    stamp: String,
-    avatar: String,
-    sent: Boolean,
-  },
+  template: `<q-chat-message v-bind="$attrs" />`,
 };
 };

+ 14 - 2
nicegui/elements/chat_message.py

@@ -1,5 +1,7 @@
 from pathlib import Path
 from pathlib import Path
 from typing import Optional
 from typing import Optional
+import html
+from typing import List, Optional, Union
 
 
 from ..dependencies import register_vue_component
 from ..dependencies import register_vue_component
 from ..element import Element
 from ..element import Element
@@ -10,26 +12,36 @@ register_vue_component(name='chat_message', path=Path(__file__).parent.joinpath(
 class ChatMessage(Element):
 class ChatMessage(Element):
 
 
     def __init__(self,
     def __init__(self,
-                 text: str, *,
+                 text: Union[str, List[str]], *,
                  name: Optional[str] = None,
                  name: Optional[str] = None,
                  label: Optional[str] = None,
                  label: Optional[str] = None,
                  stamp: Optional[str] = None,
                  stamp: Optional[str] = None,
                  avatar: Optional[str] = None,
                  avatar: Optional[str] = None,
                  sent: bool = False,
                  sent: bool = False,
+                 text_html: bool = False,
                  ) -> None:
                  ) -> None:
         """Chat Message
         """Chat Message
 
 
         Based on Quasar's `Chat Message <https://quasar.dev/vue-components/chat/>`_ component.
         Based on Quasar's `Chat Message <https://quasar.dev/vue-components/chat/>`_ component.
 
 
-        :param text: the message body
+        :param text: the message body (can be a list of strings for multiple message parts)
         :param name: the name of the message author
         :param name: the name of the message author
         :param label: renders a label header/section only
         :param label: renders a label header/section only
         :param stamp: timestamp of the message
         :param stamp: timestamp of the message
         :param avatar: URL to an avatar
         :param avatar: URL to an avatar
         :param sent: render as a sent message (so from current user) (default: False)
         :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')
         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'] = text
+        self._props['text-html'] = True
+
         if name is not None:
         if name is not None:
             self._props['name'] = name
             self._props['name'] = name
         if label is not None:
         if label is not None:

+ 1 - 1
nicegui/elements/color_input.py

@@ -17,7 +17,7 @@ class ColorInput(ValueElement, DisableableElement):
         :param label: displayed label for the color input
         :param label: displayed label for the color input
         :param placeholder: text to show if no color is selected
         :param placeholder: text to show if no color is selected
         :param value: the current color value
         :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)
         super().__init__(tag='q-input', value=value, on_value_change=on_change)
         if label is not None:
         if label is not None:

+ 0 - 1
nicegui/elements/colors.py

@@ -30,6 +30,5 @@ class Colors(Element):
         self._props['negative'] = negative
         self._props['negative'] = negative
         self._props['info'] = info
         self._props['info'] = info
         self._props['warning'] = warning
         self._props['warning'] = warning
-        self._props['key'] = self.id  # HACK: workaround for #600
         self.use_component('colors')
         self.use_component('colors')
         self.update()
         self.update()

+ 2 - 1
nicegui/elements/input.py

@@ -57,8 +57,9 @@ class Input(ValueElement, DisableableElement):
         if autocomplete:
         if autocomplete:
             def find_autocompletion() -> Optional[str]:
             def find_autocompletion() -> Optional[str]:
                 if self.value:
                 if self.value:
+                    needle = str(self.value).casefold()
                     for item in autocomplete:
                     for item in autocomplete:
-                        if item.startswith(self.value):
+                        if item.casefold().startswith(needle):
                             return item
                             return item
 
 
             def autocomplete_input() -> None:
             def autocomplete_input() -> None:

+ 0 - 1
nicegui/elements/link.py

@@ -26,7 +26,6 @@ class Link(TextElement):
         super().__init__(tag='link', text=text)
         super().__init__(tag='link', text=text)
         self._props['href'] = target if isinstance(target, str) else globals.page_routes[target]
         self._props['href'] = target if isinstance(target, str) else globals.page_routes[target]
         self._props['target'] = '_blank' if new_tab else '_self'
         self._props['target'] = '_blank' if new_tab else '_self'
-        self._props['key'] = self.id  # HACK: workaround for #600
         self._classes = ['nicegui-link']
         self._classes = ['nicegui-link']
         self.use_component('link')
         self.use_component('link')
 
 

+ 3 - 4
nicegui/elements/log.py

@@ -1,3 +1,4 @@
+import urllib.parse
 from collections import deque
 from collections import deque
 from pathlib import Path
 from pathlib import Path
 from typing import Any, Optional
 from typing import Any, Optional
@@ -20,16 +21,14 @@ class Log(Element):
         super().__init__('log')
         super().__init__('log')
         self._props['max_lines'] = max_lines
         self._props['max_lines'] = max_lines
         self._props['lines'] = ''
         self._props['lines'] = ''
-        self._props['key'] = self.id  # HACK: workaround for #600
         self._classes = ['nicegui-log']
         self._classes = ['nicegui-log']
         self.lines: deque[str] = deque(maxlen=max_lines)
         self.lines: deque[str] = deque(maxlen=max_lines)
         self.use_component('log')
         self.use_component('log')
 
 
     def push(self, line: Any) -> None:
     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._props['lines'] = '\n'.join(self.lines)
-        self.run_method('push', line)
+        self.run_method('push', urllib.parse.quote(str(line)))
 
 
     def clear(self) -> None:
     def clear(self) -> None:
         """Clear the log"""
         """Clear the log"""

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

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

+ 1 - 1
nicegui/elements/number.py

@@ -35,7 +35,7 @@ class Number(ValueElement, DisableableElement):
         :param prefix: a prefix to prepend to the displayed value
         :param prefix: a prefix to prepend to the displayed value
         :param suffix: a suffix to append 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 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}``
         :param validation: dictionary of validation rules, e.g. ``{'Too small!': lambda value: value < 3}``
         """
         """
         self.format = format
         self.format = format

+ 3 - 3
nicegui/elements/scene.py

@@ -6,6 +6,7 @@ from .. import binding, globals
 from ..dependencies import register_library, register_vue_component
 from ..dependencies import register_library, register_vue_component
 from ..element import Element
 from ..element import Element
 from ..events import SceneClickEventArguments, SceneClickHit, handle_event
 from ..events import SceneClickEventArguments, SceneClickHit, handle_event
+from ..helpers import KWONLY_SLOTS
 from .scene_object3d import Object3D
 from .scene_object3d import Object3D
 from .scene_objects import Scene as SceneObject
 from .scene_objects import Scene as SceneObject
 
 
@@ -24,7 +25,7 @@ register_library(name='tween',
                  path=Path(__file__).parent.joinpath('lib', 'tween', 'tween.umd.js'))
                  path=Path(__file__).parent.joinpath('lib', 'tween', 'tween.umd.js'))
 
 
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class SceneCamera:
 class SceneCamera:
     x: float = 0
     x: float = 0
     y: float = -3
     y: float = -3
@@ -37,7 +38,7 @@ class SceneCamera:
     up_z: float = 1
     up_z: float = 1
 
 
 
 
-@dataclass
+@dataclass(**KWONLY_SLOTS)
 class SceneObject:
 class SceneObject:
     id: str = 'scene'
     id: str = 'scene'
 
 
@@ -80,7 +81,6 @@ class Scene(Element):
         self._props['width'] = width
         self._props['width'] = width
         self._props['height'] = height
         self._props['height'] = height
         self._props['grid'] = grid
         self._props['grid'] = grid
-        self._props['key'] = self.id  # HACK: workaround for #600
         self.objects: Dict[str, Object3D] = {}
         self.objects: Dict[str, Object3D] = {}
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self.camera: SceneCamera = SceneCamera()
         self.camera: SceneCamera = SceneCamera()

+ 1 - 1
nicegui/elements/textarea.py

@@ -19,7 +19,7 @@ class Textarea(Input):
         :param label: displayed name for the textarea
         :param label: displayed name for the textarea
         :param placeholder: text to show if no value is entered
         :param placeholder: text to show if no value is entered
         :param value: the initial value of the field
         :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}``
         :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)
         super().__init__(label, placeholder=placeholder, value=value, on_change=on_change, validation=validation)

+ 4 - 2
nicegui/elements/upload.py

@@ -1,13 +1,14 @@
+from pathlib import Path
 from typing import Callable, Optional
 from typing import Callable, Optional
 
 
 from fastapi import Request, Response
 from fastapi import Request, Response
 
 
-from ..dependencies import register_component
+from ..dependencies import register_vue_component
 from ..events import EventArguments, UploadEventArguments, handle_event
 from ..events import EventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
 from ..nicegui import app
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 
 
-register_component('upload', __file__, 'upload.js')
+register_vue_component(name='upload', path=Path(__file__).parent.joinpath('upload.js'))
 
 
 
 
 class Upload(DisableableElement):
 class Upload(DisableableElement):
@@ -40,6 +41,7 @@ class Upload(DisableableElement):
         self._props['label'] = label
         self._props['label'] = label
         self._props['auto-upload'] = auto_upload
         self._props['auto-upload'] = auto_upload
         self._props['url'] = f'/_nicegui/client/{self.client.id}/upload/{self.id}'
         self._props['url'] = f'/_nicegui/client/{self.client.id}/upload/{self.id}'
+        self.use_component('upload')
 
 
         if max_file_size is not None:
         if max_file_size is not None:
             self._props['max-file-size'] = max_file_size
             self._props['max-file-size'] = max_file_size

+ 3 - 1
nicegui/event_listener.py

@@ -2,8 +2,10 @@ import uuid
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from typing import Any, Callable, Dict, List
 from typing import Any, Callable, Dict, List
 
 
+from .helpers import KWONLY_SLOTS
 
 
-@dataclass
+
+@dataclass(**KWONLY_SLOTS)
 class EventListener:
 class EventListener:
     id: str = field(init=False)
     id: str = field(init=False)
     element_id: int
     element_id: int

+ 17 - 17
nicegui/events.py

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

+ 19 - 14
nicegui/functions/refreshable.py

@@ -38,23 +38,12 @@ class refreshable:
         self.prune()
         self.prune()
         for container, args, kwargs in self.containers:
         for container, args, kwargs in self.containers:
             container.clear()
             container.clear()
+            result = self._run_in_container(container, *args, **kwargs)
             if is_coroutine(self.func):
             if is_coroutine(self.func):
-                async def wait_for_result(container: Element, args, kwargs):
-                    with container:
-                        if self.instance is None:
-                            await self.func(*args, **kwargs)
-                        else:
-                            await self.func(self.instance, *args, **kwargs)
                 if globals.loop and globals.loop.is_running():
                 if globals.loop and globals.loop.is_running():
-                    background_tasks.create(wait_for_result(container=container, args=args, kwargs=kwargs))
+                    background_tasks.create(result)
                 else:
                 else:
-                    globals.app.on_startup(wait_for_result(container=container, args=args, kwargs=kwargs))
-            else:
-                with container:
-                    if self.instance is None:
-                        self.func(*args, **kwargs)
-                    else:
-                        self.func(self.instance, *args, **kwargs)
+                    globals.app.on_startup(result)
 
 
     def prune(self) -> None:
     def prune(self) -> None:
         self.containers = [
         self.containers = [
@@ -62,3 +51,19 @@ class refreshable:
             for container, args, kwargs in self.containers
             for container, args, kwargs in self.containers
             if container.client.id in globals.clients
             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 . import background_tasks
 from .app import App
 from .app import App
+from .language import Language
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
@@ -36,6 +37,7 @@ title: str
 viewport: str
 viewport: str
 favicon: Optional[str]
 favicon: Optional[str]
 dark: Optional[bool]
 dark: Optional[bool]
+language: Language
 binding_refresh_interval: float
 binding_refresh_interval: float
 excludes: List[str]
 excludes: List[str]
 tailwind: bool
 tailwind: bool

+ 3 - 0
nicegui/helpers.py

@@ -2,6 +2,7 @@ import asyncio
 import functools
 import functools
 import inspect
 import inspect
 import socket
 import socket
+import sys
 import threading
 import threading
 import time
 import time
 import webbrowser
 import webbrowser
@@ -13,6 +14,8 @@ from . import background_tasks, globals
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
 
 
+KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}
+
 
 
 def is_coroutine(object: Any) -> bool:
 def is_coroutine(object: Any) -> bool:
     while isinstance(object, functools.partial):
     while isinstance(object, functools.partial):

+ 3 - 0
nicegui/json/orjson_wrapper.py

@@ -1,3 +1,4 @@
+from decimal import Decimal
 from typing import Any, Optional, Tuple
 from typing import Any, Optional, Tuple
 
 
 import numpy as np
 import numpy as np
@@ -42,6 +43,8 @@ def _orjson_converter(obj):
     """Custom serializer/converter, e.g. for numpy object arrays."""
     """Custom serializer/converter, e.g. for numpy object arrays."""
     if isinstance(obj, np.ndarray) and obj.dtype == np.object_:
     if isinstance(obj, np.ndarray) and obj.dtype == np.object_:
         return obj.tolist()
         return obj.tolist()
+    if isinstance(obj, Decimal):
+        return float(obj)
 
 
 
 
 class NiceGUIJSONResponse(Response):
 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 dataclasses import dataclass, field
 from typing import Any, Dict
 from typing import Any, Dict
 
 
+from .helpers import KWONLY_SLOTS
 
 
-@dataclass
+
+@dataclass(**KWONLY_SLOTS)
 class Native:
 class Native:
     start_args: Dict[str, Any] = field(default_factory=dict)
     start_args: Dict[str, Any] = field(default_factory=dict)
     window_args: Dict[str, Any] = field(default_factory=dict)
     window_args: Dict[str, Any] = field(default_factory=dict)

+ 18 - 1
nicegui/nicegui.py

@@ -1,5 +1,6 @@
 import asyncio
 import asyncio
 import os
 import os
+import socket
 import time
 import time
 import urllib.parse
 import urllib.parse
 from pathlib import Path
 from pathlib import Path
@@ -81,7 +82,23 @@ def handle_startup(with_welcome_message: bool = True) -> None:
     background_tasks.create(prune_slot_stacks())
     background_tasks.create(prune_slot_stacks())
     globals.state = globals.State.STARTED
     globals.state = globals.State.STARTED
     if with_welcome_message:
     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')
 @app.on_event('shutdown')

+ 7 - 0
nicegui/page.py

@@ -8,6 +8,7 @@ from fastapi import Request, Response
 from . import background_tasks, globals
 from . import background_tasks, globals
 from .client import Client
 from .client import Client
 from .favicon import create_favicon_route
 from .favicon import create_favicon_route
+from .language import Language
 
 
 
 
 class page:
 class page:
@@ -18,6 +19,7 @@ class page:
                  viewport: Optional[str] = None,
                  viewport: Optional[str] = None,
                  favicon: Optional[str] = None,
                  favicon: Optional[str] = None,
                  dark: Optional[bool] = ...,
                  dark: Optional[bool] = ...,
+                 language: Language = ...,
                  response_timeout: float = 3.0,
                  response_timeout: float = 3.0,
                  **kwargs,
                  **kwargs,
                  ) -> None:
                  ) -> None:
@@ -33,6 +35,7 @@ class page:
         :param viewport: optional viewport meta tag content
         :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 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 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 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
         :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
         """
         """
@@ -41,6 +44,7 @@ class page:
         self.viewport = viewport
         self.viewport = viewport
         self.favicon = favicon
         self.favicon = favicon
         self.dark = dark
         self.dark = dark
+        self.language = language
         self.response_timeout = response_timeout
         self.response_timeout = response_timeout
         self.kwargs = kwargs
         self.kwargs = kwargs
 
 
@@ -55,6 +59,9 @@ class page:
     def resolve_dark(self) -> Optional[bool]:
     def resolve_dark(self) -> Optional[bool]:
         return self.dark if self.dark is not ... else globals.dark
         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:
     def __call__(self, func: Callable) -> Callable:
         globals.app.remove_route(self.path)  # NOTE make sure only the latest route definition is used
         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())
         parameters_of_decorated_func = list(inspect.signature(func).parameters.keys())

+ 7 - 2
nicegui/run.py

@@ -10,6 +10,7 @@ from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
 from uvicorn.supervisors import ChangeReload, Multiprocess
 
 
 from . import globals, helpers, native_mode
 from . import globals, helpers, native_mode
+from .language import Language
 
 
 
 
 def run(*,
 def run(*,
@@ -19,6 +20,7 @@ def run(*,
         viewport: str = 'width=device-width, initial-scale=1',
         viewport: str = 'width=device-width, initial-scale=1',
         favicon: Optional[str] = None,
         favicon: Optional[str] = None,
         dark: Optional[bool] = False,
         dark: Optional[bool] = False,
+        language: Language = 'en-US',
         binding_refresh_interval: float = 0.1,
         binding_refresh_interval: float = 0.1,
         show: bool = True,
         show: bool = True,
         native: bool = False,
         native: bool = False,
@@ -43,6 +45,7 @@ def run(*,
     :param viewport: page meta viewport content (default: `'width=device-width, initial-scale=1'`, 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, absolute URL to a favicon (default: `None`, NiceGUI icon will be used) or emoji (e.g. `'🚀'`, works for most browsers)
     :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 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 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 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)
     :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.viewport = viewport
     globals.favicon = favicon
     globals.favicon = favicon
     globals.dark = dark
     globals.dark = dark
+    globals.language = language
     globals.binding_refresh_interval = binding_refresh_interval
     globals.binding_refresh_interval = binding_refresh_interval
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.tailwind = tailwind
     globals.tailwind = tailwind
@@ -88,8 +92,9 @@ def run(*,
     else:
     else:
         host = host or '0.0.0.0'
         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:
     if show:
         helpers.schedule_browser(host, port)
         helpers.schedule_browser(host, port)

+ 3 - 0
nicegui/run_with.py

@@ -3,6 +3,7 @@ from typing import Optional
 from fastapi import FastAPI
 from fastapi import FastAPI
 
 
 from nicegui import globals
 from nicegui import globals
+from nicegui.language import Language
 from nicegui.nicegui import handle_shutdown, handle_startup
 from nicegui.nicegui import handle_shutdown, handle_startup
 
 
 
 
@@ -12,6 +13,7 @@ def run_with(
     viewport: str = 'width=device-width, initial-scale=1',
     viewport: str = 'width=device-width, initial-scale=1',
     favicon: Optional[str] = None,
     favicon: Optional[str] = None,
     dark: Optional[bool] = False,
     dark: Optional[bool] = False,
+    language: Language = 'en-US',
     binding_refresh_interval: float = 0.1,
     binding_refresh_interval: float = 0.1,
     exclude: str = '',
     exclude: str = '',
     mount_path: str = '/',
     mount_path: str = '/',
@@ -21,6 +23,7 @@ def run_with(
     globals.viewport = viewport
     globals.viewport = viewport
     globals.favicon = favicon
     globals.favicon = favicon
     globals.dark = dark
     globals.dark = dark
+    globals.language = language
     globals.binding_refresh_interval = binding_refresh_interval
     globals.binding_refresh_interval = binding_refresh_interval
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.tailwind = True
     globals.tailwind = True

+ 9 - 0
nicegui/static/nicegui.css

@@ -65,6 +65,7 @@
   height: 16rem;
   height: 16rem;
 }
 }
 .nicegui-log {
 .nicegui-log {
+  padding: 0.25rem;
   border-width: 1px;
   border-width: 1px;
   white-space: pre;
   white-space: pre;
   font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
   font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
@@ -76,6 +77,14 @@
   padding: 1rem 1rem 0.5rem 1rem;
   padding: 1rem 1rem 0.5rem 1rem;
   margin: 1rem 0;
   margin: 1rem 0;
 }
 }
+.nicegui-markdown th {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
+.nicegui-markdown td {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
 
 
 #popup {
 #popup {
   position: fixed;
   position: fixed;

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.ar-TN.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.ar.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.az-Latn.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.bg.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.bn.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.ca.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.cs.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.da.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.de.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.el.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.en-GB.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.en-US.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.eo.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.es.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.et.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.eu.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.fa-IR.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.fa.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.fi.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.fr.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.gn.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.he.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.hr.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.hu.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.id.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.is.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.it.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.ja.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.kk.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.km.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.ko-KR.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.kur-CKB.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.lt.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.lu.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.lv.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.ml.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.mm.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.ms.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.my.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.nb-NO.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.nl.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.pl.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.pt-BR.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.pt.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.ro.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.ru.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.sk.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.sl.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.sm.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.sr-CYR.umd.prod.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
nicegui/static/quasar.sr.umd.prod.js


Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott