diff --git a/CITATION.cff b/CITATION.cff index 78c903712..3621f8ded 100644 --- a/CITATION.cff +++ b/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.4.36 -date-released: '2024-08-15' +version: 1.4.37 +date-released: '2024-08-22' url: https://github.com/zauberzeug/nicegui -doi: 10.5281/zenodo.13325243 +doi: 10.5281/zenodo.13358977 diff --git a/examples/chat_with_ai/requirements.txt b/examples/chat_with_ai/requirements.txt index d1a841bbb..2e4742358 100644 --- a/examples/chat_with_ai/requirements.txt +++ b/examples/chat_with_ai/requirements.txt @@ -1,3 +1,4 @@ -langchain>=0.0.142 +langchain>=0.2 +langchain-community langchain_openai nicegui diff --git a/examples/node_module_integration/.gitignore b/examples/node_module_integration/.gitignore new file mode 100644 index 000000000..504afef81 --- /dev/null +++ b/examples/node_module_integration/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/examples/node_module_integration/README.md b/examples/node_module_integration/README.md new file mode 100644 index 000000000..c8dff7e1c --- /dev/null +++ b/examples/node_module_integration/README.md @@ -0,0 +1,30 @@ +# Use Bundled Node Modules as Third-party Dependencies + +This example demonstrates how to use multiple third-party node modules as dependencies in a NiceGUI app. +The app uses the [is-odd](https://www.npmjs.com/package/is-odd) node modules to check if a number is even or odd. +We chose this package to demonstrate a very simple node module which has a dependency itself, +namely the [is-number](https://www.npmjs.com/package/is-number) package. +Using NPM, we can easily install both packages and bundle them into a single file which can be used in the app. +The package.json file defines the is-odd dependency and some dev dependencies for bundling the node module, +the webpack.config.js file specifies the entry point for the node module, +and number_checker.js as well as number_checker.py define a new UI element to be used in the NiceGUI app main.py. + +1. First, install all third-party node modules (assuming you have NPM installed): + + ```bash + npm install + ``` + + This will create a node_modules directory containing the is-odd and is-number modules as well as some dev dependencies. + +2. Now bundle the node module: + + ```bash + npm run build + ``` + +3. Finally, you can run the app as usual: + + ```bash + python3 main.py + ``` diff --git a/examples/node_module_integration/main.py b/examples/node_module_integration/main.py new file mode 100755 index 000000000..f384fa8f4 --- /dev/null +++ b/examples/node_module_integration/main.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +from number_checker import NumberChecker + +from nicegui import ui + + +@ui.page('/') +def page(): + number_checker = NumberChecker() + number = ui.number(value=42.0) + + async def check(): + even = await number_checker.is_even(number.value) + ui.notify(f'{number.value} is {"even" if even else "odd"}') + + ui.button('Check', on_click=check) + + +ui.run() diff --git a/examples/node_module_integration/number_checker.js b/examples/node_module_integration/number_checker.js new file mode 100644 index 000000000..e551eba39 --- /dev/null +++ b/examples/node_module_integration/number_checker.js @@ -0,0 +1,13 @@ +export default { + async mounted() { + await import("is-odd"); + }, + methods: { + isOdd(number) { + return isOdd(number); + }, + isEven(number) { + return !isOdd(number); + }, + }, +}; diff --git a/examples/node_module_integration/number_checker.py b/examples/node_module_integration/number_checker.py new file mode 100644 index 000000000..9a2433e40 --- /dev/null +++ b/examples/node_module_integration/number_checker.py @@ -0,0 +1,19 @@ +from nicegui import ui + + +class NumberChecker(ui.element, component='number_checker.js', dependencies=['dist/is-odd.js']): + + def __init__(self) -> None: + """NumberChecker + + A number checker based on the `is-odd `_ NPM package. + """ + super().__init__() + + async def is_odd(self, number: int) -> bool: + """Check if a number is odd.""" + return await self.run_method('isOdd', number) + + async def is_even(self, number: int) -> bool: + """Check if a number is even.""" + return await self.run_method('isEven', number) diff --git a/examples/node_module_integration/package.json b/examples/node_module_integration/package.json new file mode 100644 index 000000000..a8f836cbc --- /dev/null +++ b/examples/node_module_integration/package.json @@ -0,0 +1,15 @@ +{ + "scripts": { + "build": "webpack --config webpack.config.js" + }, + "dependencies": { + "is-odd": "^3.0.1" + }, + "devDependencies": { + "@babel/core": "^7.24.5", + "@babel/preset-env": "^7.24.5", + "babel-loader": "^9.1.3", + "webpack": "^5.91.0", + "webpack-cli": "^5.1.4" + } +} diff --git a/examples/node_module_integration/webpack.config.js b/examples/node_module_integration/webpack.config.js new file mode 100644 index 000000000..ec31f71ae --- /dev/null +++ b/examples/node_module_integration/webpack.config.js @@ -0,0 +1,26 @@ +const path = require("path"); + +module.exports = { + entry: "is-odd/index.js", + mode: "development", + output: { + path: path.resolve(__dirname, "dist"), + filename: "is-odd.js", + library: "isOdd", + libraryTarget: "umd", + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: "babel-loader", + options: { + presets: ["@babel/preset-env"], + }, + }, + }, + ], + }, +}; diff --git a/examples/signature_pad/.gitignore b/examples/signature_pad/.gitignore new file mode 100644 index 000000000..504afef81 --- /dev/null +++ b/examples/signature_pad/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/examples/signature_pad/README.md b/examples/signature_pad/README.md new file mode 100644 index 000000000..ed463d9c7 --- /dev/null +++ b/examples/signature_pad/README.md @@ -0,0 +1,20 @@ +# Use Node Modules as Third-party Dependencies + +This example demonstrates how to use third-party node modules as dependencies in a NiceGUI app. +The app uses the [signature_pad](https://www.npmjs.com/package/signature_pad) node module to create a signature pad. +In package.json, the signature_pad module is listed as a dependency, +while signature_pad.js and signature_pad.py define the new UI element which can be used in main.py. + +1. First, install the third-party node modules (assuming you have NPM installed): + + ```bash + npm install + ``` + + This will create a node_modules directory containing the signature_pad module. + +2. Now you can run the app as usual: + + ```bash + python3 main.py + ``` diff --git a/examples/signature_pad/main.py b/examples/signature_pad/main.py new file mode 100755 index 000000000..cdee636fe --- /dev/null +++ b/examples/signature_pad/main.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +from signature_pad import SignaturePad + +from nicegui import ui + +pad = SignaturePad().classes('border') +ui.button('Clear', on_click=pad.clear) + +ui.run() diff --git a/examples/signature_pad/package.json b/examples/signature_pad/package.json new file mode 100644 index 000000000..171fd4c80 --- /dev/null +++ b/examples/signature_pad/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "signature_pad": "^4.1.7" + } +} diff --git a/examples/signature_pad/signature_pad.js b/examples/signature_pad/signature_pad.js new file mode 100644 index 000000000..ce44c968c --- /dev/null +++ b/examples/signature_pad/signature_pad.js @@ -0,0 +1,16 @@ +import SignaturePad from "signature_pad"; + +export default { + template: "", + props: { + options: Array, + }, + mounted() { + this.pad = new SignaturePad(this.$el, this.options); + }, + methods: { + clear() { + this.pad.clear(); + }, + }, +}; diff --git a/examples/signature_pad/signature_pad.py b/examples/signature_pad/signature_pad.py new file mode 100644 index 000000000..1775155fa --- /dev/null +++ b/examples/signature_pad/signature_pad.py @@ -0,0 +1,20 @@ +from typing import Dict, Optional + +from nicegui import ui + + +class SignaturePad(ui.element, + component='signature_pad.js', + dependencies=['node_modules/signature_pad/dist/signature_pad.min.js']): + + def __init__(self, options: Optional[Dict] = None) -> None: + """SignaturePad + + An element that integrates the `Signature Pad library `_. + """ + super().__init__() + self._props['options'] = options or {} + + def clear(self): + """Clear the signature.""" + self.run_method('clear') diff --git a/examples/table_and_slots/main.py b/examples/table_and_slots/main.py index 934c4c2d3..5ecab165e 100755 --- a/examples/table_and_slots/main.py +++ b/examples/table_and_slots/main.py @@ -25,7 +25,7 @@ with table.row(): with table.cell(): ui.button(on_click=lambda: ( - table.add_rows({'id': time.time(), 'name': new_name.value, 'age': new_age.value}), + table.add_row({'id': time.time(), 'name': new_name.value, 'age': new_age.value}), new_name.set_value(None), new_age.set_value(None), ), icon='add').props('flat fab-mini') @@ -35,7 +35,7 @@ new_age = ui.number('Age') ui.label().bind_text_from(table, 'selected', lambda val: f'Current selection: {val}') -ui.button('Remove', on_click=lambda: table.remove_rows(*table.selected)) \ +ui.button('Remove', on_click=lambda: table.remove_rows(table.selected)) \ .bind_visibility_from(table, 'selected', backward=lambda val: bool(val)) ui.run() diff --git a/nicegui/app/app.py b/nicegui/app/app.py index 535d706a8..4cd7e29e7 100644 --- a/nicegui/app/app.py +++ b/nicegui/app/app.py @@ -32,7 +32,7 @@ class State(Enum): class App(FastAPI): def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) + super().__init__(**kwargs, docs_url=None, redoc_url=None, openapi_url=None) self.native = NativeConfig() self.storage = Storage() self.urls = ObservableSet() diff --git a/nicegui/classes.py b/nicegui/classes.py new file mode 100644 index 000000000..74f90c1e1 --- /dev/null +++ b/nicegui/classes.py @@ -0,0 +1,45 @@ +from typing import TYPE_CHECKING, Generic, List, Optional, TypeVar + +if TYPE_CHECKING: + from .element import Element + +T = TypeVar('T', bound='Element') + + +class Classes(list, Generic[T]): + + def __init__(self, *args, element: T, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.element = element + + def __call__(self, + add: Optional[str] = None, *, + remove: Optional[str] = None, + replace: Optional[str] = None) -> T: + """Apply, remove, or replace HTML classes. + + This allows modifying the look of the element or its layout using `Tailwind `_ or `Quasar `_ 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 + """ + new_classes = self.update_list(self, add, remove, replace) + if self != new_classes: + self[:] = new_classes + self.element.update() + return self.element + + @staticmethod + def update_list(classes: List[str], + add: Optional[str] = None, + remove: Optional[str] = None, + replace: Optional[str] = None) -> List[str]: + """Update a list of classes.""" + class_list = 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() + class_list += (replace or '').split() + return list(dict.fromkeys(class_list)) # NOTE: remove duplicates while preserving order diff --git a/nicegui/client.py b/nicegui/client.py index c8953de10..5f9325890 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -90,8 +90,9 @@ def is_auto_index_client(self) -> bool: @property def ip(self) -> Optional[str]: - """Return the IP address of the client, or None if the client is not connected.""" - return self.environ['asgi.scope']['client'][0] if self.environ else None # pylint: disable=unsubscriptable-object + """Return the IP address of the client, or None if it is an + `auto-index page `_.""" + return self.request.client.host if self.request is not None and self.request.client is not None else None @property def has_socket_connection(self) -> bool: @@ -180,11 +181,7 @@ async def disconnected(self, check_interval: float = 0.1) -> None: await asyncio.sleep(check_interval) self.is_waiting_for_disconnect = False - def run_javascript(self, code: str, *, - respond: Optional[bool] = None, # DEPRECATED - timeout: float = 1.0, - check_interval: float = 0.01, # DEPRECATED - ) -> AwaitableResponse: + def run_javascript(self, code: str, *, timeout: float = 1.0) -> AwaitableResponse: """Execute JavaScript on the client. The client connection must be established before this method is called. @@ -198,19 +195,6 @@ def run_javascript(self, code: str, *, :return: AwaitableResponse that can be awaited to get the result of the JavaScript code """ - if respond is True: - helpers.warn_once('The "respond" argument of run_javascript() has been removed. ' - 'Now the method always returns an AwaitableResponse that can be awaited. ' - 'Please remove the "respond=True" argument.') - if respond is False: - raise ValueError('The "respond" argument of run_javascript() has been removed. ' - 'Now the method always returns an AwaitableResponse that can be awaited. ' - 'Please remove the "respond=False" argument and call the method without awaiting.') - if check_interval != 0.01: - helpers.warn_once('The "check_interval" argument of run_javascript() and similar methods has been removed. ' - 'Now the method automatically returns when receiving a response without checking regularly in an interval. ' - 'Please remove the "check_interval" argument.') - request_id = str(uuid.uuid4()) target_id = self._temporary_socket_id or self.id @@ -282,7 +266,7 @@ def handle_event(self, msg: Dict) -> None: def handle_javascript_response(self, msg: Dict) -> None: """Store the result of a JavaScript command.""" - JavaScriptRequest.resolve(msg['request_id'], msg['result']) + JavaScriptRequest.resolve(msg['request_id'], msg.get('result')) def safe_invoke(self, func: Union[Callable[..., Any], Awaitable]) -> None: """Invoke the potentially async function in the client context and catch any exceptions.""" diff --git a/nicegui/context.py b/nicegui/context.py index 418051d95..f56258fdd 100644 --- a/nicegui/context.py +++ b/nicegui/context.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING, List -from . import helpers from .slot import Slot if TYPE_CHECKING: @@ -11,21 +10,6 @@ class Context: - def get_slot_stack(self) -> List[Slot]: - """Return the slot stack of the current asyncio task. (DEPRECATED, use context.slot_stack instead)""" - helpers.warn_once('context.get_slot_stack() is deprecated, use context.slot_stack instead') - return self.slot_stack - - def get_slot(self) -> Slot: - """Return the current slot. (DEPRECATED, use context.slot instead)""" - helpers.warn_once('context.get_slot() is deprecated, use context.slot instead') - return self.slot - - def get_client(self) -> Client: - """Return the current client. (DEPRECATED, use context.client instead)""" - helpers.warn_once('context.get_client() is deprecated, use context.client instead') - return self.client - @property def slot_stack(self) -> List[Slot]: """Return the slot stack of the current asyncio task.""" diff --git a/nicegui/element.py b/nicegui/element.py index 71f830d6d..d3c3a733d 100644 --- a/nicegui/element.py +++ b/nicegui/element.py @@ -1,53 +1,42 @@ from __future__ import annotations -import ast import inspect import re -from copy import copy, deepcopy +from copy import copy from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, overload +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Dict, + Iterator, + List, + Optional, + Sequence, + Union, + cast, + overload, +) from typing_extensions import Self from . import core, events, helpers, json, storage from .awaitable_response import AwaitableResponse, NullResponse +from .classes import Classes from .context import context from .dependencies import Component, Library, register_library, register_resource, register_vue_component from .elements.mixins.visibility import Visibility from .event_listener import EventListener +from .props import Props from .slot import Slot +from .style import Style from .tailwind import Tailwind from .version import __version__ if TYPE_CHECKING: from .client import Client -PROPS_PATTERN = re.compile(r''' -# Match a key-value pair optionally followed by whitespace or end of string -([:\w\-]+) # Capture group 1: Key -(?: # Optional non-capturing group for value - = # Match the equal sign - (?: # Non-capturing group for value options - ( # Capture group 2: Value enclosed in double quotes - " # Match double quote - [^"\\]* # Match any character except quotes or backslashes zero or more times - (?:\\.[^"\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times - " # Match the closing quote - ) - | - ( # Capture group 3: Value enclosed in single quotes - ' # Match a single quote - [^'\\]* # Match any character except quotes or backslashes zero or more times - (?:\\.[^'\\]*)* # Match any escaped character followed by any character except quotes or backslashes zero or more times - ' # Match the closing quote - ) - | # Or - ([\w\-.,%:\/=]+) # Capture group 4: Value without quotes - ) -)? # End of optional non-capturing group for value -(?:$|\s) # Match end of string or whitespace -''', re.VERBOSE) - # https://www.w3.org/TR/xml/#sec-common-syn TAG_START_CHAR = r':|[A-Z]|_|[a-z]|[\u00C0-\u00D6]|[\u00D8-\u00F6]|[\u00F8-\u02FF]|[\u0370-\u037D]|[\u037F-\u1FFF]|[\u200C-\u200D]|[\u2070-\u218F]|[\u2C00-\u2FEF]|[\u3001-\uD7FF]|[\uF900-\uFDCF]|[\uFDF0-\uFFFD]|[\U00010000-\U000EFFFF]' TAG_CHAR = TAG_START_CHAR + r'|-|\.|[0-9]|\u00B7|[\u0300-\u036F]|[\u203F-\u2040]' @@ -79,12 +68,9 @@ def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = Non self.tag = tag if tag else self.component.tag if self.component else 'div' if not TAG_PATTERN.match(self.tag): raise ValueError(f'Invalid HTML tag: {self.tag}') - self._classes: List[str] = [] - self._classes.extend(self._default_classes) - self._style: Dict[str, str] = {} - self._style.update(self._default_style) - self._props: Dict[str, Any] = {} - self._props.update(self._default_props) + self._classes: Classes[Self] = Classes(self._default_classes, element=cast(Self, self)) + self._style: Style[Self] = Style(self._default_style, element=cast(Self, self)) + self._props: Props[Self] = Props(self._default_props, element=cast(Self, self)) self._markers: List[str] = [] self._event_listeners: Dict[str, EventListener] = {} self._text: Optional[str] = None @@ -107,9 +93,10 @@ def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = Non def __init_subclass__(cls, *, component: Union[str, Path, None] = None, - libraries: List[Union[str, Path]] = [], # noqa: B006 - exposed_libraries: List[Union[str, Path]] = [], # noqa: B006 - extra_libraries: List[Union[str, Path]] = [], # noqa: B006 + dependencies: List[Union[str, Path]] = [], # noqa: B006 + libraries: List[Union[str, Path]] = [], # noqa: B006 # DEPRECATED + exposed_libraries: List[Union[str, Path]] = [], # noqa: B006 # DEPRECATED + extra_libraries: List[Union[str, Path]] = [], # noqa: B006 # DEPRECATED ) -> None: super().__init_subclass__() base = Path(inspect.getfile(cls)).parent @@ -120,6 +107,19 @@ def glob_absolute_paths(file: Union[str, Path]) -> List[Path]: path = base / path return sorted(path.parent.glob(path.name), key=lambda p: p.stem) + if libraries: + helpers.warn_once(f'The `libraries` parameter for subclassing "{cls.__name__}" is deprecated. ' + 'It will be removed in NiceGUI 3.0. ' + 'Use `dependencies` instead.') + if exposed_libraries: + helpers.warn_once(f'The `exposed_libraries` parameter for subclassing "{cls.__name__}" is deprecated. ' + 'It will be removed in NiceGUI 3.0. ' + 'Use `dependencies` instead.') + if extra_libraries: + helpers.warn_once(f'The `extra_libraries` parameter for subclassing "{cls.__name__}" is deprecated. ' + 'It will be removed in NiceGUI 3.0. ' + 'Use `dependencies` instead.') + cls.component = copy(cls.component) cls.libraries = copy(cls.libraries) cls.extra_libraries = copy(cls.extra_libraries) @@ -133,7 +133,7 @@ def glob_absolute_paths(file: Union[str, Path]) -> List[Path]: for library in extra_libraries: for path in glob_absolute_paths(library): cls.extra_libraries.append(register_library(path)) - for library in exposed_libraries: + for library in exposed_libraries + dependencies: for path in glob_absolute_paths(library): cls.exposed_libraries.append(register_library(path, expose=True)) @@ -220,36 +220,10 @@ def _to_dict(self) -> Dict[str, Any]: }, } - @staticmethod - def _update_classes_list(classes: List[str], - add: Optional[str] = None, - remove: Optional[str] = None, - replace: Optional[str] = None) -> List[str]: - class_list = 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() - class_list += (replace or '').split() - return list(dict.fromkeys(class_list)) # NOTE: remove duplicates while preserving order - - 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 `_ or `Quasar `_ 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 - """ - new_classes = self._update_classes_list(self._classes, add, remove, replace) - if self._classes != new_classes: - self._classes = new_classes - self.update() - return self + @property + def classes(self) -> Classes[Self]: + """The classes of the element.""" + return self._classes @classmethod def default_classes(cls, @@ -268,40 +242,13 @@ def default_classes(cls, :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 """ - cls._default_classes = cls._update_classes_list(cls._default_classes, add, remove, replace) + cls._default_classes = Classes.update_list(cls._default_classes, add, remove, replace) return cls - @staticmethod - def _parse_style(text: Optional[str]) -> Dict[str, str]: - result = {} - for word in (text or '').split(';'): - word = word.strip() # noqa: PLW2901 - if word: - key, value = word.split(':', 1) - result[key.strip()] = value.strip() - return result - - 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. - - :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): - style_dict.pop(key, None) - style_dict.update(self._parse_style(add)) - style_dict.update(self._parse_style(replace)) - if self._style != style_dict: - self._style = style_dict - self.update() - return self + @property + def style(self) -> Style[Self]: + """The style of the element.""" + return self._style @classmethod def default_style(cls, @@ -320,51 +267,16 @@ def default_style(cls, """ if replace is not None: cls._default_style.clear() - for key in cls._parse_style(remove): + for key in Style.parse(remove): cls._default_style.pop(key, None) - cls._default_style.update(cls._parse_style(add)) - cls._default_style.update(cls._parse_style(replace)) + cls._default_style.update(Style.parse(add)) + cls._default_style.update(Style.parse(replace)) return cls - @staticmethod - def _parse_props(text: Optional[str]) -> Dict[str, Any]: - dictionary = {} - for match in PROPS_PATTERN.finditer(text or ''): - key = match.group(1) - value = match.group(2) or match.group(3) or match.group(4) - if value is None: - dictionary[key] = True - else: - if (value.startswith("'") and value.endswith("'")) or (value.startswith('"') and value.endswith('"')): - value = ast.literal_eval(value) - dictionary[key] = value - return dictionary - - 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 `_ props. - Since props are simply applied as HTML attributes, they can be used with any HTML element. - - 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: - needs_update = True - del self._props[key] - for key, value in self._parse_props(add).items(): - if self._props.get(key) != value: - needs_update = True - self._props[key] = value - if needs_update: - self.update() - return self + @property + def props(self) -> Props[Self]: + """The props of the element.""" + return self._props @classmethod def default_props(cls, @@ -382,10 +294,10 @@ def default_props(cls, :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 """ - for key in cls._parse_props(remove): + for key in Props.parse(remove): if key in cls._default_props: del cls._default_props[key] - for key, value in cls._parse_props(add).items(): + for key, value in Props.parse(add).items(): cls._default_props[key] = value return cls @@ -482,7 +394,7 @@ def update(self) -> None: return self.client.outbox.enqueue_update(self) - def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse: + def run_method(self, name: str, *args: Any, timeout: float = 1) -> AwaitableResponse: """Run a method on the client side. If the function is awaited, the result of the method call is returned. @@ -494,8 +406,7 @@ def run_method(self, name: str, *args: Any, timeout: float = 1, check_interval: """ if not core.loop: return NullResponse() - return self.client.run_javascript(f'return runMethod({self.id}, "{name}", {json.dumps(args)})', - timeout=timeout, check_interval=check_interval) + return self.client.run_javascript(f'return runMethod({self.id}, "{name}", {json.dumps(args)})', timeout=timeout) def get_computed_prop(self, prop_name: str, *, timeout: float = 1) -> AwaitableResponse: """Return a computed property. diff --git a/nicegui/element_filter.py b/nicegui/element_filter.py index a6c662c58..1c0208b70 100644 --- a/nicegui/element_filter.py +++ b/nicegui/element_filter.py @@ -91,7 +91,6 @@ def __init__(self, *, self._scope = context.slot.parent if local_scope else context.client.layout def __iter__(self) -> Iterator[T]: - # pylint: disable=protected-access for element in self._scope.descendants(): if self._kind and not isinstance(element, self._kind): continue @@ -105,11 +104,11 @@ def __iter__(self) -> Iterator[T]: if self._contents or self._exclude_content: element_contents = [content for content in ( - element._props.get('text'), - element._props.get('label'), - element._props.get('icon'), - element._props.get('placeholder'), - element._props.get('value'), + element.props.get('text'), + element.props.get('label'), + element.props.get('icon'), + element.props.get('placeholder'), + element.props.get('value'), element.text if isinstance(element, TextElement) else None, element.content if isinstance(element, ContentElement) else None, element.source if isinstance(element, SourceElement) else None, @@ -117,7 +116,7 @@ def __iter__(self) -> Iterator[T]: if isinstance(element, Notification): element_contents.append(element.message) if isinstance(element, Select): - options = {option['value']: option['label'] for option in element._props.get('options', [])} + options = {option['value']: option['label'] for option in element.props.get('options', [])} element_contents.append(options.get(element.value, '')) if element.is_showing_popup: element_contents.extend(options.values()) diff --git a/nicegui/elements/aggrid.js b/nicegui/elements/aggrid.js index 5f5419ce3..c0c85982a 100644 --- a/nicegui/elements/aggrid.js +++ b/nicegui/elements/aggrid.js @@ -1,3 +1,4 @@ +import "ag-grid-community"; import { convertDynamicProperties } from "../../static/utils/dynamic_properties.js"; export default { diff --git a/nicegui/elements/aggrid.py b/nicegui/elements/aggrid.py index 60161d9c1..3a7b6e70c 100644 --- a/nicegui/elements/aggrid.py +++ b/nicegui/elements/aggrid.py @@ -13,7 +13,7 @@ import pandas as pd -class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-community.min.js']): +class AgGrid(Element, component='aggrid.js', dependencies=['lib/aggrid/ag-grid-community.min.js']): def __init__(self, options: Dict, *, @@ -92,11 +92,7 @@ def update(self) -> None: super().update() self.run_method('update_grid') - def call_api_method(self, name: str, *args, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse: - """DEPRECATED: Use `run_grid_method` instead.""" - return self.run_grid_method(name, *args, timeout=timeout, check_interval=check_interval) - - def run_grid_method(self, name: str, *args, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse: + def run_grid_method(self, name: str, *args, timeout: float = 1) -> AwaitableResponse: """Run an AG Grid API method. See `AG Grid API `_ for a list of methods. @@ -110,14 +106,9 @@ def run_grid_method(self, name: str, *args, timeout: float = 1, check_interval: :return: AwaitableResponse that can be awaited to get the result of the method call """ - return self.run_method('run_grid_method', name, *args, timeout=timeout, check_interval=check_interval) - - def call_column_method(self, name: str, *args, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse: - """DEPRECATED: Use `run_column_method` instead.""" - return self.run_column_method(name, *args, timeout=timeout, check_interval=check_interval) + return self.run_method('run_grid_method', name, *args, timeout=timeout) - def run_column_method(self, name: str, *args, - timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse: + def run_column_method(self, name: str, *args, timeout: float = 1) -> AwaitableResponse: """Run an AG Grid Column API method. See `AG Grid Column API `_ for a list of methods. @@ -131,10 +122,9 @@ def run_column_method(self, name: str, *args, :return: AwaitableResponse that can be awaited to get the result of the method call """ - return self.run_method('run_column_method', name, *args, timeout=timeout, check_interval=check_interval) + return self.run_method('run_column_method', name, *args, timeout=timeout) - def run_row_method(self, row_id: str, name: str, *args, - timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse: + def run_row_method(self, row_id: str, name: str, *args, timeout: float = 1) -> AwaitableResponse: """Run an AG Grid API method on a specific row. See `AG Grid Row Reference `_ for a list of methods. @@ -149,7 +139,7 @@ def run_row_method(self, row_id: str, name: str, *args, :return: AwaitableResponse that can be awaited to get the result of the method call """ - return self.run_method('run_row_method', row_id, name, *args, timeout=timeout, check_interval=check_interval) + return self.run_method('run_row_method', row_id, name, *args, timeout=timeout) async def get_selected_rows(self) -> List[Dict]: """Get the currently selected rows. @@ -177,7 +167,6 @@ async def get_client_data( self, *, timeout: float = 1, - check_interval: float = 0.01, method: Literal['all_unsorted', 'filtered_unsorted', 'filtered_sorted', 'leaf'] = 'all_unsorted' ) -> List[Dict]: """Get the data from the client including any edits made by the client. @@ -190,7 +179,6 @@ async def get_client_data( This does not happen when the cell loses focus, unless ``stopEditingWhenCellsLoseFocus: True`` is set. :param timeout: timeout in seconds (default: 1 second) - :param check_interval: interval in seconds to check for the result (default: 0.01 seconds) :param method: method to access the data, "all_unsorted" (default), "filtered_unsorted", "filtered_sorted", "leaf" :return: list of row data @@ -205,7 +193,7 @@ async def get_client_data( const rowData = []; getElement({self.id}).gridOptions.api.{API_METHODS[method]}(node => rowData.push(node.data)); return rowData; - ''', timeout=timeout, check_interval=check_interval) + ''', timeout=timeout) return cast(List[Dict], result) async def load_client_data(self) -> None: diff --git a/nicegui/elements/card.py b/nicegui/elements/card.py index cedf66f5b..d0bcdeae2 100644 --- a/nicegui/elements/card.py +++ b/nicegui/elements/card.py @@ -16,10 +16,9 @@ def __init__(self, *, It provides a container with a dropped shadow. Note: - There are subtle differences between the Quasar component and this element. - In contrast to this element, the original QCard has no padding by default and hides outer borders of nested elements. + In contrast to this element, + the original QCard has no padding by default and hides outer borders and shadows of nested elements. If you want the original behavior, use the `tight` method. - If you want the padding and borders for nested children, move the children into another container. :param align_items: alignment of the items in the card ("start", "end", "center", "baseline", or "stretch"; default: `None`) """ diff --git a/nicegui/elements/carousel.py b/nicegui/elements/carousel.py index bea26c569..0a168d542 100644 --- a/nicegui/elements/carousel.py +++ b/nicegui/elements/carousel.py @@ -33,11 +33,11 @@ def __init__(self, *, self._props['navigation'] = navigation def _value_to_model_value(self, value: Any) -> Any: - return value._props['name'] if isinstance(value, CarouselSlide) else value # pylint: disable=protected-access + return value.props['name'] if isinstance(value, CarouselSlide) else value def _handle_value_change(self, value: Any) -> None: super()._handle_value_change(value) - names = [slide._props['name'] for slide in self] # pylint: disable=protected-access + names = [slide.props['name'] for slide in self] for i, slide in enumerate(self): done = i < names.index(value) if value in names else False slide.props(f':done={done}') diff --git a/nicegui/elements/chart.py b/nicegui/elements/chart.py deleted file mode 100644 index 514793728..000000000 --- a/nicegui/elements/chart.py +++ /dev/null @@ -1,5 +0,0 @@ -def chart(*args, **kwargs) -> None: - """Deprecated. Please use `ui.highchart` instead.""" - # DEPRECATED - raise RuntimeError('`ui.chart` is now `ui.highchart`. ' - 'Please install `nicegui[highcharts]` and use `ui.highchart` instead.') diff --git a/nicegui/elements/code.py b/nicegui/elements/code.py index 7ba0ab977..84463d635 100644 --- a/nicegui/elements/code.py +++ b/nicegui/elements/code.py @@ -17,6 +17,8 @@ def __init__(self, content: str = '', *, language: Optional[str] = 'python') -> This element displays a code block with syntax highlighting. + In secure environments (HTTPS or localhost), a copy button is displayed to copy the code to the clipboard. + :param content: code to display :param language: language of the code (default: "python") """ @@ -34,6 +36,10 @@ def __init__(self, content: str = '', *, language: Optional[str] = 'python') -> self.markdown.on('scroll', self._handle_scroll) timer(0.1, self._update_copy_button) + self.client.on_connect(lambda: self.client.run_javascript(f''' + if (!navigator.clipboard) getElement({self.copy_button.id}).$el.style.display = 'none'; + ''')) + async def show_checkmark(self) -> None: """Show a checkmark icon for 3 seconds.""" self.copy_button.props('icon=check') diff --git a/nicegui/elements/echart.js b/nicegui/elements/echart.js index 4e3807c32..911100f74 100644 --- a/nicegui/elements/echart.js +++ b/nicegui/elements/echart.js @@ -1,9 +1,13 @@ +import "echarts"; import { convertDynamicProperties } from "../../static/utils/dynamic_properties.js"; export default { template: "
", async mounted() { await this.$nextTick(); // wait for Tailwind classes to be applied + if (this.enable_3d) { + await import("echarts-gl"); + } this.chart = echarts.init(this.$el); this.chart.on("click", (e) => this.$emit("pointClick", e)); @@ -81,5 +85,6 @@ export default { }, props: { options: Object, + enable_3d: Boolean, }, }; diff --git a/nicegui/elements/echart.py b/nicegui/elements/echart.py index 3ca856258..3c806cb29 100644 --- a/nicegui/elements/echart.py +++ b/nicegui/elements/echart.py @@ -17,7 +17,7 @@ pass -class EChart(Element, component='echart.js', libraries=['lib/echarts/echarts.min.js'], extra_libraries=['lib/echarts-gl/echarts-gl.min.js']): +class EChart(Element, component='echart.js', dependencies=['lib/echarts/echarts.min.js', 'lib/echarts-gl/echarts-gl.min.js']): def __init__(self, options: Dict, on_point_click: Optional[Callable] = None, *, enable_3d: bool = False) -> None: """Apache EChart @@ -32,11 +32,8 @@ def __init__(self, options: Dict, on_point_click: Optional[Callable] = None, *, """ super().__init__() self._props['options'] = options + self._props['enable_3d'] = enable_3d or any('3D' in key for key in options) self._classes.append('nicegui-echart') - for key in options: - if '3D' in key or enable_3d: - self.libraries.extend(library for library in self.extra_libraries if library.name == 'echarts-gl') - break if on_point_click: self.on_point_click(on_point_click) @@ -102,8 +99,7 @@ def update(self) -> None: super().update() self.run_method('update_chart') - def run_chart_method(self, name: str, *args, timeout: float = 1, - check_interval: float = 0.01) -> AwaitableResponse: + def run_chart_method(self, name: str, *args, timeout: float = 1) -> AwaitableResponse: """Run a method of the JSONEditor instance. See the `ECharts documentation `_ for a list of methods. @@ -117,4 +113,4 @@ def run_chart_method(self, name: str, *args, timeout: float = 1, :return: AwaitableResponse that can be awaited to get the result of the method call """ - return self.run_method('run_chart_method', name, *args, timeout=timeout, check_interval=check_interval) + return self.run_method('run_chart_method', name, *args, timeout=timeout) diff --git a/nicegui/elements/joystick.py b/nicegui/elements/joystick.py index e17047a56..021c50612 100644 --- a/nicegui/elements/joystick.py +++ b/nicegui/elements/joystick.py @@ -6,7 +6,7 @@ from ..events import GenericEventArguments, JoystickEventArguments, handle_event -class Joystick(Element, component='joystick.vue', libraries=['lib/nipplejs/nipplejs.js']): +class Joystick(Element, component='joystick.vue', dependencies=['lib/nipplejs/nipplejs.js']): def __init__(self, *, on_start: Optional[Callable[..., Any]] = None, diff --git a/nicegui/elements/joystick.vue b/nicegui/elements/joystick.vue index 1cded3c8f..23c8290bd 100644 --- a/nicegui/elements/joystick.vue +++ b/nicegui/elements/joystick.vue @@ -4,7 +4,8 @@