Repository: zauberzeug/nicegui
Pull request #4347 (2025-02-13 23:11:01)

Introduce QRating element
This PR introduces a Rating element based on Quasars QRating https://quasar.dev/vue-components/rating



Patch:
diff --git a/.gitattributes b/.gitattributes
index df6e86d8..45f6cf44 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -11,6 +11,7 @@ nicegui/static/quasar.*            linguist-vendored
 nicegui/static/socket.*            linguist-vendored
 nicegui/static/tailwindcss.*       linguist-vendored
 nicegui/static/vue.*               linguist-vendored
+nicegui/language.py                linguist-generated
 website/**                         linguist-documentation
 website/static/fuse.js@*           linguist-vendored
 examples/**                        linguist-documentation
diff --git a/fetch_languages.py b/fetch_languages.py
new file mode 100755
index 00000000..537259e7
--- /dev/null
+++ b/fetch_languages.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+from pathlib import Path
+
+codes = sorted(path.name.split('.')[0] for path in Path('nicegui/static/lang').glob('*.umd.prod.js'))
+
+with (Path(__file__).parent / 'nicegui' / 'language.py').open('w') as f:
+    f.write('from typing import Literal\n')
+    f.write('\n')
+    f.write('Language = Literal[\n')
+    for code in codes:
+        f.write(f"    '{code}',\n")
+    f.write(']\n')
diff --git a/nicegui/client.py b/nicegui/client.py
index c18deb94..f5b4fc9a 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -23,6 +23,7 @@ from .javascript_request import JavaScriptRequest
 from .logging import log
 from .observables import ObservableDict
 from .outbox import Outbox
+from .translations import translations
 from .version import __version__
 
 if TYPE_CHECKING:
@@ -157,6 +158,7 @@ class Client:
                 'favicon_url': get_favicon_url(self.page, prefix),
                 'dark': str(self.page.resolve_dark()),
                 'language': self.page.resolve_language(),
+                'translations': translations[self.page.resolve_language()],
                 'prefix': prefix,
                 'tailwind': core.app.config.tailwind,
                 'prod_js': core.app.config.prod_js,
diff --git a/nicegui/elements/leaflet.js b/nicegui/elements/leaflet.js
index cb1f3cb1..f54f7f41 100644
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -127,7 +127,11 @@ export default {
   },
   methods: {
     add_layer(layer, id) {
-      const l = L[layer.type](...layer.args);
+      let obj = L;
+      for (const part of layer.type.split(".")) {
+        obj = obj[part];
+      }
+      const l = obj(...layer.args);
       l.id = id;
       l.addTo(this.map);
     },
diff --git a/nicegui/elements/leaflet.py b/nicegui/elements/leaflet.py
index 2538a1b0..2a5bbfb9 100644
--- a/nicegui/elements/leaflet.py
+++ b/nicegui/elements/leaflet.py
@@ -16,6 +16,7 @@ class Leaflet(Element, component='leaflet.js', default_classes='nicegui-leaflet'
     from .leaflet_layers import GenericLayer as generic_layer
     from .leaflet_layers import Marker as marker
     from .leaflet_layers import TileLayer as tile_layer
+    from .leaflet_layers import WmsLayer as wms_layer
 
     center = binding.BindableProperty(lambda sender, value: cast(Leaflet, sender).set_center(value))
     zoom = binding.BindableProperty(lambda sender, value: cast(Leaflet, sender).set_zoom(value))
diff --git a/nicegui/elements/leaflet_layers.py b/nicegui/elements/leaflet_layers.py
index 56da8c55..651450e5 100644
--- a/nicegui/elements/leaflet_layers.py
+++ b/nicegui/elements/leaflet_layers.py
@@ -31,6 +31,18 @@ class TileLayer(Layer):
         }
 
 
+@dataclass(**KWONLY_SLOTS)
+class WmsLayer(Layer):
+    url_template: str
+    options: Dict = field(default_factory=dict)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': 'tileLayer.wms',
+            'args': [self.url_template, self.options],
+        }
+
+
 @dataclass(**KWONLY_SLOTS)
 class Marker(Layer):
     latlng: Tuple[float, float]
diff --git a/nicegui/elements/rating.py b/nicegui/elements/rating.py
new file mode 100644
index 00000000..3a502f6e
--- /dev/null
+++ b/nicegui/elements/rating.py
@@ -0,0 +1,52 @@
+from typing import List, Optional, Union
+
+from ..events import Handler, ValueChangeEventArguments
+from .mixins.disableable_element import DisableableElement
+from .mixins.value_element import ValueElement
+
+
+class Rating(ValueElement, DisableableElement):
+
+    def __init__(self,
+                 value: Optional[float] = None,
+                 max: int = 5,  # pylint: disable=redefined-builtin
+                 icon: Optional[str] = None,
+                 icon_selected: Optional[str] = None,
+                 icon_half: Optional[str] = None,
+                 color: Optional[Union[str, List[str]]] = 'primary',
+                 size: Optional[str] = None,
+                 on_change: Optional[Handler[ValueChangeEventArguments]] = None,
+                 ) -> None:
+        """Rating
+
+        This element is based on Quasar's `QRating <https://quasar.dev/vue-components/rating>`_ component.
+
+        *Added in version 2.12.0*
+
+        :param value: initial value (default: ``None``)
+        :param max: maximum rating, number of icons (default: 5)
+        :param icon: name of icons to be displayed (default: star)
+        :param icon_selected: name of an icon to be displayed when selected (default: same as ``icon``)
+        :param icon_half: name of an icon to be displayed when half-selected (default: same as ``icon``)
+        :param color: color(s) of the icons (Quasar, Tailwind, or CSS colors or ``None``, default: "primary")
+        :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
+        :param on_change: callback to execute when selection changes
+        """
+        super().__init__(tag='q-rating', value=value, on_value_change=on_change)
+
+        self._props['max'] = max
+
+        if icon:
+            self._props['icon'] = icon
+
+        if color:
+            self._props['color'] = color
+
+        if icon_selected:
+            self._props['icon-selected'] = icon_selected
+
+        if icon_half:
+            self._props['icon-half'] = icon_half
+
+        if size:
+            self._props['size'] = size
diff --git a/nicegui/elements/scene.py b/nicegui/elements/scene.py
index 58b4486c..bc6b9e9c 100644
--- a/nicegui/elements/scene.py
+++ b/nicegui/elements/scene.py
@@ -112,7 +112,7 @@ class Scene(Element,
         self.objects: Dict[str, Object3D] = {}
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self._click_handlers = [on_click] if on_click else []
-        self._props['click_events'] = click_events
+        self._props['click_events'] = click_events[:]
         self._drag_start_handlers = [on_drag_start] if on_drag_start else []
         self._drag_end_handlers = [on_drag_end] if on_drag_end else []
         self.on('init', self._handle_init)
diff --git a/nicegui/functions/refreshable.py b/nicegui/functions/refreshable.py
index b46b3f8d..7ef44d9a 100644
--- a/nicegui/functions/refreshable.py
+++ b/nicegui/functions/refreshable.py
@@ -60,8 +60,11 @@ class refreshable(Generic[_P, _T]):
     def __init__(self, func: Callable[_P, Union[_T, Awaitable[_T]]]) -> None:
         """Refreshable UI functions
 
-        The `@ui.refreshable` decorator allows you to create functions that have a `refresh` method.
+        The ``@ui.refreshable`` decorator allows you to create functions that have a ``refresh`` method.
         This method will automatically delete all elements created by the function and recreate them.
+
+        For decorating refreshable methods in classes, there is a ``@ui.refreshable_method`` decorator,
+        which is equivalent but prevents static type checking errors.
         """
         self.func = func
         self.instance = None
diff --git a/nicegui/language.py b/nicegui/language.py
index 76b9778b..f4bb1863 100644
--- a/nicegui/language.py
+++ b/nicegui/language.py
@@ -6,10 +6,13 @@ Language = Literal[
     'az-Latn',
     'bg',
     'bn',
+    'bs-BA',
     'ca',
     'cs',
     'da',
     'de',
+    'de-CH',
+    'de-DE',
     'el',
     'en-GB',
     'en-US',
@@ -23,6 +26,7 @@ Language = Literal[
     'fr',
     'gn',
     'he',
+    'hi',
     'hr',
     'hu',
     'id',
@@ -36,9 +40,11 @@ Language = Literal[
     'lt',
     'lu',
     'lv',
+    'mk',
     'ml',
     'mm',
     'ms',
+    'ms-MY',
     'my',
     'nb-NO',
     'nl',
@@ -55,6 +61,7 @@ Language = Literal[
     'sv',
     'ta',
     'th',
+    'tl',
     'tr',
     'ug',
     'uk',
diff --git a/nicegui/page.py b/nicegui/page.py
index c4f46d24..45dc690a 100644
--- a/nicegui/page.py
+++ b/nicegui/page.py
@@ -90,7 +90,7 @@ class page:
         """Return whether the page should use dark mode."""
         return self.dark if self.dark is not ... else core.app.config.dark
 
-    def resolve_language(self) -> Optional[str]:
+    def resolve_language(self) -> Language:
         """Return the language of the page."""
         return self.language if self.language is not ... else core.app.config.language
 
diff --git a/nicegui/templates/index.html b/nicegui/templates/index.html
index 8b47bc1b..4a6a9130 100644
--- a/nicegui/templates/index.html
+++ b/nicegui/templates/index.html
@@ -38,8 +38,8 @@
 
     <div id="app"></div>
     <div id="popup" aria-hidden="true">
-      <span>Connection lost.</span>
-      <span>Trying to reconnect...</span>
+      <span>{{ translations.connection_lost }}</span>
+      <span>{{ translations.trying_to_reconnect }}</span>
     </div>
     <script type="module">
       const app = createApp(parseElements(String.raw`{{ elements | safe }}`), {
diff --git a/nicegui/translations.py b/nicegui/translations.py
new file mode 100644
index 00000000..801ed722
--- /dev/null
+++ b/nicegui/translations.py
@@ -0,0 +1,42 @@
+translations = {
+    'en-US': {
+        'connection_lost': 'Connection lost.',
+        'trying_to_reconnect': 'Trying to reconnect...'
+    },
+    'zh-CN': {
+        'connection_lost': '连接丢失',
+        'trying_to_reconnect': '正在尝试重新连接...'
+    },
+    'zh-TW': {
+        'connection_lost': '連線丟失',
+        'trying_to_reconnect': '正在嘗試重新連線...'
+    },
+    'ja-JP': {
+        'connection_lost': '接続が切断されました',
+        'trying_to_reconnect': '再接続を試みています...'
+    },
+    'ko-KR': {
+        'connection_lost': '연결이 끊어졌습니다',
+        'trying_to_reconnect': '다시 연결하는 중...'
+    },
+    'fr-FR': {
+        'connection_lost': 'Connexion perdue',
+        'trying_to_reconnect': 'Tentative de reconnexion...'
+    },
+    'de-DE': {
+        'connection_lost': 'Verbindung verloren',
+        'trying_to_reconnect': 'Versuche erneut zu verbinden...'
+    },
+    'it-IT': {
+        'connection_lost': 'Connessione persa',
+        'trying_to_reconnect': 'Tentativo di riconnessione...'
+    },
+    'es-ES': {
+        'connection_lost': 'Conexión perdida',
+        'trying_to_reconnect': 'Intentando reconectar...'
+    },
+    'ru-RU': {
+        'connection_lost': 'Соединение потеряно',
+        'trying_to_reconnect': 'Попытка переподключения...'
+    }
+}
diff --git a/nicegui/ui.py b/nicegui/ui.py
index 452db9d5..ad2713c3 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -83,6 +83,7 @@ __all__ = [
     'query',
     'radio',
     'range',
+    'rating',
     'refreshable',
     'refreshable_method',
     'restructured_text',
@@ -192,6 +193,7 @@ from .elements.pyplot import Pyplot as pyplot
 from .elements.query import Query as query
 from .elements.radio import Radio as radio
 from .elements.range import Range as range  # pylint: disable=redefined-builtin
+from .elements.rating import Rating as rating
 from .elements.restructured_text import ReStructuredText as restructured_text
 from .elements.row import Row as row
 from .elements.scene import Scene as scene
diff --git a/poetry.lock b/poetry.lock
index 6ea44956..adae54e6 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1710,13 +1710,13 @@ pyspark = ["pyspark (>=3.5.0)"]
 
 [[package]]
 name = "nicegui-highcharts"
-version = "2.0.2"
+version = "2.1.0"
 description = "Add Highcharts elements to your NiceGUI app."
 optional = true
 python-versions = "<4.0,>=3.8"
 files = [
-    {file = "nicegui_highcharts-2.0.2-py3-none-any.whl", hash = "sha256:9004ce220bf24e37064f40fe1da38cfbd905cb92c650f31efa7abdc838c0a805"},
-    {file = "nicegui_highcharts-2.0.2.tar.gz", hash = "sha256:3240790086ce7c87171e8ab1e4e59bb2496ad0028b2b6df8da9961f5f428b5af"},
+    {file = "nicegui_highcharts-2.1.0-py3-none-any.whl", hash = "sha256:09e437caa31a434497b6b82cd0d9c1100982fca87549182a4b853f0e98c9c95b"},
+    {file = "nicegui_highcharts-2.1.0.tar.gz", hash = "sha256:343833f508304cd5df938cffc35fc6f2340d4250b50f2a7be5161469f5429a58"},
 ]
 
 [package.dependencies]
diff --git a/website/documentation/content/leaflet_documentation.py b/website/documentation/content/leaflet_documentation.py
index 37a7434e..46e27e60 100644
--- a/website/documentation/content/leaflet_documentation.py
+++ b/website/documentation/content/leaflet_documentation.py
@@ -21,11 +21,14 @@ def main_demo() -> None:
     You can find more map styles at <https://leaflet-extras.github.io/leaflet-providers/preview/>.
     Each call to `tile_layer` stacks upon the previous ones.
     So if you want to change the map style, you have to remove the default one first.
+
+    *Updated in version 2.12.0: Both WMTS and WMS map services are supported.*
 ''')
 def map_style() -> None:
-    m = ui.leaflet(center=(51.505, -0.090), zoom=3)
-    m.clear_layers()
-    m.tile_layer(
+    ui.label('Web Map Tile Service')
+    map1 = ui.leaflet(center=(51.505, -0.090), zoom=3)
+    map1.clear_layers()
+    map1.tile_layer(
         url_template=r'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
         options={
             'maxZoom': 17,
@@ -35,6 +38,16 @@ def map_style() -> None:
         },
     )
 
+    ui.label('Web Map Service')
+    map2 = ui.leaflet(center=(51.505, -0.090), zoom=3)
+    map2.clear_layers()
+    map2.wms_layer(
+        url_template='http://ows.mundialis.de/services/service?',
+        options={
+            'layers': 'TOPO-WMS,OSM-Overlay-WMS'
+        },
+    )
+
 
 @doc.demo('Add Markers on Click', '''
     You can add markers to the map with `marker`.
diff --git a/website/documentation/content/rating_documentation.py b/website/documentation/content/rating_documentation.py
new file mode 100644
index 00000000..20f390fb
--- /dev/null
+++ b/website/documentation/content/rating_documentation.py
@@ -0,0 +1,49 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.rating)
+def main_demo() -> None:
+    ui.rating(value=4)
+
+
+@doc.demo('Customize icons', '''
+    You can customize name and size of the icons.
+    Optionally, unselected, selected or half-selected values can have different icons.
+''')
+def customize_icons():
+    ui.rating(
+        value=3.5,
+        size='lg',
+        icon='sentiment_dissatisfied',
+        icon_selected='sentiment_satisfied',
+        icon_half='sentiment_neutral',
+    )
+    ui.rating(
+        value=3.5,
+        size='lg',
+        icon='star',
+        icon_selected='star',
+        icon_half='star_half',
+    )
+
+
+@doc.demo('Customize color', '''
+    You can customize the color of the rating either by providing a single color or a range of different colors.
+''')
+def rating_color():
+    ui.rating(value=3, color='red-10')
+    ui.rating(value=5, color=['green-2', 'green-4', 'green-6', 'green-8', 'green-10'])
+
+
+@doc.demo('Maximum rating', '''
+    This demo shows how to change the maximum possible rating
+    as well as binding the value to a slider.
+''')
+def rating_scale():
+    slider = ui.slider(value=5, min=0, max=10)
+    ui.rating(max=10, icon='circle').bind_value(slider)
+
+
+doc.reference(ui.range)
diff --git a/website/documentation/content/refreshable_documentation.py b/website/documentation/content/refreshable_documentation.py
index 67f45cce..23459cc2 100644
--- a/website/documentation/content/refreshable_documentation.py
+++ b/website/documentation/content/refreshable_documentation.py
@@ -145,7 +145,7 @@ def local_scope_b():
     from datetime import datetime
 
     class Clock:
-        @ui.refreshable
+        @ui.refreshable_method
         def time(self):
             ui.label(f'Time: {datetime.now()}')
 
diff --git a/website/documentation/content/section_controls.py b/website/documentation/content/section_controls.py
index e57509ba..a9737795 100644
--- a/website/documentation/content/section_controls.py
+++ b/website/documentation/content/section_controls.py
@@ -16,6 +16,7 @@ from . import (
     number_documentation,
     radio_documentation,
     range_documentation,
+    rating_documentation,
     select_documentation,
     slider_documentation,
     switch_documentation,
@@ -39,6 +40,7 @@ doc.intro(checkbox_documentation)
 doc.intro(switch_documentation)
 doc.intro(slider_documentation)
 doc.intro(range_documentation)
+doc.intro(rating_documentation)
 doc.intro(joystick_documentation)
 doc.intro(input_documentation)
 doc.intro(textarea_documentation)


Test patch:
diff --git a/tests/test_rating.py b/tests/test_rating.py
new file mode 100644
index 00000000..ac71d620
--- /dev/null
+++ b/tests/test_rating.py
@@ -0,0 +1,18 @@
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_rating_click(screen: Screen):
+    rating = ui.rating(value=2)
+    ui.label().bind_text_from(rating, 'value', lambda x: f'Value: {x}')
+
+    screen.open('/')
+    rating_icons = screen.find_all_by_class('q-rating__icon-container')
+    rating_icons[0].click()
+    screen.should_contain('Value: 1')
+
+    rating_icons[3].click()
+    screen.should_contain('Value: 4')
+
+    rating_icons[3].click()  # already selected, should unselect
+    screen.should_contain('Value: 0')