Prechádzať zdrojové kódy

Merge branch 'main' into feature/dependencies

# Conflicts:
#	nicegui/static/quasar.umd.prod.js
Dominique CLAUSE 2 rokov pred
rodič
commit
944451b540

+ 58 - 0
.github/workflows/publish.yml

@@ -108,3 +108,61 @@ jobs:
           destination_container_repo: zauberzeug/nicegui
           destination_container_repo: zauberzeug/nicegui
           provider: dockerhub
           provider: dockerhub
           short_description: "Web Based User Interface für Python with Buttons, Dialogs, Markdown, 3D Scences and Plots"
           short_description: "Web Based User Interface für Python with Buttons, Dialogs, Markdown, 3D Scences and Plots"
+
+  update_citation:
+    needs: docker
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v2
+
+      - name: Set up Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.11
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install requests PyYAML
+
+      - name: Update Citation.cff
+        env:
+          ZENODO_TOKEN: ${{ secrets.ZENODO_TOKEN }}
+        run: python .github/workflows/update_citation.py
+
+      - name: Commit and push changes
+        run: |
+          git config --global user.name "github-actions[bot]"
+          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
+          git add CITATION.cff
+          git commit -m "Update citation.cff"
+          git push
+
+  verify:
+    needs: docker
+    runs-on: ubuntu-latest
+    steps:
+      - name: Pull and Test Container
+        env:
+          DOCKER_IMAGE: zauberzeug/nicegui
+          VERSION: latest
+        run: |
+          docker pull ${DOCKER_IMAGE}:${VERSION}
+          docker run -d --name test_container ${DOCKER_IMAGE}:${VERSION}
+          sleep 10
+          CONTAINER_OUTPUT=$(docker logs test_container)
+          # Check if the container is still running
+          CONTAINER_STATUS=$(docker inspect -f '{{.State.Running}}' test_container)
+          if [ "${CONTAINER_STATUS}" != "true" ]; then
+            echo "The container is not running!"
+            exit 1
+          fi
+          # Check if the "Error" string is present in the container output
+          if echo "${CONTAINER_OUTPUT}" | grep -q "Error"; then
+            echo "Error found in container output!"
+            echo "${CONTAINER_OUTPUT}"
+            exit 1
+          fi
+          docker stop test_container
+          docker rm test_container

+ 0 - 36
.github/workflows/update_citation.yml

@@ -1,36 +0,0 @@
-name: Update Citation
-
-on:
-  release:
-    types:
-      - published
-
-jobs:
-  update_citation:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v2
-
-      - name: Set up Python
-        uses: actions/setup-python@v2
-        with:
-          python-version: 3.11
-
-      - name: Install dependencies
-        run: |
-          python -m pip install --upgrade pip
-          pip install requests PyYAML
-
-      - name: Update Citation.cff
-        env:
-          ZENODO_TOKEN: ${{ secrets.ZENODO_TOKEN }}
-        run: python .github/workflows/update_citation.py
-
-      - name: Commit and push changes
-        run: |
-          git config --global user.name "github-actions[bot]"
-          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
-          git add CITATION.cff
-          git commit -m "Update citation.cff"
-          git push

+ 1 - 1
DEPENDENCIES.md

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

+ 13 - 16
examples/single_page_app/main.py

@@ -3,27 +3,24 @@ from router import Router
 
 
 from nicegui import ui
 from nicegui import ui
 
 
-router = Router()
 
 
+@ui.page('/')  # normal index page (eg. the entry point of the app)
+@ui.page('/{_:path}')  # all other pages will be handled by the router but must be registered to also show the SPA index page
+async def main():
+    router = Router()
 
 
-@router.add('/')
-async def show_one():
-    ui.label('Content One').classes('text-2xl')
-
-
-@router.add('/two')
-async def show_two():
-    ui.label('Content Two').classes('text-2xl')
-
+    @router.add('/')
+    async def show_one():
+        ui.label('Content One').classes('text-2xl')
 
 
-@router.add('/three')
-async def show_three():
-    ui.label('Content Three').classes('text-2xl')
+    @router.add('/two')
+    async def show_two():
+        ui.label('Content Two').classes('text-2xl')
 
 
+    @router.add('/three')
+    async def show_three():
+        ui.label('Content Three').classes('text-2xl')
 
 
-@ui.page('/')  # normal index page (eg. the entry point of the app)
-@ui.page('/{_:path}')  # all other pages will be handled by the router but must be registered to also show the SPA index page
-async def main():
     # adding some navigation buttons to switch between the different pages
     # adding some navigation buttons to switch between the different pages
     with ui.row():
     with ui.row():
         ui.button('One', on_click=lambda: router.open(show_one)).classes('w-32')
         ui.button('One', on_click=lambda: router.open(show_one)).classes('w-32')

+ 7 - 3
examples/single_page_app/router.py

@@ -19,7 +19,7 @@ class Router():
             return func
             return func
         return decorator
         return decorator
 
 
-    def open(self, target: Union[Callable, str]):
+    def open(self, target: Union[Callable, str]) -> None:
         if isinstance(target, str):
         if isinstance(target, str):
             path = target
             path = target
             builder = self.routes[target]
             builder = self.routes[target]
@@ -27,9 +27,13 @@ class Router():
             path = {v: k for k, v in self.routes.items()}[target]
             path = {v: k for k, v in self.routes.items()}[target]
             builder = target
             builder = target
 
 
-        async def build():
+        async def build() -> None:
             with self.content:
             with self.content:
-                await ui.run_javascript(f'history.pushState({{page: "{path}"}}, "", "{path}")', respond=False)
+                await ui.run_javascript(f'''
+                    if (window.location.pathname !== "{path}") {{
+                        history.pushState({{page: "{path}"}}, "", "{path}");
+                    }}
+                ''', respond=False)
                 result = builder()
                 result = builder()
                 if isinstance(result, Awaitable):
                 if isinstance(result, Awaitable):
                     await result
                     await result

