Repository: zauberzeug/nicegui
Pull request #3900 (2024-10-18 20:00:02)

Introduce an `html` module with plain HTML elements
Following up on https://github.com/zauberzeug/nicegui/discussions/572#discussioncomment-10476200, this PR introduces an `html` module for creating plain HTML elements. They are basically shortcuts for `ui.element(<tag>)` and allow passing inner HTML as optional positional argument and arbitrary attributes as keyword arguments. Nesting, classes, styling and event subscriptions work like with regular NiceGUI elements. Here's a quick demo:

```py
from nicegui import html

with html.p():
    html.a('Link', href='https://google.com').classes('text-red-500')
    html.hr()
    html.button('Button').on('click', lambda: print('Hello World'))
    html.img(src='https://picsum.photos/400/300', width='200')
```

Open tasks:

- [x] Should we exclude "unusable" elements like `html`, `head`, and `body`? This might not be obvious in all cases.
- [x] Add it to the documentation.
- [x] Add pytests for some elements like `button`?



Patch:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index a91d8304..ab153c47 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -77,13 +77,6 @@ jobs:
           password: ${{ secrets.DOCKER_PASSWORD }}
       - name: Set up Docker Buildx
         uses: docker/setup-buildx-action@v3
-      - name: Cache Docker layers
-        uses: actions/cache@v4
-        with:
-          path: /tmp/.buildx-cache
-          key: ${{ runner.os }}-buildx-${{ github.sha }}
-          restore-keys: |
-            ${{ runner.os }}-buildx-
       - name: Build and push
         uses: docker/build-push-action@v6
         with:
@@ -93,15 +86,8 @@ jobs:
           push: true
           tags: ${{ steps.prep.outputs.tags }}
           build-args: VERSION=${{ steps.prep.outputs.version }}
-          cache-from: type=local,src=/tmp/.buildx-cache
-          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
-      # Temp fix
-      # https://github.com/docker/build-push-action/issues/252
-      # https://github.com/moby/buildkit/issues/1896
-      - name: Move cache
-        run: |
-          rm -rf /tmp/.buildx-cache
-          mv /tmp/.buildx-cache-new /tmp/.buildx-cache
+          cache-from: type=registry,ref=${{ steps.prep.outputs.docker_image }}:buildcache
+          cache-to: type=registry,ref=${{ steps.prep.outputs.docker_image }}:buildcache,mode=max
 
       # Uploading the README.md is not a core feature of docker/build-push-action yet
       - name: Update README
