Преглед изворни кода

Merge branch 'main' into frankvp11/main

# Conflicts:
#	website/documentation/content/section_data_elements.py
Falko Schindler пре 1 година
родитељ
комит
da7107a6d0
81 измењених фајлова са 2129 додато и 215 уклоњено
  1. 1 1
      .vscode/settings.json
  2. 3 3
      CITATION.cff
  3. 2 0
      DEPENDENCIES.md
  4. 0 20
      examples/map/leaflet.js
  5. 0 14
      examples/map/leaflet.py
  6. 0 22
      examples/map/main.py
  7. 1 1
      fetch_milestone.py
  8. 6 3
      nicegui/client.py
  9. 21 1
      nicegui/dependencies.py
  10. 15 4
      nicegui/element.py
  11. 7 6
      nicegui/elements/aggrid.py
  12. 1 0
      nicegui/elements/editor.py
  13. 3 3
      nicegui/elements/input.js
  14. 2 2
      nicegui/elements/input.py
  15. 126 0
      nicegui/elements/leaflet.js
  16. 131 0
      nicegui/elements/leaflet.py
  17. 29 0
      nicegui/elements/leaflet_layer.py
  18. 48 0
      nicegui/elements/leaflet_layers.py
  19. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/layers-2x.png
  20. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/layers.png
  21. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon-2x.png
  22. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon.png
  23. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/marker-shadow.png
  24. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet-2x.png
  25. BIN
      nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.png
  26. 156 0
      nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.svg
  27. 10 0
      nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.css
  28. 7 0
      nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.js
  29. BIN
      nicegui/elements/lib/leaflet/leaflet/images/layers-2x.png
  30. BIN
      nicegui/elements/lib/leaflet/leaflet/images/layers.png
  31. BIN
      nicegui/elements/lib/leaflet/leaflet/images/marker-icon-2x.png
  32. BIN
      nicegui/elements/lib/leaflet/leaflet/images/marker-icon.png
  33. BIN
      nicegui/elements/lib/leaflet/leaflet/images/marker-shadow.png
  34. 661 0
      nicegui/elements/lib/leaflet/leaflet/leaflet.css
  35. 4 0
      nicegui/elements/lib/leaflet/leaflet/leaflet.js
  36. 0 0
      nicegui/elements/lib/leaflet/leaflet/leaflet.js.map
  37. 1 21
      nicegui/elements/markdown.py
  38. 11 0
      nicegui/elements/notification.js
  39. 163 0
      nicegui/elements/notification.py
  40. 4 2
      nicegui/elements/pyplot.py
  41. 0 5
      nicegui/elements/scene.py
  42. 2 1
      nicegui/elements/select.js
  43. 38 7
      nicegui/elements/table.py
  44. 5 3
      nicegui/elements/tooltip.py
  45. 1 2
      nicegui/events.py
  46. 23 0
      nicegui/functions/on.py
  47. 14 0
      nicegui/functions/page_title.py
  48. 5 0
      nicegui/helpers.py
  49. 12 1
      nicegui/nicegui.py
  50. 0 0
      nicegui/py.typed
  51. 128 19
      nicegui/static/nicegui.css
  52. 27 0
      nicegui/static/utils/resources.js
  53. 6 1
      nicegui/storage.py
  54. 10 9
      nicegui/templates/index.html
  55. 8 0
      nicegui/ui.py
  56. 19 0
      npm.json
  57. 6 0
      npm.py
  58. 2 2
      pyproject.toml
  59. 2 0
      tests/test_endpoint_docs.py
  60. 39 0
      tests/test_leaflet.py
  61. 24 0
      tests/test_notification.py
  62. 11 0
      tests/test_number.py
  63. 21 0
      tests/test_page_title.py
  64. 10 4
      tests/test_select.py
  65. 13 3
      tests/test_table.py
  66. 18 16
      website/documentation/content/generic_events_documentation.py
  67. 124 0
      website/documentation/content/leaflet_documentation.py
  68. 22 0
      website/documentation/content/notification_documentation.py
  69. 8 0
      website/documentation/content/page_title_documentation.py
  70. 4 3
      website/documentation/content/section_data_elements.py
  71. 5 16
      website/documentation/content/section_page_layout.py
  72. 2 1
      website/documentation/content/section_pages_routing.py
  73. 28 0
      website/documentation/content/section_styling_appearance.py
  74. 12 0
      website/documentation/content/table_documentation.py
  75. 37 0
      website/documentation/content/tooltip_documentation.py
  76. 25 9
      website/documentation/reference.py
  77. 1 3
      website/documentation/rendering.py
  78. 2 2
      website/documentation/windows.py
  79. 0 2
      website/examples.py
  80. 1 1
      website/static/style.css
  81. 1 2
      website/style.py

+ 1 - 1
.vscode/settings.json

@@ -30,7 +30,7 @@
   "[python]": {
     "editor.defaultFormatter": "ms-python.autopep8",
     "editor.codeActionsOnSave": {
-      "source.organizeImports": true
+      "source.organizeImports": "explicit"
     }
   }
 }

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.4.4
-date-released: '2023-12-04'
+version: v1.4.6
+date-released: '2023-12-18'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.10256862
+doi: 10.5281/zenodo.10402969

+ 2 - 0
DEPENDENCIES.md

