Explorar o código

Merge branch 'joystick_fix' into ros2_example

Rodja Trappe hai 1 ano
pai
achega
66d9fdbf4f

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   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.20
-date-released: '2023-06-12'
+version: v1.2.22
+date-released: '2023-06-24'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.8029984
+doi: 10.5281/zenodo.8076547

+ 0 - 4
main.py

@@ -331,10 +331,6 @@ def documentation_page() -> None:
     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('Reference, Demos and more', '*NiceGUI* Documentation')
-        ui.markdown('''
-            This is the documentation 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')
         documentation.create_full()
 
 

+ 1 - 1
nicegui/app.py

@@ -126,7 +126,7 @@ class App(FastAPI):
         :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
         :param local_directory: local folder with files to serve as media content
         """
-        @self.get(url_path + '/{filename}')
+        @self.get(url_path + '/{filename:path}')
         async def read_item(request: Request, filename: str) -> StreamingResponse:
             filepath = Path(local_directory) / filename
             if not filepath.is_file():

+ 3 - 2
nicegui/dependencies.py

@@ -82,6 +82,7 @@ def generate_js_imports(prefix: str) -> str:
     for name, component in js_components.items():
         if name in globals.excludes:
             continue
-        result += f'import {{ default as {name} }} from "{prefix}{component.import_path}";\n'
-        result += f'app.component("{name}", {name});\n'
+        var_name = name.replace('-', '_')
+        result += f'import {{ default as {var_name} }} from "{prefix}{component.import_path}";\n'
+        result += f'app.component("{name}", {var_name});\n'
     return result

+ 60 - 0
nicegui/elements/input.js

@@ -0,0 +1,60 @@
+export default {
+  template: `
+    <q-input
+      v-bind="$attrs"
+      v-model="inputValue"
+      :shadow-text="shadowText"
+      @keydown.tab="perform_autocomplete"
+      :list="id + '-datalist'"
+    >
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-input>
+    <datalist v-if="withDatalist" :id="id + '-datalist'">
+      <option v-for="option in autocomplete" :value="option"></option>
+    </datalist>
+  `,
+  props: {
+    id: String,
+    autocomplete: Array,
+    value: String,
+  },
+  data() {
+    return {
+      inputValue: this.value,
+    };
+  },
+  watch: {
+    value(newValue) {
+      this.inputValue = newValue;
+    },
+    inputValue(newValue) {
+      this.$emit("update:value", newValue);
+    },
+  },
+  computed: {
+    shadowText() {
+      if (!this.inputValue) return "";
+      const matchingOption = this.autocomplete.find((option) =>
+        option.toLowerCase().startsWith(this.inputValue.toLowerCase())
+      );
+      return matchingOption ? matchingOption.slice(this.inputValue.length) : "";
+    },
+    withDatalist() {
+      const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+      return isMobile && this.autocomplete && this.autocomplete.length > 0;
+    },
+  },
+  methods: {
+    updateValue() {
+      this.inputValue = this.value;
+    },
+    perform_autocomplete(e) {
+      if (this.shadowText) {
+        this.inputValue += this.shadowText;
+        e.preventDefault();
+      }
+    },
+  },
+};

+ 14 - 20
nicegui/elements/input.py

@@ -1,11 +1,15 @@
 from typing import Any, Callable, Dict, List, Optional
 
+from ..dependencies import register_component
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
 from .mixins.validation_element import ValidationElement
 
+register_component('nicegui-input', __file__, 'input.js')
+
 
 class Input(ValidationElement, DisableableElement):
+    VALUE_PROP: str = 'value'
     LOOPBACK = False
 
     def __init__(self,
@@ -37,7 +41,7 @@ class Input(ValidationElement, DisableableElement):
         :param autocomplete: optional list of strings for autocompletion
         :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
-        super().__init__(tag='q-input', value=value, on_value_change=on_change, validation=validation)
+        super().__init__(tag='nicegui-input', value=value, on_value_change=on_change, validation=validation)
         if label is not None:
             self._props['label'] = label
         if placeholder is not None:
@@ -52,24 +56,14 @@ class Input(ValidationElement, DisableableElement):
                     self.props(f'type={"text" if is_hidden else "password"}')
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
 
-        if autocomplete:
-            def find_autocompletion() -> Optional[str]:
-                if self.value:
-                    needle = str(self.value).casefold()
-                    for item in autocomplete or []:
-                        if item.casefold().startswith(needle):
-                            return item
-                return None  # required by mypy
-
-            def autocomplete_input() -> None:
-                match = find_autocompletion() or ''
-                self.props(f'shadow-text="{match[len(self.value):]}"')
+        self._props['autocomplete'] = autocomplete or []
 
-            def complete_input() -> None:
-                match = find_autocompletion()
-                if match:
-                    self.set_value(match)
-                self.props('shadow-text=""')
+    def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None:
+        """Set the autocomplete list."""
+        self._props['autocomplete'] = autocomplete
+        self.update()
 
-            self.on('keyup', autocomplete_input)
-            self.on('keydown.tab', complete_input)
+    def on_value_change(self, value: Any) -> None:
+        super().on_value_change(value)
+        if self._send_update_on_value_change:
+            self.run_method('updateValue')

+ 25 - 16
nicegui/elements/joystick.py

@@ -26,20 +26,29 @@ class Joystick(Element):
         :param options: arguments like `color` which should be passed to the `underlying nipple.js library <https://github.com/yoannmoinet/nipplejs#options>`_
         """
         super().__init__('joystick')
-        self.on('start',
-                lambda _: handle_event(on_start, JoystickEventArguments(sender=self,
-                                                                        client=self.client,
-                                                                        action='start')))
-        self.on('move',
-                lambda msg: handle_event(on_move, JoystickEventArguments(sender=self,
-                                                                         client=self.client,
-                                                                         action='move',
-                                                                         x=msg['args']['data']['vector']['x'],
-                                                                         y=msg['args']['data']['vector']['y'])),
-                args=['data'],
-                throttle=throttle)
-        self.on('end',
-                lambda _: handle_event(on_end, JoystickEventArguments(sender=self,
-                                                                      client=self.client,
-                                                                      action='end')))
         self._props['options'] = options
+        self.active = False
+
+        def handle_start(msg):
+            self.active = True
+            handle_event(on_start, JoystickEventArguments(sender=self,
+                                                          client=self.client,
+                                                          action='start'))
+
+        def handle_move(msg):
+            if self.active:
+                handle_event(on_move, JoystickEventArguments(sender=self,
+                                                             client=self.client,
+                                                             action='move',
+                                                             x=msg['args']['data']['vector']['x'],
+                                                             y=msg['args']['data']['vector']['y'])),
+
+        def handle_end(msg):
+            self.active = False
+            handle_event(on_end, JoystickEventArguments(sender=self,
+                                                        client=self.client,
+                                                        action='end'))
+
+        self.on('start', handle_start)
+        self.on('move', handle_move, args=['data'], throttle=throttle),
+        self.on('end', handle_end)

+ 1 - 0
nicegui/elements/plotly.py

@@ -37,6 +37,7 @@ class Plotly(Element):
         self.update()
 
     def update(self) -> None:
+        super().update()
         self._props['options'] = self._get_figure_json()
         self.run_method('update', self._props['options'])
 

+ 11 - 2
nicegui/elements/select.py

@@ -19,6 +19,7 @@ class Select(ChoiceElement, DisableableElement):
                  on_change: Optional[Callable[..., Any]] = None,
                  with_input: bool = False,
                  multiple: bool = False,
+                 clearable: bool = False,
                  ) -> None:
         """Dropdown Selection
 
@@ -30,6 +31,7 @@ class Select(ChoiceElement, DisableableElement):
         :param on_change: callback to execute when selection changes
         :param with_input: whether to show an input field to filter the options
         :param multiple: whether to allow multiple selections
+        :param clearable: whether to add a button to clear the selection
         """
         self.multiple = multiple
         if multiple:
@@ -48,6 +50,7 @@ class Select(ChoiceElement, DisableableElement):
             self._props['fill-input'] = True
             self._props['input-debounce'] = 0
         self._props['multiple'] = multiple
+        self._props['clearable'] = clearable
 
     def on_filter(self, event: Dict) -> None:
         self.options = [
@@ -59,9 +62,15 @@ class Select(ChoiceElement, DisableableElement):
 
     def _msg_to_value(self, msg: Dict) -> Any:
         if self.multiple:
-            return [self._values[arg['value']] for arg in msg['args']]
+            if msg['args'] is None:
+                return []
+            else:
+                return [self._values[arg['value']] for arg in msg['args']]
         else:
-            return self._values[msg['args']['value']]
+            if msg['args'] is None:
+                return None
+            else:
+                return self._values[msg['args']['value']]
 
     def _value_to_model_value(self, value: Any) -> Any:
         if self.multiple:

+ 30 - 0
nicegui/elements/table.js

@@ -0,0 +1,30 @@
+export default {
+  template: `
+    <q-table v-bind="$attrs" :columns="convertedColumns">
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-table>
+  `,
+  props: {
+    columns: Array,
+  },
+  computed: {
+    convertedColumns() {
+      return this.columns.map((column) => {
+        const convertedColumn = { ...column };
+        for (const attr in convertedColumn) {
+          if (attr.startsWith(":")) {
+            try {
+              convertedColumn[attr.slice(1)] = new Function("return " + convertedColumn[attr])();
+              delete convertedColumn[attr];
+            } catch (e) {
+              console.error(`Error while converting ${attr} attribute to function:`, e);
+            }
+          }
+        }
+        return convertedColumn;
+      });
+    },
+  },
+};

+ 4 - 1
nicegui/elements/table.py

@@ -2,10 +2,13 @@ from typing import Any, Callable, Dict, List, Optional
 
 from typing_extensions import Literal
 
+from ..dependencies import register_component
 from ..element import Element
 from ..events import TableSelectionEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 
+register_component('nicegui-table', __file__, 'table.js')
+
 
 class Table(FilterElement):
 
@@ -32,7 +35,7 @@ class Table(FilterElement):
 
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         """
-        super().__init__(tag='q-table')
+        super().__init__(tag='nicegui-table')
 
         self.rows = rows
         self.row_key = row_key

+ 1 - 1
nicegui/elements/upload.js

@@ -7,7 +7,7 @@ export default {
     </q-uploader>
   `,
   mounted() {
-    setTimeout(() => compute_url, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+    setTimeout(() => this.compute_url(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
   },
   updated() {
     this.compute_url();

+ 11 - 3
nicegui/functions/timer.py

@@ -40,6 +40,14 @@ class Timer:
         else:
             globals.app.on_startup(coroutine)
 
+    def activate(self) -> None:
+        """Activate the timer."""
+        self.active = True
+
+    def deactivate(self) -> None:
+        """Deactivate the timer."""
+        self.active = False
+
     async def _run_once(self) -> None:
         try:
             if not await self._connected():
@@ -50,7 +58,7 @@ class Timer:
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
                     await self._invoke_callback()
         finally:
-            self.cleanup()
+            self._cleanup()
 
     async def _run_in_loop(self) -> None:
         try:
@@ -75,7 +83,7 @@ class Timer:
                         globals.handle_exception(e)
                         await asyncio.sleep(self.interval)
         finally:
-            self.cleanup()
+            self._cleanup()
 
     async def _invoke_callback(self) -> None:
         try:
@@ -104,6 +112,6 @@ class Timer:
                 globals.log.error(f'Timer cancelled because client is not connected after {timeout} seconds')
                 return False
 
-    def cleanup(self) -> None:
+    def _cleanup(self) -> None:
         self.slot = None
         self.callback = None

+ 1 - 1
nicegui/nicegui.py

@@ -100,7 +100,7 @@ def print_welcome_message():
     addresses = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
     if len(addresses) >= 2:
         addresses[-1] = 'and ' + addresses[-1]
-    print(f'NiceGUI ready to go on {", ".join(addresses)}')
+    print(f'NiceGUI ready to go on {", ".join(addresses)}', flush=True)
 
 
 @app.on_event('shutdown')

+ 25 - 4
tests/test_input.py

@@ -83,7 +83,7 @@ def test_input_with_multi_word_error_message(screen: Screen):
 
 
 def test_autocompletion(screen: Screen):
-    ui.input('Input', autocomplete=['foo', 'bar', 'baz'])
+    input = ui.input('Input', autocomplete=['foo', 'bar', 'baz'])
 
     screen.open('/')
     element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Input"]')
@@ -100,16 +100,21 @@ def test_autocompletion(screen: Screen):
     element.send_keys(Keys.TAB)
     screen.wait(0.2)
     assert element.get_attribute('value') == 'foo'
+    assert input.value == 'foo'
 
     element.send_keys(Keys.BACKSPACE)
-    screen.wait(0.2)
     element.send_keys(Keys.BACKSPACE)
-    screen.wait(0.2)
     element.send_keys('x')
-    screen.wait(0.2)
     element.send_keys(Keys.TAB)
     screen.wait(0.5)
     assert element.get_attribute('value') == 'fx'
+    assert input.value == 'fx'
+
+    input.set_autocomplete(['one', 'two'])
+    element.send_keys(Keys.BACKSPACE)
+    element.send_keys(Keys.BACKSPACE)
+    element.send_keys('o')
+    screen.should_contain('ne')
 
 
 def test_clearable_input(screen: Screen):
@@ -120,3 +125,19 @@ def test_clearable_input(screen: Screen):
     screen.should_contain('value: foo')
     screen.click('cancel')
     screen.should_contain('value: None')
+
+
+def test_update_input(screen: Screen):
+    input = ui.input('Name', value='Pete')
+
+    screen.open('/')
+    element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Name"]')
+    assert element.get_attribute('value') == 'Pete'
+
+    element.send_keys('r')
+    screen.wait(0.5)
+    assert element.get_attribute('value') == 'Peter'
+
+    input.value = 'Pete'
+    screen.wait(0.5)
+    assert element.get_attribute('value') == 'Pete'

+ 8 - 0
tests/test_table.py

@@ -102,3 +102,11 @@ def test_single_selection(screen: Screen):
     screen.find('Bob').find_element(By.XPATH, 'preceding-sibling::td').click()
     screen.wait(0.5)
     screen.should_contain('1 record selected.')
+
+
+def test_dynamic_column_attributes(screen: Screen):
+    ui.table(columns=[{'name': 'age', 'label': 'Age', 'field': 'age', ':format': 'value => value + " years"'}],
+             rows=[{'name': 'Alice', 'age': 18}])
+
+    screen.open('/')
+    screen.should_contain('18 years')

+ 6 - 0
website/build_search_index.py

@@ -1,5 +1,6 @@
 #!/usr/bin/env python3
 import ast
+import inspect
 import json
 import os
 import re
@@ -74,6 +75,11 @@ class DocVisitor(ast.NodeVisitor):
             if docstring is None:
                 api = getattr(ui, self.topic) if hasattr(ui, self.topic) else getattr(app, self.topic)
                 docstring = api.__doc__ or api.__init__.__doc__
+                for name, method in api.__dict__.items():
+                    if not name.startswith('_') and inspect.isfunction(method):
+                        # add method name to docstring
+                        docstring += name + ' '
+                        docstring += method.__doc__ or ''
             lines = cleanup(docstring).splitlines()
             self.add_to_search_index(lines[0], lines[1:], main=True)
 

+ 1 - 1
website/more_documentation/bindings_documentation.py

@@ -63,7 +63,7 @@ def more() -> None:
 
         # @ui.page('/')
         # def index():
-        #     ui.textarea('This note is kept between visits') \
+        #     ui.textarea('This note is kept between visits') 
         #         .classes('w-full').bind_value(app.storage.user, 'note')
         # END OF DEMO
         ui.textarea('This note is kept between visits').classes('w-full').bind_value(app.storage.user, 'note')

+ 32 - 6
website/more_documentation/table_documentation.py

@@ -74,15 +74,11 @@ def more() -> None:
             {'name': 'Bob', 'age': 21},
             {'name': 'Carol'},
         ]
-        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:
-            if visible:
-                visible_columns.add(column['name'])
-            else:
-                visible_columns.remove(column['name'])
-            table._props['columns'] = [column for column in columns if column['name'] in visible_columns]
+            column['classes'] = '' if visible else 'hidden'
+            column['headerClasses'] = '' if visible else 'hidden'
             table.update()
 
         with ui.button(icon='menu'):
@@ -160,3 +156,33 @@ def more() -> None:
             {'name': 'count', 'label': 'Count', 'field': 'count'},
         ]
         table = ui.table(columns=columns, rows=[], row_key='id').classes('w-full')
+
+    @text_demo('Custom sorting and formatting', '''
+        You can define dynamic column attributes using a `:` prefix.
+        This way you can define custom sorting and formatting functions.
+
+        The following example allows sorting the `name` column by length.
+        The `age` column is formatted to show the age in years.
+    ''')
+    def custom_formatting():
+        columns = [
+            {
+                'name': 'name',
+                'label': 'Name',
+                'field': 'name',
+                'sortable': True,
+                ':sort': '(a, b, rowA, rowB) => b.length - a.length',
+            },
+            {
+                'name': 'age',
+                'label': 'Age',
+                'field': 'age',
+                ':format': 'value => value + " years"',
+            },
+        ]
+        rows = [
+            {'name': 'Alice', 'age': 18},
+            {'name': 'Bob', 'age': 21},
+            {'name': 'Carl', 'age': 42},
+        ]
+        ui.table(columns=columns, rows=rows, row_key='name')

+ 12 - 9
website/search.py

@@ -1,4 +1,4 @@
-from nicegui import events, ui
+from nicegui import background_tasks, events, ui
 
 
 class Search:
@@ -48,14 +48,17 @@ class Search:
         if e.key == 'k' and (e.modifiers.ctrl or e.modifiers.meta):
             self.dialog.open()
 
-    async def handle_input(self, e: events.ValueChangeEventArguments) -> None:
-        self.results.clear()
-        with self.results:
-            for result in await ui.run_javascript(f'return window.fuse.search("{e.value}").slice(0, 50)'):
-                href: str = result['item']['url']
-                with ui.element('q-item').props(f'clickable').on('click', lambda href=href: self.open_url(href)):
-                    with ui.element('q-item-section'):
-                        ui.label(result['item']['title'])
+    def handle_input(self, e: events.ValueChangeEventArguments) -> None:
+        async def handle_input():
+            with self.results:
+                results = await ui.run_javascript(f'return window.fuse.search("{e.value}").slice(0, 50)')
+                self.results.clear()
+                for result in results:
+                    href: str = result['item']['url']
+                    with ui.element('q-item').props(f'clickable').on('click', lambda href=href: self.open_url(href)):
+                        with ui.element('q-item-section'):
+                            ui.label(result['item']['title'])
+        background_tasks.create_lazy(handle_input(), name='handle_search_input')
 
     async def open_url(self, url: str) -> None:
         await ui.run_javascript(f'''

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 30 - 21
website/static/search_index.json


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio