Browse Source

Merge branch 'main' into validation

Falko Schindler 2 years ago
parent
commit
9ba5c153c2

+ 4 - 0
.gitignore

@@ -5,3 +5,7 @@ dist
 /test.py
 *.pickle
 tests/screenshots/
+
+# ignore local virtual environments
+venv
+.idea

+ 128 - 0
CODE_OF_CONDUCT.md

@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+  and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+  overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+  advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+  address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+nicegui@zauberzeug.com.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior,  harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.

+ 28 - 0
CONTRIBUTING.md

@@ -0,0 +1,28 @@
+# Contributing to NiceGUI
+
+We're thrilled that you're interested in contributing to NiceGUI!
+Here are some guidelines that will help you get started.
+
+## Reporting issues
+
+If you encounter a bug or other issue with NiceGUI, the best way to report it is by opening a new issue on our [GitHub repository](https://github.com/zauberzeug/nicegui).
+When creating the issue, please provide a clear and concise description of the problem, including any relevant error messages and code snippets.
+If possible, include steps to reproduce the issue.
+
+## Contributing code
+
+If you'd like to contribute code to NiceGUI, we're always looking for bug fixes, performance improvements, and new features.
+To get started, fork the repository on GitHub, make your changes, and open a pull request (PR) with a detailed description of the changes you've made.
+
+When submitting a PR, please make sure that the code follows the existing coding style and that all tests are passing.
+If you're adding a new feature, please include tests that cover the new functionality.
+
+## Code of Conduct
+
+We follow a [Code of Conduct](https://github.com/zauberzeug/nicegui/blob/main/CODE_OF_CONDUCT.md) to ensure that everyone who participates in the NiceGUI community feels welcome and safe.
+By participating, you agree to abide by its terms.
+
+## Thank you!
+
+Thank you for your interest in contributing to NiceGUI!
+We're looking forward to working with you!

+ 2 - 2
README.md

@@ -6,7 +6,7 @@
 # NiceGUI
 
 NiceGUI is an easy-to-use, Python-based UI framework, which shows up in your web browser.
-You can create buttons, dialogs, markdown, 3D scenes, plots and much more.
+You can create buttons, dialogs, Markdown, 3D scenes, plots and much more.
 
 It is great for micro web apps, dashboards, robotics projects, smart home solutions and similar use cases.
 You can also use it in development, for example when tweaking/configuring a machine learning algorithm or tuning motor controllers.
@@ -28,7 +28,7 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
 - implicit reload on code change
 - standard GUI elements like label, button, checkbox, switch, slider, input, file upload, ...
 - simple grouping with rows, columns, cards and dialogs
-- general-purpose HTML and markdown elements
+- general-purpose HTML and Markdown elements
 - powerful high-level elements to
   - plot graphs and charts,
   - render 3D scenes,

+ 39 - 0
examples/menu_and_tabs/main.py

@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+from typing import Dict
+
+from nicegui import ui
+
+tab_names = ['A', 'B', 'C']
+
+# necessary until we improve native support for tabs (https://github.com/zauberzeug/nicegui/issues/251)
+
+
+def switch_tab(msg: Dict) -> None:
+    name = msg['args']
+    tabs.props(f'model-value={name}')
+    panels.props(f'model-value={name}')
+
+
+with ui.header().classes(replace='row items-center') as header:
+    ui.button(on_click=lambda: left_drawer.toggle()).props('flat color=white icon=menu')
+    with ui.element('q-tabs').on('update:model-value', switch_tab) as tabs:
+        for name in tab_names:
+            ui.element('q-tab').props(f'name={name} label={name}')
+
+with ui.footer(value=False) as footer:
+    ui.label('Footer')
+
+with ui.left_drawer().classes('bg-blue-100') as left_drawer:
+    ui.label('Side menu')
+
+with ui.page_sticky(position='bottom-right', x_offset=20, y_offset=20):
+    ui.button(on_click=footer.toggle).props('fab icon=contact_support')
+
+
+# the page content consists of multiple tab panels
+with ui.element('q-tab-panels').props('model-value=A animated').classes('w-full') as panels:
+    for name in tab_names:
+        with ui.element('q-tab-panel').props(f'name={name}').classes('w-full'):
+            ui.label(f'Content of {name}')
+
+ui.run()

+ 18 - 4
main.py

@@ -122,7 +122,7 @@ async def index_page(client: Client):
             features('space_dashboard', 'Layout', [
                 'navigation bars, tabs, panels, ...',
                 'grouping with rows, columns and cards',
-                'HTML and markdown elements',
+                'HTML and Markdown elements',
                 'flex layout by default',
             ])
             features('insights', 'Visualization', [
@@ -221,6 +221,7 @@ ui.run()
             example_link('Script Executor', 'executes scripts on selection and displays the output')
             example_link('Local File Picker', 'demonstrates a dialog for selecting files locally on the server')
             example_link('Search as you type', 'using public API of thecocktaildb.com to search for cocktails')
+            example_link('Menu and Tabs', 'uses Quasar to create foldable menu and tabs inside a header bar')
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')
@@ -261,9 +262,11 @@ ui.run()
 
 
 @ui.page('/reference')
-def reference_page():
+async def reference_page(client: Client):
     add_head_html()
     add_header()
+    with ui.left_drawer().classes('bg-[#eee] mt-[-20px] px-8 py-20').style('height: calc(100% + 20px) !important'):
+        menu = ui.column().classes('gap-1')
     ui.add_head_html('<style>html {scroll-behavior: auto;}</style>')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
         section_heading('Documentation and Examples', '*API* Reference')
@@ -271,7 +274,18 @@ def reference_page():
             'This is the API reference for NiceGUI >= 1.0. '
             'Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).'
         ).classes('bold-links arrow-links')
-        reference.create_full()
-
+        reference.create_full(menu)
+    await client.connected()
+    await ui.run_javascript('''
+var fragment = window.location.hash;
+if (fragment) {
+  var targetElement = document.querySelector(fragment);
+  if (targetElement) {
+    targetElement.scrollIntoView({
+      behavior: 'smooth'
+    });
+  }
+}    
+    ''', respond=False)
 
 ui.run(uvicorn_reload_includes='*.py, *.css, *.html')

+ 84 - 23
nicegui/element.py

@@ -6,6 +6,8 @@ from abc import ABC
 from copy import deepcopy
 from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
 
+from typing_extensions import Self
+
 from . import binding, events, globals, outbox
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
@@ -20,6 +22,13 @@ PROPS_PATTERN = re.compile(r'([\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%
 class Element(ABC, Visibility):
 
     def __init__(self, tag: str, *, _client: Optional[Client] = None) -> None:
+        """Generic Element
+
+        This class is also the base class for all other elements.
+
+        :param tag: HTML tag of the element
+        :param _client: client for this element (for internal use only)
+        """
         super().__init__()
         self.client = _client or globals.get_client()
         self.id = self.client.next_element_id
@@ -45,10 +54,15 @@ class Element(ABC, Visibility):
             outbox.enqueue_update(self.parent_slot.parent)
 
     def add_slot(self, name: str) -> Slot:
+        """Add a slot to the element.
+
+        :param name: name of the slot
+        :return: the slot
+        """
         self.slots[name] = Slot(self, name)
         return self.slots[name]
 
-    def __enter__(self):
+    def __enter__(self) -> Self:
         self.default_slot.__enter__()
         return self
 
@@ -111,12 +125,18 @@ class Element(ABC, Visibility):
                 raise ValueError(f'Unknown key {key}')
         return dict_
 
-    def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None):
-        '''HTML classes to modify the look of the element.
-        Every class in the `remove` parameter will be removed from the element.
-        Classes are separated with a blank space.
-        This can be helpful if the predefined classes by NiceGUI are not wanted in a particular styling.
-        '''
+    def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
+            -> Self:
+        """Apply, remove, or replace HTML classes.
+
+        This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
+
+        Removing or replacing classes can be helpful if predefined classes are not desired.
+
+        :param add: whitespace-delimited string of classes
+        :param remove: whitespace-delimited string of classes to remove from the element
+        :param replace: whitespace-delimited string of classes to use instead of existing ones
+        """
         class_list = self._classes if replace is None else []
         class_list = [c for c in class_list if c not in (remove or '').split()]
         class_list += (add or '').split()
@@ -137,12 +157,19 @@ class Element(ABC, Visibility):
                 result[key.strip()] = value.strip()
         return result
 
-    def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None):
-        '''CSS style sheet definitions to modify the look of the element.
-        Every style in the `remove` parameter will be removed from the element.
-        Styles are separated with a semicolon.
-        This can be helpful if the predefined style sheet definitions by NiceGUI are not wanted in a particular styling.
-        '''
+    def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) -> Self:
+        """Apply, remove, or replace CSS definitions.
+
+        Removing or replacing styles can be helpful if the predefined style is not desired.
+
+        .. codeblock:: python
+
+            ui.button('Click me').style('color: #6E93D6; font-size: 200%', remove='font-weight; background-color')
+
+        :param add: semicolon-separated list of styles to add to the element
+        :param remove: semicolon-separated list of styles to remove from the element
+        :param replace: semicolon-separated list of styles to use instead of existing ones
+         """
         style_dict = deepcopy(self._style) if replace is None else {}
         for key in self._parse_style(remove):
             if key in style_dict:
@@ -165,13 +192,21 @@ class Element(ABC, Visibility):
             dictionary[key] = value or True
         return dictionary
 
-    def props(self, add: Optional[str] = None, *, remove: Optional[str] = None):
-        '''Quasar props https://quasar.dev/vue-components/button#design to modify the look of the element.
-        Boolean props will automatically activated if they appear in the list of the `add` property.
-        Props are separated with a blank space. String values must be quoted.
-        Every prop passed to the `remove` parameter will be removed from the element.
-        This can be helpful if the predefined props by NiceGUI are not wanted in a particular styling.
-        '''
+    def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
+        """Add or remove props.
+
+        This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
+        Since props are simply applied as HTML attributes, they can be used with any HTML element.
+
+        .. codeblock:: python
+
+            ui.button('Open menu').props('outline icon=menu')
+
+        Boolean properties are assumed ``True`` if no value is specified.
+
+        :param add: whitespace-delimited list of either boolean values or key=value pair to add
+        :param remove: whitespace-delimited list of property keys to remove
+        """
         needs_update = False
         for key in self._parse_props(remove):
             if key in self._props:
@@ -185,13 +220,25 @@ class Element(ABC, Visibility):
             self.update()
         return self
 
-    def tooltip(self, text: str):
+    def tooltip(self, text: str) -> Self:
+        """Add a tooltip to the element.
+
+        :param text: text of the tooltip
+        """
         with self:
             tooltip = Element('q-tooltip')
             tooltip._text = text
         return self
 
-    def on(self, type: str, handler: Optional[Callable], args: Optional[List[str]] = None, *, throttle: float = 0.0):
+    def on(self, type: str, handler: Optional[Callable], args: Optional[List[str]] = None, *, throttle: float = 0.0) \
+            -> Self:
+        """Subscribe to an event.
+
+        :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
+        :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)
+        """
         if handler:
             args = args if args is not None else ['*']
             listener = EventListener(element_id=self.id, type=type, args=args, handler=handler, throttle=throttle)
@@ -204,7 +251,10 @@ class Element(ABC, Visibility):
                 events.handle_event(listener.handler, msg, sender=self)
 
     def collect_descendant_ids(self) -> List[int]:
-        '''includes own ID as first element'''
+        """Return a list of IDs of the element and each of its descendants.
+
+        The first ID in the list is that of the element itself.
+        """
         ids: List[int] = [self.id]
         for slot in self.slots.values():
             for child in slot.children:
@@ -212,15 +262,22 @@ class Element(ABC, Visibility):
         return ids
 
     def update(self) -> None:
+        """Update the element on the client side."""
         outbox.enqueue_update(self)
 
     def run_method(self, name: str, *args: Any) -> None:
+        """Run a method on the client side.
+
+        :param name: name of the method
+        :param args: arguments to pass to the method
+        """
         if not globals.loop:
             return
         data = {'id': self.id, 'name': name, 'args': args}
         outbox.enqueue_message('run_method', data, globals._socket_id or self.client.id)
 
     def clear(self) -> None:
+        """Remove all child elements."""
         descendants = [self.client.elements[id] for id in self.collect_descendant_ids()[1:]]
         binding.remove(descendants, Element)
         for element in descendants:
@@ -230,6 +287,10 @@ class Element(ABC, Visibility):
         self.update()
 
     def remove(self, element: Union[Element, int]) -> None:
+        """Remove a child element.
+
+        :param element: either the element instance or its ID
+        """
         if isinstance(element, int):
             children = [child for slot in self.slots.values() for child in slot.children]
             element = children[element]

File diff suppressed because it is too large
+ 7 - 0
nicegui/elements/lib/plotly.min.js


+ 4 - 3
nicegui/elements/line_plot.py

@@ -1,14 +1,15 @@
 from typing import List
 
-from .plot import Plot
+from .pyplot import Pyplot
 
 
-class LinePlot(Plot):
+class LinePlot(Pyplot):
 
     def __init__(self, *, n: int = 1, limit: int = 100, update_every: int = 1, close: bool = True, **kwargs) -> None:
         """Line Plot
 
-        Create a line plot. The `push` method provides live updating when utilized in combination with `ui.timer`.
+        Create a line plot using pyplot.
+        The `push` method provides live updating when utilized in combination with `ui.timer`.
 
         :param n: number of lines
         :param limit: maximum number of datapoints per line (new points will displace the oldest)

+ 3 - 3
nicegui/elements/markdown.py

@@ -16,9 +16,9 @@ class Markdown(ContentElement):
     def __init__(self, content: str = '', *, extras: List[str] = ['fenced-code-blocks', 'tables']) -> None:
         """Markdown Element
 
-        Renders markdown onto the page.
+        Renders Markdown onto the page.
 
-        :param content: the markdown content to be displayed
+        :param content: the Markdown content to be displayed
         :param extras: list of `markdown2 extensions <https://github.com/trentm/python-markdown2/wiki/Extras#implemented-extras>`_ (default: `['fenced-code-blocks', 'tables']`)
         """
         self.extras = extras
@@ -34,7 +34,7 @@ class Markdown(ContentElement):
 @lru_cache(maxsize=int(os.environ.get('MARKDOWN_CONTENT_CACHE_SIZE', '1000')))
 def prepare_content(content: str, extras: str) -> str:
     html = markdown2.markdown(content, extras=extras.split())
-    return apply_tailwind(html)  # we need explicit markdown styling because tailwind CSS removes all default styles
+    return apply_tailwind(html)  # we need explicit Markdown styling because tailwind CSS removes all default styles
 
 
 def apply_tailwind(html: str) -> str:

+ 1 - 0
nicegui/elements/mermaid.py

@@ -10,6 +10,7 @@ class Mermaid(ContentElement):
         '''Mermaid Diagrams
 
         Renders diagrams and charts written in the Markdown-inspired `Mermaid <https://mermaid.js.org/>`_ language.
+        The mermaid syntax can also be used inside Markdown elements by providing the extension string 'mermaid' to the ``ui.markdown`` element.
 
         :param content: the Mermaid content to be displayed
         '''

+ 19 - 0
nicegui/elements/plotly.js

@@ -0,0 +1,19 @@
+export default {
+  template: `<div></div>`,
+  mounted() {
+    setTimeout(() => {
+      import(window.path_prefix + this.lib).then(() => {
+        Plotly.newPlot(this.$el.id, this.options.data, this.options.layout);
+      });
+    }, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  methods: {
+    update(options) {
+      Plotly.newPlot(this.$el.id, options.data, options.layout);
+    },
+  },
+  props: {
+    options: Object,
+    lib: String,
+  },
+};

+ 29 - 0
nicegui/elements/plotly.py

@@ -0,0 +1,29 @@
+import json
+
+import plotly.graph_objects as go
+
+from ..dependencies import js_dependencies, register_component
+from ..element import Element
+
+register_component('plotly', __file__, 'plotly.js', [], ['lib/plotly.min.js'])
+
+
+class Plotly(Element):
+
+    def __init__(self, figure: go.Figure) -> None:
+        """Plotly Element
+
+        Renders a plotly figure onto the page.
+
+        See `plotly documentation <https://plotly.com/python/>`_ for more information.
+
+        :param figure: the plotly figure to be displayed
+        """
+        super().__init__('plotly')
+        self.figure = figure
+        self._props['lib'] = [d.import_path for d in js_dependencies.values() if d.path.name == 'plotly.min.js'][0]
+        self.update()
+
+    def update(self) -> None:
+        self._props['options'] = json.loads(self.figure.to_json())
+        self.run_method('update', self._props['options'])

+ 2 - 2
nicegui/elements/plot.py → nicegui/elements/pyplot.py

@@ -7,10 +7,10 @@ from .. import background_tasks, globals
 from ..element import Element
 
 
-class Plot(Element):
+class Pyplot(Element):
 
     def __init__(self, *, close: bool = True, **kwargs) -> None:
-        """Plot Context
+        """Pyplot Context
 
         Create a context to configure a `Matplotlib <https://matplotlib.org/>`_ plot.
 

+ 50 - 0
nicegui/elements/spinner.py

@@ -0,0 +1,50 @@
+from typing import Optional
+
+from typing_extensions import Literal
+
+from ..element import Element
+
+SpinnerTypes = Literal[
+    'default',
+    'audio',
+    'ball',
+    'bars',
+    'box',
+    'clock',
+    'comment',
+    'cube',
+    'dots',
+    'facebook',
+    'gears',
+    'grid',
+    'hearts',
+    'hourglass',
+    'infinity',
+    'ios',
+    'orbit',
+    'oval',
+    'pie',
+    'puff',
+    'radio',
+    'rings',
+    'tail',
+]
+
+
+class Spinner(Element):
+
+    def __init__(self, type: Optional[SpinnerTypes] = 'default', *,
+                 size: str = '1em', color: str = 'primary', thickness: float = 5.0):
+        """Spinner
+
+        See `Quasar Spinner <https://quasar.dev/vue-components/spinner>`_ for more information.
+
+        :param type: type of spinner (e.g. "audio", "ball", "bars", ..., default: "default")
+        :param size: size of the spinner (e.g. "3em", "10px", "xl", ..., default: "1em")
+        :param color: color of the spinner (default: "primary")
+        :param thickness: thickness of the spinner (applies to the "default" spinner only, default: 5.0)
+        """
+        super().__init__('q-spinner' if type is 'default' else f'q-spinner-{type}')
+        self._props['size'] = size
+        self._props['color'] = color
+        self._props['thickness'] = thickness

+ 32 - 1
nicegui/elements/table.py

@@ -1,7 +1,8 @@
-from typing import Dict, List
+from typing import Dict, List, Optional
 
 from ..dependencies import register_component
 from ..element import Element
+from ..functions.javascript import run_javascript
 
 register_component('table', __file__, 'table.js', ['lib/ag-grid-community.min.js'])
 
@@ -13,6 +14,8 @@ class Table(Element):
 
         An element to create a table using `AG Grid <https://www.ag-grid.com/>`_.
 
+        The `call_api_method` method can be used to call an AG Grid API method.
+
         :param options: dictionary of AG Grid options
         :param html_columns: list of columns that should be rendered as HTML (default: `[]`)
         :param theme: AG Grid theme (default: 'balham')
@@ -31,4 +34,32 @@ class Table(Element):
         self.run_method('update_grid')
 
     def call_api_method(self, name: str, *args) -> None:
+        """Call an AG Grid API method.
+
+        See `AG Grid API <https://www.ag-grid.com/javascript-data-grid/grid-api/>`_ for a list of methods.
+
+        :param name: name of the method
+        :param args: arguments to pass to the method
+        """
         self.run_method('call_api_method', name, *args)
+
+    async def get_selected_rows(self) -> List[Dict]:
+        """Get the currently selected rows.
+
+        This method is especially useful when the table is configured with ``rowSelection: 'multiple'``.
+
+        See `AG Grid API <https://www.ag-grid.com/javascript-data-grid/row-selection/#reference-selection-getSelectedRows>`_ for more information.
+
+        :return: list of selected row data
+        """
+        return await run_javascript(f'return getElement({self.id}).gridOptions.api.getSelectedRows();')
+
+    async def get_selected_row(self) -> Optional[Dict]:
+        """Get the single currently selected row.
+
+        This method is especially useful when the table is configured with ``rowSelection: 'single'``.
+
+        :return: row data of the first selection if any row is selected, otherwise `None`
+        """
+        rows = await self.get_selected_rows()
+        return rows[0] if rows else None

+ 14 - 0
nicegui/functions/javascript.py

@@ -5,6 +5,20 @@ from .. import globals
 
 async def run_javascript(code: str, *,
                          respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
+    """Run JavaScript
+
+    This function runs arbitrary JavaScript code on a page that is executed in the browser.
+    The asynchronous function will return after the command(s) are executed.
+    The client must be connected before this function is called.
+    To access a client-side object by ID, use the JavaScript function `getElement()`.
+
+    :param code: JavaScript code to run
+    :param respond: whether to wait for a response (default: `True`)
+    :param timeout: timeout in seconds (default: `1.0`)
+    :param check_interval: interval in seconds to check for a response (default: `0.01`)
+
+    :return: response from the browser, or `None` if `respond` is `False`
+    """
     client = globals.get_client()
     if not client.has_socket_connection:
         raise RuntimeError(

+ 16 - 9
nicegui/outbox.py

@@ -30,12 +30,19 @@ async def loop() -> None:
             await asyncio.sleep(0.01)
             continue
         coros = []
-        for client_id, elements in update_queue.items():
-            elements = {element_id: element.to_dict() for element_id, element in elements.items()}
-            coros.append(globals.sio.emit('update', elements, room=client_id))
-        update_queue.clear()
-        for client_id, message_type, data in message_queue:
-            coros.append(globals.sio.emit(message_type, data, room=client_id))
-        message_queue.clear()
-        for coro in coros:
-            await coro
+        try:
+            for client_id, elements in update_queue.items():
+                elements = {element_id: element.to_dict() for element_id, element in elements.items()}
+                coros.append(globals.sio.emit('update', elements, room=client_id))
+            update_queue.clear()
+            for client_id, message_type, data in message_queue:
+                coros.append(globals.sio.emit(message_type, data, room=client_id))
+            message_queue.clear()
+            for coro in coros:
+                try:
+                    await coro
+                except Exception:
+                    globals.log.exception('Error in outbox loop (awaiting coro)')
+        except Exception:
+            globals.log.exception('Error in outbox loop')
+            await asyncio.sleep(0.1)

+ 45 - 16
nicegui/page_layout.py

@@ -1,3 +1,5 @@
+from typing import Optional
+
 from typing_extensions import Literal
 
 from . import globals
@@ -41,12 +43,24 @@ class Header(ValueElement):
         code[1] = 'H' if fixed else 'h'
         self.client.layout._props['view'] = ''.join(code)
 
+    def toggle(self):
+        '''Toggle the header'''
+        self.value = not self.value
+
+    def show(self):
+        '''Show the header'''
+        self.value = True
+
+    def hide(self):
+        '''Hide the header'''
+        self.value = False
+
 
-class Drawer(ValueElement):
+class Drawer(Element):
 
     def __init__(self,
                  side: DrawerSides, *,
-                 value: bool = True,
+                 value: Optional[bool] = None,
                  fixed: bool = True,
                  bordered: bool = False,
                  elevated: bool = False,
@@ -55,7 +69,7 @@ class Drawer(ValueElement):
         '''Drawer
 
         :param side: side of the page where the drawer should be placed (`left` or `right`)
-        :param value: whether the drawer is already opened (default: `True`)
+        :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the drawer should have a border (default: `False`)
         :param elevated: whether the drawer should have a shadow (default: `False`)
@@ -63,8 +77,11 @@ class Drawer(ValueElement):
         :param bottom_corner: whether the drawer expands into the bottom corner (default: `False`)
         '''
         with globals.get_client().layout:
-            super().__init__(tag='q-drawer', value=value, on_value_change=None)
-        self._props['show-if-above'] = True
+            super().__init__('q-drawer')
+        if value is None:
+            self._props['show-if-above'] = True
+        else:
+            self._props['model-value'] = value
         self._props['side'] = side
         self._props['bordered'] = bordered
         self._props['elevated'] = elevated
@@ -77,21 +94,21 @@ class Drawer(ValueElement):
 
     def toggle(self) -> None:
         '''Toggle the drawer'''
-        self.value = not self.value
+        self.run_method('toggle')
 
-    def open(self) -> None:
-        '''Open the drawer'''
-        self.value = True
+    def show(self) -> None:
+        '''Show the drawer'''
+        self.run_method('show')
 
-    def close(self) -> None:
-        '''Close the drawer'''
-        self.value = False
+    def hide(self) -> None:
+        '''Hide the drawer'''
+        self.run_method('hide')
 
 
 class LeftDrawer(Drawer):
 
     def __init__(self, *,
-                 value: bool = True,
+                 value: Optional[bool] = None,
                  fixed: bool = True,
                  bordered: bool = False,
                  elevated: bool = False,
@@ -99,7 +116,7 @@ class LeftDrawer(Drawer):
                  bottom_corner: bool = False) -> None:
         '''Left drawer
 
-        :param value: whether the drawer is already opened (default: `True`)
+        :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the drawer should have a border (default: `False`)
         :param elevated: whether the drawer should have a shadow (default: `False`)
@@ -118,7 +135,7 @@ class LeftDrawer(Drawer):
 class RightDrawer(Drawer):
 
     def __init__(self, *,
-                 value: bool = True,
+                 value: Optional[bool] = None,
                  fixed: bool = True,
                  bordered: bool = False,
                  elevated: bool = False,
@@ -126,7 +143,7 @@ class RightDrawer(Drawer):
                  bottom_corner: bool = False) -> None:
         '''Right drawer
 
-        :param value: whether the drawer is already opened (default: `True`)
+        :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the drawer should have a border (default: `False`)
         :param elevated: whether the drawer should have a shadow (default: `False`)
@@ -165,6 +182,18 @@ class Footer(ValueElement):
         code[9] = 'F' if fixed else 'f'
         self.client.layout._props['view'] = ''.join(code)
 
+    def toggle(self) -> None:
+        '''Toggle the footer'''
+        self.value = not self.value
+
+    def show(self) -> None:
+        '''Show the footer'''
+        self.value = True
+
+    def hide(self) -> None:
+        '''Hide the footer'''
+        self.value = False
+
 
 class PageSticky(Element):
 

+ 2 - 0
nicegui/templates/index.html

@@ -147,8 +147,10 @@
 
       const dark = {{ dark }};
       Quasar.Dark.set(dark === None ? "auto" : dark);
+      {% if tailwind %}
       if (dark !== None) tailwind.config.darkMode = "class";
       if (dark === True) document.body.classList.add("dark");
+      {% endif %}
 
       app.mount("#app");
     </script>

+ 4 - 1
nicegui/ui.py

@@ -32,6 +32,7 @@ from .elements.menu import Menu as menu
 from .elements.menu import MenuItem as menu_item
 from .elements.mermaid import Mermaid as mermaid
 from .elements.number import Number as number
+from .elements.plotly import Plotly as plotly
 from .elements.progress import CircularProgress as circular_progress
 from .elements.progress import LinearProgress as linear_progress
 from .elements.radio import Radio as radio
@@ -40,6 +41,7 @@ from .elements.scene import Scene as scene
 from .elements.select import Select as select
 from .elements.separator import Separator as separator
 from .elements.slider import Slider as slider
+from .elements.spinner import Spinner as spinner
 from .elements.switch import Switch as switch
 from .elements.table import Table as table
 from .elements.time import Time as time
@@ -65,4 +67,5 @@ from .run_with import run_with
 
 if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
     from .elements.line_plot import LinePlot as line_plot
-    from .elements.plot import Plot as plot
+    from .elements.pyplot import Pyplot as plot  # NOTE: deprecated
+    from .elements.pyplot import Pyplot as pyplot

+ 24 - 2
poetry.lock

@@ -1126,6 +1126,13 @@ files = [
     {file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"},
     {file = "Pillow-9.4.0-1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab"},
     {file = "Pillow-9.4.0-1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9"},
+    {file = "Pillow-9.4.0-2-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:9d9a62576b68cd90f7075876f4e8444487db5eeea0e4df3ba298ee38a8d067b0"},
+    {file = "Pillow-9.4.0-2-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:87708d78a14d56a990fbf4f9cb350b7d89ee8988705e58e39bdf4d82c149210f"},
+    {file = "Pillow-9.4.0-2-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:8a2b5874d17e72dfb80d917213abd55d7e1ed2479f38f001f264f7ce7bae757c"},
+    {file = "Pillow-9.4.0-2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:83125753a60cfc8c412de5896d10a0a405e0bd88d0470ad82e0869ddf0cb3848"},
+    {file = "Pillow-9.4.0-2-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:9e5f94742033898bfe84c93c831a6f552bb629448d4072dd312306bab3bd96f1"},
+    {file = "Pillow-9.4.0-2-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:013016af6b3a12a2f40b704677f8b51f72cb007dac785a9933d5c86a72a7fe33"},
+    {file = "Pillow-9.4.0-2-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:99d92d148dd03fd19d16175b6d355cc1b01faf80dae93c6c3eb4163709edc0a9"},
     {file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
     {file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
     {file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
@@ -1195,6 +1202,21 @@ files = [
 docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinxext-opengraph"]
 tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
 
+[[package]]
+name = "plotly"
+version = "5.13.0"
+description = "An open-source, interactive data visualization library for Python"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "plotly-5.13.0-py2.py3-none-any.whl", hash = "sha256:4ac5db72176ce144f1fcde8d1ef7bdbccf5bb7a53e3d366b16fcd7c85319fdfd"},
+    {file = "plotly-5.13.0.tar.gz", hash = "sha256:81a3aae4021d5ab91790fc71c3433791f41bfc71586e857f7777f429a955039a"},
+]
+
+[package.dependencies]
+tenacity = ">=6.2.0"
+
 [[package]]
 name = "pluggy"
 version = "1.0.0"
@@ -1724,7 +1746,7 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyam
 name = "tenacity"
 version = "6.3.1"
 description = "Retry code until it succeeds"
-category = "dev"
+category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -2052,4 +2074,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.7"
-content-hash = "d295b6331955c188539cab3df96edbc39a6c5329b5ca37d73733378473367126"
+content-hash = "dd6d3d193a7b258f8630f1b5fda203037f9867aae4a89da27deb8f44026f2086"

+ 1 - 0
pyproject.toml

@@ -25,6 +25,7 @@ vbuild = "^0.8.1"
 watchfiles = "^0.18.1"
 jinja2 = "^3.1.2"
 python-multipart = "^0.0.5"
+plotly = "^5.13.0"
 
 [tool.poetry.group.dev.dependencies]
 icecream = "^2.1.0"

+ 3 - 3
tests/test_markdown.py

@@ -4,12 +4,12 @@ from .screen import Screen
 
 
 def test_markdown(screen: Screen):
-    m = ui.markdown('This is **markdown**')
+    m = ui.markdown('This is **Markdown**')
 
     screen.open('/')
     element = screen.find('This is')
-    assert element.text == 'This is markdown'
-    assert element.get_attribute('innerHTML') == 'This is <strong>markdown</strong>'
+    assert element.text == 'This is Markdown'
+    assert element.get_attribute('innerHTML') == 'This is <strong>Markdown</strong>'
 
     m.set_content('New **content**')
     element = screen.find('New')

+ 23 - 0
tests/test_plotly.py

@@ -0,0 +1,23 @@
+import plotly.graph_objects as go
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_plotly(screen: Screen):
+    fig = go.Figure(go.Scatter(x=[1, 2, 3], y=[1, 2, 3], name='Trace 1'))
+    fig.update_layout(title='Test Figure')
+    plot = ui.plotly(fig)
+
+    ui.button('Add trace', on_click=lambda: (
+        fig.add_trace(go.Scatter(x=[0, 1, 2], y=[2, 1, 0], name='Trace 2')),
+        plot.update()
+    ))
+
+    screen.open('/')
+    screen.should_contain('Test Figure')
+
+    screen.click('Add trace')
+    screen.should_contain('Trace 1')
+    screen.should_contain('Trace 2')

+ 15 - 0
tests/test_spinner.py

@@ -0,0 +1,15 @@
+from selenium.webdriver.common.by import By
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_spinner(screen: Screen):
+    ui.spinner(size='3em', thickness=10)
+
+    screen.open('/')
+    element = screen.find_by_tag('svg')
+    assert element.get_attribute('width') == '3em'
+    assert element.get_attribute('height') == '3em'
+    assert element.find_element(By.TAG_NAME, 'circle').get_attribute('stroke-width') == '10'

+ 29 - 0
tests/test_table.py

@@ -1,3 +1,6 @@
+from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.common.keys import Keys
+
 from nicegui import ui
 
 from .screen import Screen
@@ -82,3 +85,29 @@ def test_call_api_method_with_argument(screen: Screen):
     screen.should_contain('Alice')
     screen.should_not_contain('Bob')
     screen.should_not_contain('Carol')
+
+
+def test_get_selected_rows(screen: Screen):
+    table = ui.table({
+        'columnDefs': [{'field': 'name'}],
+        'rowData': [{'name': 'Alice'}, {'name': 'Bob'}, {'name': 'Carol'}],
+        'rowSelection': 'multiple',
+    })
+
+    async def get_selected_rows():
+        ui.label(str(await table.get_selected_rows()))
+    ui.button('Get selected rows', on_click=get_selected_rows)
+
+    async def get_selected_row():
+        ui.label(str(await table.get_selected_row()))
+    ui.button('Get selected row', on_click=get_selected_row)
+
+    screen.open('/')
+    screen.click('Alice')
+    screen.find('Bob')
+    ActionChains(screen.selenium).key_down(Keys.SHIFT).click(screen.find('Bob')).key_up(Keys.SHIFT).perform()
+    screen.click('Get selected rows')
+    screen.should_contain("[{'name': 'Alice'}, {'name': 'Bob'}]")
+
+    screen.click('Get selected row')
+    screen.should_contain("{'name': 'Alice'}")

+ 17 - 7
website/example.py

@@ -1,3 +1,4 @@
+import contextlib
 import inspect
 import re
 from typing import Callable, Optional, Union
@@ -28,9 +29,11 @@ class example:
 
     def __init__(self,
                  content: Union[Callable, type, str],
+                 menu: Optional[ui.element],
                  browser_title: Optional[str] = None,
                  immediate: bool = False) -> None:
         self.content = content
+        self.menu = menu
         self.browser_title = browser_title
         self.immediate = immediate
 
@@ -38,7 +41,7 @@ class example:
         with ui.column().classes('w-full mb-8'):
             if isinstance(self.content, str):
                 documentation = ui.markdown(self.content)
-                _add_markdown_anchor(documentation)
+                _add_markdown_anchor(documentation, self.menu)
             else:
                 doc = self.content.__doc__ or self.content.__init__.__doc__
                 html: str = docutils.core.publish_parts(doc, writer_name='html5_polyglot')['html_body']
@@ -47,9 +50,9 @@ class example:
                 html = html.replace('param ', '')
                 html = apply_tailwind(html)
                 documentation = ui.html(html)
-                _add_html_anchor(documentation.classes('documentation bold-links arrow-links'))
+                _add_html_anchor(documentation.classes('documentation bold-links arrow-links'), self.menu)
 
-            with ui.column().classes('w-full items-stretch gap-8 no-wrap xl:flex-row'):
+            with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
                 code = inspect.getsource(f).split('# END OF EXAMPLE')[0].strip().splitlines()
                 while not code[0].startswith(' ' * 8):
                     del code[0]
@@ -59,9 +62,10 @@ class example:
                     code.append('')
                     code.append('ui.run()')
                 code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
-                with python_window(classes='w-full max-w-[48rem]'):
+                with python_window(classes='w-full max-w-[44rem]'):
                     ui.markdown(f'```python\n{code}\n```')
-                with browser_window(self.browser_title, classes='w-full max-w-[48rem] xl:max-w-[20rem] min-h-[10rem] browser-window'):
+                with browser_window(self.browser_title,
+                                    classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window'):
                     if self.immediate:
                         f()
                     else:
@@ -70,7 +74,7 @@ class example:
         return f
 
 
-def _add_markdown_anchor(element: ui.markdown) -> None:
+def _add_markdown_anchor(element: ui.markdown, menu: Optional[ui.element]) -> None:
     first_line, _ = element.content.split('\n', 1)
     assert first_line.startswith('#### ')
     headline = first_line[5:].strip()
@@ -81,8 +85,11 @@ def _add_markdown_anchor(element: ui.markdown) -> None:
     title = f'{target}<h4>{headline} {link}</h4>'
     element.content = title + '\n' + element.content.split('\n', 1)[1]
 
+    with menu or contextlib.nullcontext():
+        ui.link(headline, f'#{headline_id}')
 
-def _add_html_anchor(element: ui.html) -> None:
+
+def _add_html_anchor(element: ui.html, menu: Optional[ui.element]) -> None:
     html = element.content
     match = REGEX_H4.search(html)
     if not match:
@@ -99,6 +106,9 @@ def _add_html_anchor(element: ui.html) -> None:
     html = html.replace('</h4>', f' {link}</h4>', 1)
     element.content = html
 
+    with menu or contextlib.nullcontext():
+        ui.link(headline, f'#{headline_id}')
+
 
 def _window_header(bgcolor: str) -> ui.row():
     return ui.row().classes(f'w-full h-8 p-2 bg-[{bgcolor}]')

+ 147 - 80
website/reference.py

@@ -12,7 +12,7 @@ def create_intro() -> None:
     @example('''#### Styling
 
 While having reasonable defaults, you can still modify the look of your app with CSS as well as Tailwind and Quasar classes.
-''')
+''', None)
     def formatting_example():
         ui.icon('thumb_up')
         ui.markdown('This is **Markdown**.')
@@ -26,7 +26,7 @@ While having reasonable defaults, you can still modify the look of your app with
     @example('''#### Common UI Elements
 
 NiceGUI comes with a collection of commonly used UI elements.
-''')
+''', None)
     def common_elements_example():
         from nicegui.events import ValueChangeEventArguments
 
@@ -47,7 +47,7 @@ NiceGUI comes with a collection of commonly used UI elements.
     @example('''#### Value Binding
 
 Binding values between UI elements and data models is built into NiceGUI.
-''')
+''', None)
     def binding_example():
         class Demo:
             def __init__(self):
@@ -61,114 +61,116 @@ Binding values between UI elements and data models is built into NiceGUI.
             ui.number().bind_value(demo, 'number')
 
 
-def create_full() -> None:
+def create_full(menu: ui.element) -> None:
     def h3(text: str) -> None:
         ui.html(f'<em>{text}</em>').classes('mt-8 text-3xl font-weight-500')
+        with menu:
+            ui.label(text).classes('font-bold mt-4')
 
     h3('Basic Elements')
 
-    @example(ui.label)
+    @example(ui.label, menu)
     def label_example():
         ui.label('some label')
 
-    @example(ui.icon)
+    @example(ui.icon, menu)
     def icon_example():
         ui.icon('thumb_up')
 
-    @example(ui.link)
+    @example(ui.link, menu)
     def link_example():
         ui.link('NiceGUI on GitHub', 'https://github.com/zauberzeug/nicegui')
 
-    @example(ui.button)
+    @example(ui.button, menu)
     def button_example():
         ui.button('Click me!', on_click=lambda: ui.notify(f'You clicked me!'))
 
-    @example(ui.badge)
+    @example(ui.badge, menu)
     def badge_example():
         with ui.button('Click me!', on_click=lambda: badge.set_text(int(badge.text) + 1)):
             badge = ui.badge('0', color='red').props('floating')
 
-    @example(ui.toggle)
+    @example(ui.toggle, menu)
     def toggle_example():
         toggle1 = ui.toggle([1, 2, 3], value=1)
         toggle2 = ui.toggle({1: 'A', 2: 'B', 3: 'C'}).bind_value(toggle1, 'value')
 
-    @example(ui.radio)
+    @example(ui.radio, menu)
     def radio_example():
         radio1 = ui.radio([1, 2, 3], value=1).props('inline')
         radio2 = ui.radio({1: 'A', 2: 'B', 3: 'C'}).props('inline').bind_value(radio1, 'value')
 
-    @example(ui.select)
+    @example(ui.select, menu)
     def select_example():
         select1 = ui.select([1, 2, 3], value=1)
         select2 = ui.select({1: 'One', 2: 'Two', 3: 'Three'}).bind_value(select1, 'value')
 
-    @example(ui.checkbox)
+    @example(ui.checkbox, menu)
     def checkbox_example():
         checkbox = ui.checkbox('check me')
         ui.label('Check!').bind_visibility_from(checkbox, 'value')
 
-    @example(ui.switch)
+    @example(ui.switch, menu)
     def switch_example():
         switch = ui.switch('switch me')
         ui.label('Switch!').bind_visibility_from(switch, 'value')
 
-    @example(ui.slider)
+    @example(ui.slider, menu)
     def slider_example():
         slider = ui.slider(min=0, max=100, value=50)
         ui.label().bind_text_from(slider, 'value')
 
-    @example(ui.joystick)
+    @example(ui.joystick, menu)
     def joystick_example():
         ui.joystick(color='blue', size=50,
                     on_move=lambda e: coordinates.set_text(f"{e.x:.3f}, {e.y:.3f}"),
                     on_end=lambda _: coordinates.set_text('0, 0'))
         coordinates = ui.label('0, 0')
 
-    @example(ui.input)
+    @example(ui.input, menu)
     def input_example():
         ui.input(label='Text', placeholder='start typing',
                  on_change=lambda e: result.set_text('you typed: ' + e.value))
         result = ui.label()
 
-    @example(ui.number)
+    @example(ui.number, menu)
     def number_example():
         ui.number(label='Number', value=3.1415927, format='%.2f',
                   on_change=lambda e: result.set_text(f'you entered: {e.value}'))
         result = ui.label()
 
-    @example(ui.color_input)
+    @example(ui.color_input, menu)
     def color_input_example():
         label = ui.label('Change my color!')
         ui.color_input(label='Color', value='#000000',
                        on_change=lambda e: label.style(f'color:{e.value}'))
 
-    @example(ui.color_picker)
+    @example(ui.color_picker, menu)
     def color_picker_example():
         picker = ui.color_picker(on_pick=lambda e: button.style(f'background-color:{e.color}!important'))
         button = ui.button(on_click=picker.open).props('icon=colorize')
 
-    @example(ui.date)
+    @example(ui.date, menu)
     def date_example():
         ui.date(value='2023-01-01', on_change=lambda e: result.set_text(e.value))
         result = ui.label()
 
-    @example(ui.time)
+    @example(ui.time, menu)
     def time_example():
         ui.time(value='12:00', on_change=lambda e: result.set_text(e.value))
         result = ui.label()
 
-    @example(ui.upload)
+    @example(ui.upload, menu)
     def upload_example():
         ui.upload(on_upload=lambda e: ui.notify(f'Uploaded {e.name}')).classes('max-w-full')
 
     h3('Markdown and HTML')
 
-    @example(ui.markdown)
+    @example(ui.markdown, menu)
     def markdown_example():
         ui.markdown('''This is **Markdown**.''')
 
-    @example(ui.mermaid)
+    @example(ui.mermaid, menu)
     def mermaid_example():
         ui.mermaid('''
         graph LR;
@@ -176,14 +178,14 @@ def create_full() -> None:
             A --> C;
         ''')
 
-    @example(ui.html)
+    @example(ui.html, menu)
     def html_example():
         ui.html('This is <strong>HTML</strong>.')
 
     @example('''#### SVG
 
 You can add Scalable Vector Graphics using the `ui.html` element.
-''')
+''', menu)
     def svg_example():
         content = '''
             <svg viewBox="0 0 200 200" width="100" height="100" xmlns="http://www.w3.org/2000/svg">
@@ -196,7 +198,7 @@ You can add Scalable Vector Graphics using the `ui.html` element.
 
     h3('Images, Audio and Video')
 
-    @example(ui.image)
+    @example(ui.image, menu)
     def image_example():
         ui.image('https://picsum.photos/id/377/640/360')
 
@@ -206,7 +208,7 @@ By nesting elements inside a `ui.image` you can create augmentations.
 
 Use [Quasar classes](https://quasar.dev/vue-components/img) for positioning and styling captions.
 To overlay an SVG, make the `viewBox` exactly the size of the image and provide `100%` width/height to match the actual rendered size.
-''')
+''', menu)
     def captions_and_overlays_example():
         with ui.image('https://picsum.photos/id/29/640/360'):
             ui.label('Nice!').classes('absolute-bottom text-subtitle2 text-center')
@@ -218,7 +220,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
                 </svg>
             ''').classes('bg-transparent')
 
-    @example(ui.interactive_image)
+    @example(ui.interactive_image, menu)
     def interactive_image_example():
         from nicegui.events import MouseEventArguments
 
@@ -230,7 +232,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
         src = 'https://picsum.photos/id/565/640/360'
         ii = ui.interactive_image(src, on_mouse=mouse_handler, events=['mousedown', 'mouseup'], cross=True)
 
-    @example(ui.audio)
+    @example(ui.audio, menu)
     def image_example():
         a = ui.audio('https://cdn.pixabay.com/download/audio/2022/02/22/audio_d1718ab41b.mp3')
         a.on('ended', lambda _: ui.notify('Audio playback completed'))
@@ -238,14 +240,14 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
         ui.button(on_click=lambda: a.props('muted')).props('outline icon=volume_up')
         ui.button(on_click=lambda: a.props(remove='muted')).props('outline icon=volume_off')
 
-    @example(ui.video)
+    @example(ui.video, menu)
     def image_example():
         v = ui.video('https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4')
         v.on('ended', lambda _: ui.notify('Video playback completed'))
 
     h3('Data Elements')
 
-    @example(ui.table)
+    @example(ui.table, menu)
     def table_example():
         table = ui.table({
             'columnDefs': [
@@ -257,6 +259,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
                 {'name': 'Bob', 'age': 21},
                 {'name': 'Carol', 'age': 42},
             ],
+            'rowSelection': 'multiple',
         }).classes('max-h-40')
 
         def update():
@@ -264,8 +267,9 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
             table.update()
 
         ui.button('Update', on_click=update)
+        ui.button('Select all', on_click=lambda: table.call_api_method('selectAll'))
 
-    @example(ui.chart)
+    @example(ui.chart, menu)
     def chart_example():
         from numpy.random import random
 
@@ -285,17 +289,17 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
 
         ui.button('Update', on_click=update)
 
-    @example(ui.plot)
+    @example(ui.pyplot, menu)
     def plot_example():
         import numpy as np
         from matplotlib import pyplot as plt
 
-        with ui.plot(figsize=(3, 2)):
+        with ui.pyplot(figsize=(3, 2)):
             x = np.linspace(0.0, 5.0)
             y = np.cos(2 * np.pi * x) * np.exp(-x)
             plt.plot(x, y, '-')
 
-    @example(ui.line_plot)
+    @example(ui.line_plot, menu)
     def line_plot_example():
         from datetime import datetime
 
@@ -324,17 +328,32 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
                 ui.timer(10.0, turn_off, once=True)
         line_checkbox.on('update:model-value', handle_change)
 
-    @example(ui.linear_progress)
+    @example(ui.plotly, menu)
+    def plotly_example():
+        import plotly.graph_objects as go
+
+        fig = go.Figure(go.Scatter(x=[1, 2, 3, 4], y=[1, 2, 3, 2.5]))
+        fig.update_layout(width=280, height=210, margin=dict(l=0, r=0, t=0, b=0))
+        ui.plotly(fig)
+
+    @example(ui.linear_progress, menu)
     def linear_progress_example():
         slider = ui.slider(min=0, max=1, step=0.01, value=0.5)
         ui.linear_progress().bind_value_from(slider, 'value')
 
-    @example(ui.circular_progress)
+    @example(ui.circular_progress, menu)
     def circular_progress_example():
         slider = ui.slider(min=0, max=1, step=0.01, value=0.5)
         ui.circular_progress().bind_value_from(slider, 'value')
 
-    @example(ui.scene)
+    @example(ui.spinner, menu)
+    def spinner_example():
+        with ui.row():
+            ui.spinner(size='lg')
+            ui.spinner('audio', size='lg', color='green')
+            ui.spinner('dots', size='lg', color='red')
+
+    @example(ui.scene, menu)
     def scene_example():
         with ui.scene(width=285, height=285) as scene:
             scene.sphere().material('#4488ff')
@@ -359,14 +378,14 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
             scene.text('2D', 'background: rgba(0, 0, 0, 0.2); border-radius: 5px; padding: 5px').move(z=2)
             scene.text3d('3D', 'background: rgba(0, 0, 0, 0.2); border-radius: 5px; padding: 5px').move(y=-2).scale(.05)
 
-    @example(ui.tree)
+    @example(ui.tree, menu)
     def tree_example():
         ui.tree([
             {'id': 'numbers', 'children': [{'id': '1'}, {'id': '2'}]},
             {'id': 'letters', 'children': [{'id': 'A'}, {'id': 'B'}]},
         ], label_key='id', on_select=lambda e: ui.notify(e.value))
 
-    @example(ui.log)
+    @example(ui.log, menu)
     def log_example():
         from datetime import datetime
 
@@ -375,21 +394,21 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
 
     h3('Layout')
 
-    @example(ui.card)
+    @example(ui.card, menu)
     def card_example():
         with ui.card().tight() as card:
             ui.image('https://picsum.photos/id/684/640/360')
             with ui.card_section():
                 ui.label('Lorem ipsum dolor sit amet, consectetur adipiscing elit, ...')
 
-    @example(ui.column)
+    @example(ui.column, menu)
     def column_example():
         with ui.column():
             ui.label('label 1')
             ui.label('label 2')
             ui.label('label 3')
 
-    @example(ui.row)
+    @example(ui.row, menu)
     def row_example():
         with ui.row():
             ui.label('label 1')
@@ -401,7 +420,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
 To remove all elements from a row, column or card container, use the `clear()` method.
 
 Alternatively, you can remove individual elements with `remove(element)`, where `element` is an Element or an index.
-''')
+''', menu)
     def clear_containers_example():
         container = ui.row()
 
@@ -414,12 +433,12 @@ Alternatively, you can remove individual elements with `remove(element)`, where
         ui.button('Remove', on_click=lambda: container.remove(0))
         ui.button('Clear', on_click=container.clear)
 
-    @example(ui.expansion)
+    @example(ui.expansion, menu)
     def expansion_example():
         with ui.expansion('Expand!', icon='work').classes('w-full'):
             ui.label('inside the expansion')
 
-    @example(ui.menu)
+    @example(ui.menu, menu)
     def menu_example():
         choice = ui.label('Try the menu.')
         with ui.row():
@@ -436,17 +455,17 @@ Alternatively, you can remove individual elements with `remove(element)`, where
 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.
-''')
+''', menu)
     def tooltips_example():
         ui.label('Tooltips...').tooltip('...are shown on mouse over')
         with ui.button().props('icon=thumb_up'):
             ui.tooltip('I like this').classes('bg-green')
 
-    @example(ui.notify)
+    @example(ui.notify, menu)
     def notify_example():
         ui.button('Say hi!', on_click=lambda: ui.notify('Hi!', close_button='OK'))
 
-    @example(ui.dialog)
+    @example(ui.dialog, menu)
     def dialog_example():
         with ui.dialog() as dialog, ui.card():
             ui.label('Hello world!')
@@ -459,7 +478,7 @@ For more artistic control you can nest tooltip elements and apply props, classes
 Dialogs can be awaited.
 Use the `submit` method to close the dialog and return a result.
 Canceling the dialog by clicking in the background or pressing the escape key yields `None`.
-''')
+''', menu)
     def async_dialog_example():
         with ui.dialog() as dialog, ui.card():
             ui.label('Are you sure?')
@@ -485,20 +504,20 @@ You can also apply [Tailwind](https://tailwindcss.com/) utility classes with the
 If you really need to apply CSS, you can use the `styles` method. Here the delimiter is `;` instead of a blank space.
 
 All three functions also provide `remove` and `replace` parameters in case the predefined look is not wanted in a particular styling.
-''')
+''', menu)
     def design_example():
         ui.radio(['x', 'y', 'z'], value='x').props('inline color=green')
         ui.button().props('icon=touch_app outline round').classes('shadow-lg')
         ui.label('Stylish!').style('color: #6E93D6; font-size: 200%; font-weight: 300')
 
-    @example(ui.colors)
+    @example(ui.colors, menu)
     def colors_example():
         ui.button('Default', on_click=lambda: ui.colors())
         ui.button('Gray', on_click=lambda: ui.colors(primary='#555'))
 
     h3('Action')
 
-    @example(ui.timer)
+    @example(ui.timer, menu)
     def timer_example():
         from datetime import datetime
 
@@ -516,7 +535,7 @@ All three functions also provide `remove` and `replace` parameters in case the p
             lazy_clock = ui.label()
             ui.timer(interval=0.1, callback=lazy_update)
 
-    @example(ui.keyboard)
+    @example(ui.keyboard, menu)
     def keyboard_example():
         from nicegui.events import KeyEventArguments
 
@@ -548,7 +567,7 @@ Binding is possible for UI element properties like text, value or visibility and
 Each element provides methods like `bind_value` and `bind_visibility` to create a two-way binding with the corresponding property.
 To define a one-way binding use the `_from` and `_to` variants of these methods.
 Just pass a property of the model as parameter to these methods to create the binding.
-''')
+''', menu)
     def bindings_example():
         class Demo:
             def __init__(self):
@@ -566,7 +585,7 @@ Just pass a property of the model as parameter to these methods to create the bi
 NiceGUI tries to automatically synchronize the state of UI elements with the client, e.g. when a label text, an input value or style/classes/props of an element have changed.
 In other cases, you can explicitly call `element.update()` or `ui.update(*elements)` to update.
 The example code shows both methods for a `ui.chart`, where it is difficult to automatically detect changes in the `options` dictionary.
-''')
+''', menu)
     def ui_updates_example():
         from random import randint
 
@@ -589,7 +608,7 @@ The example code shows both methods for a `ui.chart`, where it is difficult to a
 Most elements also support asynchronous event handlers.
 
 Note: You can also pass a `functools.partial` into the `on_click` property to wrap async functions with parameters.
-''')
+''', menu)
     def async_handlers_example():
         import asyncio
 
@@ -602,7 +621,7 @@ Note: You can also pass a `functools.partial` into the `on_click` property to wr
 
     h3('Pages')
 
-    @example(ui.page)
+    @example(ui.page, menu)
     def page_example():
         @ui.page('/other_page')
         def other_page():
@@ -627,7 +646,7 @@ UI elements that are not wrapped in a decorated page function are placed on an a
 This auto-index page is created once on startup and *shared* across all clients that might connect.
 Thus, each connected client will see the *same* elements.
 In the example to the right, the displayed ID on the auto-index page remains constant when the browser reloads the page.
-''')
+''', menu)
     def auto_index_page():
         from uuid import uuid4
 
@@ -647,7 +666,7 @@ Page routes can contain parameters like [FastAPI](https://fastapi.tiangolo.com/t
 If type-annotated, they are automatically converted to bool, int, float and complex values.
 If the page function expects a `request` argument, the request object is automatically provided.
 The `client` argument provides access to the websocket connection, layout, etc.
-''')
+''', menu)
     def page_with_path_parameters_example():
         @ui.page('/repeat/{word}/{count}')
         def page(word: str, count: int):
@@ -663,7 +682,7 @@ All code below that statement is executed after the websocket connection between
 
 For example, this allows you to run JavaScript commands; which is only possible with a client connection (see [#112](https://github.com/zauberzeug/nicegui/issues/112)).
 Also it is possible to do async stuff while the user already sees some content.
-''')
+''', menu)
     def wait_for_connected_example():
         import asyncio
 
@@ -687,7 +706,7 @@ The `top_corner` and `bottom_corner` arguments indicate whether a drawer should
 See <https://quasar.dev/layout/header-and-footer> and <https://quasar.dev/layout/drawer> for more information about possible props.
 With `ui.page_sticky` you can place an element "sticky" on the screen.
 See <https://quasar.dev/layout/page-sticky> for more information.
-''')
+''', menu)
     def page_layout_example():
         @ui.page('/page_layout')
         async def page_layout():
@@ -705,7 +724,7 @@ See <https://quasar.dev/layout/page-sticky> for more information.
 
         ui.link('show page with fancy layout', page_layout)
 
-    @example(ui.open)
+    @example(ui.open, menu)
     def ui_open_example():
         @ui.page('/yet_another_page')
         def yet_another_page():
@@ -718,7 +737,7 @@ See <https://quasar.dev/layout/page-sticky> for more information.
 
 The optional `request` argument provides insights about the client's URL parameters etc.
 It also enables you to identify sessions using a [session middleware](https://www.starlette.io/middleware/#sessionmiddleware).
-''')
+''', menu)
     def sessions_example():
         import uuid
         from collections import Counter
@@ -743,12 +762,7 @@ It also enables you to identify sessions using a [session middleware](https://ww
 
         ui.link('Visit session demo', session_demo)
 
-    @example('''#### JavaScript
-
-With `ui.run_javascript()` you can run arbitrary JavaScript code on a page that is executed in the browser.
-The asynchronous function will return after the command(s) are executed.
-You can also set `respond=False` to send a command without waiting for a response.
-''')
+    @example(ui.run_javascript, menu)
     def javascript_example():
         async def alert():
             await ui.run_javascript('alert("Hello!")', respond=False)
@@ -757,12 +771,17 @@ You can also set `respond=False` to send a command without waiting for a respons
             time = await ui.run_javascript('Date()')
             ui.notify(f'Browser time: {time}')
 
+        async def access_elements():
+            await ui.run_javascript(f'getElement({label.id}).innerText += " Hello!"')
+
         ui.button('fire and forget', on_click=alert)
         ui.button('receive result', on_click=get_date)
+        ui.button('access elements', on_click=access_elements)
+        label = ui.label()
 
     h3('Routes')
 
-    @example(app.add_static_files)
+    @example(app.add_static_files, menu)
     def add_static_files_example():
         from nicegui import app
 
@@ -783,7 +802,7 @@ Or you can run NiceGUI on top of your own FastAPI app by using `ui.run_with(app)
 You can also return any other FastAPI response object inside a page function.
 For example, you can return a `RedirectResponse` to redirect the user to another page if certain conditions are met.
 This is used in our [authentication demo](https://github.com/zauberzeug/nicegui/tree/main/examples/authentication/main.py).
-''')
+''', menu)
     def fastapi_example():
         import random
 
@@ -808,7 +827,7 @@ You can register coroutines or functions to be called for the following events:
 - `app.on_disconnect`: called for each client which disconnects (optional argument: nicegui.Client)
 
 When NiceGUI is shut down or restarted, all tasks still in execution will be automatically canceled.
-''')
+''', menu)
     def lifecycle_example():
         from datetime import datetime
 
@@ -827,7 +846,7 @@ When NiceGUI is shut down or restarted, all tasks still in execution will be aut
         global dt
         dt = datetime.now()
 
-    @example(app.shutdown)
+    @example(app.shutdown, menu)
     def shutdown_example():
         from nicegui import app
 
@@ -838,9 +857,57 @@ When NiceGUI is shut down or restarted, all tasks still in execution will be aut
         ui.button('shutdown', on_click=lambda: ui.notify(
             'Nah. We do not actually shutdown the documentation server. Try it in your own app!'))
 
+    h3('NiceGUI Fundamentals')
+
+    @example('''#### Auto-context
+
+In order to allow writing intuitive UI descriptions, NiceGUI automatically tracks the context in which elements are created.
+This means that there is no explicit `parent` parameter.
+Instead the parent context is defined using a `with` statement.
+It is also passed to event handlers and timers.
+
+In the example, the label "Card content" is added to the card.
+And because the `ui.button` is also added to the card, the label "Click!" will also be created in this context.
+The label "Tick!", which is added once after one second, is also added to the card.
+
+This design decision allows for easily creating modular components that keep working after moving them around in the UI.
+For example, you can move label and button somewhere else, maybe wrap them in another container, and the code will still work.
+''', menu)
+    def auto_context_example():
+        with ui.card():
+            ui.label('Card content')
+            ui.button('Add label', on_click=lambda: ui.label('Click!'))
+            ui.timer(1.0, lambda: ui.label('Tick!'), once=True)
+
+    @example('''#### Generic Events
+
+Most UI elements come with predefined events.
+For example, a `ui.button` like "A" in the example has an `on_click` parameter that expects a coroutine or function.
+But you can also use the `on` method to register a generic event handler like for "B".
+This allows you to register handlers for any event that is supported by JavaScript and Quasar.
+
+For example, you can register a handler for the `mousemove` event like for "C", even though there is no `on_mousemove` parameter for `ui.button`.
+Some events, like `mousemove`, are fired very often.
+To avoid performance issues, you can use the `throttle` parameter to only call the handler every `throttle` seconds ("D").
+
+The generic event handler can be synchronous or asynchronous and optionally takes an event dictionary as argument ("E").
+You can also specify which attributes of the JavaScript or Quasar event should be passed to the handler ("F").
+This can reduce the amount of data that needs to be transferred between the server and the client.
+    ''', menu)
+    def generic_events_example():
+        with ui.row():
+            ui.button('A', on_click=lambda: ui.notify('You clicked the button A.'))
+            ui.button('B').on('click', lambda: ui.notify('You clicked the button B.'))
+        with ui.row():
+            ui.button('C').on('mousemove', lambda: ui.notify('You moved on button C.'))
+            ui.button('D').on('mousemove', lambda: ui.notify('You moved on button D.'), throttle=0.5)
+        with ui.row():
+            ui.button('E').on('mousedown', lambda e: ui.notify(str(e)))
+            ui.button('F').on('mousedown', lambda e: ui.notify(str(e)), ['ctrlKey', 'shiftKey'])
+
     h3('Configuration')
 
-    @example(ui.run, browser_title='My App')
+    @example(ui.run, menu, browser_title='My App')
     def ui_run_example():
         ui.label('page with custom title')
 
@@ -850,10 +917,10 @@ When NiceGUI is shut down or restarted, all tasks still in execution will be aut
 
 You can set the following environment variables to configure NiceGUI:
 
-- `MATPLOTLIB` (default: true) can be set to `false` to avoid the potentially costly import of Matplotlib. This will make `ui.plot` and `ui.line_plot` unavailable.
+- `MATPLOTLIB` (default: true) can be set to `false` to avoid the potentially costly import of Matplotlib. This will make `ui.pyplot` and `ui.line_plot` unavailable.
 - `MARKDOWN_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of Markdown content snippets that are cached in memory.
-''')
+''', menu)
     def env_var_example():
         from nicegui.elements import markdown
 
-        ui.label(f'markdown content cache size is {markdown.prepare_content.cache_info().maxsize}')
+        ui.label(f'Markdown content cache size is {markdown.prepare_content.cache_info().maxsize}')

+ 1 - 1
website/static/header.html

@@ -1,6 +1,6 @@
 <meta
   name="description"
-  content="NiceGUI is an easy-to-use, Python-based UI framework, which shows up in your web browser. You can create buttons, dialogs, markdown, 3D scenes, plots and much more."
+  content="NiceGUI is an easy-to-use, Python-based UI framework, which shows up in your web browser. You can create buttons, dialogs, Markdown, 3D scenes, plots and much more."
 />
 
 <!-- https://realfavicongenerator.net/ -->

Some files were not shown because too many files changed in this diff