Browse Source

Merge commit '88ff37f57b6abfece6a70d6bce4669ad390207f3' into changeable_native_window

Rodja Trappe 2 years ago
parent
commit
d081cc8061

+ 10 - 10
CITATION.cff

@@ -1,14 +1,14 @@
 cff-version: 1.2.0
 message: If you use this software, please cite it as below.
 authors:
-  - family-names: Schindler
-    given-names: Falko
-    orcid: https://orcid.org/0009-0003-5359-835X
-  - family-names: Trappe
-    given-names: Rodja
-    orcid: https://orcid.org/0009-0009-4735-6227
-title: "NiceGUI: Web-based user interfaces with Python. The nice way."
-version: v1.2.13
-date-released: "2023-05-05"
+- family-names: Schindler
+  given-names: Falko
+  orcid: https://orcid.org/0009-0003-5359-835X
+- family-names: Trappe
+  given-names: Rodja
+  orcid: https://orcid.org/0009-0009-4735-6227
+title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
+version: v1.2.14
+date-released: '2023-05-14'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.7901074
+doi: 10.5281/zenodo.7933863

+ 2 - 2
examples/local_file_picker/local_file_picker.py

@@ -1,5 +1,5 @@
 from pathlib import Path
-from typing import Optional
+from typing import Dict, Optional
 
 from nicegui import ui
 
@@ -58,7 +58,7 @@ class local_file_picker(ui.dialog):
             })
         self.grid.update()
 
-    async def handle_double_click(self, msg: dict) -> None:
+    async def handle_double_click(self, msg: Dict) -> None:
         self.path = Path(msg['args']['data']['path'])
         if self.path.is_dir():
             self.update_grid()

+ 4 - 3
fetch_tailwind.py

@@ -2,6 +2,7 @@
 import re
 from dataclasses import dataclass, field
 from pathlib import Path
+from typing import List
 
 import requests
 from bs4 import BeautifulSoup
@@ -12,8 +13,8 @@ from secure import SecurePath
 class Property:
     title: str
     description: str
-    members: list[str]
-    short_members: list[str] = field(init=False)
+    members: List[str]
+    short_members: List[str] = field(init=False)
     common_prefix: str = field(init=False)
 
     def __post_init__(self) -> None:
@@ -48,7 +49,7 @@ class Property:
         return '_'.join(word.lower() for word in re.sub(r'[-/ &]', ' ', self.title).split())
 
 
-properties: list[Property] = []
+properties: List[Property] = []
 
 
 def get_soup(url: str) -> BeautifulSoup:

+ 17 - 14
main.py

@@ -10,10 +10,10 @@ if True:
 
 import os
 from pathlib import Path
-from typing import Optional
+from typing import Awaitable, Callable, Optional
 
 from fastapi import Request
-from fastapi.responses import FileResponse, RedirectResponse
+from fastapi.responses import FileResponse, RedirectResponse, Response
 from starlette.middleware.sessions import SessionMiddleware
 
 import prometheus
@@ -36,17 +36,18 @@ app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
 
 
 @app.get('/logo.png')
-def logo():
+def logo() -> FileResponse:
     return FileResponse(svg.PATH / 'logo.png', media_type='image/png')
 
 
 @app.get('/logo_square.png')
-def logo():
+def logo_square() -> FileResponse:
     return FileResponse(svg.PATH / 'logo_square.png', media_type='image/png')
 
 
 @app.middleware('http')
-async def redirect_reference_to_documentation(request: Request, call_next):
+async def redirect_reference_to_documentation(request: Request,
+                                              call_next: Callable[[Request], Awaitable[Response]]) -> Response:
     if request.url.path == '/reference':
         return RedirectResponse('/documentation')
     return await call_next(request)
@@ -75,19 +76,20 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
         if menu:
-            ui.button(on_click=menu.toggle).props('flat color=white icon=menu round') \
-                .classes('max-[405px]:hidden lg:hidden')
+            ui.button(on_click=menu.toggle).props('flat color=white icon=menu round').classes('lg:hidden')
         with ui.link(target=index_page).classes('row gap-4 items-center no-wrap mr-auto'):
             svg.face().classes('w-8 stroke-white stroke-2')
             svg.word().classes('w-24')
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
                 ui.link(title, target).classes(replace='text-lg text-white')