diff --git a/README.md b/README.md
index ba4d2569..da99c477 100644
--- a/README.md
+++ b/README.md
@@ -109,10 +109,14 @@ because of their great performance and ease of use.
 Maintenance of this project is made possible by all the [contributors](https://github.com/zauberzeug/nicegui/graphs/contributors) and [sponsors](https://github.com/sponsors/zauberzeug).
 If you would like to support this project and have your avatar or company logo appear below, please [sponsor us](https://github.com/sponsors/zauberzeug). 💖
 
+<!-- SPONSORS -->
 <p align="center">
-   <a href="https://github.com/lechler-gmbh"><img src="https://github.com/lechler-gmbh.png" width="50px" alt="Lechler GmbH" /></a>
-   <a href="https://github.com/daviborges666"><img src="https://github.com/daviborges666.png" width="50px"alt="daviborges666" /></a>
+  <a href="https://github.com/lechler-gmbh"><img src="https://github.com/lechler-gmbh.png" width="50px" alt="Lechler GmbH" /></a>
+  <a href="https://github.com/Zhifeng2019"><img src="https://github.com/Zhifeng2019.png" width="50px" alt="Zhifeng" /></a>
+  <a href="https://github.com/sereneturtlefox"><img src="https://github.com/sereneturtlefox.png" width="50px" alt="None" /></a>
+  <a href="https://github.com/daviborges666"><img src="https://github.com/daviborges666.png" width="50px" alt="Davi Borges" /></a>
 </p>
+<!-- SPONSORS -->
 
 Consider this low-barrier form of contribution yourself.
 Your [support](https://github.com/sponsors/zauberzeug) is much appreciated.
diff --git a/fetch_sponsors.py b/fetch_sponsors.py
new file mode 100755
index 00000000..1bc0fad1
--- /dev/null
+++ b/fetch_sponsors.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+import json
+import os
+import re
+from pathlib import Path
+
+import requests
+
+# NOTE:
+# requires a GitHub token with the necessary permissions read:org and read:user
+# call with `GITHUB_TOKEN=ghp_XXX ./fetch_sponsors.py`
+
+
+response = requests.post(
+    'https://api.github.com/graphql',
+    json={
+        'query': '''
+            query($organization: String!) {
+                organization(login: $organization) {
+                    sponsorshipsAsMaintainer(first: 100, includePrivate: true) {
+                        nodes {
+                            sponsorEntity {
+                                ... on User {
+                                    login
+                                    name
+                                    url
+                                    avatarUrl
+                                }
+                                ... on Organization {
+                                    login
+                                    name
+                                    url
+                                    avatarUrl
+                                }
+                            }
+                            tier {
+                                monthlyPriceInDollars
+                                name
+                                isOneTime
+                            }
+                            createdAt
+                        }
+                    }
+                }
+            }
+        ''',
+        'variables': {'organization': 'zauberzeug'},
+    },
+    headers={
+        'Authorization': f'token {os.getenv("GITHUB_TOKEN")}',
+        'Accept': 'application/vnd.github.v3+json'
+    },
+    timeout=10.0,
+)
+response.raise_for_status()
+data = response.json()
+if 'errors' in data:
+    raise RuntimeError(f'GitHub API Error: {data["errors"]}')
+
+sponsors = []
+for sponsor in data['data']['organization']['sponsorshipsAsMaintainer']['nodes']:
+    sponsor_entity = sponsor['sponsorEntity']
+    tier = sponsor['tier']
+    sponsors.append({
+        'login': sponsor_entity['login'],
+        'name': sponsor_entity['name'],
+        'url': sponsor_entity['url'],
+        'avatar_url': sponsor_entity['avatarUrl'],
+        'tier_name': tier['name'],
+        'tier_amount': tier['monthlyPriceInDollars'],
+        'tier_is_one_time': tier['isOneTime'],
+        'created_at': sponsor['createdAt'],
+    })
+sponsors.sort(key=lambda s: s['created_at'])
+
+contributors = []
+page = 1
+while True:
+    contributors_response = requests.get(
+        f'https://api.github.com/repos/zauberzeug/nicegui/contributors?page={page}&per_page=100',
+        headers={
+            'Authorization': f'token {os.getenv("GITHUB_TOKEN")}',
+            'Accept': 'application/vnd.github.v3+json'
+        },
+        timeout=10.0,
+    )
+    contributors_response.raise_for_status()
+    page_contributors = contributors_response.json()
+    if not page_contributors:
+        break
+    contributors.extend(page_contributors)
+    page += 1
+
+print(f'Found {len(sponsors)} sponsors')
+print(f'Total contributors for NiceGUI: {len(contributors)}')
+
+Path('website/sponsors.json').write_text(json.dumps({
+    'top': [s['login'] for s in sponsors if s['tier_amount'] >= 100 and not s['tier_is_one_time']],
+    'total': len(sponsors),
+    'contributors': len(contributors),
+}, indent=2) + '\n')
+
+sponsor_html = '<p align="center">\n'
+for sponsor in sponsors:
+    if sponsor['tier_amount'] >= 25 and not sponsor['tier_is_one_time']:
+        sponsor_html += f'  <a href="{sponsor["url"]}"><img src="{sponsor["url"]}.png" width="50px" alt="{sponsor["name"]}" /></a>\n'
+sponsor_html += '</p>'
+readme_path = Path('README.md')
+readme_content = readme_path.read_text()
+updated_content = re.sub(
+    r'<!-- SPONSORS -->.*?<!-- SPONSORS -->',
+    f'<!-- SPONSORS -->\n{sponsor_html}\n<!-- SPONSORS -->',
+    readme_content,
+    flags=re.DOTALL,
+)
+readme_path.write_text(updated_content)
+
+print('README.md and sponsors.json updated successfully.')
diff --git a/nicegui/__init__.py b/nicegui/__init__.py
index c59026d2..ade51999 100644
--- a/nicegui/__init__.py
+++ b/nicegui/__init__.py
@@ -1,4 +1,4 @@
-from . import elements, run, ui
+from . import elements, html, run, ui
 from .api_router import APIRouter
 from .app.app import App
 from .client import Client
@@ -16,6 +16,7 @@ __all__ = [
     'context',
     'ElementFilter',
     'elements',
+    'html',
     'run',
     'Tailwind',
     'ui',
diff --git a/nicegui/elements/scene.js b/nicegui/elements/scene.js
index 5926613e..659e9265 100644
--- a/nicegui/elements/scene.js
+++ b/nicegui/elements/scene.js
@@ -452,6 +452,8 @@ export default {
           if (camera_up_changed) {
             this.controls.dispose();
             this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+            this.controls.target.copy(this.look_at);
+            this.camera.lookAt(this.look_at);
           }
         })
         .start();
diff --git a/nicegui/html.py b/nicegui/html.py
new file mode 100644
index 00000000..a51bc2af
--- /dev/null
+++ b/nicegui/html.py
@@ -0,0 +1,113 @@
+from typing import Optional
+
+from .element import Element
+
+
+def _create_html_element(tag: str):
+    class HTMLElement(Element):
+        def __init__(self, inner_html: Optional[str] = None, **kwargs) -> None:
+            super().__init__(tag)
+            self._text = inner_html
+            self.props.update(**kwargs)
+    return HTMLElement
+
+
+a = _create_html_element('a')
+abbr = _create_html_element('abbr')
+acronym = _create_html_element('acronym')
+address = _create_html_element('address')
+area = _create_html_element('area')
+article = _create_html_element('article')
+aside = _create_html_element('aside')
+audio = _create_html_element('audio')
+b = _create_html_element('b')
+basefont = _create_html_element('basefont')
+bdi = _create_html_element('bdi')
+bdo = _create_html_element('bdo')
+big = _create_html_element('big')
+blockquote = _create_html_element('blockquote')
+br = _create_html_element('br')
+button = _create_html_element('button')
+canvas = _create_html_element('canvas')
+caption = _create_html_element('caption')
+cite = _create_html_element('cite')
+code = _create_html_element('code')
+col = _create_html_element('col')
+colgroup = _create_html_element('colgroup')
+data = _create_html_element('data')
+datalist = _create_html_element('datalist')
+dd = _create_html_element('dd')
+del_ = _create_html_element('del')
+details = _create_html_element('details')
+dfn = _create_html_element('dfn')
+dialog = _create_html_element('dialog')
+div = _create_html_element('div')
+dl = _create_html_element('dl')
+dt = _create_html_element('dt')
+em = _create_html_element('em')
+embed = _create_html_element('embed')
+fieldset = _create_html_element('fieldset')
+figcaption = _create_html_element('figcaption')
+figure = _create_html_element('figure')
+footer = _create_html_element('footer')
+form = _create_html_element('form')
+h1 = _create_html_element('h1')
+hgroup = _create_html_element('hgroup')
+hr = _create_html_element('hr')
+i = _create_html_element('i')
+iframe = _create_html_element('iframe')
+img = _create_html_element('img')
+input_ = _create_html_element('input')
+ins = _create_html_element('ins')
+kbd = _create_html_element('kbd')
+label = _create_html_element('label')
+legend = _create_html_element('legend')
+li = _create_html_element('li')
+main = _create_html_element('main')
+map_ = _create_html_element('map')
+mark = _create_html_element('mark')
+menu = _create_html_element('menu')
+meter = _create_html_element('meter')
+nav = _create_html_element('nav')
+object_ = _create_html_element('object')
+ol = _create_html_element('ol')
+optgroup = _create_html_element('optgroup')
+option = _create_html_element('option')
+output = _create_html_element('output')
+p = _create_html_element('p')
+param = _create_html_element('param')
+picture = _create_html_element('picture')
+pre = _create_html_element('pre')
+progress = _create_html_element('progress')
+q = _create_html_element('q')
+rp = _create_html_element('rp')
+rt = _create_html_element('rt')
+ruby = _create_html_element('ruby')
+s = _create_html_element('s')
+samp = _create_html_element('samp')
+search = _create_html_element('search')
+section = _create_html_element('section')
+select = _create_html_element('select')
+small = _create_html_element('small')
+source = _create_html_element('source')
+span = _create_html_element('span')
+strong = _create_html_element('strong')
+sub = _create_html_element('sub')
+summary = _create_html_element('summary')
+sup = _create_html_element('sup')
+svg = _create_html_element('svg')
+table = _create_html_element('table')
+tbody = _create_html_element('tbody')
+td = _create_html_element('td')
+template = _create_html_element('template')
+textarea = _create_html_element('textarea')
+tfoot = _create_html_element('tfoot')
+th = _create_html_element('th')
+thead = _create_html_element('thead')
+time = _create_html_element('time')
+track = _create_html_element('track')
+u = _create_html_element('u')
+ul = _create_html_element('ul')
+var = _create_html_element('var')
+video = _create_html_element('video')
+wbr = _create_html_element('wbr')
diff --git a/website/documentation/content/section_text_elements.py b/website/documentation/content/section_text_elements.py
index 1b50ca02..e34a93d7 100644
--- a/website/documentation/content/section_text_elements.py
+++ b/website/documentation/content/section_text_elements.py
@@ -20,3 +20,24 @@ doc.intro(markdown_documentation)
 doc.intro(restructured_text_documentation)
 doc.intro(mermaid_documentation)
 doc.intro(html_documentation)
+
+
+@doc.demo('Other HTML Elements', '''
+    There is an `html` module that allows you to insert other HTML elements like `<span>`, `<div>`, `<p>`, etc.
+    It is equivalent to using the `ui.element` method with the `tag` argument.
+
+    Like with any other element, you can add classes, style, props, tooltips and events.
+    One convenience is that the keyword arguments are automatically added to the element's `props` dictionary.
+''')
+def other_html_elements():
+    from nicegui import html, ui
+
+    with html.section().style('font-size: 120%'):
+        html.strong('This is bold.') \
+            .classes('cursor-pointer') \
+            .on('click', lambda: ui.notify('Bold!'))
+        html.hr()
+        html.em('This is italic.').tooltip('Nice!')
+        with ui.row():
+            html.img().props('src=https://placehold.co/60')
+            html.img(src='https://placehold.co/60')
diff --git a/website/main_page.py b/website/main_page.py
index 6091dc0e..b2f3d3b3 100644
--- a/website/main_page.py
+++ b/website/main_page.py
@@ -1,3 +1,6 @@
+import json
+from pathlib import Path
+
 from nicegui import ui
 
 from . import documentation, example_card, svg
@@ -5,6 +8,8 @@ from .examples import examples
 from .header import add_head_html, add_header
 from .style import example_link, features, heading, link_target, section_heading, subtitle, title
 
+SPONSORS = json.loads((Path(__file__).parent / 'sponsors.json').read_text())
+
 
 def create() -> None:
     """Create the content of the main page."""
@@ -159,6 +164,30 @@ def create() -> None:
             for example in examples:
                 example_link(example)
 
+    with ui.column().classes('dark-box p-8 lg:p-16 my-16 bg-transparent border-y-2'):
+        with ui.column().classes('mx-auto items-center gap-y-8 gap-x-32 lg:flex-row'):
+            with ui.column().classes('max-lg:items-center max-lg:text-center'):
+                ui.markdown('NiceGUI is supported by') \
+                    .classes('text-2xl md:text-3xl font-medium')
+                with ui.row(align_items='center'):
+                    assert SPONSORS['total'] > 0
+                    ui.markdown(f'''
+                        our top {'sponsor' if SPONSORS['total'] == 1 else 'sponsors'}
+                    ''')
+                    for sponsor in SPONSORS['top']:
+                        with ui.link(target=f'https://github.com/{sponsor}').classes('row items-center gap-2'):
+                            ui.image(f'https://github.com/{sponsor}.png').classes('w-12 h-12 border')
+                            ui.label(f'@{sponsor}')
+                ui.markdown(f'''
+                    as well as {SPONSORS['total'] - len(SPONSORS['top'])} other [sponsors](https://github.com/sponsors/zauberzeug)
+                    and {SPONSORS['contributors']} [contributors](https://github.com/zauberzeug/nicegui/graphs/contributors).
+                ''').classes('bold-links arrow-links')
+            with ui.link(target='https://github.com/sponsors/zauberzeug').style('color: black !important') \
+                    .classes('rounded-full mx-auto px-12 py-2 border-2 border-[#3e6a94] font-medium text-lg md:text-xl'):
+                with ui.row().classes('items-center gap-4'):
+                    ui.icon('sym_o_favorite', color='#3e6a94')
+                    ui.label('Become a sponsor').classes('text-[#3e6a94]')
+
     with ui.row().classes('dark-box min-h-screen mt-16'):
         link_target('why')
         with ui.column().classes('''
diff --git a/website/sponsors.json b/website/sponsors.json
new file mode 100644
index 00000000..15ee0d84
--- /dev/null
+++ b/website/sponsors.json
@@ -0,0 +1,7 @@
+{
+  "top": [
+    "daviborges666"
+  ],
+  "total": 18,
+  "contributors": 125
+}
diff --git a/website/sponsors.py b/website/sponsors.py
new file mode 100644
index 00000000..444be0fe
--- /dev/null
+++ b/website/sponsors.py
@@ -0,0 +1,1 @@
+NUM_SPONSORS = 18


Test patch:
diff --git a/tests/test_html.py b/tests/test_html.py
new file mode 100644
index 00000000..495af990
--- /dev/null
+++ b/tests/test_html.py
@@ -0,0 +1,17 @@
+from nicegui import html, ui
+from nicegui.testing import Screen
+
+
+def test_html_element(screen: Screen):
+    ui.html('This is strong.', tag='strong')
+
+    screen.open('/')
+    assert screen.find_by_tag('strong').text == 'This is strong.'
+
+
+def test_html_button(screen: Screen):
+    html.button('Click me').on('click', lambda: ui.notify('Hi!'))
+
+    screen.open('/')
+    screen.click('Click me')
+    screen.should_contain('Hi!')