瀏覽代碼

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 年之前
父節點
當前提交
f0a8b9093b
共有 100 個文件被更改,包括 757 次插入150 次删除
  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 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.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
-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.
 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,7 +40,7 @@ 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
@@ -46,6 +49,7 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
 - 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')]

+ 15 - 13
examples/chat_app/main.py

@@ -1,42 +1,44 @@
 #!/usr/bin/env python3
 from datetime import datetime
 from typing import List, Tuple
+from uuid import uuid4
 
 from nicegui import Client, ui
 
-messages: List[Tuple[str, str]] = []
+messages: List[Tuple[str, str, str, str]] = []
 
 
 @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)
 
 
 @ui.page('/')
 async def main(client: Client):
     def send() -> None:
-        messages.append((name.value, text.value))
+        stamp = datetime.utcnow().strftime('%X')
+        messages.append((user_id, avatar, text.value, stamp))
         text.value = ''
         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()  # 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(name_input=name)
+        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()

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

+ 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>"
 

+ 39 - 27
main.py

@@ -29,7 +29,7 @@ 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'))
@@ -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') \
             .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('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'):
             svg.face().classes('w-8 stroke-white stroke-2')
             svg.word().classes('w-24')
         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'):
             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/'):
             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.button().props('flat color=white icon=more_vert round'):
                 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'):
             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'):
@@ -187,12 +192,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',
             ])
@@ -209,8 +214,8 @@ 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',
                 '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('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('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')
@@ -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('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')
@@ -325,13 +332,17 @@ def documentation_page():
 
 
 @ui.page('/documentation/{name}')
-def documentation_page_more(name: str):
+async def documentation_page_more(name: str, client: Client):
     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()
@@ -349,6 +360,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')

+ 5 - 5
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,12 +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
         """
-        if path == '/':
+        if url_path == '/':
             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:
         """Remove routes with the given path."""

+ 4 - 3
nicegui/client.py

@@ -53,7 +53,7 @@ class Client:
     @property
     def ip(self) -> Optional[str]:
         """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
     def has_socket_connection(self) -> bool:
@@ -68,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', ''))
         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())
         return templates.TemplateResponse('index.html', {
@@ -85,6 +85,7 @@ 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,
@@ -112,7 +113,7 @@ class 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(...)`.
+        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())

+ 2 - 2
nicegui/dependencies.py

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

+ 15 - 1
nicegui/element.py

@@ -39,7 +39,7 @@ 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: Optional[str] = None
         self.components: List[str] = []
@@ -270,6 +270,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.
 

+ 0 - 1
nicegui/elements/aggrid.py

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

+ 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
 

+ 9 - 1
nicegui/elements/button.py

@@ -1,3 +1,4 @@
+import asyncio
 from typing import Callable, Optional
 
 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.
 
-        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.
@@ -34,3 +35,10 @@ class Button(TextElement, DisableableElement):
 
     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()

+ 0 - 1
nicegui/elements/chart.py

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

+ 1 - 18
nicegui/elements/chat_message.js

@@ -1,20 +1,3 @@
 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 typing import Optional
+import html
+from typing import List, Optional, Union
 
 from ..dependencies import register_vue_component
 from ..element import Element
@@ -10,26 +12,36 @@ register_vue_component(name='chat_message', path=Path(__file__).parent.joinpath(
 class ChatMessage(Element):
 
     def __init__(self,
-                 text: str, *,
+                 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
+        :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:

+ 1 - 1
nicegui/elements/color_input.py

@@ -17,7 +17,7 @@ class ColorInput(ValueElement, DisableableElement):
         :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:

+ 0 - 1
nicegui/elements/colors.py

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

+ 2 - 1
nicegui/elements/input.py

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

+ 0 - 1
nicegui/elements/link.py

@@ -26,7 +26,6 @@ class Link(TextElement):
         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._props['key'] = self.id  # HACK: workaround for #600
         self._classes = ['nicegui-link']
         self.use_component('link')
 

+ 3 - 4
nicegui/elements/log.py

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

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

+ 3 - 3
nicegui/elements/scene.py

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

+ 4 - 2
nicegui/elements/upload.py

@@ -1,13 +1,14 @@
+from pathlib import Path
 from typing import Callable, Optional
 
 from fastapi import Request, Response
 
-from ..dependencies import register_component
+from ..dependencies import register_vue_component
 from ..events import EventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
 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):
@@ -40,6 +41,7 @@ class Upload(DisableableElement):
         self._props['label'] = label
         self._props['auto-upload'] = auto_upload
         self._props['url'] = f'/_nicegui/client/{self.client.id}/upload/{self.id}'
+        self.use_component('upload')
 
         if max_file_size is not None:
             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 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

+ 17 - 17
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,7 +269,7 @@ 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:

+ 19 - 14
nicegui/functions/refreshable.py

@@ -38,23 +38,12 @@ class refreshable:
         self.prune()
         for container, args, kwargs in self.containers:
             container.clear()
+            result = self._run_in_container(container, *args, **kwargs)
             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():
-                    background_tasks.create(wait_for_result(container=container, args=args, kwargs=kwargs))
+                    background_tasks.create(result)
                 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:
         self.containers = [
@@ -62,3 +51,19 @@ class refreshable:
             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

+ 3 - 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):

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

+ 18 - 1
nicegui/nicegui.py

@@ -1,5 +1,6 @@
 import asyncio
 import os
+import socket
 import time
 import urllib.parse
 from pathlib import Path
@@ -81,7 +82,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')

+ 7 - 0
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,6 +19,7 @@ class page:
                  viewport: Optional[str] = None,
                  favicon: Optional[str] = None,
                  dark: Optional[bool] = ...,
+                 language: Language = ...,
                  response_timeout: float = 3.0,
                  **kwargs,
                  ) -> None:
@@ -33,6 +35,7 @@ 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
         """
@@ -41,6 +44,7 @@ class page:
         self.viewport = viewport
         self.favicon = favicon
         self.dark = dark
+        self.language = language
         self.response_timeout = response_timeout
         self.kwargs = kwargs
 
@@ -55,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())

+ 7 - 2
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,
@@ -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 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
@@ -88,8 +92,9 @@ def run(*,
     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)

+ 3 - 0
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,6 +13,7 @@ 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 = '/',
@@ -21,6 +23,7 @@ def run_with(
     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

+ 9 - 0
nicegui/static/nicegui.css

@@ -65,6 +65,7 @@
   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;
@@ -76,6 +77,14 @@
   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;

File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.ar-TN.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.ar.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.az-Latn.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.bg.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.bn.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.ca.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.cs.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.da.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.de.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.el.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.en-GB.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.en-US.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.eo.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.es.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.et.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.eu.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.fa-IR.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.fa.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.fi.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.fr.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.gn.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.he.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.hr.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.hu.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.id.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.is.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.it.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.ja.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.kk.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.km.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.ko-KR.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.kur-CKB.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.lt.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.lu.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.lv.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.ml.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.mm.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.ms.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.my.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.nb-NO.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.nl.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.pl.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.pt-BR.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.pt.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.ro.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.ru.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.sk.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.sl.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.sm.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.sr-CYR.umd.prod.js


File diff suppressed because it is too large
+ 5 - 0
nicegui/static/quasar.sr.umd.prod.js


Some files were not shown because too many files changed in this diff