-        with ui.link(target='https://discord.gg/TEpFeAaF4f'):
+        with ui.link(target='https://discord.gg/TEpFeAaF4f').classes('max-[435px]:hidden').tooltip('Discord'):
             svg.discord().classes('fill-white scale-125 m-1')
-        with ui.link(target='https://github.com/zauberzeug/nicegui/'):
+        with ui.link(target='https://www.reddit.com/r/nicegui/').classes('max-[385px]:hidden').tooltip('Reddit'):
+            svg.reddit().classes('fill-white scale-125 m-1')
+        with ui.link(target='https://github.com/zauberzeug/nicegui/').tooltip('GitHub'):
             svg.github().classes('fill-white scale-125 m-1')
-        add_star().classes('max-[460px]:hidden')
+        add_star().classes('max-[480px]:hidden')
         with ui.row().classes('lg:hidden'):
             with ui.button().props('flat color=white icon=more_vert round'):
                 with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):
@@ -96,7 +98,7 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
 
 
 @ui.page('/')
-async def index_page(client: Client):
+async def index_page(client: Client) -> None:
     client.content.classes('p-0 gap-0')
     add_head_html()
     add_header()
@@ -316,7 +318,7 @@ async def index_page(client: Client):
 
 
 @ui.page('/documentation')
-def documentation_page():
+def documentation_page() -> None:
     add_head_html()
     menu = side_menu()
     add_header(menu)
@@ -331,7 +333,7 @@ def documentation_page():
 
 
 @ui.page('/documentation/{name}')
-def documentation_page_more(name: str):
+async def documentation_page_more(name: str, client: Client) -> None:
     if not hasattr(ui, name):
         name = name.replace('_', '')  # NOTE: "AG Grid" leads to anchor name "ag_grid", but class is `ui.aggrid`
     module = importlib.import_module(f'website.more_documentation.{name}_documentation')
@@ -359,6 +361,7 @@ def documentation_page_more(name: str):
                 ui.markdown('**Reference**').classes('mt-4')
             ui.markdown('## Reference').classes('mt-16')
             generate_class_doc(api)
-
+    await client.connected()
+    await ui.run_javascript(f'document.title = "{name} • NiceGUI";', respond=False)
 
 ui.run(uvicorn_reload_includes='*.py, *.css, *.html')

+ 10 - 0
nicegui/__init__.py

@@ -9,3 +9,13 @@ from . import elements, globals, ui
 from .client import Client
 from .nicegui import app
 from .tailwind import Tailwind
+
+__all__ = [
+    'app',
+    'Client',
+    'elements',
+    'globals',
+    'Tailwind',
+    'ui',
+    '__version__',
+]

+ 6 - 6
nicegui/background_tasks.py

@@ -1,4 +1,4 @@
-'''inspired from https://quantlane.com/blog/ensure-asyncio-task-exceptions-get-logged/'''
+"""inspired from https://quantlane.com/blog/ensure-asyncio-task-exceptions-get-logged/"""
 import asyncio
 import sys
 from typing import Awaitable, Dict, Set, TypeVar
@@ -15,12 +15,12 @@ lazy_tasks_waiting: Dict[str, Awaitable[T]] = {}
 
 
 def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.Task[T]':
-    '''Wraps a loop.create_task call and ensures there is an exception handler added to the task.
+    """Wraps a loop.create_task call and ensures there is an exception handler added to the task.
 
     If the task raises an exception, it is logged and handled by the global exception handlers.
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
-    '''
+    """
     task = globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
     task.add_done_callback(_handle_task_result)
     running_tasks.add(task)
@@ -28,11 +28,11 @@ def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.T
     return task
 
 
-def create_lazy(coroutine: Awaitable[T], *, name: str) -> 'asyncio.Task[T]':
-    '''Wraps a create call and ensures a second task with the same name is delayed until the first one is done.
+def create_lazy(coroutine: Awaitable[T], *, name: str) -> None:
+    """Wraps a create call and ensures a second task with the same name is delayed until the first one is done.
 
     If a third task with the same name is created while the first one is still running, the second one is discarded.
