Jelajahi Sumber

Merge branch 'main' into page_layout

Falko Schindler 2 tahun lalu
induk
melakukan
92e906a208

+ 5 - 1
README.md

@@ -87,8 +87,12 @@ You may also have a look at the following examples for in-depth demonstrations o
   uses the JavaScript library [leaflet](https://leafletjs.com/) to display a map at specific locations
 - [AI User Interface](https://github.com/zauberzeug/nicegui/blob/main/examples/ai_interface/main.py):
   utilizes the great but non-async API from <https://replicate.com> to perform voice-to-text transcription and generate images from prompts with Stable Diffusion
-- [3D scene](https://github.com/zauberzeug/nicegui/blob/main/examples/3d_scene/main.py):
+- [3D Scene](https://github.com/zauberzeug/nicegui/blob/main/examples/3d_scene/main.py):
   creates a 3D scene and loads an STL mesh illuminated with a spotlight
+- [Custom Vue Component](https://github.com/zauberzeug/nicegui/blob/main/examples/custom_vue_component/main.py)
+  shows how to write and integrate a custom vue component
+- [Image Mask Overlay](https://github.com/zauberzeug/nicegui/blob/main/examples/image_mask_overlay/main.py):
+  shows how to overlay an image with a mask
 
 ## Why?
 

+ 16 - 13
api_docs_and_examples.py

@@ -13,7 +13,7 @@ SPECIAL_CHARACTERS = re.compile('[^(a-z)(A-Z)(0-9)-]')
 
 
 @contextmanager
-def example(content: Union[Callable, type, str], first_col=4) -> None:
+def example(content: Union[Callable, type, str], tight: bool = False) -> None:
     callFrame = inspect.currentframe().f_back.f_back
     begin = callFrame.f_lineno
 
@@ -34,20 +34,23 @@ def example(content: Union[Callable, type, str], first_col=4) -> None:
         element.view.inner_html = html
 
     with ui.row().classes('flex w-full'):
+        markdown_classes = f'mr-8 w-full flex-none lg:w-{48 if tight else 80} xl:w-80'
+        rendering_classes = f'w-{48 if tight else 64} flex-none lg:mt-12'
+        source_classes = f'w-80 flex-grow overflow-auto lg:mt-12'
+
         if isinstance(content, str):
-            add_html_anchor(ui.markdown(content).classes(f'mr-8 w-{first_col}/12'))
+            add_html_anchor(ui.markdown(content).classes(markdown_classes))
         else:
             doc = content.__doc__ or content.__init__.__doc__
             html = docutils.core.publish_parts(doc, writer_name='html')['html_body']
             html = html.replace('<p>', '<h4>', 1)
             html = html.replace('</p>', '</h4>', 1)
             html = ui.markdown.apply_tailwind(html)
-            add_html_anchor(ui.html(html).classes('mr-8 w-4/12'))
+            add_html_anchor(ui.html(html).classes(markdown_classes))
 
         try:
-            with ui.card().classes('mt-12 w-2/12'):
-                with ui.column().classes('flex w-full'):
-                    yield
+            with ui.card().classes(rendering_classes):
+                yield
         finally:
             code: str = open(__file__).read()
             end = begin + 1
@@ -72,7 +75,7 @@ def example(content: Union[Callable, type, str], first_col=4) -> None:
                 code.append('ui.run()')
             code.append('```')
             code = '\n'.join(code)
-            ui.markdown(code).classes(f'mt-12 w-{9-first_col}/12 overflow-auto')
+            ui.markdown(code).classes(source_classes)
 
 
 def create_intro() -> None:
@@ -83,7 +86,7 @@ def create_intro() -> None:
 
 Creating a user interface with NiceGUI is as simple as writing a single line of code.
 '''
-    with example(hello_world, first_col=2):
+    with example(hello_world, tight=True):
         ui.label('Hello, world!')
         ui.markdown('Have a look at the full <br/> [API reference](reference)!')
 
@@ -91,7 +94,7 @@ Creating a user interface with NiceGUI is as simple as writing a single line of
 
 NiceGUI comes with a collection of commonly used UI elements.
 '''
-    with example(common_elements, first_col=2):
+    with example(common_elements, tight=True):
         ui.button('Button', on_click=lambda: ui.notify('Click'))
         ui.checkbox('Checkbox', on_change=lambda e: ui.notify('Checked' if e.value else 'Unchecked'))
         ui.switch('Switch', on_change=lambda e: ui.notify('Switched' if e.value else 'Unswitched'))
@@ -104,7 +107,7 @@ NiceGUI comes with a collection of commonly used UI elements.
 
 Binding values between UI elements or [to data models](http://127.0.0.1:8080/reference#bindings) is built into NiceGUI.
 '''
-    with example(binding, first_col=2):
+    with example(binding, tight=True):
         slider = ui.slider(min=0, max=100, value=50)
         ui.number('Value').bind_value(slider, 'value').classes('fit')
 
@@ -274,7 +277,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
                 {'name': 'Alpha', 'data': [0.1, 0.2]},
                 {'name': 'Beta', 'data': [0.3, 0.4]},
             ],
-        }).classes('max-w-full h-64')
+        }).classes('w-full h-64')
 
         def update():
             chart.options.series[0].data[:] = random(2)
@@ -312,7 +315,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
         line_checkbox = ui.checkbox('active').bind_value(line_updates, 'active')
 
     with example(ui.scene):
-        with ui.scene(width=200, height=200) as scene:
+        with ui.scene(width=225, height=225) as scene:
             scene.sphere().material('#4488ff')
             scene.cylinder(1, 0.5, 2, 20).material('#ff8800', opacity=0.5).move(-2, 1)
             scene.extrusion([[0, 0], [0, 1], [1, 0.5]], 0.1).material('#ff8888').move(-2, -2)
@@ -344,7 +347,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
     with example(ui.log):
         from datetime import datetime
 
-        log = ui.log(max_lines=10).classes('h-16')
+        log = ui.log(max_lines=10).classes('w-full h-16')
         ui.button('Log time', on_click=lambda: log.push(datetime.now().strftime("%X.%f")[:-5]))
 
     h3('Layout')

+ 30 - 0
examples/image_mask_overlay/main.py

@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+from nicegui import ui
+
+img_src = 'https://i.stack.imgur.com/PpIqU.png'
+mask_src = 'https://i.stack.imgur.com/OfwWp.png'
+
+with ui.row().classes('w-full flex items-center'):
+    ui.image(img_src).style('width: 25%')
+    ui.label('+').style('font-size: 18em')
+    ui.image(mask_src).style('width: 25%')
+    ui.label('=').style('font-size: 18em')
+    image = ui.interactive_image(img_src).style('width: 25%')
+    image.svg_content = f'''
+        <image xlink:href="{mask_src}" width="100%" height="100%" x="0" y="0" filter="url(#mask)" />
+        <filter id="mask">
+            <feComponentTransfer>
+                <feFuncR type="linear" slope="40" intercept="-(0.5 * 40) + 0.5"/>
+                <feFuncG type="linear" slope="40" intercept="-(0.5 * 40) + 0.5"/>
+                <feFuncB type="linear" slope="40" intercept="-(0.5 * 40) + 0.5"/>
+                <feFuncR type="linear" slope="1000"/>
+            </feComponentTransfer>
+            <feColorMatrix type="matrix" values="1 0 0 0 0   0 1 0 0 0   0 0 1 0 0  3 -1 -1 0 0" />
+        </filter>
+    '''
+ui.markdown(
+    'Images where discovered through <https://stackoverflow.com/a/57579290/364388>. '
+    'SVG filters where used to colorize the mask. You may want to check out <https://webplatform.github.io/docs/svg/tutorials/smarter_svg_filters/>.'
+).classes('mt-4')
+
+ui.run()

TEMPAT SAMPAH
examples/image_mask_overlay/screenshot.png


+ 15 - 17
main.py

@@ -33,6 +33,7 @@ async def go_to_anchor() -> None:
 async def index():
     # avoid display:block for PyPI/Docker/GitHub badges
     ui.add_head_html('<style>p a img {display: inline; vertical-align: baseline}</style>')
+    ui.add_head_html('<meta name="viewport" content="width=device-width, initial-scale=1" />')
 
     ui.html(
         '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-fork-ribbon-css/0.2.3/gh-fork-ribbon.min.css" />'
@@ -45,14 +46,12 @@ async def index():
     assert installation_start >= 0
     assert documentation_start >= 0
 
-    with ui.row().classes('flex w-full'):
-        ui.html(README[:installation_start]).classes('w-6/12')
+    with ui.row().classes('flex w-full overflow-scroll'):
+        ui.html(README[:installation_start]).classes('w-1/2 flex-grow')
 
-        with ui.column().classes('w-5/12 flex-center'):
-            width = 450
-
-            with ui.card(), ui.row().style(f'width:{width}px'):
-                with ui.column():
+        with ui.column().classes('flex-none items-center'):
+            with ui.card(), ui.row().classes('w-96'):
+                with ui.column().classes('w-5/12'):
                     ui.button('Click me!', on_click=lambda: output.set_text('Click'))
                     ui.checkbox('Check me!', on_change=lambda e: output.set_text('Checked' if e.value else 'Unchecked'))
                     ui.switch('Switch me!', on_change=lambda e: output.set_text(
@@ -60,31 +59,30 @@ async def index():
                     ui.input('Text', value='abc', on_change=lambda e: output.set_text(e.value))
                     ui.number('Number', value=3.1415927, format='%.2f', on_change=lambda e: output.set_text(e.value))
 
-                with ui.column():
+                with ui.column().classes('w-6/12'):
                     ui.slider(min=0, max=100, value=50, step=0.1, on_change=lambda e: output.set_text(e.value))
                     ui.radio(['A', 'B', 'C'], value='A', on_change=lambda e: output.set_text(e.value)).props('inline')
-                    ui.toggle(['1', '2', '3'], value='1', on_change=lambda e: output.set_text(e.value)).classes('mx-auto')
-                    ui.select({1: 'One', 2: 'Two', 3: 'Three'}, value=1,
-                              on_change=lambda e: output.set_text(e.value)).classes('mx-auto')
+                    ui.toggle(['1', '2', '3'], value='1', on_change=lambda e: output.set_text(e.value))
+                    ui.select({1: 'One', 2: 'Two', 3: 'Three'}, value=1, on_change=lambda e: output.set_text(e.value))
 
-                with ui.column().classes('w-24'):
+                with ui.row().classes('mt-8'):
                     ui.label('Output:')
-                    output = ui.label('').classes('text-bold')
+                    output = ui.label().classes('text-bold')
 
-            with ui.row().style('margin-top: 40px'):
-                traffic_tracking.chart().style(f'width:{width}px;height:250px')
+            traffic_tracking.chart().classes('mt-8 w-full h-64')
 
-    ui.html(README[installation_start:documentation_start])
+    ui.html(README[installation_start:documentation_start]).classes('w-full')
 
     api_docs_and_examples.create_intro()
     with ui.row().style('background-color: #e8f0fa; width: 100%; margin: 1em 0; padding: 1em 1em 0.5em 1em; font-size: large'):
         ui.markdown('See the [API reference](/reference) for many more interactive examples!')
 
-    ui.html(README[documentation_start:])
+    ui.html(README[documentation_start:]).classes('w-full')
 
 
 @ui.page('/reference')
 def reference():
+    ui.add_head_html('<meta name="viewport" content="width=device-width, initial-scale=1" />')
     api_docs_and_examples.create_full()
 
 

+ 1 - 0
nicegui/page.py

@@ -51,6 +51,7 @@ class Page(jp.QuasarPage):
         self.page_ready_handler = on_page_ready
         self.page_ready_generator: Optional[Generator[None, None, None]] = None
         self.disconnect_handler = on_disconnect
+        self.shared = shared
         self.delete_flag = not shared
 
         self.waiting_javascript_commands: Dict[str, str] = {}

+ 12 - 2
nicegui/timer.py

@@ -2,12 +2,14 @@ import asyncio
 import time
 import traceback
 from collections import namedtuple
-from typing import Callable, List
+from typing import Callable, List, Optional
+
+from starlette.websockets import WebSocket
 
 from . import globals
 from .binding import BindableProperty
 from .helpers import is_coroutine
-from .page import find_parent_view
+from .page import Page, find_parent_page, find_parent_view
 from .task_logger import create_task
 
 NamedCoroutine = namedtuple('NamedCoroutine', ['name', 'coro'])
@@ -33,6 +35,8 @@ class Timer:
 
         self.active = active
         self.interval = interval
+        self.socket: Optional[WebSocket] = None
+        self.parent_page = find_parent_page()
         self.parent_view = find_parent_view()
 
         async def do_callback():
@@ -50,6 +54,12 @@ class Timer:
 
         async def loop():
             while True:
+                if not self.parent_page.shared:
+                    sockets = list(Page.sockets.get(self.parent_page.page_id, {}).values())
+                    if not self.socket and sockets:
+                        self.socket = sockets[0]
+                    elif self.socket and not sockets:
+                        return
                 try:
                     start = time.time()
                     if self.active:

+ 2 - 1
tests/conftest.py

@@ -58,5 +58,6 @@ def remove_all_screenshots() -> None:
 def screen(selenium: webdriver.Chrome, request: pytest.FixtureRequest) -> Generator[Screen, None, None]:
     screen = Screen(selenium)
     yield screen
-    screen.shot(request.node.name)
+    if screen.is_open:
+        screen.shot(request.node.name)
     screen.stop_server()

+ 14 - 1
tests/screen.py

@@ -31,9 +31,18 @@ class Screen:
         self.server_thread = threading.Thread(target=ui.run, kwargs=self.UI_RUN_KWARGS)
         self.server_thread.start()
 
+    @property
+    def is_open(self) -> None:
+        # https://stackoverflow.com/a/66150779/3419103
+        try:
+            self.selenium.current_url
+            return True
+        except:
+            return False
+
     def stop_server(self) -> None:
         '''Stop the webserver.'''
-        self.selenium.close()
+        self.close()
         globals.server.should_exit = True
         self.server_thread.join()
 
@@ -52,6 +61,10 @@ class Screen:
                 if not self.server_thread.is_alive():
                     raise RuntimeError('The NiceGUI server has stopped running')
 
+    def close(self) -> None:
+        if self.is_open:
+            self.selenium.close()
+
     def should_contain(self, text: str) -> None:
         assert self.selenium.title == text or self.find(text), \
             f'could not find "{text}" on:\n{self.render_content()}'

+ 45 - 0
tests/test_timer.py

@@ -0,0 +1,45 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+class Counter:
+    value = 0
+
+    def increment(self):
+        self.value += 1
+
+
+def test_timer(screen: Screen):
+    counter = Counter()
+    ui.timer(0.1, counter.increment)
+
+    assert counter.value == 0, 'count is initially zero'
+    screen.wait(0.5)
+    assert counter.value == 0, 'timer is not running'
+
+    screen.start_server()
+    screen.wait(0.5)
+    assert counter.value > 0, 'timer is running after starting the server'
+
+
+def test_timer_on_private_page(screen: Screen):
+    counter = Counter()
+
+    @ui.page('/')
+    def page():
+        ui.timer(0.1, counter.increment)
+
+    assert counter.value == 0, 'count is initially zero'
+    screen.start_server()
+    screen.wait(0.5)
+    assert counter.value == 0, 'timer is not running even after starting the server'
+
+    screen.open('/')
+    screen.wait(0.5)
+    assert counter.value > 0, 'timer is running after opening the page'
+
+    screen.close()
+    count = counter.value
+    screen.wait(0.5)
+    assert counter.value == count, 'timer is not running anymore after closing the page'