+ 2 - 0
nicegui/app.py

@@ -71,6 +71,8 @@ class App(FastAPI):
         :param path: string that starts with a slash "/"
         :param path: string that starts with a slash "/"
         :param directory: folder with static files to serve under the given path
         :param directory: folder with static files to serve under the given path
         """
         """
+        if 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(path, StaticFiles(directory=directory))
 
 
     def remove_route(self, path: str) -> None:
     def remove_route(self, path: str) -> None:

+ 33 - 0
nicegui/colors.py

@@ -0,0 +1,33 @@
+from typing import Optional
+
+from typing_extensions import get_args
+
+from .element import Element
+from .tailwind_types.background_color import BackgroundColor
+
+QUASAR_COLORS = {'primary', 'secondary', 'accent', 'dark', 'positive', 'negative', 'info', 'warning'}
+for color in {'red', 'pink', 'purple', 'deep-purple', 'indigo', 'blue', 'light-blue', 'cyan', 'teal', 'green',
+              'light-green', 'lime', 'yellow', 'amber', 'orange', 'deep-orange', 'brown', 'grey', 'blue-grey'}:
+    QUASAR_COLORS.add(color)
+    for i in range(1, 15):
+        QUASAR_COLORS.add(f'{color}-{i}')
+
+TAILWIND_COLORS = get_args(BackgroundColor)
+
+
+def set_text_color(element: Element, color: Optional[str], *, prop_name: str = 'color') -> None:
+    if color in QUASAR_COLORS:
+        element._props[prop_name] = color
+    elif color in TAILWIND_COLORS:
+        element._classes.append(f'text-{color}')
+    elif color is not None:
+        element._style['color'] = color
+
+
+def set_background_color(element: Element, color: Optional[str], *, prop_name: str = 'color') -> None:
+    if color in QUASAR_COLORS:
+        element._props[prop_name] = color
+    elif color in TAILWIND_COLORS:
+        element._classes.append(f'bg-{color}')
+    elif color is not None:
+        element._style['background-color'] = color

+ 5 - 5
nicegui/elements/avatar.py

@@ -1,5 +1,6 @@
 from typing import Optional
 from typing import Optional
 
 
+from ..colors import set_background_color, set_text_color
 from ..element import Element
 from ..element import Element
 
 
 
 
@@ -7,7 +8,7 @@ class Avatar(Element):
 
 
     def __init__(self,
     def __init__(self,
                  icon: str = 'none', *,
                  icon: str = 'none', *,
-                 color: str = 'primary',
+                 color: Optional[str] = 'primary',
                  text_color: Optional[str] = None,
                  text_color: Optional[str] = None,
                  size: Optional[str] = None,
                  size: Optional[str] = None,
                  font_size: Optional[str] = None,
                  font_size: Optional[str] = None,
@@ -20,7 +21,7 @@ class Avatar(Element):
         `QAvatar <https://quasar.dev/vue-components/avatar>`_ component.
         `QAvatar <https://quasar.dev/vue-components/avatar>`_ component.
 
 
         :param icon: name of the icon or image path with "img:" prefix (e.g. "map", "img:path/to/image.png")
         :param icon: name of the icon or image path with "img:" prefix (e.g. "map", "img:path/to/image.png")
-        :param color: color name for component from the Quasar Color Palette (e.g. "primary", "teal-10")
+        :param color: background color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param text_color: color name from the Quasar Color Palette (e.g. "primary", "teal-10")
         :param text_color: color name from the Quasar Color Palette (e.g. "primary", "teal-10")
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl) (e.g. "16px", "2rem")
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl) (e.g. "16px", "2rem")
         :param font_size: size in CSS units, including unit name, of the content (icon, text) (e.g. "18px", "2rem")
         :param font_size: size in CSS units, including unit name, of the content (icon, text) (e.g. "18px", "2rem")
@@ -30,12 +31,11 @@ class Avatar(Element):
         super().__init__('q-avatar')
         super().__init__('q-avatar')
 
 
         self._props['icon'] = icon
         self._props['icon'] = icon
-        self._props['color'] = color
         self._props['square'] = square
         self._props['square'] = square
         self._props['rounded'] = rounded
         self._props['rounded'] = rounded
 
 
-        if text_color is not None:
-            self._props['text-color'] = text_color
+        set_background_color(self, color)
+        set_text_color(self, text_color, prop_name='text-color')
 
 
         if size is not None:
         if size is not None:
             self._props['size'] = size
             self._props['size'] = size

+ 12 - 6
nicegui/elements/badge.py

@@ -1,21 +1,27 @@
+from typing import Optional
+
+from ..colors import set_background_color, set_text_color
 from .mixins.text_element import TextElement
 from .mixins.text_element import TextElement
 
 
 
 
 class Badge(TextElement):
 class Badge(TextElement):
 
 
-    def __init__(self, text: str = '', *,
-                 color: str = 'blue', text_color: str = 'white', outline: bool = False) -> None:
+    def __init__(self,
+                 text: str = '', *,
+                 color: Optional[str] = 'primary',
+                 text_color: Optional[str] = None,
+                 outline: bool = False) -> None:
         """Badge
         """Badge
 
 
         A badge element wrapping Quasar's
         A badge element wrapping Quasar's
         `QBadge <https://quasar.dev/vue-components/badge>`_ component.
         `QBadge <https://quasar.dev/vue-components/badge>`_ component.
 
 
         :param text: the initial value of the text field
         :param text: the initial value of the text field
-        :param color: the color name for component from the Quasar Color Palette (default: "blue")
-        :param text_color: overrides text color (if needed); color name from the Quasar Color Palette (default: "white")
+        :param color: the color name for component (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
+        :param text_color: text color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         :param outline: use 'outline' design (colored text and borders only) (default: False)
         :param outline: use 'outline' design (colored text and borders only) (default: False)
         """
         """
         super().__init__(tag='q-badge', text=text)
         super().__init__(tag='q-badge', text=text)
-        self._props['color'] = color
-        self._props['text_color'] = text_color
+        set_background_color(self, color)
+        set_text_color(self, text_color, prop_name='text-color')
         self._props['outline'] = outline
         self._props['outline'] = outline

+ 15 - 2
nicegui/elements/button.py

@@ -1,19 +1,32 @@
 from typing import Callable, Optional
 from typing import Callable, Optional
 
 
+from ..colors import set_background_color
 from ..events import ClickEventArguments, handle_event
 from ..events import ClickEventArguments, handle_event
 from .mixins.text_element import TextElement
 from .mixins.text_element import TextElement
 
 
 
 
 class Button(TextElement):
 class Button(TextElement):
 
 
-    def __init__(self, text: str = '', *, on_click: Optional[Callable] = None) -> None:
+    def __init__(self,
+                 text: str = '', *,
+                 on_click: Optional[Callable] = None,
+                 color: Optional[str] = 'primary',
+                 ) -> None:
         """Button
         """Button
 
 
+        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.
+        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.
+
         :param text: the label of the button
         :param text: the label of the button
         :param on_click: callback which is invoked when button is pressed
         :param on_click: callback which is invoked when button is pressed
+        :param color: the color of the button (either a Quasar, Tailwind, or CSS color or `None`, default: 'primary')
         """
         """
         super().__init__(tag='q-btn', text=text)
         super().__init__(tag='q-btn', text=text)
-        self._props['color'] = 'primary'
+        set_background_color(self, color)
 
 
         if on_click:
         if on_click:
             self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))
             self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))

+ 9 - 1
nicegui/elements/card.py

@@ -6,12 +6,20 @@ class Card(Element):
     def __init__(self) -> None:
     def __init__(self) -> None:
         """Card
         """Card
 
 
-        Provides a container with a dropped shadow.
+        This element is based on Quasar's `QCard <https://quasar.dev/vue-components/card>`_ component.
+        It provides a container with a dropped shadow.
+
+        Note:
+        There are subtle differences between the Quasar component and this element.
+        In contrast to this element, the original QCard has no padding by default and hides outer borders of nested elements.
+        If you want the original behavior, use the `tight` method.
+        If you want the padding and borders for nested children, move the children into another container.
         """
         """
         super().__init__('q-card')
         super().__init__('q-card')
         self._classes = ['nicegui-card']
         self._classes = ['nicegui-card']
 
 
     def tight(self):
     def tight(self):
+        """Removes padding and gaps between nested elements."""
         self._classes.clear()
         self._classes.clear()
         self._style.clear()
         self._style.clear()
         return self
         return self

+ 1 - 0
nicegui/elements/colors.js

@@ -8,6 +8,7 @@ export default {
     primary: String,
     primary: String,
     secondary: String,
     secondary: String,
     accent: String,
     accent: String,
+    dark: String,
     positive: String,
     positive: String,
     negative: String,
     negative: String,
     info: String,
     info: String,

+ 2 - 0
nicegui/elements/colors.py

@@ -12,6 +12,7 @@ class Colors(Element):
                  primary='#5898d4',
                  primary='#5898d4',
                  secondary='#26a69a',
                  secondary='#26a69a',
                  accent='#9c27b0',
                  accent='#9c27b0',
+                 dark='#1d1d1d',
                  positive='#21ba45',
                  positive='#21ba45',
                  negative='#c10015',
                  negative='#c10015',
                  info='#31ccec',
                  info='#31ccec',
@@ -24,6 +25,7 @@ class Colors(Element):
         self._props['primary'] = primary
         self._props['primary'] = primary
         self._props['secondary'] = secondary
         self._props['secondary'] = secondary
         self._props['accent'] = accent
         self._props['accent'] = accent
+        self._props['dark'] = dark
         self._props['positive'] = positive
         self._props['positive'] = positive
         self._props['negative'] = negative
         self._props['negative'] = negative
         self._props['info'] = info
         self._props['info'] = info

+ 3 - 3
nicegui/elements/icon.py

@@ -1,5 +1,6 @@
 from typing import Optional
 from typing import Optional
 
 
+from ..colors import set_text_color
 from ..element import Element
 from ..element import Element
 
 
 
 
@@ -19,7 +20,7 @@ class Icon(Element):
 
 
         :param name: name of the icon
         :param name: name of the icon
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
-        :param color: color name for component, examples: primary, teal-10
+        :param color: icon color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         """
         """
         super().__init__('q-icon')
         super().__init__('q-icon')
         self._props['name'] = name
         self._props['name'] = name
@@ -27,5 +28,4 @@ class Icon(Element):
         if size:
         if size:
             self._props['size'] = size
             self._props['size'] = size
 
 
-        if color:
-            self._props['color'] = color
+        set_text_color(self, color)

+ 3 - 2
nicegui/elements/knob.py

@@ -1,5 +1,6 @@
 from typing import Optional
 from typing import Optional
 
 
+from ..colors import set_text_color
 from .label import Label
 from .label import Label
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
 
 
@@ -27,7 +28,7 @@ class Knob(ValueElement):
         :param min: the minimum value (default: 0.0)
         :param min: the minimum value (default: 0.0)
         :param max: the maximum value (default: 1.0)
         :param max: the maximum value (default: 1.0)
         :param step: the step size (default: 0.01)
         :param step: the step size (default: 0.01)
-        :param color: color name for component, examples: primary, teal-10 (default: "primary")
+        :param color: knob color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param center_color: color name for the center part of the component, examples: primary, teal-10
         :param center_color: color name for the center part of the component, examples: primary, teal-10
         :param track_color: color name for the track of the component, examples: primary, teal-10
         :param track_color: color name for the track of the component, examples: primary, teal-10
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
@@ -38,7 +39,7 @@ class Knob(ValueElement):
         self._props['min'] = min
         self._props['min'] = min
         self._props['max'] = max
         self._props['max'] = max
         self._props['step'] = step
         self._props['step'] = step
-        self._props['color'] = color
+        set_text_color(self, color)
         self._props['show-value'] = True  # NOTE: enable default slot, e.g. for nested icon
         self._props['show-value'] = True  # NOTE: enable default slot, e.g. for nested icon
 
 
         if center_color:
         if center_color:

+ 1 - 0
nicegui/elements/number.py

@@ -35,6 +35,7 @@ class Number(ValueElement):
         if placeholder is not None:
         if placeholder is not None:
             self._props['placeholder'] = placeholder
             self._props['placeholder'] = placeholder
         self.validation = validation
         self.validation = validation
+        self.on('blur', self.update)  # NOTE: to apply format (#736)
 
 
     def on_value_change(self, value: Any) -> None:
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)
         super().on_value_change(value)