-    '''
+    """
     if name in lazy_tasks_running:
         lazy_tasks_waiting[name] = coroutine
         return

+ 1 - 1
nicegui/client.py

@@ -112,7 +112,7 @@ class Client:
         """Execute JavaScript on the client.
 
         The client connection must be established before this method is called.
-        You can do this by `await client.connected()` or register a callback with `client.on_connected(...)`.
+        You can do this by `await client.connected()` or register a callback with `client.on_connect(...)`.
         If respond is True, the javascript code must return a string.
         """
         request_id = str(uuid.uuid4())

+ 12 - 5
nicegui/elements/chart.js

@@ -4,6 +4,7 @@ export default {
     setTimeout(() => {
       const imports = this.extras.map((extra) => import(window.path_prefix + extra));
       Promise.allSettled(imports).then(() => {
+        this.seriesCount = this.options.series ? this.options.series.length : 0;
         this.chart = Highcharts[this.type](this.$el, this.options);
         this.chart.reflow();
       });
@@ -18,16 +19,22 @@ export default {
   methods: {
     update_chart() {
       if (this.chart) {
-        while (this.chart.series.length > this.options.series.length) this.chart.series[0].remove();
-        while (this.chart.series.length < this.options.series.length) this.chart.addSeries({}, false);
+        while (this.seriesCount > this.options.series.length) {
+          this.chart.series[0].remove();
+          this.seriesCount--;
+        }
+        while (this.seriesCount < this.options.series.length) {
+          this.chart.addSeries({}, false);
+          this.seriesCount++;
+        }
         this.chart.update(this.options);
       }
     },
-    destroyChart () {
+    destroyChart() {
       if (this.chart) {
-        this.chart.destroy()
+        this.chart.destroy();
       }
-    }
+    },
   },
   props: {
     type: String,

+ 1 - 1
nicegui/elements/mixins/visibility.py

@@ -70,7 +70,7 @@ class Visibility:
         bind(self, 'visible', target_object, target_name, forward=forward, backward=backward)
         return self
 
-    def set_visibility(self, visible: str) -> None:
+    def set_visibility(self, visible: bool) -> None:
         """Set the visibility of this element.
 
         :param visible: Whether the element should be visible.

+ 0 - 1
nicegui/elements/scene.py

@@ -7,7 +7,6 @@ from ..element import Element
 from ..events import SceneClickEventArguments, SceneClickHit, handle_event
 from ..helpers import KWONLY_SLOTS
 from .scene_object3d import Object3D
-from .scene_objects import Scene as SceneObject
 
 register_component('scene', __file__, 'scene.js', [
     'lib/three.min.js',

+ 3 - 3
nicegui/events.py

@@ -1,6 +1,6 @@
 from dataclasses import dataclass
 from inspect import signature
-from typing import TYPE_CHECKING, Any, BinaryIO, Callable, List, Optional, Union
+from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
 
 from . import background_tasks, globals
 from .helpers import KWONLY_SLOTS, is_coroutine
@@ -269,14 +269,14 @@ class KeyEventArguments(EventArguments):
 
 
 def handle_event(handler: Optional[Callable],
-                 arguments: Union[EventArguments, dict], *,
+                 arguments: Union[EventArguments, Dict], *,
                  sender: Optional['Element'] = None) -> None:
     try:
         if handler is None:
             return
         no_arguments = not signature(handler).parameters
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
-        assert sender.parent_slot is not None
+        assert sender is not None and sender.parent_slot is not None
         with sender.parent_slot:
             result = handler() if no_arguments else handler(arguments)
         if is_coroutine(handler):

+ 1 - 1
nicegui/templates/index.html

@@ -76,7 +76,7 @@
           let event_name = 'on' + event.type[0].toLocaleUpperCase() + event.type.substring(1);
           event.specials.forEach(s => event_name += s[0].toLocaleUpperCase() + s.substring(1));
           let handler = (e) => {
-            const all = typeof e !== 'object' || !event.args;
+            const all = (typeof e !== 'object' || e === null) || !event.args;
             const args = all ? e : Object.fromEntries(event.args.map(a => [a, e[a]]));
             const emitter = () => window.socket.emit("event", {id: element.id, listener_id: event.listener_id, args});
             throttle(emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);

+ 86 - 0
nicegui/ui.py

@@ -1,5 +1,90 @@
 import os
 
+__all__ = [
+    'deprecated',
+    'element',
+    'aggrid',
+    'audio',
+    'avatar',
+    'badge',
+    'button',
+    'card',
+    'card_actions',
+    'card_section',
+    'chart',
+    'chat_message',
+    'checkbox',
+    'color_input',
+    'color_picker',
+    'colors',
+    'column',
+    'dark_mode',
+    'date',
+    'dialog',
+    'expansion',
+    'grid',
+    'html',
+    'icon',
+    'image',
+    'input',
+    'interactive_image',
+    'joystick',
+    'keyboard',
+    'knob',
+    'label',
+    'link',
+    'link_target',
+    'log',
+    'markdown',
+    'menu',
+    'menu_item',
+    'mermaid',
+    'number',
+    'plotly',
+    'circular_progress',
+    'linear_progress',
+    'query',
+    'radio',
+    'row',
+    'scene',
+    'select',
+    'separator',
+    'slider',
+    'spinner',
+    'splitter',
+    'switch',
+    'table',
+    'tab',
+    'tab_panel',
+    'tab_panels',
+    'tabs',
+    'textarea',
+    'time',
+    'toggle',
+    'tooltip',
+    'tree',
+    'upload',
+    'video',
+    'download',
+    'add_body_html',
+    'add_head_html',
+    'run_javascript',
+    'notify',
+    'open',
+    'refreshable',
+    'timer',
+    'update',
+    'page',
+    'drawer',
+    'footer',
+    'header',
+    'left_drawer',
+    'page_sticky',
+    'right_drawer',
+    'run',
+    'run_with',
+]
+
 from .deprecation import deprecated
 from .element import Element as element
 from .elements.aggrid import AgGrid as aggrid
@@ -86,3 +171,4 @@ if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
     from .elements.line_plot import LinePlot as line_plot
     from .elements.pyplot import Pyplot as pyplot
     plot = deprecated(pyplot, 'ui.plot', 'ui.pyplot', 317)
+    __all__.extend(['line_plot', 'pyplot', 'plot'])

+ 22 - 0
tests/test_chart.py

@@ -109,3 +109,25 @@ def test_replace_chart(screen: Screen):
     screen.click('Replace')
     screen.should_contain('B')
     screen.should_not_contain('A')
+
+
+def test_stock_chart(screen: Screen):
+    """https://github.com/zauberzeug/nicegui/discussions/948"""
+    chart = ui.chart({'legend': {'enabled': True}, 'series': []}, type='stockChart', extras=['stock'])
+    ui.button('update', on_click=lambda: (
+        chart.options['series'].extend([{'name': 'alice'}, {'name': 'bob'}]),
+        chart.update(),
+    ))
+    ui.button('clear', on_click=lambda: (
+        chart.options['series'].clear(),
+        chart.update(),
+    ))
+
+    screen.open('/')
+    screen.click('update')
+    screen.should_contain('alice')
+    screen.should_contain('bob')
+    screen.click('clear')
+    screen.wait(0.5)
+    screen.should_not_contain('alice')
+    screen.should_not_contain('bob')

+ 10 - 0
tests/test_input.py

@@ -108,3 +108,13 @@ def test_autocompletion(screen: Screen):
     element.send_keys(Keys.TAB)
     screen.wait(0.5)
     assert element.get_attribute('value') == 'fx'
+
+
+def test_clearable_input(screen: Screen):
+    input = ui.input(value='foo').props('clearable')
+    ui.label().bind_text_from(input, 'value', lambda value: f'value: {value}')
+
+    screen.open('/')
+    screen.should_contain('value: foo')
+    screen.click('cancel')
+    screen.should_contain('value: None')

+ 10 - 0
tests/test_number.py

@@ -29,3 +29,13 @@ def test_max_value(screen: Screen):
     element.send_keys('6')
     screen.click('Button')
     screen.should_contain_input('10')
+
+
+def test_clearable_number(screen: Screen):
+    number = ui.number(value=42).props('clearable')
+    ui.label().bind_text_from(number, 'value', lambda value: f'value: {value}')
+
+    screen.open('/')
+    screen.should_contain('value: 42')
+    screen.click('cancel')
+    screen.should_contain('value: None')

+ 2 - 2
website/example_card.py

@@ -3,7 +3,7 @@ from nicegui import ui
 from . import svg
 
 
-def create():
+def create() -> None:
     with ui.row().style('filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1))'):
         with ui.card().style(r'clip-path: polygon(0 0, 100% 0, 100% 90%, 0 100%)') \
                 .classes('pb-16 no-shadow'), ui.row().classes('no-wrap'):
@@ -27,7 +27,7 @@ def create():
                 ui.radio(['A', 'B', 'C'], value='A', on_change=lambda e: output.set_text(e.value)).props('inline')
 
 
-def create_narrow():
+def create_narrow() -> None:
     with ui.row().style('filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1))'):
         with ui.card().style(r'clip-path: polygon(0 0, 100% 0, 100% 90%, 0 100%)') \
                 .classes('pb-16 no-shadow'), ui.row().classes('no-wrap'):

+ 12 - 0
website/more_documentation/button_documentation.py

@@ -8,6 +8,18 @@ def main_demo() -> None:
 
 
 def more() -> None:
+    @text_demo('Icons', '''
+        You can also add an icon to a button.
+    ''')
+    async def icons() -> None:
+        with ui.row():
+            ui.button('demo').props('icon=history')
+            ui.button().props('icon=thumb_up')
+            with ui.button():
+                ui.label('sub-elements')
+                ui.image('https://picsum.photos/id/377/640/360') \
+                    .classes('rounded-full w-16 h-16 ml-4')
+
     @text_demo('Await button click', '''
         Sometimes it is convenient to wait for a button click before continuing the execution.
     ''')

+ 15 - 0
website/more_documentation/column_documentation.py

@@ -1,8 +1,23 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     with ui.column():
         ui.label('label 1')
         ui.label('label 2')
         ui.label('label 3')
+
+
+def more() -> None:
+    @text_demo('Masonry or Pinterest-Style Layout', '''
+        To create a masonry/Pinterest layout, the normal `ui.column` can not be used.
+        But it can be achieved with a few TailwindCSS classes.
+    ''')
+    def masonry() -> None:
+        with ui.element('div').classes('columns-3 w-full gap-2'):
+            for i, height in enumerate([50, 50, 50, 150, 100, 50]):
+                tailwind = f'mb-2 p-2 h-[{height}px] bg-blue-100 break-inside-avoid'
+                with ui.card().classes(tailwind):
+                    ui.label(f'Card #{i+1}')

+ 21 - 0
website/more_documentation/label_documentation.py

@@ -1,5 +1,26 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     ui.label('some label')
+
+
+def more() -> None:
+    @text_demo('Change Appearance Depending on the Content', '''
+        You can overwrite the `on_text_change` method to update other attributes of a label depending on its content. 
+        This technique also works for bindings as shown in the example below.
+    ''')
+    def status():
+        class status_label(ui.label):
+            def on_text_change(self, text: str) -> None:
+                super().on_text_change(text)
+                if text == 'ok':
+                    self.classes(replace='text-positive')
+                else:
+                    self.classes(replace='text-negative')
+
+        model = {'status': 'error'}
+        status_label().bind_text_from(model, 'status')
+        ui.switch(on_change=lambda e: model.update(status='ok' if e.value else 'error'))

+ 16 - 1
website/more_documentation/notify_documentation.py

@@ -4,7 +4,7 @@ from ..documentation_tools import text_demo
 
 
 def main_demo() -> None:
-    ui.button('Say hi!', on_click=lambda: ui.notify('Hi!', close_button='OK'))
+    ui.button('Say hi!', on_click=lambda: ui.notify('Hi!', closeBtn='OK'))
 
 
 def more() -> None:
@@ -15,3 +15,18 @@ def more() -> None:
         ui.button('negative', on_click=lambda: ui.notify('error', type='negative'))
         ui.button('positive', on_click=lambda: ui.notify('success', type='positive'))
         ui.button('warning', on_click=lambda: ui.notify('warning', type='warning'))
+
+    @text_demo('Multiline Notifications', '''
+        To allow a notification text to span multiple lines, it is sufficient to pass the `mutliLine` keyword with `True`.
+        If manual newline breaks are required (e.g. `\n`), you need to define a CSS style and pass it to the notification as shown in the example.
+    ''')
+    def multiline():
+        ui.html('<style>.multi-line-notification { white-space: pre-line; }</style>')
+        ui.button('show', on_click=lambda: ui.notify(
+            'Lorem ipsum dolor sit amet, consectetur adipisicing elit. \n'
+            'Hic quisquam non ad sit assumenda consequuntur esse inventore officia. \n'
+            'Corrupti reiciendis impedit vel, '
+            'fugit odit quisquam quae porro exercitationem eveniet quasi.',
+            multiLine=True,
+            classes='multi-line-notification',
+        ))

+ 21 - 1
website/more_documentation/table_documentation.py

@@ -1,3 +1,5 @@
+from typing import Dict
+
 from nicegui import ui
 
 from ..documentation_tools import text_demo
@@ -75,7 +77,7 @@ def more() -> None:
         visible_columns = {column['name'] for column in columns}
         table = ui.table(columns=columns, rows=rows, row_key='name')
 
-        def toggle(column: dict, visible: bool) -> None:
+        def toggle(column: Dict, visible: bool) -> None:
             if visible:
                 visible_columns.add(column['name'])
             else:
@@ -140,3 +142,21 @@ def more() -> None:
             columns=[{'name': col, 'label': col, 'field': col} for col in df.columns],
             rows=df.to_dict('records'),
         )
+
+    @text_demo('Adding rows', '''
+        It's simple to add new rows with the `add_rows(dict)` method.
+    ''')
+    def adding_rows():
+        import os
+        import random
+
+        def add():
+            item = os.urandom(10 // 2).hex()
+            table.add_rows({'id': item, 'count': random.randint(0, 100)})
+
+        ui.button('add', on_click=add)
+        columns = [
+            {'name': 'id', 'label': 'ID', 'field': 'id'},
+            {'name': 'count', 'label': 'Count', 'field': 'count'},
+        ]
+        table = ui.table(columns=columns, rows=[], row_key='id').classes('w-full')

+ 3 - 0
website/static/reddit.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="3 3 14 14" width="24" height="24">
+    <path fill="#FFF" d="M16.67,10A1.46,1.46,0,0,0,14.2,9a7.12,7.12,0,0,0-3.85-1.23L11,4.65,13.14,5.1a1,1,0,1,0,.13-0.61L10.82,4a0.31,0.31,0,0,0-.37.24L9.71,7.71a7.14,7.14,0,0,0-3.9,1.23A1.46,1.46,0,1,0,4.2,11.33a2.87,2.87,0,0,0,0,.44c0,2.24,2.61,4.06,5.83,4.06s5.83-1.82,5.83-4.06a2.87,2.87,0,0,0,0-.44A1.46,1.46,0,0,0,16.67,10Zm-10,1a1,1,0,1,1,1,1A1,1,0,0,1,6.67,11Zm5.81,2.75a3.84,3.84,0,0,1-2.47.77,3.84,3.84,0,0,1-2.47-.77,0.27,0.27,0,0,1,.38-0.38A3.27,3.27,0,0,0,10,14a3.28,3.28,0,0,0,2.09-.61A0.27,0.27,0,1,1,12.48,13.79Zm-0.18-1.71a1,1,0,1,1,1-1A1,1,0,0,1,12.29,12.08Z"></path>
+</svg>

+ 5 - 0
website/svg.py

@@ -19,5 +19,10 @@ def word() -> ui.html:
 def github() -> ui.html:
     return ui.html((PATH / 'github.svg').read_text())
 
+
 def discord() -> ui.html:
     return ui.html((PATH / 'discord.svg').read_text())
+
+
+def reddit() -> ui.html:
+    return ui.html((PATH / 'reddit.svg').read_text())