Repository: zauberzeug/nicegui
Pull request #3848 (2024-10-08 19:59:10)

Allow customizing the crosshair in `ui.interactive_image`
These new props enable or disable drawing the corresponding line when cross is enabled.

The motivation in my case was the need to show only the vertical crosshair line in an audio editing context (in this instance the audio is represented as an interactive_image element of a spectrogram).

Simple demo of the functionality:

```py
from nicegui import ui

image = ui.interactive_image(source='https://picsum.photos/id/29/640/360', cross=True)

def toggle_prop(prop: str) -> None:
    image._props[prop] = not image._props[prop]
    image.update()

ui.button("toggle vertical", on_click=lambda: toggle_prop("cross_vertical"))
ui.button("toggle horizontal", on_click=lambda: toggle_prop("cross_horizontal"))

ui.run()
```

parlance-zz: As this is a very small change I've skipped over some of the requirements in the contribution guide. If any changes are required please let me know and I will amend the request.

Patch:
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index cff50b49..e35defd5 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -29,6 +29,7 @@ ENV CHROME_BINARY_LOCATION=/usr/bin/chromium
 RUN pip install -U pip && pip install poetry==$POETRY_VERSION
 COPY pyproject.toml poetry.lock README.md ./
 RUN poetry install --all-extras --no-root
+RUN pip install latex2mathml
 
 USER $USERNAME
 
diff --git a/development.dockerfile b/development.dockerfile
index eaa60e34..c4c17109 100644
--- a/development.dockerfile
+++ b/development.dockerfile
@@ -13,4 +13,6 @@ WORKDIR /app
 COPY . .
 RUN poetry install --all-extras
 
+RUN pip install latex2mathml
+
 CMD python3 -m debugpy --listen 5678 main.py
diff --git a/fly.dockerfile b/fly.dockerfile
index 283057cd..8357e3b9 100644
--- a/fly.dockerfile
+++ b/fly.dockerfile
@@ -29,6 +29,8 @@ COPY pyproject.toml poetry.lock*  ./
 
 RUN poetry install --no-root --extras "plotly matplotlib highcharts sass"
 
+RUN pip install latex2mathml
+
 ADD . .
 
 # ensure unique version to not serve cached and hence potentially wrong static files
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index 4cd7e29e..312a6402 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -9,7 +9,6 @@ from typing import Any, Awaitable, Callable, List, Optional, Union
 
 from fastapi import FastAPI, HTTPException, Request, Response
 from fastapi.responses import FileResponse
-from fastapi.staticfiles import StaticFiles
 
 from .. import background_tasks, helpers
 from ..client import Client
@@ -17,6 +16,7 @@ from ..logging import log
 from ..native import NativeConfig
 from ..observables import ObservableSet
 from ..server import Server
+from ..staticfiles import CacheControlledStaticFiles
 from ..storage import Storage
 from .app_config import AppConfig
 from .range_response import get_range_response
@@ -157,7 +157,7 @@ class App(FastAPI):
         if url_path == '/':
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
 
-        handler = StaticFiles(directory=local_directory, follow_symlink=follow_symlink)
+        handler = CacheControlledStaticFiles(directory=local_directory, follow_symlink=follow_symlink)
 
         @self.get(url_path + '/{path:path}')
         async def static_file(request: Request, path: str = '') -> Response:
diff --git a/nicegui/elements/interactive_image.js b/nicegui/elements/interactive_image.js
index 37c68f4d..47985552 100644
--- a/nicegui/elements/interactive_image.js
+++ b/nicegui/elements/interactive_image.js
@@ -11,9 +11,10 @@ export default {
         draggable="false"
       />
       <svg ref="svg" style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
-        <g v-if="cross" :style="{ display: showCross ? 'block' : 'none' }">
-          <line :x1="x" y1="0" :x2="x" y2="100%" :stroke="cross === true ? 'black' : cross" />
-          <line x1="0" :y1="y" x2="100%" :y2="y" :stroke="cross === true ? 'black' : cross" />
+        <g :style="{ display: showCross ? 'block' : 'none' }">
+          <line v-if="cross" :x1="x" y1="0" :x2="x" y2="100%" :stroke="cross === true ? 'black' : cross" />
+          <line v-if="cross" x1="0" :y1="y" x2="100%" :y2="y" :stroke="cross === true ? 'black' : cross" />
+          <slot name="cross" :x="x" :y="y"></slot>
         </g>
         <g v-html="content"></g>
       </svg>
@@ -118,7 +119,7 @@ export default {
   },
   computed: {
     onCrossEvents() {
-      if (!this.cross) return {};
+      if (!this.cross && !this.$slots.cross) return {};
       return {
         mouseenter: () => (this.showCross = true),
         mouseleave: () => (this.showCross = false),
diff --git a/nicegui/elements/scene_object3d.py b/nicegui/elements/scene_object3d.py
index 5cf508bc..67a133ed 100644
--- a/nicegui/elements/scene_object3d.py
+++ b/nicegui/elements/scene_object3d.py
@@ -196,10 +196,14 @@ class Object3D:
             self._draggable()
         return self
 
+    @property
+    def children(self) -> List[Object3D]:
+        """List of children of the object."""
+        return [object for object in self.scene.objects.values() if object.parent == self]
+
     def delete(self) -> None:
         """Delete the object."""
-        children = [object for object in self.scene.objects.values() if object.parent == self]
-        for child in children:
+        for child in self.children:
             child.delete()
         del self.scene.objects[self.id]
         self._delete()
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index 72878a9d..456027eb 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -9,7 +9,6 @@ import socketio
 from fastapi import HTTPException, Request
 from fastapi.middleware.gzip import GZipMiddleware
 from fastapi.responses import FileResponse, Response
-from fastapi.staticfiles import StaticFiles
 
 from . import air, background_tasks, binding, core, favicon, helpers, json, run, welcome
 from .app import App
@@ -21,6 +20,7 @@ from .logging import log
 from .middlewares import RedirectWithPrefixMiddleware
 from .page import page
 from .slot import Slot
+from .staticfiles import CacheControlledStaticFiles
 from .version import __version__
 
 
@@ -56,7 +56,7 @@ mimetypes.add_type('text/css', '.css')
 
 app.add_middleware(GZipMiddleware)
 app.add_middleware(RedirectWithPrefixMiddleware)
-static_files = StaticFiles(
+static_files = CacheControlledStaticFiles(
     directory=(Path(__file__).parent / 'static').resolve(),
     follow_symlink=True,
 )
diff --git a/nicegui/scripts/pack.py b/nicegui/scripts/pack.py
index 7bb87e01..347d60e8 100755
--- a/nicegui/scripts/pack.py
+++ b/nicegui/scripts/pack.py
@@ -41,7 +41,9 @@ def main() -> None:
     parser.add_argument('--add-data', type=str, action='append', default=[
         f'{Path(nicegui.__file__).parent}{os.pathsep}nicegui',
     ], help='Include additional data.')
-    parser.add_argument('--dry-run', action='store_true', help='Dry run', default=False)
+    parser.add_argument('--icon', type=str, help='Path to an icon file.')
+    parser.add_argument('--osx-bundle-identifier', type=str, help='Mac OS .app bundle identifier.')
+    parser.add_argument('--dry-run', action='store_true', help='Dry run.', default=False)
     parser.add_argument('main', default='main.py', help='Main file which calls `ui.run()`.')
     args = parser.parse_args()
 
@@ -57,6 +59,11 @@ def main() -> None:
         command.append('--onefile')
     for data in args.add_data:
         command.extend(['--add-data', data])
+    if args.icon:
+        command.extend(['--icon', args.icon])
+    if args.osx_bundle_identifier:
+        command.extend(['--osx-bundle-identifier', args.osx_bundle_identifier])
+
     command.extend([args.main])
 
     print('PyInstaller command:')
diff --git a/nicegui/staticfiles.py b/nicegui/staticfiles.py
new file mode 100644
index 00000000..731beaba
--- /dev/null
+++ b/nicegui/staticfiles.py
@@ -0,0 +1,11 @@
+from starlette.responses import Response
+from starlette.staticfiles import StaticFiles
+from starlette.types import Scope
+
+
+class CacheControlledStaticFiles(StaticFiles):
+
+    async def get_response(self, path: str, scope: Scope) -> Response:
+        response = await super().get_response(path, scope)
+        response.headers['Cache-Control'] = 'public, max-age=3600'
+        return response
diff --git a/release.dockerfile b/release.dockerfile
index eea0b52f..12506482 100644
--- a/release.dockerfile
+++ b/release.dockerfile
@@ -26,6 +26,7 @@ RUN python -m pip install \
     itsdangerous \
     pytest \
     requests \
+    latex2mathml \
     selenium
 
 WORKDIR /app
diff --git a/website/documentation/content/interactive_image_documentation.py b/website/documentation/content/interactive_image_documentation.py
index c269c8c7..d5ea67d2 100644
--- a/website/documentation/content/interactive_image_documentation.py
+++ b/website/documentation/content/interactive_image_documentation.py
@@ -65,10 +65,19 @@ def loaded_event():
 @doc.demo('Crosshairs', '''
     You can show crosshairs by passing `cross=True`.
     You can also change the color of the crosshairs by passing a color string.
+
+    Alternatively, you can use the `add_slot` method to add a custom "cross" slot with your own SVG template.
+    The `props.x` and `props.y` variables will be available in the template, representing the crosshair position.
 ''')
 def crosshairs():
     ui.interactive_image('https://picsum.photos/id/565/640/360', cross='red')
 
+    ui.interactive_image('https://picsum.photos/id/565/640/360').add_slot('cross', '''
+        <circle :cx="props.x" :cy="props.y" r="30" stroke="red" fill="none" />
+        <line :x1="props.x - 30" :y1="props.y" :x2="props.x + 30" :y2="props.y" stroke="red" />
+        <line :x1="props.x" :y1="props.y - 30" :x2="props.x" :y2="props.y + 30" stroke="red" />
+    ''')
+
 
 @doc.demo('SVG events', '''
     You can subscribe to events of the SVG elements by using the `on` method with an "svg:" prefix.
diff --git a/website/documentation/content/markdown_documentation.py b/website/documentation/content/markdown_documentation.py
index 2a1b80ae..28d7532b 100644
--- a/website/documentation/content/markdown_documentation.py
+++ b/website/documentation/content/markdown_documentation.py
@@ -55,6 +55,18 @@ def markdown_tables():
     ''', extras=['tables'])
 
 
+@doc.demo('LaTeX formulas', '''
+    By activating the "latex" extra, you can use LaTeX formulas.
+    This requires markdown2 version >=2.5 as well as latex2mathml to be installed.
+''')
+def markdown_latex():
+    ui.markdown(r'''
+        Euler's identity:
+
+        $$e^{i\pi} = -1$$
+    ''', extras=['latex'])
+
+
 @doc.demo('Change Markdown content', '''
     You can change the content of a Markdown element by setting its `content` property or calling `set_content`.
 ''')


Test patch:
diff --git a/tests/test_serving_files.py b/tests/test_serving_files.py
index 131dd2f0..ebca6e85 100644
--- a/tests/test_serving_files.py
+++ b/tests/test_serving_files.py
@@ -61,6 +61,7 @@ def test_get_from_static_files_dir(url_path: str, screen: Screen):
     with httpx.Client() as http_client:
         r = http_client.get(f'http://localhost:{Screen.PORT}/static/examples/slideshow/slides/slide1.jpg')
         assert r.status_code == 200
+        assert 'max-age=' in r.headers['Cache-Control']
 
 
 def test_404_for_non_existing_static_file(screen: Screen):