+ 21 - 4
nicegui/elements/progress.py

@@ -2,13 +2,19 @@ from typing import Optional
 
 
 from nicegui import ui
 from nicegui import ui
 
 
+from ..colors import set_text_color
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
 
 
 
 
 class LinearProgress(ValueElement):
 class LinearProgress(ValueElement):
     VALUE_PROP = 'value'
     VALUE_PROP = 'value'
 
 
-    def __init__(self, value: float = 0.0, *, size: Optional[str] = None, show_value: bool = True) -> None:
+    def __init__(self,
+                 value: float = 0.0, *,
+                 size: Optional[str] = None,
+                 show_value: bool = True,
+                 color: Optional[str] = 'primary',
+                 ) -> None:
         """Linear Progress
         """Linear Progress
 
 
         A linear progress bar wrapping Quasar's
         A linear progress bar wrapping Quasar's
@@ -17,9 +23,11 @@ class LinearProgress(ValueElement):
         :param value: the initial value of the field (from 0.0 to 1.0)
         :param value: the initial value of the field (from 0.0 to 1.0)
         :param size: the height of the progress bar (default: "20px" with value label and "4px" without)
         :param size: the height of the progress bar (default: "20px" with value label and "4px" without)
         :param show_value: whether to show a value label in the center (default: `True`)
         :param show_value: whether to show a value label in the center (default: `True`)
+        :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         """
         """
         super().__init__(tag='q-linear-progress', value=value, on_value_change=None)
         super().__init__(tag='q-linear-progress', value=value, on_value_change=None)
         self._props['size'] = size if size is not None else '20px' if show_value else '4px'
         self._props['size'] = size if size is not None else '20px' if show_value else '4px'
+        set_text_color(self, color)
 
 
         if show_value:
         if show_value:
             with self:
             with self:
@@ -29,23 +37,32 @@ class LinearProgress(ValueElement):
 class CircularProgress(ValueElement):
 class CircularProgress(ValueElement):
     VALUE_PROP = 'value'
     VALUE_PROP = 'value'
 
 
-    def __init__(self, value: float = 0.0, *,
-                 min: float = 0.0, max: float = 1.0, size: str = 'xl', show_value: bool = True) -> None:
+    def __init__(self,
+                 value: float = 0.0, *,
+                 min: float = 0.0,
+                 max: float = 1.0,
+                 size: str = 'xl',
+                 show_value: bool = True,
+                 color: Optional[str] = 'primary',
+                 ) -> None:
         """Circular Progress
         """Circular Progress
 
 
         A circular progress bar wrapping Quasar's
         A circular progress bar wrapping Quasar's
         `QCircularProgress <https://quasar.dev/vue-components/circular-progress>`_.
         `QCircularProgress <https://quasar.dev/vue-components/circular-progress>`_.
 
 
         :param value: the initial value of the field
         :param value: the initial value of the field
+        :param min: the minimum value (default: 0.0)
+        :param max: the maximum value (default: 1.0)
         :param size: the size of the progress circle (default: "xl")
         :param size: the size of the progress circle (default: "xl")
         :param show_value: whether to show a value label in the center (default: `True`)
         :param show_value: whether to show a value label in the center (default: `True`)
+        :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         """
         """
         super().__init__(tag='q-circular-progress', value=value, on_value_change=None)
         super().__init__(tag='q-circular-progress', value=value, on_value_change=None)
         self._props['min'] = min
         self._props['min'] = min
         self._props['max'] = max
         self._props['max'] = max
         self._props['size'] = size
         self._props['size'] = size
         self._props['show-value'] = show_value
         self._props['show-value'] = show_value
-        self._props['color'] = 'primary'
+        set_text_color(self, color)
         self._props['track-color'] = 'grey-4'
         self._props['track-color'] = 'grey-4'
 
 
         if show_value:
         if show_value:

+ 10 - 5
nicegui/elements/spinner.py

@@ -2,6 +2,7 @@ from typing import Optional
 
 
 from typing_extensions import Literal
 from typing_extensions import Literal
 
 
+from ..colors import set_text_color
 from ..element import Element
 from ..element import Element
 
 
 SpinnerTypes = Literal[
 SpinnerTypes = Literal[
@@ -33,18 +34,22 @@ SpinnerTypes = Literal[
 
 
 class Spinner(Element):
 class Spinner(Element):
 
 
-    def __init__(self, type: Optional[SpinnerTypes] = 'default', *,
-                 size: str = '1em', color: str = 'primary', thickness: float = 5.0):
+    def __init__(self,
+                 type: Optional[SpinnerTypes] = 'default', *,
+                 size: str = '1em',
+                 color: Optional[str] = 'primary',
+                 thickness: float = 5.0,
+                 ) -> None:
         """Spinner
         """Spinner
 
 
-        See `Quasar Spinner <https://quasar.dev/vue-components/spinner>`_ for more information.
+        See `Quasar Spinner <https://quasar.dev/vue-components/spinners>`_ for more information.
 
 
         :param type: type of spinner (e.g. "audio", "ball", "bars", ..., default: "default")
         :param type: type of spinner (e.g. "audio", "ball", "bars", ..., default: "default")
         :param size: size of the spinner (e.g. "3em", "10px", "xl", ..., default: "1em")
         :param size: size of the spinner (e.g. "3em", "10px", "xl", ..., default: "1em")
-        :param color: color of the spinner (default: "primary")
+        :param color: color of the spinner (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param thickness: thickness of the spinner (applies to the "default" spinner only, default: 5.0)
         :param thickness: thickness of the spinner (applies to the "default" spinner only, default: 5.0)
         """
         """
         super().__init__('q-spinner' if type == 'default' else f'q-spinner-{type}')
         super().__init__('q-spinner' if type == 'default' else f'q-spinner-{type}')
         self._props['size'] = size
         self._props['size'] = size
-        self._props['color'] = color
+        set_text_color(self, color)
         self._props['thickness'] = thickness
         self._props['thickness'] = thickness

+ 22 - 1
nicegui/elements/upload.py

@@ -3,7 +3,7 @@ from typing import Callable, Optional
 from fastapi import Request, Response
 from fastapi import Request, Response
 
 
 from ..element import Element
 from ..element import Element
-from ..events import UploadEventArguments, handle_event
+from ..events import EventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
 from ..nicegui import app
 
 
 
 
@@ -11,7 +11,11 @@ class Upload(Element):
 
 
     def __init__(self, *,
     def __init__(self, *,
                  multiple: bool = False,
                  multiple: bool = False,
+                 max_file_size: Optional[int] = None,
+                 max_total_size: Optional[int] = None,
+                 max_files: Optional[int] = None,
                  on_upload: Optional[Callable] = None,
                  on_upload: Optional[Callable] = None,
+                 on_rejected: Optional[Callable] = None,
                  label: str = '',
                  label: str = '',
                  auto_upload: bool = False,
                  auto_upload: bool = False,
                  ) -> None:
                  ) -> None:
@@ -20,7 +24,11 @@ class Upload(Element):
         Based on Quasar's `QUploader <https://quasar.dev/vue-components/uploader>`_ component.
         Based on Quasar's `QUploader <https://quasar.dev/vue-components/uploader>`_ component.
 
 
         :param multiple: allow uploading multiple files at once (default: `False`)
         :param multiple: allow uploading multiple files at once (default: `False`)
+        :param max_file_size: maximum file size in bytes (default: `0`)
+        :param max_total_size: maximum total size of all files in bytes (default: `0`)
+        :param max_files: maximum number of files (default: `0`)
         :param on_upload: callback to execute for each uploaded file (type: nicegui.events.UploadEventArguments)
         :param on_upload: callback to execute for each uploaded file (type: nicegui.events.UploadEventArguments)
+        :param on_rejected: callback to execute for each rejected file
         :param label: label for the uploader (default: `''`)
         :param label: label for the uploader (default: `''`)
         :param auto_upload: automatically upload files when they are selected (default: `False`)
         :param auto_upload: automatically upload files when they are selected (default: `False`)
         """
         """
@@ -30,6 +38,15 @@ class Upload(Element):
         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}'
 
 
+        if max_file_size is not None:
+            self._props['max-file-size'] = max_file_size
+
+        if max_total_size is not None:
+            self._props['max-total-size'] = max_total_size
+
+        if max_files is not None:
+            self._props['max-files'] = max_files
+
         @app.post(self._props['url'])
         @app.post(self._props['url'])
         async def upload_route(request: Request) -> Response:
         async def upload_route(request: Request) -> Response:
             for data in (await request.form()).values():
             for data in (await request.form()).values():
@@ -43,6 +60,10 @@ class Upload(Element):
                 handle_event(on_upload, args)
                 handle_event(on_upload, args)
             return {'upload': 'success'}
             return {'upload': 'success'}
 
 
+        if on_rejected:
+            self.on('rejected', lambda _: handle_event(on_rejected, EventArguments(sender=self, client=self.client)),
+                    args=[])
+
     def reset(self) -> None:
     def reset(self) -> None:
         self.run_method('reset')
         self.run_method('reset')
 
 

+ 5 - 0
nicegui/run.py

@@ -4,6 +4,7 @@ import os
 import sys
 import sys
 from typing import List, Optional, Tuple
 from typing import List, Optional, Tuple
 
 
+import __main__
 import uvicorn
 import uvicorn
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
 from uvicorn.supervisors import ChangeReload, Multiprocess
@@ -70,6 +71,10 @@ def run(*,
     if multiprocessing.current_process().name != 'MainProcess':
     if multiprocessing.current_process().name != 'MainProcess':
         return
         return
 
 
+    if reload and not hasattr(__main__, '__file__'):
+        logging.warning('auto-reloading is only supported when running from a file')
+        globals.reload = reload = False
+
     if fullscreen:
     if fullscreen:
         native = True
         native = True
     if window_size:
     if window_size:

+ 1 - 1
release.dockerfile

@@ -3,7 +3,7 @@ ARG VERSION
 
 
 LABEL maintainer="Zauberzeug GmbH <info@zauberzeug.com>"
 LABEL maintainer="Zauberzeug GmbH <info@zauberzeug.com>"
 
 
-RUN python -m pip install nicegui==$VERSION itsdangerous isort
+RUN python -m pip install nicegui==$VERSION itsdangerous isort docutils
 
 
 WORKDIR /app
 WORKDIR /app
 
 

+ 3 - 0
tests/screen.py

@@ -134,6 +134,9 @@ class Screen:
         except NoSuchElementException as e:
         except NoSuchElementException as e:
             raise AssertionError(f'Could not find "{text}"') from e
             raise AssertionError(f'Could not find "{text}"') from e
 
 
+    def find_by_id(self, id: str) -> WebElement:
+        return self.selenium.find_element(By.ID, id)
+
     def find_by_tag(self, name: str) -> WebElement:
     def find_by_tag(self, name: str) -> WebElement:
         return self.selenium.find_element(By.TAG_NAME, name)
         return self.selenium.find_element(By.TAG_NAME, name)
 
 

+ 4 - 4
tests/test_auto_context.py

@@ -43,9 +43,9 @@ def test_adding_elements_with_async_await(screen: Screen):
     with screen.implicitly_wait(10.0):
     with screen.implicitly_wait(10.0):
         screen.should_contain('A')
         screen.should_contain('A')
         screen.should_contain('B')
         screen.should_contain('B')
-    cA = screen.selenium.find_element(By.ID, cardA.id)
+    cA = screen.find_by_id(cardA.id)
     cA.find_element(By.XPATH, './/*[contains(text(), "A")]')
     cA.find_element(By.XPATH, './/*[contains(text(), "A")]')
-    cB = screen.selenium.find_element(By.ID, cardB.id)
+    cB = screen.find_by_id(cardB.id)
     cB.find_element(By.XPATH, './/*[contains(text(), "B")]')
     cB.find_element(By.XPATH, './/*[contains(text(), "B")]')
 
 
 
 
@@ -132,7 +132,7 @@ def test_adding_elements_from_different_tasks(screen: Screen):
     background_tasks.create(add_label2())
     background_tasks.create(add_label2())
     screen.should_contain('1')
     screen.should_contain('1')
     screen.should_contain('2')
     screen.should_contain('2')
-    c1 = screen.selenium.find_element(By.ID, card1.id)
+    c1 = screen.find_by_id(card1.id)
     c1.find_element(By.XPATH, './/*[contains(text(), "1")]')
     c1.find_element(By.XPATH, './/*[contains(text(), "1")]')
-    c2 = screen.selenium.find_element(By.ID, card2.id)
+    c2 = screen.find_by_id(card2.id)
     c2.find_element(By.XPATH, './/*[contains(text(), "2")]')
     c2.find_element(By.XPATH, './/*[contains(text(), "2")]')

+ 18 - 0
tests/test_button.py

@@ -0,0 +1,18 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_quasar_colors(screen: Screen):
+    b1 = ui.button()
+    b2 = ui.button(color=None)
+    b3 = ui.button(color='red-5')
+    b4 = ui.button(color='red-500')
+    b5 = ui.button(color='#ff0000')
+
+    screen.open('/')
+    assert screen.find_by_id(b1.id).value_of_css_property('background-color') == 'rgba(88, 152, 212, 1)'
+    assert screen.find_by_id(b2.id).value_of_css_property('background-color') == 'rgba(0, 0, 0, 0)'
+    assert screen.find_by_id(b3.id).value_of_css_property('background-color') == 'rgba(239, 83, 80, 1)'
+    assert screen.find_by_id(b4.id).value_of_css_property('background-color') == 'rgba(239, 68, 68, 1)'
+    assert screen.find_by_id(b5.id).value_of_css_property('background-color') == 'rgba(255, 0, 0, 1)'

+ 2 - 3
tests/test_joystick.py

@@ -1,5 +1,4 @@
 from selenium.webdriver.common.action_chains import ActionChains
 from selenium.webdriver.common.action_chains import ActionChains
-from selenium.webdriver.common.by import By
 
 
 from nicegui import ui
 from nicegui import ui
 
 
@@ -12,7 +11,7 @@ def test_joystick(screen: Screen):
     coordinates = ui.label('start 0, 0')
     coordinates = ui.label('start 0, 0')
 
 
     screen.open('/')
     screen.open('/')
-    joystick = screen.selenium.find_element(By.ID, j.id)
+    joystick = screen.find_by_id(j.id)
     assert joystick
     assert joystick
     screen.should_contain('start 0, 0')
     screen.should_contain('start 0, 0')
 
 
@@ -36,6 +35,6 @@ def test_styling_joystick(screen: Screen):
     j = ui.joystick().style('background-color: gray;').classes('shadow-lg')
     j = ui.joystick().style('background-color: gray;').classes('shadow-lg')
 
 
     screen.open('/')
     screen.open('/')
-    joystick = screen.selenium.find_element(By.ID, j.id)
+    joystick = screen.find_by_id(j.id)
     assert 'background-color: gray;' in joystick.get_attribute('style')
     assert 'background-color: gray;' in joystick.get_attribute('style')
     assert 'shadow-lg' in joystick.get_attribute('class')
     assert 'shadow-lg' in joystick.get_attribute('class')

+ 3 - 5
tests/test_log.py

@@ -1,5 +1,3 @@
-from selenium.webdriver.common.by import By
-
 from nicegui import ui
 from nicegui import ui
 
 
 from .screen import Screen
 from .screen import Screen
@@ -13,11 +11,11 @@ def test_log(screen: Screen):
     log.push('D')
     log.push('D')
 
 
     screen.open('/')
     screen.open('/')
-    assert screen.selenium.find_element(By.ID, log.id).text == 'B\nC\nD'
+    assert screen.find_by_id(log.id).text == 'B\nC\nD'
 
 
     log.clear()
     log.clear()
     screen.wait(0.5)
     screen.wait(0.5)
-    assert screen.selenium.find_element(By.ID, log.id).text == ''
+    assert screen.find_by_id(log.id).text == ''
 
 
 
 
 def test_log_with_newlines(screen: Screen):
 def test_log_with_newlines(screen: Screen):
@@ -27,4 +25,4 @@ def test_log_with_newlines(screen: Screen):
     log.push('C\nD')
     log.push('C\nD')
 
 
     screen.open('/')
     screen.open('/')
-    assert screen.selenium.find_element(By.ID, log.id).text == 'B\nC\nD'
+    assert screen.find_by_id(log.id).text == 'B\nC\nD'

+ 18 - 0
tests/test_number.py

@@ -0,0 +1,18 @@
+from selenium.webdriver.common.by import By
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_apply_format_on_blur(screen: Screen):
+    ui.number('Number', format='%.4f', value=3.14159)
+    ui.button('Button')
+
+    screen.open('/')
+    screen.should_contain_input('3.1416')
+
+    element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Number"]')
+    element.send_keys('789')
+    screen.click('Button')
+    screen.should_contain_input('3.1417')

+ 20 - 0
website/documentation.py

@@ -604,6 +604,16 @@ def create_full() -> None:
     # HACK: restore color
     # HACK: restore color
     demo.BROWSER_BGCOLOR = demo_BROWSER_BGCOLOR
     demo.BROWSER_BGCOLOR = demo_BROWSER_BGCOLOR
 
 
+    # Show a helpful workaround until issue is fixed upstream.
+    # For more info see: https://github.com/r0x0r/pywebview/issues/1078
+    ui.markdown('''
+        If webview has trouble finding required libraries, you may get an error relating to "WebView2Loader.dll".
+        To work around this issue, try moving the DLL file up a directory, e.g.:
+        
+        * from `.venv/Lib/site-packages/webview/lib/x64/WebView2Loader.dll`
+        * to `.venv/Lib/site-packages/webview/lib/WebView2Loader.dll`
+    ''')
+
     @text_demo('Environment Variables', '''
     @text_demo('Environment Variables', '''
         You can set the following environment variables to configure NiceGUI:
         You can set the following environment variables to configure NiceGUI:
 
 
@@ -756,4 +766,14 @@ def create_full() -> None:
             ```
             ```
         ''')
         ''')
 
 
+    ui.markdown('''
+        **Note:**
+        If you're getting an error "TypeError: a bytes-like object is required, not 'str'", try adding the following lines to the top of your `main.py` file:
+        ```py
+        import sys
+        sys.stdout = open('logs.txt', 'w')
+        ```
+        See <https://github.com/zauberzeug/nicegui/issues/681> for more information.
+    ''')
+
     ui.element('div').classes('h-32')
     ui.element('div').classes('h-32')

+ 33 - 0
website/more_documentation/card_documentation.py

@@ -1,8 +1,41 @@
 from nicegui import ui
 from nicegui import ui
 
 
+from ..documentation_tools import text_demo
+
 
 
 def main_demo() -> None:
 def main_demo() -> None:
     with ui.card().tight() as card:
     with ui.card().tight() as card:
         ui.image('https://picsum.photos/id/684/640/360')
         ui.image('https://picsum.photos/id/684/640/360')
         with ui.card_section():
         with ui.card_section():
             ui.label('Lorem ipsum dolor sit amet, consectetur adipiscing elit, ...')
             ui.label('Lorem ipsum dolor sit amet, consectetur adipiscing elit, ...')
+
+
+def more() -> None:
+    @text_demo('The issue with nested borders', '''
+        The following example shows a table nested in a card.
+        Cards have a default padding in NiceGUI, so the table is not flush with the card's border.
+        The table has the `flat` and `bordered` props set, so it should have a border.
+        However, due to the way QCard is designed, the border is not visible (first card).
+        There are two ways to fix this:
+
+        - To get the original QCard behavior, use the `tight` method (second card).
+            It removes the padding and the table border collapses with the card border.
+        
+        - To preserve the padding _and_ the table border, move the table into another container like a `ui.row` (third card).
+
+        See https://github.com/zauberzeug/nicegui/issues/726 for more information.
+    ''')
+    def custom_context_menu() -> None:
+        columns = [{'name': 'age', 'label': 'Age', 'field': 'age'}]
+        rows = [{'age': '16'}, {'age': '18'}, {'age': '21'}]
+
+        with ui.row():
+            with ui.card():
+                ui.table(columns, rows).props('flat bordered')
+
+            with ui.card().tight():
+                ui.table(columns, rows).props('flat bordered')
+
+            with ui.card():
+                with ui.row():
+                    ui.table(columns, rows).props('flat bordered')

+ 67 - 26
website/more_documentation/table_documentation.py

@@ -32,32 +32,32 @@ def more() -> None:
             {'name': 'Carol'},
             {'name': 'Carol'},
         ]
         ]
 
 
-        with ui.table(columns=columns, rows=rows, row_key='name').classes('w-72') as table:
-            table.add_slot('header', r'''
-                <q-tr :props="props">
-                    <q-th auto-width />
-                    <q-th v-for="col in props.cols" :key="col.name" :props="props">
-                        {{ col.label }}
-                    </q-th>
-                </q-tr>
-            ''')
-            table.add_slot('body', r'''
-                <q-tr :props="props">
-                    <q-td auto-width>
-                        <q-btn size="sm" color="accent" round dense
-                            @click="props.expand = !props.expand"
-                            :icon="props.expand ? 'remove' : 'add'" />
-                    </q-td>
-                    <q-td v-for="col in props.cols" :key="col.name" :props="props">
-                        {{ col.value }}
-                    </q-td>
-                </q-tr>
-                <q-tr v-show="props.expand" :props="props">
-                    <q-td colspan="100%">
-                        <div class="text-left">This is {{ props.row.name }}.</div>
-                    </q-td>
-                </q-tr>
-            ''')
+        table = ui.table(columns=columns, rows=rows, row_key='name').classes('w-72')
+        table.add_slot('header', r'''
+            <q-tr :props="props">
+                <q-th auto-width />
+                <q-th v-for="col in props.cols" :key="col.name" :props="props">
+                    {{ col.label }}
+                </q-th>
+            </q-tr>
+        ''')
+        table.add_slot('body', r'''
+            <q-tr :props="props">
+                <q-td auto-width>
+                    <q-btn size="sm" color="accent" round dense
+                        @click="props.expand = !props.expand"
+                        :icon="props.expand ? 'remove' : 'add'" />
+                </q-td>
+                <q-td v-for="col in props.cols" :key="col.name" :props="props">
+                    {{ col.value }}
+                </q-td>
+            </q-tr>
+            <q-tr v-show="props.expand" :props="props">
+                <q-td colspan="100%">
+                    <div class="text-left">This is {{ props.row.name }}.</div>
+                </q-td>
+            </q-tr>
+        ''')
 
 
     @text_demo('Show and hide columns', '''
     @text_demo('Show and hide columns', '''
         Here is an example of how to show and hide columns in a table.
         Here is an example of how to show and hide columns in a table.
@@ -87,3 +87,44 @@ def more() -> None:
             with ui.menu() as menu, ui.column().classes('gap-0 p-2'):
             with ui.menu() as menu, ui.column().classes('gap-0 p-2'):
                 for column in columns:
                 for column in columns:
                     ui.switch(column['label'], value=True, on_change=lambda e, column=column: toggle(column, e.value))
                     ui.switch(column['label'], value=True, on_change=lambda e, column=column: toggle(column, e.value))
+
+    @text_demo('Table with drop down selection', '''
+        Here is an example of how to use a drop down selection in a table.
+        After emitting a `rename` event from the scoped slot, the `rename` function updates the table rows.
+    ''')
+    def table_with_drop_down_selection():
+        from typing import Dict
+
+        columns = [
+            {'name': 'name', 'label': 'Name', 'field': 'name'},
+            {'name': 'age', 'label': 'Age', 'field': 'age'},
+        ]
+        rows = [
+            {'id': 0, 'name': 'Alice', 'age': 18},
+            {'id': 1, 'name': 'Bob', 'age': 21},
+            {'id': 2, 'name': 'Carol'},
+        ]
+        name_options = ['Alice', 'Bob', 'Carol']
+
+        def rename(msg: Dict) -> None:
+            for row in rows:
+                if row['id'] == msg['args']['id']:
+                    row['name'] = msg['args']['name']
+            ui.notify(f'Table.rows is now: {table.rows}')
+
+        table = ui.table(columns=columns, rows=rows, row_key='name').classes('w-full')
+        table.add_slot('body', r'''
+            <q-tr :props="props">
+                <q-td key="name" :props="props">
+                    <q-select
+                        v-model="props.row.name"
+                        :options="''' + str(name_options) + r'''"
+                        @update:model-value="() => $parent.$emit('rename', props.row)"
+                    />
+                </q-td>
+                <q-td key="age" :props="props">
+                    {{ props.row.age }}
+                </q-td>
+            </q-tr>
+        ''')
+        table.on('rename', rename)

+ 27 - 0
website/more_documentation/tree_documentation.py

@@ -1,8 +1,35 @@
 from nicegui import ui
 from nicegui import ui
 
 
+from ..documentation_tools import text_demo
+
 
 
 def main_demo() -> None:
 def main_demo() -> None:
     ui.tree([
     ui.tree([
         {'id': 'numbers', 'children': [{'id': '1'}, {'id': '2'}]},
         {'id': 'numbers', 'children': [{'id': '1'}, {'id': '2'}]},
         {'id': 'letters', 'children': [{'id': 'A'}, {'id': 'B'}]},
         {'id': 'letters', 'children': [{'id': 'A'}, {'id': 'B'}]},
     ], label_key='id', on_select=lambda e: ui.notify(e.value))
     ], label_key='id', on_select=lambda e: ui.notify(e.value))
+
+
+def more() -> None:
+    @text_demo('Tree with custom header and body', '''
+        Scoped slots can be used to insert custom content into the header and body of a tree node.
+        See the [Quasar documentation](https://quasar.dev/vue-components/tree#customize-content) for more information.
+    ''')
+    def tree_with_custom_header_and_body():
+        tree = ui.tree([
+            {'id': 'numbers', 'description': 'Just some numbers', 'children': [
+                {'id': '1', 'description': 'The first number'},
+                {'id': '2', 'description': 'The second number'},
+            ]},
+            {'id': 'letters', 'description': 'Some latin letters', 'children': [
+                {'id': 'A', 'description': 'The first letter'},
+                {'id': 'B', 'description': 'The second letter'},
+            ]},
+        ], label_key='id', on_select=lambda e: ui.notify(e.value))
+
+        tree.add_slot('default-header', '''
+            <span :props="props">Node <strong>{{ props.node.id }}</strong></span>
+        ''')
+        tree.add_slot('default-body', '''
+            <span :props="props">Description: "{{ props.node.description }}"</span>
+        ''')

+ 13 - 0
website/more_documentation/upload_documentation.py

@@ -1,5 +1,18 @@
 from nicegui import ui
 from nicegui import ui
 
 
+from ..documentation_tools import text_demo
+
 
 
 def main_demo() -> None:
 def main_demo() -> None:
     ui.upload(on_upload=lambda e: ui.notify(f'Uploaded {e.name}')).classes('max-w-full')
     ui.upload(on_upload=lambda e: ui.notify(f'Uploaded {e.name}')).classes('max-w-full')
+
+
+def more() -> None:
+    @text_demo('Upload restrictions', '''
+        In this demo, the upload is restricted to a maximum file size of 1 MB.
+        When a file is rejected, a notification is shown.
+    ''')
+    def upload_restrictions() -> None:
+        ui.upload(on_upload=lambda e: ui.notify(f'Uploaded {e.name}'),
+                  on_rejected=lambda: ui.notify('Rejected!'),
+                  max_file_size=1_000_000).classes('max-w-full')