@@ -7,6 +7,8 @@
 - es-module-shims: 1.8.0 ([MIT](https://opensource.org/licenses/MIT))
 - aggrid: 30.2.0 ([MIT](https://opensource.org/licenses/MIT))
 - echarts: 5.4.3 ([Apache-2.0](https://opensource.org/licenses/Apache-2.0))
+- leaflet: 1.9.4 ([BSD-2-Clause](https://opensource.org/licenses/BSD-2-Clause))
+- leaflet-draw: 1.0.4 ([MIT](https://opensource.org/licenses/MIT))
 - mermaid: 10.5.1 ([MIT](https://opensource.org/licenses/MIT))
 - nipplejs: 0.10.1 ([MIT](https://opensource.org/licenses/MIT))
 - plotly: 2.27.0 ([MIT](https://opensource.org/licenses/MIT))

+ 0 - 20
examples/map/leaflet.js

@@ -1,20 +0,0 @@
-export default {
-  template: "<div></div>",
-  mounted() {
-    this.map = L.map(this.$el);
-    L.tileLayer("http://{s}.tile.osm.org/{z}/{x}/{y}.png", {
-      attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>',
-    }).addTo(this.map);
-  },
-  methods: {
-    set_location(latitude, longitude) {
-      this.target = L.latLng(latitude, longitude);
-      this.map.setView(this.target, 9);
-      if (this.marker) {
-        this.map.removeLayer(this.marker);
-      }
-      this.marker = L.marker(this.target);
-      this.marker.addTo(this.map);
-    },
-  },
-};

+ 0 - 14
examples/map/leaflet.py

@@ -1,14 +0,0 @@
-from typing import Tuple
-
-from nicegui import ui
-
-
-class leaflet(ui.element, component='leaflet.js'):
-
-    def __init__(self) -> None:
-        super().__init__()
-        ui.add_head_html('<link href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css" rel="stylesheet"/>')
-        ui.add_head_html('<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"></script>')
-
-    def set_location(self, location: Tuple[float, float]) -> None:
-        self.run_method('set_location', location[0], location[1])

+ 0 - 22
examples/map/main.py

@@ -1,22 +0,0 @@
-#!/usr/bin/env python3
-from leaflet import leaflet  # this module wraps the JavaScript lib leafletjs.com into an easy-to-use NiceGUI element
-
-from nicegui import Client, ui
-
-locations = {
-    (52.5200, 13.4049): 'Berlin',
-    (40.7306, -74.0060): 'New York',
-    (39.9042, 116.4074): 'Beijing',
-    (35.6895, 139.6917): 'Tokyo',
-}
-
-
-@ui.page('/')
-async def main_page(client: Client):
-    map = leaflet().classes('w-full h-96')
-    selection = ui.select(locations, on_change=lambda e: map.set_location(e.value)).classes('w-40')
-    await client.connected()  # wait for websocket connection
-    selection.set_value(next(iter(locations)))  # trigger map.set_location with first location in selection
-
-
-ui.run()

+ 1 - 1
fetch_milestone.py

@@ -13,7 +13,7 @@ parser.add_argument('milestone_title', help='Title of the milestone to fetch.')
 args = parser.parse_args()
 milestone_title: str = args.milestone_title
 
-milestones = requests.get(f'{BASE_URL}/milestones', timeout=5).json()
+milestones = requests.get(f'{BASE_URL}/milestones?state=all&per_page=100', timeout=5).json()
 matching_milestones = [milestone for milestone in milestones if milestone['title'] == milestone_title]
 if not matching_milestones:
     print(f'Milestone "{milestone_title}" not found!')

+ 6 - 3
nicegui/client.py

@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, Iter
 from fastapi import Request
 from fastapi.responses import Response
 from fastapi.templating import Jinja2Templates
+from typing_extensions import Self
 
 from . import background_tasks, binding, core, helpers, json, outbox
 from .awaitable_response import AwaitableResponse
@@ -63,6 +64,8 @@ class Client:
 
         self.waiting_javascript_commands: Dict[str, Any] = {}
 
+        self.title: Optional[str] = None
+
         self._head_html = ''
         self._body_html = ''
 
@@ -98,11 +101,11 @@ class Client:
         """Return the HTML code to be inserted in the <body> of the page template."""
         return self.shared_body_html + self._body_html
 
-    def __enter__(self):
+    def __enter__(self) -> Self:
         self.content.__enter__()
         return self
 
-    def __exit__(self, *_):
+    def __exit__(self, *_) -> None:
         self.content.__exit__()
 
     def build_response(self, request: Request, status_code: int = 200) -> Response:
@@ -127,7 +130,7 @@ class Client:
             'imports': json.dumps(imports),
             'js_imports': '\n'.join(js_imports),
             'quasar_config': json.dumps(core.app.config.quasar_config),
-            'title': self.page.resolve_title(),
+            'title': self.page.resolve_title() if self.title is None else self.title,
             'viewport': self.page.resolve_viewport(),
             'favicon_url': get_favicon_url(self.page, prefix),
             'dark': str(self.page.resolve_dark()),

+ 21 - 1
nicegui/dependencies.py

@@ -38,6 +38,12 @@ class JsComponent(Component):
     pass
 
 
+@dataclass(**KWONLY_SLOTS)
+class Resource:
+    key: str
+    path: Path
+
+
 @dataclass(**KWONLY_SLOTS)
 class Library:
     key: str
@@ -49,6 +55,7 @@ class Library:
 vue_components: Dict[str, VueComponent] = {}
 js_components: Dict[str, JsComponent] = {}
 libraries: Dict[str, Library] = {}
+resources: Dict[str, Resource] = {}
 
 
 def register_vue_component(path: Path) -> Component:
@@ -89,17 +96,30 @@ def register_library(path: Path, *, expose: bool = False) -> Library:
     raise ValueError(f'Unsupported library type "{path.suffix}"')
 
 
+def register_resource(path: Path) -> Resource:
+    """Register a resource."""
+    key = compute_key(path)
+    if key in resources and resources[key].path == path:
+        return resources[key]
+    assert key not in resources, f'Duplicate resource {key}'
+    resources[key] = Resource(key=key, path=path)
+    return resources[key]
+
+
 def compute_key(path: Path) -> str:
     """Compute a key for a given path using a hash function.
 
     If the path is relative to the NiceGUI base directory, the key is computed from the relative path.
     """
     nicegui_base = Path(__file__).parent
+    is_file = path.is_file()
     try:
         path = path.relative_to(nicegui_base)
     except ValueError:
         pass
-    return f'{hash_file_path(path.parent)}/{path.name}'
+    if is_file:
+        return f'{hash_file_path(path.parent)}/{path.name}'
+    return f'{hash_file_path(path)}'
 
 
 def _get_name(path: Path) -> str:

+ 15 - 4
nicegui/element.py

@@ -9,13 +9,14 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional,
 
 from typing_extensions import Self
 
-from . import context, core, events, json, outbox, storage
+from . import context, core, events, helpers, json, outbox, storage
 from .awaitable_response import AwaitableResponse, NullResponse
-from .dependencies import Component, Library, register_library, register_vue_component
+from .dependencies import Component, Library, register_library, register_resource, register_vue_component
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .slot import Slot
 from .tailwind import Tailwind
+from .version import __version__
 
 if TYPE_CHECKING:
     from .client import Client
@@ -138,6 +139,14 @@ class Element(Visibility):
         cls._default_classes = copy(cls._default_classes)
         cls._default_style = copy(cls._default_style)
 
+    def add_resource(self, path: Union[str, Path]) -> None:
+        """Add a resource to the element.
+
+        :param path: path to the resource (e.g. folder with CSS and JavaScript files)
+        """
+        resource = register_resource(Path(path))
+        self._props['resource_path'] = f'/_nicegui/{__version__}/resources/{resource.key}'
+
     def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
         """Add a slot to the element.
 
@@ -152,7 +161,7 @@ class Element(Visibility):
         self.default_slot.__enter__()
         return self
 
-    def __exit__(self, *_):
+    def __exit__(self, *_) -> None:
         self.default_slot.__exit__(*_)
 
     def __iter__(self) -> Iterator[Element]:
@@ -388,7 +397,7 @@ class Element(Visibility):
         if handler:
             listener = EventListener(
                 element_id=self.id,
-                type=type,
+                type=helpers.kebab_to_camel_case(type),
                 args=[args] if args and isinstance(args[0], str) else args,  # type: ignore
                 handler=handler,
                 throttle=throttle,
@@ -408,6 +417,8 @@ class Element(Visibility):
 
     def update(self) -> None:
         """Update the element on the client side."""
+        if self.is_deleted:
+            return
         outbox.enqueue_update(self)
 
     def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:

+ 7 - 6
nicegui/elements/aggrid.py

@@ -1,7 +1,7 @@
-from __future__ import annotations
-
 from typing import Dict, List, Optional, cast
 
+from typing_extensions import Self
+
 from .. import optional_features
 from ..awaitable_response import AwaitableResponse
 from ..element import Element
@@ -39,11 +39,12 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
         self._classes.append('nicegui-aggrid')
         self._classes.append(f'ag-theme-{theme}')
 
-    @staticmethod
-    def from_pandas(df: pd.DataFrame, *,
+    @classmethod
+    def from_pandas(cls,
+                    df: pd.DataFrame, *,
                     theme: str = 'balham',
                     auto_size_columns: bool = True,
-                    options: Dict = {}) -> AgGrid:
+                    options: Dict = {}) -> Self:
         """Create an AG Grid from a Pandas DataFrame.
 
         Note:
@@ -69,7 +70,7 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
             df[complex_cols] = df[complex_cols].astype(str)
             df[period_cols] = df[period_cols].astype(str)
 
-        return AgGrid({
+        return cls({
             'columnDefs': [{'field': str(col)} for col in df.columns],
             'rowData': df.to_dict('records'),
             'suppressDotNotation': True,

+ 1 - 0
nicegui/elements/editor.py

@@ -21,5 +21,6 @@ class Editor(ValueElement, DisableableElement):
         :param on_change: callback to be invoked when the value changes
         """
         super().__init__(tag='q-editor', value=value, on_value_change=on_change)
+        self._classes.append('nicegui-editor')
         if placeholder is not None:
             self._props['placeholder'] = placeholder

+ 3 - 3
nicegui/elements/input.js

@@ -18,7 +18,7 @@ export default {
   `,
   props: {
     id: String,
-    autocomplete: Array,
+    _autocomplete: Array,
     value: String,
   },
   data() {
@@ -41,14 +41,14 @@ export default {
   computed: {
     shadowText() {
       if (!this.inputValue) return "";
-      const matchingOption = this.autocomplete.find((option) =>
+      const matchingOption = this._autocomplete.find((option) =>
         option.toLowerCase().startsWith(this.inputValue.toLowerCase())
       );
       return matchingOption ? matchingOption.slice(this.inputValue.length) : "";
     },
     withDatalist() {
       const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
-      return isMobile && this.autocomplete && this.autocomplete.length > 0;
+      return isMobile && this._autocomplete && this._autocomplete.length > 0;
     },
   },
   methods: {

+ 2 - 2
nicegui/elements/input.py

@@ -59,11 +59,11 @@ class Input(ValidationElement, DisableableElement, component='input.js'):
                     self.props(f'type={"text" if is_hidden else "password"}')
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
 
-        self._props['autocomplete'] = autocomplete or []
+        self._props['_autocomplete'] = autocomplete or []
 
     def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None:
         """Set the autocomplete list."""
-        self._props['autocomplete'] = autocomplete
+        self._props['_autocomplete'] = autocomplete
         self.update()
 
     def _handle_value_change(self, value: Any) -> None:

+ 126 - 0
nicegui/elements/leaflet.js

@@ -0,0 +1,126 @@
+import { loadResource } from "../../static/utils/resources.js";
+
+export default {
+  template: "<div></div>",
+  props: {
+    center: Array,
+    zoom: Number,
+    options: Object,
+    draw_control: Object,
+    resource_path: String,
+  },
+  async mounted() {
+    await this.$nextTick(); // NOTE: wait for window.path_prefix to be set
+    await Promise.all([
+      loadResource(window.path_prefix + `${this.resource_path}/leaflet/leaflet.css`),
+      loadResource(window.path_prefix + `${this.resource_path}/leaflet/leaflet.js`),
+    ]);
+    if (this.draw_control) {
+      await Promise.all([
+        loadResource(window.path_prefix + `${this.resource_path}/leaflet-draw/leaflet.draw.css`),
+        loadResource(window.path_prefix + `${this.resource_path}/leaflet-draw/leaflet.draw.js`),
+      ]);
+    }
+    this.map = L.map(this.$el, {
+      ...this.options,
+      center: this.center,
+      zoom: this.zoom,
+    });
+    for (const type of [
+      "baselayerchange",
+      "overlayadd",
+      "overlayremove",
+      "layeradd",
+      "layerremove",
+      "zoomlevelschange",
+      "resize",
+      "unload",
+      "viewreset",
+      "load",
+      "zoomstart",
+      "movestart",
+      "zoom",
+      "move",
+      "zoomend",
+      "moveend",
+      "popupopen",
+      "popupclose",
+      "autopanstart",
+      "tooltipopen",
+      "tooltipclose",
+      "locationerror",
+      "locationfound",
+      "click",
+      "dblclick",
+      "mousedown",
+      "mouseup",
+      "mouseover",
+      "mouseout",
+      "mousemove",
+      "contextmenu",
+      "keypress",
+      "keydown",
+      "keyup",
+      "preclick",
+      "zoomanim",
+    ]) {
+      this.map.on(type, (e) => {
+        this.$emit(`map-${type}`, {
+          ...e,
+          originalEvent: undefined,
+          target: undefined,
+          sourceTarget: undefined,
+          center: [e.target.getCenter().lat, e.target.getCenter().lng],
+          zoom: e.target.getZoom(),
+        });
+      });
+    }
+    if (this.draw_control) {
+      for (const key in L.Draw.Event) {
+        const type = L.Draw.Event[key];
+        this.map.on(type, (e) => {
+          this.$emit(type, {
+            ...e,
+            layer: e.layer ? { ...e.layer, editing: undefined, _events: undefined } : undefined,
+            target: undefined,
+            sourceTarget: undefined,
+          });
+        });
+      }
+      const drawnItems = new L.FeatureGroup();
+      this.map.addLayer(drawnItems);
+      const drawControl = new L.Control.Draw({
+        edit: { featureGroup: drawnItems },
+        ...this.draw_control,
+      });
+      this.map.addControl(drawControl);
+    }
+    const connectInterval = setInterval(async () => {
+      if (window.socket.id === undefined) return;
+      this.$emit("init", { socket_id: window.socket.id });
+      clearInterval(connectInterval);
+    }, 100);
+  },
+  methods: {
+    setCenter(center) {
+      this.map.panTo(center);
+    },
+    setZoom(zoom) {
+      this.map.setZoom(zoom);
+    },
+    add_layer(layer, id) {
+      const l = L[layer.type](...layer.args);
+      l.id = id;
+      l.addTo(this.map);
+    },
+    remove_layer(id) {
+      this.map.eachLayer((layer) => layer.id === id && this.map.removeLayer(layer));
+    },
+    clear_layers() {
+      this.map.eachLayer((layer) => this.map.removeLayer(layer));
+    },
+    run_map_method(name, ...args) {
+      return this.map[name](...args);
+    },
+  },
+};

+ 131 - 0
nicegui/elements/leaflet.py

@@ -0,0 +1,131 @@
+from pathlib import Path
+from typing import Any, Dict, List, Tuple, Union, cast
+
+from typing_extensions import Self
+
+from .. import binding
+from ..awaitable_response import AwaitableResponse, NullResponse
+from ..element import Element
+from ..events import GenericEventArguments
+from .leaflet_layer import Layer
+
+
+class Leaflet(Element, component='leaflet.js'):
+    # pylint: disable=import-outside-toplevel
+    from .leaflet_layers import GenericLayer as generic_layer
+    from .leaflet_layers import Marker as marker
+    from .leaflet_layers import TileLayer as tile_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))
+
+    def __init__(self,
+                 center: Tuple[float, float] = (0.0, 0.0),
+                 zoom: int = 13,
+                 *,
+                 options: Dict = {},
+                 draw_control: Union[bool, Dict] = False,
+                 ) -> None:
+        """Leaflet map
+
+        This element is a wrapper around the `Leaflet <https://leafletjs.com/>`_ JavaScript library.
+
+        :param center: initial center location of the map (latitude/longitude, default: (0.0, 0.0))
+        :param zoom: initial zoom level of the map (default: 13)
+        :param draw_control: whether to show the draw toolbar (default: False)
+        :param options: additional options passed to the Leaflet map (default: {})
+        """
+        super().__init__()
+        self.add_resource(Path(__file__).parent / 'lib' / 'leaflet')
+        self._classes.append('nicegui-leaflet')
+
+        self.layers: List[Layer] = []
+        self.is_initialized = False
+
+        self.center = center
+        self.zoom = zoom
+        self._props['center'] = center
+        self._props['zoom'] = zoom
+        self._props['options'] = options
+        self._props['draw_control'] = draw_control
+
+        self.on('init', self._handle_init)
+        self.on('map-moveend', self._handle_moveend)
+        self.on('map-zoomend', self._handle_zoomend)
+
+        self.tile_layer(
+            url_template=r'https://{s}.tile.osm.org/{z}/{x}/{y}.png',
+            options={'attribution': '&copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'},
+        )
+
+    def __enter__(self) -> Self:
+        Layer.current_leaflet = self
+        return super().__enter__()
+
+    def __getattribute__(self, name: str) -> Any:
+        attribute = super().__getattribute__(name)
+        if isinstance(attribute, type) and issubclass(attribute, Layer):
+            Layer.current_leaflet = self
+        return attribute
+
+    def _handle_init(self, e: GenericEventArguments) -> None:
+        self.is_initialized = True
+        with self.client.individual_target(e.args['socket_id']):
+            for layer in self.layers:
+                self.run_method('add_layer', layer.to_dict(), layer.id)
+
+    def _handle_moveend(self, e: GenericEventArguments) -> None:
+        self.center = e.args['center']
+
+    def _handle_zoomend(self, e: GenericEventArguments) -> None:
+        self.zoom = e.args['zoom']
+
+    def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
+        if not self.is_initialized:
+            return NullResponse()
+        return super().run_method(name, *args, timeout=timeout, check_interval=check_interval)
+
+    def set_center(self, center: Tuple[float, float]) -> None:
+        """Set the center location of the map."""
+        if self._props['center'] == center:
+            return
+        self._props['center'] = center
+        self.run_method('setCenter', center)
+
+    def set_zoom(self, zoom: int) -> None:
+        """Set the zoom level of the map."""
+        if self._props['zoom'] == zoom:
+            return
+        self._props['zoom'] = zoom
+        self.run_method('setZoom', zoom)
+
+    def remove_layer(self, layer: Layer) -> None:
+        """Remove a layer from the map."""
+        self.layers.remove(layer)
+        self.run_method('remove_layer', layer.id)
+
+    def clear_layers(self) -> None:
+        """Remove all layers from the map."""
+        self.layers.clear()
+        self.run_method('clear_layers')
+
+    def run_map_method(self, name: str, *args, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
+        """Run a method of the Leaflet map instance.
+
+        Refer to the `Leaflet documentation <https://leafletjs.com/reference.html#map-methods-for-modifying-map-state>`_ for a list of methods.
+
+        If the function is awaited, the result of the method call is returned.
+        Otherwise, the method is executed without waiting for a response.
+
+        :param name: name of the method
+        :param args: arguments to pass to the method
+        :param timeout: timeout in seconds (default: 1 second)
+        :param check_interval: interval in seconds to check for a response (default: 0.01 seconds)
+
+        :return: AwaitableResponse that can be awaited to get the result of the method call
+        """
+        return self.run_method('run_map_method', name, *args, timeout=timeout, check_interval=check_interval)
+
+    def _handle_delete(self) -> None:
+        binding.remove(self.layers, Layer)
+        super().delete()

+ 29 - 0
nicegui/elements/leaflet_layer.py

@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+import uuid
+from abc import abstractmethod
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, ClassVar, Optional
+
+from ..dataclasses import KWONLY_SLOTS
+
+if TYPE_CHECKING:
+    from .leaflet import Leaflet
+
+
+@dataclass(**KWONLY_SLOTS)
+class Layer:
+    current_leaflet: ClassVar[Optional[Leaflet]] = None
+    leaflet: Leaflet = field(init=False)
+    id: str = field(init=False)
+
+    def __post_init__(self) -> None:
+        self.id = str(uuid.uuid4())
+        assert self.current_leaflet is not None
+        self.leaflet = self.current_leaflet
+        self.leaflet.layers.append(self)
+        self.leaflet.run_method('add_layer', self.to_dict(), self.id)
+
+    @abstractmethod
+    def to_dict(self) -> dict:
+        """Return a dictionary representation of the layer."""

+ 48 - 0
nicegui/elements/leaflet_layers.py

@@ -0,0 +1,48 @@
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Tuple
+
+from typing_extensions import Self
+
+from ..dataclasses import KWONLY_SLOTS
+from .leaflet_layer import Layer
+
+
+@dataclass(**KWONLY_SLOTS)
+class GenericLayer(Layer):
+    name: str
+    args: List[Any] = field(default_factory=list)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': self.name,
+            'args': self.args,
+        }
+
+
+@dataclass(**KWONLY_SLOTS)
+class TileLayer(Layer):
+    url_template: str
+    options: Dict = field(default_factory=dict)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': 'tileLayer',
+            'args': [self.url_template, self.options],
+        }
+
+
+@dataclass(**KWONLY_SLOTS)
+class Marker(Layer):
+    latlng: Tuple[float, float]
+    options: Dict = field(default_factory=dict)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': 'marker',
+            'args': [{'lat': self.latlng[0], 'lng': self.latlng[1]}, self.options],
+        }
+
+    def draggable(self, value: bool = True) -> Self:
+        """Make the marker draggable."""
+        self.options['draggable'] = value
+        return self

BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/layers-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/layers.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/marker-icon.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/marker-shadow.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.png


+ 156 - 0
nicegui/elements/lib/leaflet/leaflet-draw/images/spritesheet.svg

@@ -0,0 +1,156 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   viewBox="0 0 600 60"
+   height="60"
+   width="600"
+   id="svg4225"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="spritesheet.svg"
+   inkscape:export-filename="/home/fpuga/development/upstream/icarto.Leaflet.draw/src/images/spritesheet-2x.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <metadata
+     id="metadata4258">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs4256" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1920"
+     inkscape:window-height="1056"
+     id="namedview4254"
+     showgrid="false"
+     inkscape:zoom="1.3101852"
+     inkscape:cx="237.56928"
+     inkscape:cy="7.2419621"
+     inkscape:window-x="1920"
+     inkscape:window-y="24"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4225" />
+  <g
+     id="enabled"
+     style="fill:#464646;fill-opacity:1">
+    <g
+       id="polyline"
+       style="fill:#464646;fill-opacity:1">
+      <path
+         d="m 18,36 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
+         id="path4229"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 36,18 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
+         id="path4231"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 23.142,39.145 -2.285,-2.29 16,-15.998 2.285,2.285 z"
+         id="path4233"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+    </g>
+    <path
+       id="polygon"
+       d="M 100,24.565 97.904,39.395 83.07,42 76,28.773 86.463,18 Z"
+       inkscape:connector-curvature="0"
+       style="fill:#464646;fill-opacity:1" />
+    <path
+       id="rectangle"
+       d="m 140,20 20,0 0,20 -20,0 z"
+       inkscape:connector-curvature="0"
+       style="fill:#464646;fill-opacity:1" />
+    <path
+       id="circle"
+       d="m 221,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
+       inkscape:connector-curvature="0"
+       style="fill:#464646;fill-opacity:1" />
+    <path
+       id="marker"
+       d="m 270,19 c -4.971,0 -9,4.029 -9,9 0,4.971 5.001,12 9,14 4.001,-2 9,-9.029 9,-14 0,-4.971 -4.029,-9 -9,-9 z m 0,12.5 c -2.484,0 -4.5,-2.014 -4.5,-4.5 0,-2.484 2.016,-4.5 4.5,-4.5 2.485,0 4.5,2.016 4.5,4.5 0,2.486 -2.015,4.5 -4.5,4.5 z"
+       inkscape:connector-curvature="0"
+       style="fill:#464646;fill-opacity:1" />
+    <g
+       id="edit"
+       style="fill:#464646;fill-opacity:1">
+      <path
+         d="m 337,30.156 0,0.407 0,5.604 c 0,1.658 -1.344,3 -3,3 l -10,0 c -1.655,0 -3,-1.342 -3,-3 l 0,-10 c 0,-1.657 1.345,-3 3,-3 l 6.345,0 3.19,-3.17 -9.535,0 c -3.313,0 -6,2.687 -6,6 l 0,10 c 0,3.313 2.687,6 6,6 l 10,0 c 3.314,0 6,-2.687 6,-6 l 0,-8.809 -3,2.968"
+         id="path4240"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 338.72,24.637 -8.892,8.892 -2.828,0 0,-2.829 8.89,-8.89 z"
+         id="path4242"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 338.697,17.826 4,0 0,4 -4,0 z"
+         transform="matrix(-0.70698336,-0.70723018,0.70723018,-0.70698336,567.55917,274.78273)"
+         id="path4244"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+    </g>
+    <g
+       id="remove"
+       style="fill:#464646;fill-opacity:1">
+      <path
+         d="m 381,42 18,0 0,-18 -18,0 0,18 z m 14,-16 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z"
+         id="path4247"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+      <path
+         d="m 395,20 0,-4 -10,0 0,4 -6,0 0,2 22,0 0,-2 -6,0 z m -2,0 -6,0 0,-2 6,0 0,2 z"
+         id="path4249"
+         inkscape:connector-curvature="0"
+         style="fill:#464646;fill-opacity:1" />
+    </g>
+  </g>
+  <g
+     id="disabled"
+     transform="translate(120,0)"
+     style="fill:#bbbbbb">
+    <use
+       xlink:href="#edit"
+       id="edit-disabled"
+       x="0"
+       y="0"
+       width="100%"
+       height="100%" />
+    <use
+       xlink:href="#remove"
+       id="remove-disabled"
+       x="0"
+       y="0"
+       width="100%"
+       height="100%" />
+  </g>
+  <path
+     style="fill:none;stroke:#464646;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+     id="circle-3"
+     d="m 581.65725,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
+     inkscape:connector-curvature="0" />
+</svg>

+ 10 - 0
nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.css

@@ -0,0 +1,10 @@
+.leaflet-draw-section{position:relative}.leaflet-draw-toolbar{margin-top:12px}.leaflet-draw-toolbar-top{margin-top:0}.leaflet-draw-toolbar-notop a:first-child{border-top-right-radius:0}.leaflet-draw-toolbar-nobottom a:last-child{border-bottom-right-radius:0}.leaflet-draw-toolbar a{background-image:url('images/spritesheet.png');background-image:linear-gradient(transparent,transparent),url('images/spritesheet.svg');background-repeat:no-repeat;background-size:300px 30px;background-clip:padding-box}.leaflet-retina .leaflet-draw-toolbar a{background-image:url('images/spritesheet-2x.png');background-image:linear-gradient(transparent,transparent),url('images/spritesheet.svg')}
+.leaflet-draw a{display:block;text-align:center;text-decoration:none}.leaflet-draw a .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.leaflet-draw-actions{display:none;list-style:none;margin:0;padding:0;position:absolute;left:26px;top:0;white-space:nowrap}.leaflet-touch .leaflet-draw-actions{left:32px}.leaflet-right .leaflet-draw-actions{right:26px;left:auto}.leaflet-touch .leaflet-right .leaflet-draw-actions{right:32px;left:auto}.leaflet-draw-actions li{display:inline-block}
+.leaflet-draw-actions li:first-child a{border-left:0}.leaflet-draw-actions li:last-child a{-webkit-border-radius:0 4px 4px 0;border-radius:0 4px 4px 0}.leaflet-right .leaflet-draw-actions li:last-child a{-webkit-border-radius:0;border-radius:0}.leaflet-right .leaflet-draw-actions li:first-child a{-webkit-border-radius:4px 0 0 4px;border-radius:4px 0 0 4px}.leaflet-draw-actions a{background-color:#919187;border-left:1px solid #AAA;color:#FFF;font:11px/19px "Helvetica Neue",Arial,Helvetica,sans-serif;line-height:28px;text-decoration:none;padding-left:10px;padding-right:10px;height:28px}
+.leaflet-touch .leaflet-draw-actions a{font-size:12px;line-height:30px;height:30px}.leaflet-draw-actions-bottom{margin-top:0}.leaflet-draw-actions-top{margin-top:1px}.leaflet-draw-actions-top a,.leaflet-draw-actions-bottom a{height:27px;line-height:27px}.leaflet-draw-actions a:hover{background-color:#a0a098}.leaflet-draw-actions-top.leaflet-draw-actions-bottom a{height:26px;line-height:26px}.leaflet-draw-toolbar .leaflet-draw-draw-polyline{background-position:-2px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-polyline{background-position:0 -1px}
+.leaflet-draw-toolbar .leaflet-draw-draw-polygon{background-position:-31px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-polygon{background-position:-29px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-rectangle{background-position:-62px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-rectangle{background-position:-60px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-circle{background-position:-92px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-circle{background-position:-90px -1px}
+.leaflet-draw-toolbar .leaflet-draw-draw-marker{background-position:-122px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-marker{background-position:-120px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-circlemarker{background-position:-273px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-circlemarker{background-position:-271px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-edit{background-position:-152px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-edit{background-position:-150px -1px}
+.leaflet-draw-toolbar .leaflet-draw-edit-remove{background-position:-182px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-remove{background-position:-180px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled{background-position:-212px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled{background-position:-210px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled{background-position:-242px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled{background-position:-240px -2px}
+.leaflet-mouse-marker{background-color:#fff;cursor:crosshair}.leaflet-draw-tooltip{background:#363636;background:rgba(0,0,0,0.5);border:1px solid transparent;-webkit-border-radius:4px;border-radius:4px;color:#fff;font:12px/18px "Helvetica Neue",Arial,Helvetica,sans-serif;margin-left:20px;margin-top:-21px;padding:4px 8px;position:absolute;visibility:hidden;white-space:nowrap;z-index:6}.leaflet-draw-tooltip:before{border-right:6px solid black;border-right-color:rgba(0,0,0,0.5);border-top:6px solid transparent;border-bottom:6px solid transparent;content:"";position:absolute;top:7px;left:-7px}
+.leaflet-error-draw-tooltip{background-color:#f2dede;border:1px solid #e6b6bd;color:#b94a48}.leaflet-error-draw-tooltip:before{border-right-color:#e6b6bd}.leaflet-draw-tooltip-single{margin-top:-12px}.leaflet-draw-tooltip-subtext{color:#f8d5e4}.leaflet-draw-guide-dash{font-size:1%;opacity:.6;position:absolute;width:5px;height:5px}.leaflet-edit-marker-selected{background-color:rgba(254,87,161,0.1);border:4px dashed rgba(254,87,161,0.6);-webkit-border-radius:4px;border-radius:4px;box-sizing:content-box}
+.leaflet-edit-move{cursor:move}.leaflet-edit-resize{cursor:pointer}.leaflet-oldie .leaflet-draw-toolbar{border:1px solid #999}

Разлика између датотеке није приказан због своје велике величине
+ 7 - 0
nicegui/elements/lib/leaflet/leaflet-draw/leaflet.draw.js


BIN
nicegui/elements/lib/leaflet/leaflet/images/layers-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet/images/layers.png


BIN
nicegui/elements/lib/leaflet/leaflet/images/marker-icon-2x.png


BIN
nicegui/elements/lib/leaflet/leaflet/images/marker-icon.png


BIN
nicegui/elements/lib/leaflet/leaflet/images/marker-shadow.png


+ 661 - 0
nicegui/elements/lib/leaflet/leaflet/leaflet.css

@@ -0,0 +1,661 @@
+/* required styles */
+
+.leaflet-pane,
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-tile-container,
+.leaflet-pane > svg,
+.leaflet-pane > canvas,
+.leaflet-zoom-box,
+.leaflet-image-layer,
+.leaflet-layer {
+	position: absolute;
+	left: 0;
+	top: 0;
+	}
+.leaflet-container {
+	overflow: hidden;
+	}
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+	-webkit-user-select: none;
+	   -moz-user-select: none;
+	        user-select: none;
+	  -webkit-user-drag: none;
+	}
+/* Prevents IE11 from highlighting tiles in blue */
+.leaflet-tile::selection {
+	background: transparent;
+}
+/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
+.leaflet-safari .leaflet-tile {
+	image-rendering: -webkit-optimize-contrast;
+	}
+/* hack that prevents hw layers "stretching" when loading new tiles */
+.leaflet-safari .leaflet-tile-container {
+	width: 1600px;
+	height: 1600px;
+	-webkit-transform-origin: 0 0;
+	}
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+	display: block;
+	}
+/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
+/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
+.leaflet-container .leaflet-overlay-pane svg {
+	max-width: none !important;
+	max-height: none !important;
+	}
+.leaflet-container .leaflet-marker-pane img,
+.leaflet-container .leaflet-shadow-pane img,
+.leaflet-container .leaflet-tile-pane img,
+.leaflet-container img.leaflet-image-layer,
+.leaflet-container .leaflet-tile {
+	max-width: none !important;
+	max-height: none !important;
+	width: auto;
+	padding: 0;
+	}
+
+.leaflet-container img.leaflet-tile {
+	/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
+	mix-blend-mode: plus-lighter;
+}
+
+.leaflet-container.leaflet-touch-zoom {
+	-ms-touch-action: pan-x pan-y;
+	touch-action: pan-x pan-y;
+	}
+.leaflet-container.leaflet-touch-drag {
+	-ms-touch-action: pinch-zoom;
+	/* Fallback for FF which doesn't support pinch-zoom */
+	touch-action: none;
+	touch-action: pinch-zoom;
+}
+.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
+	-ms-touch-action: none;
+	touch-action: none;
+}
+.leaflet-container {
+	-webkit-tap-highlight-color: transparent;
+}
+.leaflet-container a {
+	-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
+}
+.leaflet-tile {
+	filter: inherit;
+	visibility: hidden;
+	}
+.leaflet-tile-loaded {
+	visibility: inherit;
+	}
+.leaflet-zoom-box {
+	width: 0;
+	height: 0;
+	-moz-box-sizing: border-box;
+	     box-sizing: border-box;
+	z-index: 800;
+	}
+/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
+.leaflet-overlay-pane svg {
+	-moz-user-select: none;
+	}
+
+.leaflet-pane         { z-index: 400; }
+
+.leaflet-tile-pane    { z-index: 200; }
+.leaflet-overlay-pane { z-index: 400; }
+.leaflet-shadow-pane  { z-index: 500; }
+.leaflet-marker-pane  { z-index: 600; }
+.leaflet-tooltip-pane   { z-index: 650; }
+.leaflet-popup-pane   { z-index: 700; }
+
+.leaflet-map-pane canvas { z-index: 100; }
+.leaflet-map-pane svg    { z-index: 200; }
+
+.leaflet-vml-shape {
+	width: 1px;
+	height: 1px;
+	}
+.lvml {
+	behavior: url(#default#VML);
+	display: inline-block;
+	position: absolute;
+	}
+
+
+/* control positioning */
+
+.leaflet-control {
+	position: relative;
+	z-index: 800;
+	pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+	pointer-events: auto;
+	}
+.leaflet-top,
+.leaflet-bottom {
+	position: absolute;
+	z-index: 1000;
+	pointer-events: none;
+	}
+.leaflet-top {
+	top: 0;
+	}
+.leaflet-right {
+	right: 0;
+	}
+.leaflet-bottom {
+	bottom: 0;
+	}
+.leaflet-left {
+	left: 0;
+	}
+.leaflet-control {
+	float: left;
+	clear: both;
+	}
+.leaflet-right .leaflet-control {
+	float: right;
+	}
+.leaflet-top .leaflet-control {
+	margin-top: 10px;
+	}
+.leaflet-bottom .leaflet-control {
+	margin-bottom: 10px;
+	}
+.leaflet-left .leaflet-control {
+	margin-left: 10px;
+	}
+.leaflet-right .leaflet-control {
+	margin-right: 10px;
+	}
+
+
+/* zoom and fade animations */
+
+.leaflet-fade-anim .leaflet-popup {
+	opacity: 0;
+	-webkit-transition: opacity 0.2s linear;
+	   -moz-transition: opacity 0.2s linear;
+	        transition: opacity 0.2s linear;
+	}
+.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
+	opacity: 1;
+	}
+.leaflet-zoom-animated {
+	-webkit-transform-origin: 0 0;
+	    -ms-transform-origin: 0 0;
+	        transform-origin: 0 0;
+	}
+svg.leaflet-zoom-animated {
+	will-change: transform;
+}
+
+.leaflet-zoom-anim .leaflet-zoom-animated {
+	-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
+	   -moz-transition:    -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
+	        transition:         transform 0.25s cubic-bezier(0,0,0.25,1);
+	}
+.leaflet-zoom-anim .leaflet-tile,
+.leaflet-pan-anim .leaflet-tile {
+	-webkit-transition: none;
+	   -moz-transition: none;
+	        transition: none;
+	}
+
+.leaflet-zoom-anim .leaflet-zoom-hide {
+	visibility: hidden;
+	}
+
+
+/* cursors */
+
+.leaflet-interactive {
+	cursor: pointer;
+	}
+.leaflet-grab {
+	cursor: -webkit-grab;
+	cursor:    -moz-grab;
+	cursor:         grab;
+	}
+.leaflet-crosshair,
+.leaflet-crosshair .leaflet-interactive {
+	cursor: crosshair;
+	}
+.leaflet-popup-pane,
+.leaflet-control {
+	cursor: auto;
+	}
+.leaflet-dragging .leaflet-grab,
+.leaflet-dragging .leaflet-grab .leaflet-interactive,
+.leaflet-dragging .leaflet-marker-draggable {
+	cursor: move;
+	cursor: -webkit-grabbing;
+	cursor:    -moz-grabbing;
+	cursor:         grabbing;
+	}
+
+/* marker & overlays interactivity */
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-image-layer,
+.leaflet-pane > svg path,
+.leaflet-tile-container {
+	pointer-events: none;
+	}
+
+.leaflet-marker-icon.leaflet-interactive,
+.leaflet-image-layer.leaflet-interactive,
+.leaflet-pane > svg path.leaflet-interactive,
+svg.leaflet-image-layer.leaflet-interactive path {
+	pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+	pointer-events: auto;
+	}
+
+/* visual tweaks */
+
+.leaflet-container {
+	background: #ddd;
+	outline-offset: 1px;
+	}
+.leaflet-container a {
+	color: #0078A8;
+	}
+.leaflet-zoom-box {
+	border: 2px dotted #38f;
+	background: rgba(255,255,255,0.5);
+	}
+
+
+/* general typography */
+.leaflet-container {
+	font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
+	font-size: 12px;
+	font-size: 0.75rem;
+	line-height: 1.5;
+	}
+
+
+/* general toolbar styles */
+
+.leaflet-bar {
+	box-shadow: 0 1px 5px rgba(0,0,0,0.65);
+	border-radius: 4px;
+	}
+.leaflet-bar a {
+	background-color: #fff;
+	border-bottom: 1px solid #ccc;
+	width: 26px;
+	height: 26px;
+	line-height: 26px;
+	display: block;
+	text-align: center;
+	text-decoration: none;
+	color: black;
+	}
+.leaflet-bar a,
+.leaflet-control-layers-toggle {
+	background-position: 50% 50%;
+	background-repeat: no-repeat;
+	display: block;
+	}
+.leaflet-bar a:hover,
+.leaflet-bar a:focus {
+	background-color: #f4f4f4;
+	}
+.leaflet-bar a:first-child {
+	border-top-left-radius: 4px;
+	border-top-right-radius: 4px;
+	}
+.leaflet-bar a:last-child {
+	border-bottom-left-radius: 4px;
+	border-bottom-right-radius: 4px;
+	border-bottom: none;
+	}
+.leaflet-bar a.leaflet-disabled {
+	cursor: default;
+	background-color: #f4f4f4;
+	color: #bbb;
+	}
+
+.leaflet-touch .leaflet-bar a {
+	width: 30px;
+	height: 30px;
+	line-height: 30px;
+	}
+.leaflet-touch .leaflet-bar a:first-child {
+	border-top-left-radius: 2px;
+	border-top-right-radius: 2px;
+	}
+.leaflet-touch .leaflet-bar a:last-child {
+	border-bottom-left-radius: 2px;
+	border-bottom-right-radius: 2px;
+	}
+
+/* zoom control */
+
+.leaflet-control-zoom-in,
+.leaflet-control-zoom-out {
+	font: bold 18px 'Lucida Console', Monaco, monospace;
+	text-indent: 1px;
+	}
+
+.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out  {
+	font-size: 22px;
+	}
+
+
+/* layers control */
+
+.leaflet-control-layers {
+	box-shadow: 0 1px 5px rgba(0,0,0,0.4);
+	background: #fff;
+	border-radius: 5px;
+	}
+.leaflet-control-layers-toggle {
+	background-image: url(images/layers.png);
+	width: 36px;
+	height: 36px;
+	}
+.leaflet-retina .leaflet-control-layers-toggle {
+	background-image: url(images/layers-2x.png);
+	background-size: 26px 26px;
+	}
+.leaflet-touch .leaflet-control-layers-toggle {
+	width: 44px;
+	height: 44px;
+	}
+.leaflet-control-layers .leaflet-control-layers-list,
+.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
+	display: none;
+	}
+.leaflet-control-layers-expanded .leaflet-control-layers-list {
+	display: block;
+	position: relative;
+	}
+.leaflet-control-layers-expanded {
+	padding: 6px 10px 6px 6px;
+	color: #333;
+	background: #fff;
+	}
+.leaflet-control-layers-scrollbar {
+	overflow-y: scroll;
+	overflow-x: hidden;
+	padding-right: 5px;
+	}
+.leaflet-control-layers-selector {
+	margin-top: 2px;
+	position: relative;
+	top: 1px;
+	}
+.leaflet-control-layers label {
+	display: block;
+	font-size: 13px;
+	font-size: 1.08333em;
+	}
+.leaflet-control-layers-separator {
+	height: 0;
+	border-top: 1px solid #ddd;
+	margin: 5px -10px 5px -6px;
+	}
+
+/* Default icon URLs */
+.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
+	background-image: url(images/marker-icon.png);
+	}
+
+
+/* attribution and scale controls */
+
+.leaflet-container .leaflet-control-attribution {
+	background: #fff;
+	background: rgba(255, 255, 255, 0.8);
+	margin: 0;
+	}
+.leaflet-control-attribution,
+.leaflet-control-scale-line {
+	padding: 0 5px;
+	color: #333;
+	line-height: 1.4;
+	}
+.leaflet-control-attribution a {
+	text-decoration: none;
+	}
+.leaflet-control-attribution a:hover,
+.leaflet-control-attribution a:focus {
+	text-decoration: underline;
+	}
+.leaflet-attribution-flag {
+	display: inline !important;
+	vertical-align: baseline !important;
+	width: 1em;
+	height: 0.6669em;
+	}
+.leaflet-left .leaflet-control-scale {
+	margin-left: 5px;
+	}
+.leaflet-bottom .leaflet-control-scale {
+	margin-bottom: 5px;
+	}
+.leaflet-control-scale-line {
+	border: 2px solid #777;
+	border-top: none;
+	line-height: 1.1;
+	padding: 2px 5px 1px;
+	white-space: nowrap;
+	-moz-box-sizing: border-box;
+	     box-sizing: border-box;
+	background: rgba(255, 255, 255, 0.8);
+	text-shadow: 1px 1px #fff;
+	}
+.leaflet-control-scale-line:not(:first-child) {
+	border-top: 2px solid #777;
+	border-bottom: none;
+	margin-top: -2px;
+	}
+.leaflet-control-scale-line:not(:first-child):not(:last-child) {
+	border-bottom: 2px solid #777;
+	}
+
+.leaflet-touch .leaflet-control-attribution,
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+	box-shadow: none;
+	}
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+	border: 2px solid rgba(0,0,0,0.2);
+	background-clip: padding-box;
+	}
+
+
+/* popup */
+
+.leaflet-popup {
+	position: absolute;
+	text-align: center;
+	margin-bottom: 20px;
+	}
+.leaflet-popup-content-wrapper {
+	padding: 1px;
+	text-align: left;
+	border-radius: 12px;
+	}
+.leaflet-popup-content {
+	margin: 13px 24px 13px 20px;
+	line-height: 1.3;
+	font-size: 13px;
+	font-size: 1.08333em;
+	min-height: 1px;
+	}
+.leaflet-popup-content p {
+	margin: 17px 0;
+	margin: 1.3em 0;
+	}
+.leaflet-popup-tip-container {
+	width: 40px;
+	height: 20px;
+	position: absolute;
+	left: 50%;
+	margin-top: -1px;
+	margin-left: -20px;
+	overflow: hidden;
+	pointer-events: none;
+	}
+.leaflet-popup-tip {
+	width: 17px;
+	height: 17px;
+	padding: 1px;
+
+	margin: -10px auto 0;
+	pointer-events: auto;
+
+	-webkit-transform: rotate(45deg);
+	   -moz-transform: rotate(45deg);
+	    -ms-transform: rotate(45deg);
+	        transform: rotate(45deg);
+	}
+.leaflet-popup-content-wrapper,
+.leaflet-popup-tip {
+	background: white;
+	color: #333;
+	box-shadow: 0 3px 14px rgba(0,0,0,0.4);
+	}
+.leaflet-container a.leaflet-popup-close-button {
+	position: absolute;
+	top: 0;
+	right: 0;
+	border: none;
+	text-align: center;
+	width: 24px;
+	height: 24px;
+	font: 16px/24px Tahoma, Verdana, sans-serif;
+	color: #757575;
+	text-decoration: none;
+	background: transparent;
+	}
+.leaflet-container a.leaflet-popup-close-button:hover,
+.leaflet-container a.leaflet-popup-close-button:focus {
+	color: #585858;
+	}
+.leaflet-popup-scrolled {
+	overflow: auto;
+	}
+
+.leaflet-oldie .leaflet-popup-content-wrapper {
+	-ms-zoom: 1;
+	}
+.leaflet-oldie .leaflet-popup-tip {
+	width: 24px;
+	margin: 0 auto;
+
+	-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
+	filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
+	}
+
+.leaflet-oldie .leaflet-control-zoom,
+.leaflet-oldie .leaflet-control-layers,
+.leaflet-oldie .leaflet-popup-content-wrapper,
+.leaflet-oldie .leaflet-popup-tip {
+	border: 1px solid #999;
+	}
+
+
+/* div icon */
+
+.leaflet-div-icon {
+	background: #fff;
+	border: 1px solid #666;
+	}
+
+
+/* Tooltip */
+/* Base styles for the element that has a tooltip */
+.leaflet-tooltip {
+	position: absolute;
+	padding: 6px;
+	background-color: #fff;
+	border: 1px solid #fff;
+	border-radius: 3px;
+	color: #222;
+	white-space: nowrap;
+	-webkit-user-select: none;
+	-moz-user-select: none;
+	-ms-user-select: none;
+	user-select: none;
+	pointer-events: none;
+	box-shadow: 0 1px 3px rgba(0,0,0,0.4);
+	}
+.leaflet-tooltip.leaflet-interactive {
+	cursor: pointer;
+	pointer-events: auto;
+	}
+.leaflet-tooltip-top:before,
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+	position: absolute;
+	pointer-events: none;
+	border: 6px solid transparent;
+	background: transparent;
+	content: "";
+	}
+
+/* Directions */
+
+.leaflet-tooltip-bottom {
+	margin-top: 6px;
+}
+.leaflet-tooltip-top {
+	margin-top: -6px;
+}
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-top:before {
+	left: 50%;
+	margin-left: -6px;
+	}
+.leaflet-tooltip-top:before {
+	bottom: 0;
+	margin-bottom: -12px;
+	border-top-color: #fff;
+	}
+.leaflet-tooltip-bottom:before {
+	top: 0;
+	margin-top: -12px;
+	margin-left: -6px;
+	border-bottom-color: #fff;
+	}
+.leaflet-tooltip-left {
+	margin-left: -6px;
+}
+.leaflet-tooltip-right {
+	margin-left: 6px;
+}
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+	top: 50%;
+	margin-top: -6px;
+	}
+.leaflet-tooltip-left:before {
+	right: 0;
+	margin-right: -12px;
+	border-left-color: #fff;
+	}
+.leaflet-tooltip-right:before {
+	left: 0;
+	margin-left: -12px;
+	border-right-color: #fff;
+	}
+
+/* Printing */
+
+@media print {
+	/* Prevent printers from removing background-images of controls. */
+	.leaflet-control {
+		-webkit-print-color-adjust: exact;
+		print-color-adjust: exact;
+		}
+	}

Разлика између датотеке није приказан због своје велике величине
+ 4 - 0
nicegui/elements/lib/leaflet/leaflet/leaflet.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
nicegui/elements/lib/leaflet/leaflet/leaflet.js.map


+ 1 - 21
nicegui/elements/markdown.py

@@ -1,5 +1,4 @@
 import os
-import re
 from functools import lru_cache
 from typing import List
 
@@ -41,26 +40,7 @@ class Markdown(ContentElement, component='markdown.js'):
 @lru_cache(maxsize=int(os.environ.get('MARKDOWN_CONTENT_CACHE_SIZE', '1000')))
 def prepare_content(content: str, extras: str) -> str:
     """Render Markdown content to HTML."""
-    html = markdown2.markdown(remove_indentation(content), extras=extras.split())
-    return apply_tailwind(html)  # we need explicit Markdown styling because tailwind CSS removes all default styles
-
-
-def apply_tailwind(html: str) -> str:
-    """Apply tailwind CSS classes to the HTML."""
-    rep = {
-        '<h1': '<h1 class="text-5xl mb-4 mt-6"',
-        '<h2': '<h2 class="text-4xl mb-3 mt-5"',
-        '<h3': '<h3 class="text-3xl mb-2 mt-4"',
-        '<h4': '<h4 class="text-2xl mb-1 mt-3"',
-        '<h5': '<h5 class="text-1xl mb-0.5 mt-2"',
-        '<a': '<a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"',
-        '<ul': '<ul class="list-disc ml-6"',
-        '<p>': '<p class="mb-2">',
-        r'<div\ class="codehilite">': '<div class="codehilite mb-2 p-2">',
-        '<code': '<code style="background-color: transparent"',
-    }
-    pattern = re.compile('|'.join(rep.keys()))
-    return pattern.sub(lambda m: rep[re.escape(m.group(0))], html)
+    return markdown2.markdown(remove_indentation(content), extras=extras.split())
 
 
 def remove_indentation(text: str) -> str:

+ 11 - 0
nicegui/elements/notification.js

@@ -0,0 +1,11 @@
+export default {
+  mounted() {
+    this.notification = Quasar.Notify.create(this.options);
+  },
+  updated() {
+    this.notification(this.options);
+  },
+  props: {
+    options: Object,
+  },
+};

+ 163 - 0
nicegui/elements/notification.py

@@ -0,0 +1,163 @@
+from typing import Any, Literal, Optional, Union
+
+from .. import context
+from ..element import Element
+from .timer import Timer
+
+NotificationPosition = Literal[
+    'top-left',
+    'top-right',
+    'bottom-left',
+    'bottom-right',
+    'top',
+    'bottom',
+    'left',
+    'right',
+    'center',
+]
+
+NotificationType = Optional[Literal[
+    'positive',
+    'negative',
+    'warning',
+    'info',
+    'ongoing',
+]]
+
+
+class Notification(Element, component='notification.js'):
+
+    def __init__(self,
+                 message: Any = '', *,
+                 position: NotificationPosition = 'bottom',
+                 close_button: Union[bool, str] = False,
+                 type: NotificationType = None,  # pylint: disable=redefined-builtin
+                 color: Optional[str] = None,
+                 multi_line: bool = False,
+                 icon: Optional[str] = None,
+                 spinner: bool = False,
+                 timeout: Optional[float] = 5.0,
+                 **kwargs: Any,
+                 ) -> None:
+        """Notification element
+
+        Displays a notification on the screen.
+        In contrast to `ui.notify`, this element allows to update the notification message and other properties once the notification is displayed.
+
+        :param message: content of the notification
+        :param position: position on the screen ("top-left", "top-right", "bottom-left", "bottom-right", "top", "bottom", "left", "right" or "center", default: "bottom")
+        :param close_button: optional label of a button to dismiss the notification (default: `False`)
+        :param type: optional type ("positive", "negative", "warning", "info" or "ongoing")
+        :param color: optional color name
+        :param multi_line: enable multi-line notifications
+        :param timeout: optional timeout in seconds after which the notification is dismissed (default: 5.0)
+
+        Note: You can pass additional keyword arguments according to `Quasar's Notify API <https://quasar.dev/quasar-plugins/notify#notify-api>`_.
+        """
+        with context.get_client().layout:
+            super().__init__()
+        self._props['options'] = {
+            'message': str(message),
+            'position': position,
+            'type': type,
+            'color': color,
+            'multiLine': multi_line,
+            'icon': icon,
+            'spinner': spinner,
+            'closeBtn': close_button,
+            'timeout': (timeout or 0) * 1000,
+            'group': False,
+            'attrs': {'data-id': f'nicegui-dialog-{self.id}'},
+        }
+        self._props['options'].update(kwargs)
+        with self:
+            def delete():
+                self.clear()
+                self.delete()
+
+            async def try_delete():
+                query = f'''!!document.querySelector("[data-id='nicegui-dialog-{self.id}']")'''
+                if not await self.client.run_javascript(query):
+                    delete()
+
+            Timer(1.0, try_delete)
+
+    @property
+    def message(self) -> str:
+        """Message text."""
+        return self._props['options']['message']
+
+    @message.setter
+    def message(self, value: Any) -> None:
+        self._props['options']['message'] = str(value)
+        self.update()
+
+    @property
+    def position(self) -> NotificationPosition:
+        """Position on the screen."""
+        return self._props['options']['position']
+
+    @position.setter
+    def position(self, value: NotificationPosition) -> None:
+        self._props['options']['position'] = value
+        self.update()
+
+    @property
+    def type(self) -> NotificationType:
+        """Type of the notification."""
+        return self._props['options']['type']
+
+    @type.setter
+    def type(self, value: NotificationType) -> None:
+        self._props['options']['type'] = value
+        self.update()
+
+    @property
+    def color(self) -> Optional[str]:
+        """Color of the notification."""
+        return self._props['options']['color']
+
+    @color.setter
+    def color(self, value: Optional[str]) -> None:
+        self._props['options']['color'] = value
+        self.update()
+
+    @property
+    def multi_line(self) -> bool:
+        """Whether the notification is multi-line."""
+        return self._props['options']['multiLine']
+
+    @multi_line.setter
+    def multi_line(self, value: bool) -> None:
+        self._props['options']['multiLine'] = value
+        self.update()
+
+    @property
+    def icon(self) -> Optional[str]:
+        """Icon of the notification."""
+        return self._props['options']['icon']
+
+    @icon.setter
+    def icon(self, value: Optional[str]) -> None:
+        self._props['options']['icon'] = value
+        self.update()
+
+    @property
+    def spinner(self) -> bool:
+        """Whether the notification is a spinner."""
+        return self._props['options']['spinner']
+
+    @spinner.setter
+    def spinner(self, value: bool) -> None:
+        self._props['options']['spinner'] = value
+        self.update()
+
+    @property
+    def close_button(self) -> Union[bool, str]:
+        """Whether the notification has a close button."""
+        return self._props['options']['closeBtn']
+
+    @close_button.setter
+    def close_button(self, value: Union[bool, str]) -> None:
+        self._props['options']['closeBtn'] = value
+        self.update()

+ 4 - 2
nicegui/elements/pyplot.py

@@ -3,6 +3,8 @@ import io
 import os
 from typing import Any
 
+from typing_extensions import Self
+
 from .. import background_tasks, optional_features
 from ..client import Client
 from ..element import Element
@@ -41,11 +43,11 @@ class Pyplot(Element):
             self.fig.savefig(output, format='svg')
             self._props['innerHTML'] = output.getvalue()
 
-    def __enter__(self):
+    def __enter__(self) -> Self:
         plt.figure(self.fig)
         return self
 
-    def __exit__(self, *_):
+    def __exit__(self, *_) -> None:
         self._convert_to_html()
         if self.close:
             plt.close(self.fig)

+ 0 - 5
nicegui/elements/scene.py

@@ -118,11 +118,6 @@ class Scene(Element,
                 obj.send()
 
     def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
-        """Run a method on the client.
-
-        :param name: name of the method
-        :param args: arguments to pass to the method
-        """
         if not self.is_initialized:
             return NullResponse()
         return super().run_method(name, *args, timeout=timeout, check_interval=check_interval)

+ 2 - 1
nicegui/elements/select.js

@@ -20,7 +20,7 @@ export default {
   },
   methods: {
     filterFn(val, update, abort) {
-      update(() => (this.filteredOptions = this.findFilteredOptions()));
+      update(() => (this.filteredOptions = val ? this.findFilteredOptions() : this.initialOptions));
     },
     findFilteredOptions() {
       const needle = this.$el.querySelector("input[type=search]")?.value.toLocaleLowerCase();
@@ -30,6 +30,7 @@ export default {
     },
   },
   updated() {
+    if (!this.$attrs.multiple) return;
     const newFilteredOptions = this.findFilteredOptions();
     if (newFilteredOptions.length !== this.filteredOptions.length) {
       this.filteredOptions = newFilteredOptions;

+ 38 - 7
nicegui/elements/table.py

@@ -1,10 +1,10 @@
-from __future__ import annotations
-
 from typing import Any, Callable, Dict, List, Literal, Optional, Union
 
+from typing_extensions import Self
+
 from .. import optional_features
 from ..element import Element
-from ..events import GenericEventArguments, TableSelectionEventArguments, handle_event
+from ..events import GenericEventArguments, TableSelectionEventArguments, ValueChangeEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 
 try:
@@ -24,6 +24,7 @@ class Table(FilterElement, component='table.js'):
                  selection: Optional[Literal['single', 'multiple']] = None,
                  pagination: Optional[Union[int, dict]] = None,
                  on_select: Optional[Callable[..., Any]] = None,
+                 on_pagination_change: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """Table
 
@@ -36,6 +37,7 @@ class Table(FilterElement, component='table.js'):
         :param selection: selection type ("single" or "multiple"; default: `None`)
         :param pagination: a dictionary correlating to a pagination object or number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`).
         :param on_select: callback which is invoked when the selection changes
+        :param on_pagination_change: callback which is invoked when the pagination changes
 
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         """
@@ -63,13 +65,21 @@ class Table(FilterElement, component='table.js'):
             handle_event(on_select, arguments)
         self.on('selection', handle_selection, ['added', 'rows', 'keys'])
 
-    @staticmethod
-    def from_pandas(df: pd.DataFrame,
+        def handle_pagination_change(e: GenericEventArguments) -> None:
+            self.pagination = e.args
+            self.update()
+            arguments = ValueChangeEventArguments(sender=self, client=self.client, value=self.pagination)
+            handle_event(on_pagination_change, arguments)
+        self.on('update:pagination', handle_pagination_change)
+
+    @classmethod
+    def from_pandas(cls,
+                    df: pd.DataFrame,
                     row_key: str = 'id',
                     title: Optional[str] = None,
                     selection: Optional[Literal['single', 'multiple']] = None,
                     pagination: Optional[Union[int, dict]] = None,
-                    on_select: Optional[Callable[..., Any]] = None) -> Table:
+                    on_select: Optional[Callable[..., Any]] = None) -> Self:
         """Create a table from a Pandas DataFrame.
 
         Note:
@@ -97,7 +107,7 @@ class Table(FilterElement, component='table.js'):
             df[complex_cols] = df[complex_cols].astype(str)
             df[period_cols] = df[period_cols].astype(str)
 
-        return Table(
+        return cls(
             columns=[{'name': col, 'label': col, 'field': col} for col in df.columns],
             rows=df.to_dict('records'),
             row_key=row_key,
@@ -146,6 +156,16 @@ class Table(FilterElement, component='table.js'):
         self._props['selected'][:] = value
         self.update()
 
+    @property
+    def pagination(self) -> dict:
+        """Pagination object."""
+        return self._props['pagination']
+
+    @pagination.setter
+    def pagination(self, value: dict) -> None:
+        self._props['pagination'] = value
+        self.update()
+
     @property
     def is_fullscreen(self) -> bool:
         """Whether the table is in fullscreen mode."""
@@ -177,6 +197,17 @@ class Table(FilterElement, component='table.js'):
         self.selected[:] = [row for row in self.selected if row[self.row_key] not in keys]
         self.update()
 
+    def update_rows(self, rows: List[Dict], *, clear_selection: bool = True) -> None:
+        """Update rows in the table.
+
+        :param rows: list of rows to update
+        :param clear_selection: whether to clear the selection (default: True)
+        """
+        self.rows[:] = rows
+        if clear_selection:
+            self.selected.clear()
+        self.update()
+
     class row(Element):
 
         def __init__(self) -> None:

+ 5 - 3
nicegui/elements/tooltip.py

@@ -3,12 +3,14 @@ from .mixins.text_element import TextElement
 
 class Tooltip(TextElement):
 
-    def __init__(self, text: str) -> None:
+    def __init__(self, text: str = '') -> None:
         """Tooltip
 
         This element is based on Quasar's `QTooltip <https://quasar.dev/vue-components/tooltip>`_ component.
-        It be placed in another element to show additional information on hover.
+        It can be placed in another element to show additional information on hover.
 
-        :param text: the content of the tooltip
+        Instead of passing a string as the first argument, you can also nest other elements inside the tooltip.
+
+        :param text: the content of the tooltip (default: '')
         """
         super().__init__(tag='q-tooltip', text=text)

+ 1 - 2
nicegui/events.py

@@ -392,8 +392,7 @@ def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArgument
         if isinstance(arguments, UiEventArguments):
             if arguments.sender.is_ignoring_events:
                 return
-            assert arguments.sender.parent_slot is not None
-            parent_slot = arguments.sender.parent_slot
+            parent_slot = arguments.sender.parent_slot or arguments.sender.client.layout.default_slot
         else:
             parent_slot = nullcontext()
 

+ 23 - 0
nicegui/functions/on.py

@@ -0,0 +1,23 @@
+from typing import Any, Callable, Optional, Sequence, Union
+
+from .. import context
+
+
+def on(type: str,  # pylint: disable=redefined-builtin
+       handler: Optional[Callable[..., Any]] = None,
+       args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None, *,
+       throttle: float = 0.0,
+       leading_events: bool = True,
+       trailing_events: bool = True,
+       ):
+    """Subscribe to a global event.
+
+    :param type: name of the event
+    :param handler: callback that is called upon occurrence of the event
+    :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
+    :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
+    :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
+    :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
+    """
+    context.get_client().layout.on(type, handler, args,
+                                   throttle=throttle, leading_events=leading_events, trailing_events=trailing_events)

+ 14 - 0
nicegui/functions/page_title.py

@@ -0,0 +1,14 @@
+from .. import context, json
+
+
+def page_title(title: str) -> None:
+    """Page title
+
+    Set the page title for the current client.
+
+    :param title: page title
+    """
+    client = context.get_client()
+    client.title = title
+    if client.has_socket_connection:
+        client.run_javascript(f'document.title = {json.dumps(title)}')

+ 5 - 0
nicegui/helpers.py

@@ -85,3 +85,8 @@ def schedule_browser(host: str, port: int) -> Tuple[threading.Thread, threading.
     thread = threading.Thread(target=in_thread, args=(host, port), daemon=True)
     thread.start()
     return thread, cancel
+
+
+def kebab_to_camel_case(string: str) -> str:
+    """Convert a kebab-case string to camelCase."""
+    return ''.join(word.capitalize() if i else word for i, word in enumerate(string.split('-')))

+ 12 - 1
nicegui/nicegui.py

@@ -14,7 +14,7 @@ from fastapi_socketio import SocketManager
 from . import air, background_tasks, binding, core, favicon, helpers, json, outbox, run, welcome
 from .app import App
 from .client import Client
-from .dependencies import js_components, libraries
+from .dependencies import js_components, libraries, resources
 from .error import error_content
 from .json import NiceGUIJSONResponse
 from .logging import log
@@ -76,6 +76,17 @@ def _get_component(key: str) -> FileResponse:
     raise HTTPException(status_code=404, detail=f'component "{key}" not found')
 
 
+@app.get(f'/_nicegui/{__version__}' + '/resources/{key}/{path:path}')
+def _get_resource(key: str, path: str) -> FileResponse:
+    if key in resources:
+        filepath = resources[key].path / path
+        if filepath.exists():
+            headers = {'Cache-Control': 'public, max-age=3600'}
+            media_type, _ = mimetypes.guess_type(filepath)
+            return FileResponse(filepath, media_type=media_type, headers=headers)
+    raise HTTPException(status_code=404, detail=f'resource "{key}" not found')
+
+
 async def _startup() -> None:
     """Handle the startup event."""
     if not app.config.has_run_config:

+ 0 - 0
nicegui/py.typed


+ 128 - 19
nicegui/static/nicegui.css

@@ -1,3 +1,9 @@
+/* variables */
+:root {
+  --nicegui-default-padding: 1rem;
+  --nicegui-default-gap: 1rem;
+}
+
 /* prevent q-layout from getting strange outline when focussed */
 .nicegui-layout {
   outline: 2px solid transparent;
@@ -23,8 +29,8 @@
   display: flex;
   flex-direction: column;
   align-items: flex-start;
-  gap: 1rem;
-  padding: 1rem;
+  gap: var(--nicegui-default-gap);
+  padding: var(--nicegui-default-padding);
 }
 .nicegui-header,
 .nicegui-footer,
@@ -64,20 +70,132 @@
 
 /* HACK: avoid stutter when expansion item is toggled */
 .nicegui-expansion .q-expansion-item__content {
-  padding: 0 1rem;
+  padding: 0 var(--nicegui-default-padding);
 }
 .nicegui-expansion .q-expansion-item__content::before,
 .nicegui-expansion .q-expansion-item__content::after {
   content: ""; /* the gap compensates for the missing vertical padding */
 }
 
+/* revert Tailwind's CSS reset for ui.editor and ui.markdown */
+.nicegui-editor .q-editor__content h1,
+.nicegui-markdown h1 {
+  font-size: 3rem;
+  line-height: 1;
+  margin-bottom: 1rem;
+  margin-top: 1.5rem;
+  font-weight: 300;
+}
+.nicegui-editor .q-editor__content h2,
+.nicegui-markdown h2 {
+  font-size: 2.25rem;
+  line-height: 2.5rem;
+  margin-bottom: 0.75rem;
+  margin-top: 1.25rem;
+  font-weight: 300;
+}
+.nicegui-editor .q-editor__content h3,
+.nicegui-markdown h3 {
+  font-size: 1.875rem;
+  line-height: 2.25rem;
+  margin-bottom: 0.5rem;
+  margin-top: 1rem;
+  font-weight: 400;
+}
+.nicegui-editor .q-editor__content h4,
+.nicegui-markdown h4 {
+  font-size: 1.5rem;
+  line-height: 2rem;
+  margin-bottom: 0.25rem;
+  margin-top: 0.75rem;
+  font-weight: 400;
+}
+.nicegui-editor .q-editor__content h5,
+.nicegui-markdown h5 {
+  font-size: 1.25rem;
+  line-height: 1.75rem;
+  margin-bottom: 0.125rem;
+  margin-top: 0.5rem;
+  font-weight: 400;
+}
+.nicegui-editor .q-editor__content h6,
+.nicegui-markdown h6 {
+  font-size: 1.125rem;
+  line-height: 1.75rem;
+  margin-bottom: 0.125rem;
+  margin-top: 0.5rem;
+  font-weight: 500;
+}
+.nicegui-editor .q-editor__content a,
+.nicegui-markdown a {
+  text-decoration-line: underline;
+  color: rgb(37 99 235); /* blue-600 */
+}
+.nicegui-editor .q-editor__content a:hover,
+.nicegui-markdown a:hover {
+  color: rgb(30 64 175); /* blue-800 */
+}
+.nicegui-editor .q-editor__content a:visited,
+.nicegui-markdown a:visited {
+  color: rgb(147 51 234); /* purple-600 */
+}
+.nicegui-editor .q-editor__content hr,
+.nicegui-markdown hr {
+  margin-block-start: 0.5em;
+  margin-block-end: 0.5em;
+  height: 1px;
+  border-style: none;
+  background-color: rgba(128, 128, 128, 0.25);
+}
+.nicegui-editor .q-editor__content ul,
+.nicegui-markdown ul {
+  list-style-type: initial;
+  padding-inline-start: 2.5rem;
+  margin-block-start: 0.25rem;
+  margin-block-end: 0.25rem;
+}
+.nicegui-editor .q-editor__content ol,
+.nicegui-markdown ol {
+  list-style-type: decimal;
+  padding-inline-start: 2.5rem;
+  margin-block-start: 0.25rem;
+  margin-block-end: 0.25rem;
+}
+.nicegui-editor .q-editor__content blockquote,
+.nicegui-markdown blockquote {
+  border-left: 0.25rem solid #8884;
+  padding: 0.25rem 1rem 0.25rem 1rem;
+  margin: 0.5rem 0;
+}
+.nicegui-editor .q-editor__content p,
+.nicegui-markdown p {
+  margin: 0.5rem 0;
+}
+.nicegui-editor .q-editor__content table,
+.nicegui-markdown table {
+  border-collapse: collapse;
+  margin: 0.5rem 0;
+}
+.nicegui-editor .q-editor__content th,
+.nicegui-markdown th {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
+.nicegui-editor .q-editor__content td,
+.nicegui-markdown td {
+  padding: 0.5rem;
+  border: 1px solid #8884;
+}
+.nicegui-markdown .codehilite pre {
+  margin: 0.5rem 0;
+}
+
 /* other NiceGUI elements */
 .nicegui-grid {
   display: grid;
-  gap: 1rem;
+  gap: var(--nicegui-default-gap);
 }
-.nicegui-link:link,
-.nicegui-link:visited {
+.nicegui-link {
   text-decoration-line: underline;
   color: rgb(59 130 246);
 }
@@ -90,6 +208,7 @@
 }
 .nicegui-aggrid,
 .nicegui-echart,
+.nicegui-leaflet,
 .nicegui-scroll-area {
   width: 100%;
   height: 16rem;
@@ -102,19 +221,6 @@
   opacity: 1 !important;
   cursor: text !important;
 }
-.nicegui-markdown blockquote {
-  border-left: 0.25rem solid #8884;
-  padding: 1rem 1rem 0.5rem 1rem;
-  margin: 1rem 0;
-}
-.nicegui-markdown th {
-  padding: 0.5rem;
-  border: 1px solid #8884;
-}
-.nicegui-markdown td {
-  padding: 0.5rem;
-  border: 1px solid #8884;
-}
 h6.q-timeline__title {
   font-size: 1.25rem;
   font-weight: 500;
@@ -126,6 +232,9 @@ h6.q-timeline__title {
   box-shadow: 0 0 0.5em rgba(127, 159, 191, 0.05);
   border-radius: 0.25rem;
 }
+.nicegui-code .codehilite {
+  padding: 0 0.5rem;
+}
 
 /* connection popup */
 #popup {

+ 27 - 0
nicegui/static/utils/resources.js

@@ -0,0 +1,27 @@
+const resourceLoadPromises = {};
+
+export function loadResource(url) {
+  if (resourceLoadPromises[url]) return resourceLoadPromises[url];
+  const loadPromise = new Promise((resolve, reject) => {
+    const dataAttribute = `data-${url.split("/").pop().replace(/\./g, "-")}`;
+    if (document.querySelector(`[${dataAttribute}]`)) {
+      resolve();
+      return;
+    }
+    let element;
+    if (url.endsWith(".css")) {
+      element = document.createElement("link");
+      element.setAttribute("rel", "stylesheet");
+      element.setAttribute("href", url);
+    } else if (url.endsWith(".js")) {
+      element = document.createElement("script");
+      element.setAttribute("src", url);
+    }
+    element.setAttribute(dataAttribute, "");
+    document.head.appendChild(element);
+    element.onload = resolve;
+    element.onerror = reject;
+  });
+  resourceLoadPromises[url] = loadPromise;
+  return loadPromise;
+}

+ 6 - 1
nicegui/storage.py

@@ -13,6 +13,7 @@ from starlette.requests import Request
 from starlette.responses import Response
 
 from . import background_tasks, context, core, json, observables
+from .logging import log
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 
@@ -43,7 +44,11 @@ class PersistentDict(observables.ObservableDict):
 
     def __init__(self, filepath: Path) -> None:
         self.filepath = filepath
-        data = json.loads(filepath.read_text()) if filepath.exists() else {}
+        try:
+            data = json.loads(filepath.read_text()) if filepath.exists() else {}
+        except Exception:
+            log.warning(f'Could not load storage file {filepath}')
+            data = {}
         super().__init__(data, on_change=self.backup)
 
     def backup(self) -> None:

+ 10 - 9
nicegui/templates/index.html

@@ -54,6 +54,9 @@
           return element.$refs.qRef[method_name](...args);
         }
       }
+      function emitEvent(event_name, ...args) {
+        getElement(0).$emit(event_name, ...args);
+      }
     </script>
     <script type="module">
       const True = true;
@@ -160,9 +163,9 @@
               listener_id: event.listener_id,
               args: stringifyEventArgs(args, event.args),
             };
-            const emitter = () => window.socket.emit("event", data);
+            const emitter = () => window.socket?.emit("event", data);
             throttle(emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
-            if (element.props["loopback"] === False && event.type == "update:model-value") {
+            if (element.props["loopback"] === False && event.type == "update:modelValue") {
               element.props["model-value"] = args;
             }
           };
@@ -272,13 +275,11 @@
                 window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
               }
             },
-            try_reconnect: () => {
-              const checkAndReload = async () => {
-                await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
-                console.log('reloading because reconnect was requested')
-                window.location.reload();
-              };
-              setInterval(checkAndReload, 500);
+            try_reconnect: async () => {
+              document.getElementById('popup').style.opacity = 1;
+              await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
+              console.log('reloading because reconnect was requested')
+              window.location.reload();
             },
             disconnect: () => {
               document.getElementById('popup').style.opacity = 1;

+ 8 - 0
nicegui/ui.py

@@ -38,6 +38,7 @@ __all__ = [
     'keyboard',
     'knob',
     'label',
+    'leaflet',
     'line_plot',
     'link',
     'link_target',
@@ -46,6 +47,7 @@ __all__ = [
     'menu',
     'menu_item',
     'mermaid',
+    'notification',
     'number',
     'pagination',
     'plotly',
@@ -87,6 +89,7 @@ __all__ = [
     'run_javascript',
     'notify',
     'open',
+    'page_title',
     'refreshable',
     'state',
     'update',
@@ -95,6 +98,7 @@ __all__ = [
     'footer',
     'header',
     'left_drawer',
+    'on',
     'page_sticky',
     'right_drawer',
     'run',
@@ -140,6 +144,7 @@ from .elements.json_editor import JsonEditor as json_editor
 from .elements.keyboard import Keyboard as keyboard
 from .elements.knob import Knob as knob
 from .elements.label import Label as label
+from .elements.leaflet import Leaflet as leaflet
 from .elements.line_plot import LinePlot as line_plot
 from .elements.link import Link as link
 from .elements.link import LinkTarget as link_target
@@ -148,6 +153,7 @@ from .elements.markdown import Markdown as markdown
 from .elements.menu import Menu as menu
 from .elements.menu import MenuItem as menu_item
 from .elements.mermaid import Mermaid as mermaid
+from .elements.notification import Notification as notification
 from .elements.number import Number as number
 from .elements.pagination import Pagination as pagination
 from .elements.plotly import Plotly as plotly
@@ -187,7 +193,9 @@ from .functions.download import download
 from .functions.html import add_body_html, add_head_html
 from .functions.javascript import run_javascript
 from .functions.notify import notify
+from .functions.on import on
 from .functions.open import open  # pylint: disable=redefined-builtin
+from .functions.page_title import page_title
 from .functions.refreshable import refreshable, state
 from .functions.update import update
 from .page import page

+ 19 - 0
npm.json

@@ -52,6 +52,25 @@
       "package/dist/": ""
     }
   },
+  "leaflet": {
+    "destination": "nicegui/elements/lib/leaflet",
+    "keep": [
+      "package/dist/leaflet\\.css",
+      "package/dist/leaflet\\.js",
+      "package/dist/leaflet\\.js\\.map",
+      "package/dist/images/.*"
+    ],
+    "rename": {
+      "package/dist/": ""
+    }
+  },
+  "leaflet-draw": {
+    "destination": "nicegui/elements/lib/leaflet",
+    "keep": ["package/dist/leaflet\\.draw\\.css", "package/dist/leaflet\\.draw\\.js", "package/dist/images/.*"],
+    "rename": {
+      "package/dist/": ""
+    }
+  },
   "mermaid": {
     "destination": "nicegui/elements/lib",
     "keep": ["package/dist/.*\\.m?js"],

+ 6 - 0
npm.py

@@ -22,8 +22,10 @@ import requests
 
 parser = ArgumentParser()
 parser.add_argument('path', default='.', help='path to the root of the repository')
+parser.add_argument('--name', nargs='*', help='filter library updates by name')
 args = parser.parse_args()
 root_path = Path(args.path)
+names = args.name or None
 
 
 def prepare(path: Path) -> Path:
@@ -56,6 +58,7 @@ KNOWN_LICENSES = {
     'MIT': 'https://opensource.org/licenses/MIT',
     'ISC': 'https://opensource.org/licenses/ISC',
     'Apache-2.0': 'https://opensource.org/licenses/Apache-2.0',
+    'BSD-2-Clause': 'https://opensource.org/licenses/BSD-2-Clause',
 }
 
 # Create a hidden folder to work in.
@@ -63,6 +66,9 @@ tmp = cleanup(root_path / '.npm')
 
 dependencies: dict[str, dict] = json.loads((root_path / 'npm.json').read_text())
 for key, dependency in dependencies.items():
+    if names is not None and key not in names:
+        continue
+
     # Reset destination folder.
     destination = prepare(root_path / dependency['destination'] / key)
 

+ 2 - 2
pyproject.toml

@@ -11,7 +11,7 @@ keywords = ["gui", "ui", "web", "interface", "live"]
 [tool.poetry.dependencies]
 python = "^3.8"
 typing-extensions = ">=4.0.0"
-markdown2 = "^2.4.7"
+markdown2 = ">=2.4.7,<2.4.11"
 Pygments = ">=2.15.1,<3.0.0"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 fastapi = ">=0.93,<1.0.0"
@@ -70,4 +70,4 @@ addopts = "--driver Chrome"
 asyncio_mode = "auto"
 
 [tool.mypy]
-ignore_missing_imports = true
+ignore_missing_imports = true

+ 2 - 0
tests/test_endpoint_docs.py

@@ -28,6 +28,7 @@ def test_endpoint_documentation_internal_only(screen: Screen):
     assert get_openapi_paths() == {
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
+        f'/_nicegui/{__version__}/resources/{{key}}/{{path}}',
     }
 
 
@@ -38,4 +39,5 @@ def test_endpoint_documentation_all(screen: Screen):
         '/',
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
+        f'/_nicegui/{__version__}/resources/{{key}}/{{path}}',
     }

+ 39 - 0
tests/test_leaflet.py

@@ -0,0 +1,39 @@
+import time
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_leaflet(screen: Screen):
+    m = ui.leaflet(center=(51.505, -0.09), zoom=13)
+    ui.label().bind_text_from(m, 'center', lambda center: f'Center: {center[0]:.3f}, {center[1]:.3f}')
+    ui.label().bind_text_from(m, 'zoom', lambda zoom: f'Zoom: {zoom}')
+
+    ui.button('Zoom in', on_click=lambda: m.set_zoom(m.zoom + 1))
+    ui.button('Zoom out', on_click=lambda: m.set_zoom(m.zoom - 1))
+
+    ui.button('Berlin', on_click=lambda: m.set_center((52.520, 13.405)))
+    ui.button('London', on_click=lambda: m.set_center((51.505, -0.090)))
+
+    screen.open('/')
+    assert screen.find_all_by_class('leaflet-pane')
+    assert screen.find_all_by_class('leaflet-control-container')
+
+    deadline = time.time() + 3
+    while not m.is_initialized and time.time() < deadline:
+        screen.wait(0.1)
+    screen.should_contain('Center: 51.505, -0.090')
+    screen.should_contain('Zoom: 13')
+
+    screen.click('Zoom in')
+    screen.should_contain('Zoom: 14')
+
+    screen.click('Zoom out')
+    screen.should_contain('Zoom: 13')
+
+    screen.click('Berlin')
+    screen.should_contain('Center: 52.520, 13.405')
+
+    screen.click('London')
+    screen.should_contain('Center: 51.505, -0.090')

+ 24 - 0
tests/test_notification.py

@@ -0,0 +1,24 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_notification(screen: Screen):
+    ui.button('Notify', on_click=lambda: ui.notification('Hi!'))
+
+    screen.open('/')
+    screen.click('Notify')
+    screen.should_contain('Hi!')
+
+
+def test_close_button(screen: Screen):
+    b = ui.button('Notify', on_click=lambda: ui.notification('Hi!', timeout=None, close_button=True))
+
+    screen.open('/')
+    screen.click('Notify')
+    screen.should_contain('Hi!')
+    assert len(b.client.layout.default_slot.children) == 2
+    screen.click('Close')
+    screen.wait(1.5)
+    screen.should_not_contain('Hi!')
+    assert len(b.client.layout.default_slot.children) == 1

+ 11 - 0
tests/test_number.py

@@ -6,6 +6,17 @@ from nicegui import ui
 from .screen import Screen
 
 
+def test_number_input(screen: Screen):
+    ui.number('Number')
+    ui.button('Button')
+
+    screen.open('/')
+    element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Number"]')
+    element.send_keys('42')
+    screen.click('Button')
+    screen.should_contain_input('42')
+
+
 def test_apply_format_on_blur(screen: Screen):
     ui.number('Number', format='%.4f', value=3.14159)
     ui.button('Button')

+ 21 - 0
tests/test_page_title.py

@@ -0,0 +1,21 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_page_title(screen: Screen):
+    ui.page_title('Initial title')
+    ui.button('Change title', on_click=lambda: ui.page_title('"New title"'))
+
+    @ui.page('/{title}')
+    def page(title: str):
+        ui.page_title(f'Title: {title}')
+
+    screen.open('/')
+    screen.should_contain('Initial title')
+
+    screen.click('Change title')
+    screen.should_contain('"New title"')
+
+    screen.open('/test')
+    screen.should_contain('Title: test')

+ 10 - 4
tests/test_select.py

@@ -142,8 +142,9 @@ def test_add_new_values(screen:  Screen, option_dict: bool, multiple: bool, new_
                                   "options = ['a', 'b', 'c']")
 
 
-def test_keep_filtered_options(screen: Screen):
-    ui.select(options=['A1', 'A2', 'B1', 'B2'], with_input=True, multiple=True)
+@pytest.mark.parametrize('multiple', [False, True])
+def test_keep_filtered_options(multiple: bool, screen: Screen):
+    ui.select(options=['A1', 'A2', 'B1', 'B2'], with_input=True, multiple=multiple)
 
     screen.open('/')
     screen.find_by_tag('input').click()
@@ -161,7 +162,12 @@ def test_keep_filtered_options(screen: Screen):
 
     screen.click('A1')
     screen.wait(0.5)
+    screen.find_by_tag('input').click()
     screen.should_contain('A1')
     screen.should_contain('A2')
-    screen.should_not_contain('B1')
-    screen.should_not_contain('B2')
+    if multiple:
+        screen.should_not_contain('B1')
+        screen.should_not_contain('B2')
+    else:
+        screen.should_contain('B1')
+        screen.should_contain('B2')

+ 13 - 3
tests/test_table.py

@@ -145,22 +145,32 @@ def test_remove_selection(screen: Screen):
 def test_replace_rows(screen: Screen):
     t = ui.table(columns=columns(), rows=rows())
 
-    def replace_rows():
+    def replace_rows_with_carol():
         t.rows = [{'id': 3, 'name': 'Carol', 'age': 32}]
-    ui.button('Replace rows', on_click=replace_rows)
+
+    def replace_rows_with_daniel():
+        t.update_rows([{'id': 4, 'name': 'Daniel', 'age': 33}])
+
+    ui.button('Replace rows with C.', on_click=replace_rows_with_carol)
+    ui.button('Replace rows with D.', on_click=replace_rows_with_daniel)
 
     screen.open('/')
     screen.should_contain('Alice')
     screen.should_contain('Bob')
     screen.should_contain('Lionel')
 
-    screen.click('Replace rows')
+    screen.click('Replace rows with C.')
     screen.wait(0.5)
     screen.should_not_contain('Alice')
     screen.should_not_contain('Bob')
     screen.should_not_contain('Lionel')
     screen.should_contain('Carol')
 
+    screen.click('Replace rows with D.')
+    screen.wait(0.5)
+    screen.should_not_contain('Carol')
+    screen.should_contain('Daniel')
+
 
 def test_create_from_pandas(screen: Screen):
     df = pd.DataFrame({'name': ['Alice', 'Bob'], 'age': [18, 21], 42: 'answer'})

+ 18 - 16
website/documentation/content/generic_events_documentation.py

@@ -99,26 +99,28 @@ def modifiers() -> None:
 
 
 @doc.demo('Custom events', '''
-    It is fairly easy to emit custom events from JavaScript which can be listened to with `element.on(...)`.
+    It is fairly easy to emit custom events from JavaScript with `emitEvent(...)` which can be listened to with `ui.on(...)`.
     This can be useful if you want to call Python code when something happens in JavaScript.
     In this example we are listening to the `visibilitychange` event of the browser tab.
 ''')
 async def custom_events() -> None:
-    tabwatch = ui.checkbox('Watch browser tab re-entering') \
-        .on('tabvisible', lambda: ui.notify('Welcome back!') if tabwatch.value else None, args=[])
-    ui.add_head_html(f'''
-        <script>
-        document.addEventListener('visibilitychange', () => {{
-            if (document.visibilityState === 'visible')
-                getElement({tabwatch.id}).$emit('tabvisible');
-        }});
-        </script>
-    ''')
+    tabwatch = ui.checkbox('Watch browser tab re-entering')
+    ui.on('tabvisible', lambda: ui.notify('Welcome back!') if tabwatch.value else None)
+    # ui.add_head_html('''
+    #     <script>
+    #     document.addEventListener('visibilitychange', () => {
+    #         if (document.visibilityState === 'visible') {
+    #             emitEvent('tabvisible');
+    #         }
+    #     });
+    #     </script>
+    # ''')
     # END OF DEMO
     await context.get_client().connected()
-    ui.run_javascript(f'''
-        document.addEventListener('visibilitychange', () => {{
-            if (document.visibilityState === 'visible')
-                getElement({tabwatch.id}).$emit('tabvisible');
-        }});
+    ui.run_javascript('''
+        document.addEventListener('visibilitychange', () => {
+            if (document.visibilityState === 'visible') {
+                emitEvent('tabvisible');
+            }
+        });
     ''')

+ 124 - 0
website/documentation/content/leaflet_documentation.py

@@ -0,0 +1,124 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.leaflet)
+def main_demo() -> None:
+    m = ui.leaflet(center=(51.505, -0.09))
+    ui.label().bind_text_from(m, 'center', lambda center: f'Center: {center[0]:.3f}, {center[1]:.3f}')
+    ui.label().bind_text_from(m, 'zoom', lambda zoom: f'Zoom: {zoom}')
+
+    with ui.grid(columns=2):
+        ui.button('London', on_click=lambda: m.set_center((51.505, -0.090)))
+        ui.button('Berlin', on_click=lambda: m.set_center((52.520, 13.405)))
+        ui.button(icon='zoom_in', on_click=lambda: m.set_zoom(m.zoom + 1))
+        ui.button(icon='zoom_out', on_click=lambda: m.set_zoom(m.zoom - 1))
+
+
+@doc.demo('Changing the Map Style', '''
+    The default map style is OpenStreetMap.
+    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.
+''')
+def map_style() -> None:
+    m = ui.leaflet(center=(51.505, -0.090), zoom=3)
+    m.clear_layers()
+    m.tile_layer(
+        url_template=r'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
+        options={
+            'maxZoom': 17,
+            'attribution':
+                'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | '
+                'Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)'
+        },
+    )
+
+
+@doc.demo('Add Markers on Click', '''
+    You can add markers to the map with `marker`. 
+    The `center` argument is a tuple of latitude and longitude.
+    This demo adds markers by clicking on the map.
+    Note that the "map-click" event refers to the click event of the map object,
+    while the "click" event refers to the click event of the container div.
+''')
+def markers() -> None:
+    from nicegui import events
+
+    m = ui.leaflet(center=(51.505, -0.09))
+
+    def handle_click(e: events.GenericEventArguments):
+        lat = e.args['latlng']['lat']
+        lng = e.args['latlng']['lng']
+        m.marker(latlng=(lat, lng))
+    m.on('map-click', handle_click)
+
+
+@doc.demo('Vector Layers', '''
+    Leaflet supports a set of [vector layers](https://leafletjs.com/reference.html#:~:text=VideoOverlay-,Vector%20Layers,-Path) like circle, polygon etc.
+    These can be added with the `generic_layer` method.
+    We are happy to review any pull requests to add more specific layers to simplify usage.
+''')
+def vector_layers() -> None:
+    m = ui.leaflet(center=(51.505, -0.09)).classes('h-32')
+    m.generic_layer(name='circle',
+                    args=[[51.505, -0.09], {'color': 'red', 'radius': 300}])
+
+
+@doc.demo('Disable Pan and Zoom', '''
+    There are [several options to configure the map in Leaflet](https://leafletjs.com/reference.html#map).
+    This demo disables the pan and zoom controls.
+''')
+def disable_pan_zoom() -> None:
+    options = {
+        'zoomControl': False,
+        'scrollWheelZoom': False,
+        'doubleClickZoom': False,
+        'boxZoom': False,
+        'keyboard': False,
+        'dragging': False,
+    }
+    ui.leaflet(center=(51.505, -0.09), options=options)
+
+
+@doc.demo('Draw on Map', '''
+    You can enable a toolbar to draw on the map.
+    The `draw_control` can be used to configure the toolbar.
+    This demo adds markers and polygons by clicking on the map.
+''')
+def draw_on_map() -> None:
+    from nicegui import events
+
+    def handle_draw(e: events.GenericEventArguments):
+        if e.args['layerType'] == 'marker':
+            m.marker(latlng=(e.args['layer']['_latlng']['lat'],
+                             e.args['layer']['_latlng']['lng']))
+        if e.args['layerType'] == 'polygon':
+            m.generic_layer(name='polygon', args=[e.args['layer']['_latlngs']])
+
+    draw_control = {
+        'draw': {
+            'polygon': True,
+            'marker': True,
+            'circle': False,
+            'rectangle': False,
+            'polyline': False,
+            'circlemarker': False,
+        },
+        'edit': False,
+    }
+    m = ui.leaflet(center=(51.505, -0.09), zoom=13, draw_control=draw_control)
+    m.on('draw:created', handle_draw)
+
+
+@doc.demo('Run Map Methods', '''
+    You can run methods of the Leaflet map object with `run_map_method`.
+    This demo shows how to fit the map to the whole world.
+''')
+def run_map_methods() -> None:
+    m = ui.leaflet(center=(51.505, -0.09)).classes('h-32')
+    ui.button('Fit world', on_click=lambda: m.run_map_method('fitWorld'))
+
+
+doc.reference(ui.leaflet)

+ 22 - 0
website/documentation/content/notification_documentation.py

@@ -0,0 +1,22 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.notification)
+def main_demo() -> None:
+    import asyncio
+
+    async def compute():
+        n = ui.notification()
+        for i in range(10):
+            n.message = f'Computing {i/10:.0%}'
+            n.spinner = True
+            await asyncio.sleep(0.2)
+        n.message = 'Done!'
+        n.spinner = False
+
+    ui.button('Compute', on_click=compute)
+
+
+doc.reference(ui.notification)

+ 8 - 0
website/documentation/content/page_title_documentation.py

@@ -0,0 +1,8 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.page_title)
+def main_demo() -> None:
+    ui.button('Change page title', on_click=lambda: ui.page_title('New Title'))

+ 4 - 3
website/documentation/content/section_data_elements.py

@@ -2,9 +2,9 @@ from nicegui import optional_features
 
 from . import (aggrid_documentation, circular_progress_documentation, code_documentation, doc, echart_documentation,
                editor_documentation, full_calendar_documentation, highchart_documentation, json_editor_documentation,
-               line_plot_documentation, linear_progress_documentation, log_documentation, plotly_documentation,
-               pyplot_documentation, scene_documentation, spinner_documentation, table_documentation,
-               tree_documentation)
+               leaflet_documentation, line_plot_documentation, linear_progress_documentation, log_documentation,
+               plotly_documentation, pyplot_documentation, scene_documentation, spinner_documentation,
+               table_documentation, tree_documentation)
 
 doc.title('*Data* Elements')
 
@@ -22,6 +22,7 @@ doc.intro(linear_progress_documentation)
 doc.intro(circular_progress_documentation)
 doc.intro(spinner_documentation)
 doc.intro(scene_documentation)
+doc.intro(leaflet_documentation)
 doc.intro(tree_documentation)
 doc.intro(log_documentation)
 doc.intro(editor_documentation)

+ 5 - 16
website/documentation/content/section_page_layout.py

@@ -2,9 +2,9 @@ from nicegui import ui
 
 from . import (card_documentation, carousel_documentation, column_documentation, context_menu_documentation,
                dialog_documentation, doc, expansion_documentation, grid_documentation, menu_documentation,
-               notify_documentation, pagination_documentation, row_documentation, scroll_area_documentation,
-               separator_documentation, splitter_documentation, stepper_documentation, tabs_documentation,
-               timeline_documentation)
+               notification_documentation, notify_documentation, pagination_documentation, row_documentation,
+               scroll_area_documentation, separator_documentation, splitter_documentation, stepper_documentation,
+               tabs_documentation, timeline_documentation, tooltip_documentation)
 
 doc.title('Page *Layout*')
 
@@ -71,18 +71,7 @@ doc.intro(carousel_documentation)
 doc.intro(pagination_documentation)
 doc.intro(menu_documentation)
 doc.intro(context_menu_documentation)
-
-
-@doc.demo('Tooltips', '''
-    Simply call the `tooltip(text:str)` method on UI elements to provide a tooltip.
-
-    For more artistic control you can nest tooltip elements and apply props, classes and styles.
-''')
-def tooltips_demo():
-    ui.label('Tooltips...').tooltip('...are shown on mouse over')
-    with ui.button(icon='thumb_up'):
-        ui.tooltip('I like this').classes('bg-green')
-
-
+doc.intro(tooltip_documentation)
 doc.intro(notify_documentation)
+doc.intro(notification_documentation)
 doc.intro(dialog_documentation)

+ 2 - 1
website/documentation/content/section_pages_routing.py

@@ -2,7 +2,7 @@ import uuid
 
 from nicegui import app, ui
 
-from . import doc, download_documentation, open_documentation, page_documentation
+from . import doc, download_documentation, open_documentation, page_documentation, page_title_documentation
 
 CONSTANT_UUID = str(uuid.uuid4())
 
@@ -79,6 +79,7 @@ def parameter_demo():
     ui.link('Water', '/icon/water_drop?amount=3')
 
 
+doc.intro(page_title_documentation)
 doc.intro(open_documentation)
 doc.intro(download_documentation)
 

+ 28 - 0
website/documentation/content/section_styling_appearance.py

@@ -124,4 +124,32 @@ def tailwind_demo():
 
 doc.intro(query_documentation)
 doc.intro(colors_documentation)
+
+
+@doc.demo('CSS Variables', '''
+    You can customize the appearance of NiceGUI by setting CSS variables.
+    Currently, the following variables with their default values are available:
+    
+    - `--nicegui-default-padding: 1rem`
+    - `--nicegui-default-gap: 1rem`
+
+''')
+def css_variables_demo():
+    # ui.add_head_html('''
+    #     <style>
+    #         :root {
+    #             --nicegui-default-padding: 0.5rem;
+    #             --nicegui-default-gap: 3rem;
+    #         }
+    #     </style>
+    # ''')
+    # with ui.card():
+    #     ui.label('small padding')
+    #     ui.label('large gap')
+    # END OF DEMO
+    with ui.card().classes('p-[0.5rem] gap-[3rem]'):
+        ui.label('small padding')
+        ui.label('large gap')
+
+
 doc.intro(dark_mode_documentation)

+ 12 - 0
website/documentation/content/table_documentation.py

@@ -235,6 +235,18 @@ def pagination() -> None:
     ui.table(columns=columns, rows=rows, pagination={'rowsPerPage': 4, 'sortBy': 'age', 'page': 2})
 
 
+@doc.demo('Handle pagination changes', '''
+    You can handle pagination changes using the `on_pagination_change` parameter.
+''')
+def handle_pagination_changes() -> None:
+    ui.table(
+        columns=[{'id': 'Name', 'label': 'Name', 'field': 'Name', 'align': 'left'}],
+        rows=[{'Name': f'Person {i}'} for i in range(100)],
+        pagination=3,
+        on_pagination_change=lambda e: ui.notify(e.value),
+    )
+
+
 @doc.demo('Computed fields', '''
     You can use functions to compute the value of a column.
     The function receives the row as an argument.

+ 37 - 0
website/documentation/content/tooltip_documentation.py

@@ -0,0 +1,37 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.tooltip)
+def tooltips_demo():
+    with ui.button(icon='thumb_up'):
+        ui.tooltip('I like this').classes('bg-green')
+
+
+@doc.demo('Tooltip method', '''
+    Instead of nesting a tooltip element inside another element, you can also use the `tooltip` method.
+''')
+def tooltip_method_demo():
+    ui.label('Tooltips...').tooltip('...are shown on mouse over')
+
+
+@doc.demo('Tooltip with HTML', '''
+    You can use HTML in tooltips by nesting a `ui.html` element.
+''')
+def tooltip_html_demo():
+    with ui.label('HTML...'):
+        with ui.tooltip():
+            ui.html('<b>b</b>, <em>em</em>, <u>u</u>, <s>s</s>')
+
+
+@doc.demo('Tooltip with other content', '''
+    You can use HTML in tooltips.
+''')
+def tooltip_html_demo():
+    with ui.label('Mountains...'):
+        with ui.tooltip().classes('bg-transparent'):
+            ui.image('https://picsum.photos/id/377/640/360').classes('w-64')
+
+
+doc.reference(ui.tooltip)

+ 25 - 9
website/documentation/reference.py

@@ -5,7 +5,6 @@ from typing import Callable, Optional
 import docutils.core
 
 from nicegui import binding, ui
-from nicegui.elements.markdown import apply_tailwind, remove_indentation
 
 from ..style import subheading
 
@@ -33,14 +32,17 @@ def generate_class_doc(class_obj: type) -> None:
         subheading('Methods')
         with ui.column().classes('gap-2'):
             for name, method in sorted(methods.items()):
-                ui.markdown(f'**`{name}`**`{_generate_method_signature_description(method)}`')
+                decorator = ''
+                if isinstance(class_obj.__dict__.get(name), staticmethod):
+                    decorator += '`@staticmethod`<br />'
+                if isinstance(class_obj.__dict__.get(name), classmethod):
+                    decorator += '`@classmethod`<br />'
+                ui.markdown(f'{decorator}**`{name}`**`{_generate_method_signature_description(method)}`')
                 if method.__doc__:
                     _render_docstring(method.__doc__).classes('ml-8')
     if ancestors:
         subheading('Inherited from')
-        with ui.column().classes('gap-2'):
-            for ancestor in ancestors:
-                ui.markdown(f'- `{ancestor.__name__}`')
+        ui.markdown('\n'.join(f'- `{ancestor.__name__}`' for ancestor in ancestors))
 
 
 def _is_method_or_property(cls: type, attribute_name: str) -> bool:
@@ -48,7 +50,12 @@ def _is_method_or_property(cls: type, attribute_name: str) -> bool:
     return (
         inspect.isfunction(attribute) or
         inspect.ismethod(attribute) or
-        isinstance(attribute, (property, binding.BindableProperty))
+        isinstance(attribute, (
+            staticmethod,
+            classmethod,
+            property,
+            binding.BindableProperty,
+        ))
     )
 
 
@@ -92,10 +99,19 @@ def _generate_method_signature_description(method: Callable) -> str:
 
 
 def _render_docstring(doc: str, with_params: bool = True) -> ui.html:
-    doc = remove_indentation(doc)
+    doc = _remove_indentation_from_docstring(doc)
     doc = doc.replace('param ', '')
     html = docutils.core.publish_parts(doc, writer_name='html5_polyglot')['html_body']
-    html = apply_tailwind(html)
     if not with_params:
         html = re.sub(r'<dl class=".* simple">.*?</dl>', '', html, flags=re.DOTALL)
-    return ui.html(html).classes('documentation bold-links arrow-links')
+    return ui.html(html).classes('bold-links arrow-links nicegui-markdown')
+
+
+def _remove_indentation_from_docstring(text: str) -> str:
+    lines = text.splitlines()
+    if not lines:
+        return ''
+    if len(lines) == 1:
+        return lines[0]
+    indentation = min(len(line) - len(line.lstrip()) for line in lines[1:] if line.strip())
+    return lines[0] + '\n'.join(line[indentation:] for line in lines[1:])

+ 1 - 3
website/documentation/rendering.py

@@ -1,7 +1,6 @@
 import docutils.core
 
 from nicegui import ui
-from nicegui.elements.markdown import apply_tailwind
 
 from ..header import add_head_html, add_header
 from ..style import section_heading, subheading
@@ -43,8 +42,7 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                 if part.description_format == 'rst':
                     description = part.description.replace('param ', '')
                     html = docutils.core.publish_parts(description, writer_name='html5_polyglot')['html_body']
-                    html = apply_tailwind(html)
-                    ui.html(html).classes('bold-links arrow-links')
+                    ui.html(html).classes('bold-links arrow-links nicegui-markdown')
                 else:
                     ui.markdown(part.description).classes('bold-links arrow-links')
             if part.ui:

+ 2 - 2
website/documentation/windows.py

@@ -47,12 +47,12 @@ def _window(type_: WindowType, *, title: str = '', tab: Union[str, Callable] = '
 
 def python_window(title: Optional[str] = None, *, classes: str = '') -> ui.column:
     """Create a window for Python code."""
-    return _window('python', title=title or 'main.py', classes=classes).classes('p-2 python-window')
+    return _window('python', title=title or 'main.py', classes=classes).classes('px-4 py-2 python-window')
 
 
 def bash_window(*, classes: str = '') -> ui.column:
     """Create a window for Bash code."""
-    return _window('bash', title='bash', classes=classes).classes('p-2 bash-window')
+    return _window('bash', title='bash', classes=classes).classes('px-4 py-2 bash-window')
 
 
 def browser_window(title: Optional[Union[str, Callable]] = None, *, classes: str = '') -> ui.column:

+ 0 - 2
website/examples.py

@@ -24,8 +24,6 @@ examples: List[Example] = [
     Example('Authentication', 'shows how to use sessions to build a login screen'),
     Example('Modularization', 'provides an example of how to modularize your application into multiple files and reuse code'),
     Example('FastAPI', 'illustrates the integration of NiceGUI with an existing FastAPI application'),
-    Example('Map',
-            'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) to display a map at specific locations'),
     Example('AI Interface',
             'utilizes the [replicate](https://replicate.com) library to perform voice-to-text transcription and generate images from prompts with Stable Diffusion'),
     Example('3D Scene', 'creates a webGL view and loads an STL mesh illuminated with a spotlight'),

+ 1 - 1
website/static/style.css

@@ -119,7 +119,7 @@ dl.docinfo dd {
 }
 dl.field-list p,
 dl.docinfo p {
-  margin-bottom: 0;
+  margin: 0;
 }
 
 .dark-box {

+ 1 - 2
website/style.py

@@ -51,8 +51,7 @@ def features(icon: str, title_: str, items: List[str]) -> None:
     with ui.column().classes('gap-1'):
         ui.icon(icon).classes('max-sm:hidden text-3xl md:text-5xl mb-3 text-primary opacity-80')
         ui.label(title_).classes('font-bold mb-3')
-        for item in items:
-            ui.markdown(f'- {item}').classes('bold-links arrow-links')
+        ui.markdown('\n'.join(f'- {item}' for item in items)).classes('bold-links arrow-links -ml-4')
 
 
 def side_menu() -> ui.left_drawer:

Неке датотеке нису приказане због велике количине промена