From 392e0d0cf2679944289f1770cfef7259711fd4f3 Mon Sep 17 00:00:00 2001 From: Falko Schindler Date: Thu, 29 Aug 2024 14:37:54 +0200 Subject: [PATCH] Provide public API for accessing `_props`, `_classes` and `_style`. (#3588) * move props parsing into separate class derived from dict * replace use of old `_parse_props` * tiny fix * extend principle to classes and style * fix linting errors in query.py * fix `ui.tree` and `ui.menu` * remove obsolete pylint markers * parameterize parsing tests * use new API where possible --- nicegui/classes.py | 45 +++++ nicegui/element.py | 180 ++++-------------- nicegui/element_filter.py | 13 +- nicegui/elements/carousel.py | 4 +- nicegui/elements/menu.py | 17 +- nicegui/elements/mixins/visibility.py | 2 +- nicegui/elements/query.py | 71 +++---- nicegui/elements/stepper.py | 4 +- nicegui/elements/tabs.py | 6 +- nicegui/elements/tree.py | 15 +- nicegui/page_layout.py | 12 +- nicegui/props.py | 93 +++++++++ nicegui/style.py | 47 +++++ nicegui/testing/user_interaction.py | 2 +- tests/test_element.py | 156 +++++++-------- tests/test_element_filter.py | 8 +- tests/test_tailwind.py | 2 +- tests/test_tree.py | 4 +- .../content/mermaid_documentation.py | 4 +- .../content/section_styling_appearance.py | 4 +- 20 files changed, 378 insertions(+), 311 deletions(-) create mode 100644 nicegui/classes.py create mode 100644 nicegui/props.py create mode 100644 nicegui/style.py 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/element.py b/nicegui/element.py index 71f830d6d..20c836a33 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 @@ -220,36 +206,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 +228,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 +253,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 +280,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 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/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/menu.py b/nicegui/elements/menu.py index f741176e6..2ab15fde7 100644 --- a/nicegui/elements/menu.py +++ b/nicegui/elements/menu.py @@ -1,8 +1,5 @@ from typing import Any, Callable, Optional, Union -from typing_extensions import Self - -from .. import helpers from ..element import Element from .context_menu import ContextMenu from .item import Item @@ -24,6 +21,11 @@ def __init__(self, *, value: bool = False) -> None: """ super().__init__(tag='q-menu', value=value, on_value_change=None) + # https://github.com/zauberzeug/nicegui/issues/1738 + self._props.add_warning('touch-position', + 'The prop "touch-position" is not supported by `ui.menu`. ' + 'Use "ui.context_menu()" instead.') + def open(self) -> None: """Open the menu.""" self.value = True @@ -36,15 +38,6 @@ def toggle(self) -> None: """Toggle the menu.""" self.value = not self.value - def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self: - super().props(add, remove=remove) - if 'touch-position' in self._props: - # https://github.com/zauberzeug/nicegui/issues/1738 - del self._props['touch-position'] - helpers.warn_once('The prop "touch-position" is not supported by `ui.menu`.\n' - 'Use "ui.context_menu()" instead.') - return self - class MenuItem(Item): diff --git a/nicegui/elements/mixins/visibility.py b/nicegui/elements/mixins/visibility.py index 620ed6f54..c853d46bd 100644 --- a/nicegui/elements/mixins/visibility.py +++ b/nicegui/elements/mixins/visibility.py @@ -100,7 +100,7 @@ def _handle_visibility_change(self, visible: str) -> None: :param visible: Whether the element should be visible. """ element: Element = cast('Element', self) - classes = element._classes # pylint: disable=protected-access, no-member + classes = element.classes # pylint: disable=no-member if visible and 'hidden' in classes: classes.remove('hidden') element.update() # pylint: disable=no-member diff --git a/nicegui/elements/query.py b/nicegui/elements/query.py index 2c9e85a1c..67d4a78b8 100644 --- a/nicegui/elements/query.py +++ b/nicegui/elements/query.py @@ -2,8 +2,11 @@ from typing_extensions import Self +from ..classes import Classes from ..context import context from ..element import Element +from ..props import Props +from ..style import Style class QueryElement(Element, component='query.js'): @@ -15,43 +18,6 @@ def __init__(self, selector: str) -> None: self._props['style'] = {} self._props['props'] = {} - def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \ - -> Self: - classes = self._update_classes_list(self._props['classes'], add, remove, replace) - new_classes = [c for c in classes if c not in self._props['classes']] - old_classes = [c for c in self._props['classes'] if c not in classes] - if new_classes: - self.run_method('add_classes', new_classes) - if old_classes: - self.run_method('remove_classes', old_classes) - self._props['classes'] = classes - return self - - def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \ - -> Self: - old_style = Element._parse_style(remove) - for key in old_style: - self._props['style'].pop(key, None) - if old_style: - self.run_method('remove_style', list(old_style)) - self._props['style'].update(Element._parse_style(add)) - self._props['style'].update(Element._parse_style(replace)) - if self._props['style']: - self.run_method('add_style', self._props['style']) - return self - - def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self: - old_props = self._parse_props(remove) - for key in old_props: - self._props['props'].pop(key, None) - if old_props: - self.run_method('remove_props', list(old_props)) - new_props = self._parse_props(add) - self._props['props'].update(new_props) - if self._props['props']: - self.run_method('add_props', self._props['props']) - return self - class Query: @@ -65,7 +31,7 @@ def __init__(self, selector: str) -> None: :param selector: the CSS selector (e.g. "body", "#my-id", ".my-class", "div > p") """ for element in context.client.elements.values(): - if isinstance(element, QueryElement) and element._props['selector'] == selector: # pylint: disable=protected-access + if isinstance(element, QueryElement) and element.props['selector'] == selector: self.element = element break else: @@ -83,7 +49,14 @@ def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, re :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 """ - self.element.classes(add, remove=remove, replace=replace) + classes = Classes.update_list(self.element.props['classes'], add, remove, replace) + new_classes = [c for c in classes if c not in self.element.props['classes']] + old_classes = [c for c in self.element.props['classes'] if c not in classes] + if new_classes: + self.element.run_method('add_classes', new_classes) + if old_classes: + self.element.run_method('remove_classes', old_classes) + self.element.props['classes'] = classes return self def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \ @@ -96,7 +69,15 @@ def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, repl :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 """ - self.element.style(add, remove=remove, replace=replace) + old_style = Style.parse(remove) + for key in old_style: + self.element.props['style'].pop(key, None) + if old_style: + self.element.run_method('remove_style', list(old_style)) + self.element.props['style'].update(Style.parse(add)) + self.element.props['style'].update(Style.parse(replace)) + if self.element.props['style']: + self.element.run_method('add_style', self.element.props['style']) return self def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self: @@ -110,5 +91,13 @@ def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> S :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 """ - self.element.props(add, remove=remove) + old_props = Props.parse(remove) + for key in old_props: + self.element.props['props'].pop(key, None) + if old_props: + self.element.run_method('remove_props', list(old_props)) + new_props = Props.parse(add) + self.element.props['props'].update(new_props) + if self.element.props['props']: + self.element.run_method('add_props', self.element.props['props']) return self diff --git a/nicegui/elements/stepper.py b/nicegui/elements/stepper.py index 3db7750c3..1c762c301 100644 --- a/nicegui/elements/stepper.py +++ b/nicegui/elements/stepper.py @@ -34,11 +34,11 @@ def __init__(self, *, self._classes.append('nicegui-stepper') def _value_to_model_value(self, value: Any) -> Any: - return value._props['name'] if isinstance(value, Step) else value # pylint: disable=protected-access + return value.props['name'] if isinstance(value, Step) else value def _handle_value_change(self, value: Any) -> None: super()._handle_value_change(value) - names = [step._props['name'] for step in self] # pylint: disable=protected-access + names = [step.props['name'] for step in self] for i, step in enumerate(self): done = i < names.index(value) if value in names else False step.props(f':done={done}') diff --git a/nicegui/elements/tabs.py b/nicegui/elements/tabs.py index b6053a33e..cb14a798b 100644 --- a/nicegui/elements/tabs.py +++ b/nicegui/elements/tabs.py @@ -25,7 +25,7 @@ def __init__(self, *, super().__init__(tag='q-tabs', value=value, on_value_change=on_change) def _value_to_model_value(self, value: Any) -> Any: - return value._props['name'] if isinstance(value, (Tab, TabPanel)) else value # pylint: disable=protected-access + return value.props['name'] if isinstance(value, (Tab, TabPanel)) else value class Tab(IconElement, DisableableElement): @@ -77,7 +77,7 @@ def __init__(self, self._props['keep-alive'] = keep_alive def _value_to_model_value(self, value: Any) -> Any: - return value._props['name'] if isinstance(value, (Tab, TabPanel)) else value # pylint: disable=protected-access + return value.props['name'] if isinstance(value, (Tab, TabPanel)) else value class TabPanel(DisableableElement): @@ -91,5 +91,5 @@ def __init__(self, name: Union[Tab, str]) -> None: :param name: `ui.tab` or the name of a tab element """ super().__init__(tag='q-tab-panel') - self._props['name'] = name._props['name'] if isinstance(name, Tab) else name + self._props['name'] = name.props['name'] if isinstance(name, Tab) else name self._classes.append('nicegui-tab-panel') diff --git a/nicegui/elements/tree.py b/nicegui/elements/tree.py index e814b7bc8..9fc74f6a6 100644 --- a/nicegui/elements/tree.py +++ b/nicegui/elements/tree.py @@ -2,7 +2,6 @@ from typing_extensions import Self -from .. import helpers from ..events import GenericEventArguments, ValueChangeEventArguments, handle_event from .mixins.filter_element import FilterElement @@ -50,6 +49,11 @@ def __init__(self, self._expand_handlers = [on_expand] if on_expand else [] self._tick_handlers = [on_tick] if on_tick else [] + # https://github.com/zauberzeug/nicegui/issues/1385 + self._props.add_warning('default-expand-all', + 'The prop "default-expand-all" is not supported by `ui.tree`. ' + 'Use ".expand()" instead.') + def update_prop(name: str, value: Any) -> None: if self._props[name] != value: self._props[name] = value @@ -150,12 +154,3 @@ def iterate_nodes(nodes: List[Dict]) -> Iterator[Dict]: yield node yield from iterate_nodes(node.get(CHILDREN_KEY, [])) return {node[NODE_KEY] for node in iterate_nodes(self._props['nodes'])} - - def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self: - super().props(add, remove=remove) - if 'default-expand-all' in self._props: - # https://github.com/zauberzeug/nicegui/issues/1385 - del self._props['default-expand-all'] - helpers.warn_once('The prop "default-expand-all" is not supported by `ui.tree`.\n' - 'Use ".expand()" instead.') - return self diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py index 87afd09e3..ff3a059d7 100644 --- a/nicegui/page_layout.py +++ b/nicegui/page_layout.py @@ -52,9 +52,9 @@ def __init__(self, *, self._props['elevated'] = elevated if wrap: self._classes.append('wrap') - code = list(self.client.layout._props['view']) + code = list(self.client.layout.props['view']) code[1] = 'H' if fixed else 'h' - self.client.layout._props['view'] = ''.join(code) + self.client.layout.props['view'] = ''.join(code) self.move(target_index=0) @@ -119,11 +119,11 @@ def __init__(self, self._props['bordered'] = bordered self._props['elevated'] = elevated self._classes.append('nicegui-drawer') - code = list(self.client.layout._props['view']) + code = list(self.client.layout.props['view']) code[0 if side == 'left' else 2] = side[0].lower() if top_corner else 'h' code[4 if side == 'left' else 6] = side[0].upper() if fixed else side[0].lower() code[8 if side == 'left' else 10] = side[0].lower() if bottom_corner else 'f' - self.client.layout._props['view'] = ''.join(code) + self.client.layout.props['view'] = ''.join(code) page_container_index = self.client.layout.default_slot.children.index(self.client.page_container) self.move(target_index=page_container_index if side == 'left' else page_container_index + 1) @@ -235,9 +235,9 @@ def __init__(self, *, self._props['elevated'] = elevated if wrap: self._classes.append('wrap') - code = list(self.client.layout._props['view']) + code = list(self.client.layout.props['view']) code[9] = 'F' if fixed else 'f' - self.client.layout._props['view'] = ''.join(code) + self.client.layout.props['view'] = ''.join(code) self.move(target_index=-1) diff --git a/nicegui/props.py b/nicegui/props.py new file mode 100644 index 000000000..96cb293e9 --- /dev/null +++ b/nicegui/props.py @@ -0,0 +1,93 @@ +import ast +import re +from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, TypeVar + +from . import helpers + +if TYPE_CHECKING: + from .element import Element + +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) + +T = TypeVar('T', bound='Element') + + +class Props(dict, Generic[T]): + + def __init__(self, *args, element: T, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.element = element + self._warnings: Dict[str, str] = {} + + def add_warning(self, prop: str, message: str) -> None: + """Add a warning message for a prop.""" + self._warnings[prop] = message + + def __call__(self, + add: Optional[str] = None, *, + remove: Optional[str] = None) -> T: + """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(remove): + if key in self: + needs_update = True + del self[key] + for key, value in self.parse(add).items(): + if self.get(key) != value: + needs_update = True + self[key] = value + if needs_update: + self.element.update() + for name, message in self._warnings.items(): + if name in self: + del self[name] + helpers.warn_once(message) + return self.element + + @staticmethod + def parse(text: Optional[str]) -> Dict[str, Any]: + """Parse a string of props into a dictionary.""" + 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 diff --git a/nicegui/style.py b/nicegui/style.py new file mode 100644 index 000000000..9b4093a05 --- /dev/null +++ b/nicegui/style.py @@ -0,0 +1,47 @@ +from typing import TYPE_CHECKING, Dict, Generic, Optional, TypeVar + +if TYPE_CHECKING: + from .element import Element + +T = TypeVar('T', bound='Element') + + +class Style(dict, 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 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 = {**self} if replace is None else {} + for key in self.parse(remove): + style_dict.pop(key, None) + style_dict.update(self.parse(add)) + style_dict.update(self.parse(replace)) + if self != style_dict: + self.clear() + self.update(style_dict) + self.element.update() + return self.element + + @staticmethod + def parse(text: Optional[str]) -> Dict[str, str]: + """Parse a string of styles into a dictionary.""" + 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 diff --git a/nicegui/testing/user_interaction.py b/nicegui/testing/user_interaction.py index 848aefa87..e396992d8 100644 --- a/nicegui/testing/user_interaction.py +++ b/nicegui/testing/user_interaction.py @@ -57,7 +57,7 @@ def click(self) -> Self: with self.user.client: for element in self.elements: if isinstance(element, ui.link): - href = element._props.get('href', '#') # pylint: disable=protected-access + href = element.props.get('href', '#') background_tasks.create(self.user.open(href)) return self if isinstance(element, ui.select): diff --git a/tests/test_element.py b/tests/test_element.py index eb9be98da..66cce207e 100644 --- a/tests/test_element.py +++ b/tests/test_element.py @@ -1,7 +1,11 @@ +from typing import Dict, Optional + import pytest from selenium.webdriver.common.by import By from nicegui import background_tasks, ui +from nicegui.props import Props +from nicegui.style import Style from nicegui.testing import Screen @@ -32,39 +36,41 @@ def assert_classes(classes: str) -> None: assert_classes('four') -def test_style_parsing(nicegui_reset_globals): - # pylint: disable=protected-access - assert ui.element._parse_style(None) == {} # pylint: disable=use-implicit-booleaness-not-comparison - assert ui.element._parse_style('color: red; background-color: blue') == {'color': 'red', 'background-color': 'blue'} - assert ui.element._parse_style('width:12em;height:34.5em') == {'width': '12em', 'height': '34.5em'} - assert ui.element._parse_style('transform: translate(120.0px, 50%)') == {'transform': 'translate(120.0px, 50%)'} - assert ui.element._parse_style('box-shadow: 0 0 0.5em #1976d2') == {'box-shadow': '0 0 0.5em #1976d2'} - - -def test_props_parsing(nicegui_reset_globals): - # pylint: disable=protected-access - assert ui.element._parse_props(None) == {} # pylint: disable=use-implicit-booleaness-not-comparison - assert ui.element._parse_props('one two=1 three="abc def"') == {'one': True, 'two': '1', 'three': 'abc def'} - assert ui.element._parse_props('loading percentage=12.5') == {'loading': True, 'percentage': '12.5'} - assert ui.element._parse_props('size=50%') == {'size': '50%'} - assert ui.element._parse_props('href=http://192.168.42.100/') == {'href': 'http://192.168.42.100/'} - assert ui.element._parse_props('hint="Your \\"given\\" name"') == {'hint': 'Your "given" name'} - assert ui.element._parse_props('input-style="{ color: #ff0000 }"') == {'input-style': '{ color: #ff0000 }'} - assert ui.element._parse_props('accept=.jpeg,.jpg,.png') == {'accept': '.jpeg,.jpg,.png'} - - assert ui.element._parse_props('empty=""') == {'empty': ''} - assert ui.element._parse_props("empty=''") == {'empty': ''} - - assert ui.element._parse_props("""hint='Your \\"given\\" name'""") == {'hint': 'Your "given" name'} - assert ui.element._parse_props("one two=1 three='abc def'") == {'one': True, 'two': '1', 'three': 'abc def'} - assert ui.element._parse_props('''three='abc def' four="hhh jjj"''') == {'three': 'abc def', 'four': 'hhh jjj', } - assert ui.element._parse_props('''foo="quote'quote"''') == {'foo': "quote'quote"} - assert ui.element._parse_props("""foo='quote"quote'""") == {'foo': 'quote"quote'} - assert ui.element._parse_props("""foo="single '" bar='double "'""") == {'foo': "single '", 'bar': 'double "'} - assert ui.element._parse_props("""foo="single '" bar='double \\"'""") == {'foo': "single '", 'bar': 'double "'} - assert ui.element._parse_props("input-style='{ color: #ff0000 }'") == {'input-style': '{ color: #ff0000 }'} - assert ui.element._parse_props("""input-style='{ myquote: "quote" }'""") == {'input-style': '{ myquote: "quote" }'} - assert ui.element._parse_props('filename=foo=bar.txt') == {'filename': 'foo=bar.txt'} +@pytest.mark.parametrize('value,expected', [ + (None, {}), + ('color: red; background-color: blue', {'color': 'red', 'background-color': 'blue'}), + ('width:12em;height:34.5em', {'width': '12em', 'height': '34.5em'}), + ('transform: translate(120.0px, 50%)', {'transform': 'translate(120.0px, 50%)'}), + ('box-shadow: 0 0 0.5em #1976d2', {'box-shadow': '0 0 0.5em #1976d2'}), +]) +def test_style_parsing(value: Optional[str], expected: Dict[str, str]): + assert Style.parse(value) == expected + + +@pytest.mark.parametrize('value,expected', [ + (None, {}), + ('one two=1 three="abc def"', {'one': True, 'two': '1', 'three': 'abc def'}), + ('loading percentage=12.5', {'loading': True, 'percentage': '12.5'}), + ('size=50%', {'size': '50%'}), + ('href=http://192.168.42.100/', {'href': 'http://192.168.42.100/'}), + ('hint="Your \\"given\\" name"', {'hint': 'Your "given" name'}), + ('input-style="{ color: #ff0000 }"', {'input-style': '{ color: #ff0000 }'}), + ('accept=.jpeg,.jpg,.png', {'accept': '.jpeg,.jpg,.png'}), + ('empty=""', {'empty': ''}), + ("empty=''", {'empty': ''}), + ("""hint='Your \\"given\\" name'""", {'hint': 'Your "given" name'}), + ("one two=1 three='abc def'", {'one': True, 'two': '1', 'three': 'abc def'}), + ('''three='abc def' four="hhh jjj"''', {'three': 'abc def', 'four': 'hhh jjj', }), + ('''foo="quote'quote"''', {'foo': "quote'quote"}), + ("""foo='quote"quote'""", {'foo': 'quote"quote'}), + ("""foo="single '" bar='double "'""", {'foo': "single '", 'bar': 'double "'}), + ("""foo="single '" bar='double \\"'""", {'foo': "single '", 'bar': 'double "'}), + ("input-style='{ color: #ff0000 }'", {'input-style': '{ color: #ff0000 }'}), + ("""input-style='{ myquote: "quote" }'""", {'input-style': '{ myquote: "quote" }'}), + ('filename=foo=bar.txt', {'filename': 'foo=bar.txt'}), +]) +def test_props_parsing(value: Optional[str], expected: Dict[str, str]): + assert Props.parse(value) == expected def test_style(screen: Screen): @@ -198,105 +204,105 @@ def test_default_props(nicegui_reset_globals): ui.button.default_props('rounded outline') button_a = ui.button('Button A') button_b = ui.button('Button B') - assert button_a._props.get('rounded') is True, 'default props are set' - assert button_a._props.get('outline') is True - assert button_b._props.get('rounded') is True - assert button_b._props.get('outline') is True + assert button_a.props.get('rounded') is True, 'default props are set' + assert button_a.props.get('outline') is True + assert button_b.props.get('rounded') is True + assert button_b.props.get('outline') is True ui.button.default_props(remove='outline') button_c = ui.button('Button C') - assert button_c._props.get('outline') is None, '"outline" prop was removed' - assert button_c._props.get('rounded') is True, 'other props are still there' + assert button_c.props.get('outline') is None, '"outline" prop was removed' + assert button_c.props.get('rounded') is True, 'other props are still there' ui.input.default_props('filled') input_a = ui.input() - assert input_a._props.get('filled') is True - assert input_a._props.get('rounded') is None, 'default props of ui.button do not affect ui.input' + assert input_a.props.get('filled') is True + assert input_a.props.get('rounded') is None, 'default props of ui.button do not affect ui.input' class MyButton(ui.button): pass MyButton.default_props('flat') button_d = MyButton() button_e = ui.button() - assert button_d._props.get('flat') is True - assert button_d._props.get('rounded') is True, 'default props are inherited' - assert button_e._props.get('flat') is None, 'default props of MyButton do not affect ui.button' - assert button_e._props.get('rounded') is True + assert button_d.props.get('flat') is True + assert button_d.props.get('rounded') is True, 'default props are inherited' + assert button_e.props.get('flat') is None, 'default props of MyButton do not affect ui.button' + assert button_e.props.get('rounded') is True ui.button.default_props('no-caps').default_props('no-wrap') button_f = ui.button() - assert button_f._props.get('no-caps') is True - assert button_f._props.get('no-wrap') is True + assert button_f.props.get('no-caps') is True + assert button_f.props.get('no-wrap') is True def test_default_classes(nicegui_reset_globals): ui.button.default_classes('bg-white text-green') button_a = ui.button('Button A') button_b = ui.button('Button B') - assert 'bg-white' in button_a._classes, 'default classes are set' - assert 'text-green' in button_a._classes - assert 'bg-white' in button_b._classes - assert 'text-green' in button_b._classes + assert 'bg-white' in button_a.classes, 'default classes are set' + assert 'text-green' in button_a.classes + assert 'bg-white' in button_b.classes + assert 'text-green' in button_b.classes ui.button.default_classes(remove='text-green') button_c = ui.button('Button C') - assert 'text-green' not in button_c._classes, '"text-green" class was removed' - assert 'bg-white' in button_c._classes, 'other classes are still there' + assert 'text-green' not in button_c.classes, '"text-green" class was removed' + assert 'bg-white' in button_c.classes, 'other classes are still there' ui.input.default_classes('text-black') input_a = ui.input() - assert 'text-black' in input_a._classes - assert 'bg-white' not in input_a._classes, 'default classes of ui.button do not affect ui.input' + assert 'text-black' in input_a.classes + assert 'bg-white' not in input_a.classes, 'default classes of ui.button do not affect ui.input' class MyButton(ui.button): pass MyButton.default_classes('w-full') button_d = MyButton() button_e = ui.button() - assert 'w-full' in button_d._classes - assert 'bg-white' in button_d._classes, 'default classes are inherited' - assert 'w-full' not in button_e._classes, 'default classes of MyButton do not affect ui.button' - assert 'bg-white' in button_e._classes + assert 'w-full' in button_d.classes + assert 'bg-white' in button_d.classes, 'default classes are inherited' + assert 'w-full' not in button_e.classes, 'default classes of MyButton do not affect ui.button' + assert 'bg-white' in button_e.classes ui.button.default_classes('h-40').default_classes('max-h-80') button_f = ui.button() - assert 'h-40' in button_f._classes - assert 'max-h-80' in button_f._classes + assert 'h-40' in button_f.classes + assert 'max-h-80' in button_f.classes def test_default_style(nicegui_reset_globals): ui.button.default_style('color: green; font-size: 200%') button_a = ui.button('Button A') button_b = ui.button('Button B') - assert button_a._style.get('color') == 'green', 'default style is set' - assert button_a._style.get('font-size') == '200%' - assert button_b._style.get('color') == 'green' - assert button_b._style.get('font-size') == '200%' + assert button_a.style.get('color') == 'green', 'default style is set' + assert button_a.style.get('font-size') == '200%' + assert button_b.style.get('color') == 'green' + assert button_b.style.get('font-size') == '200%' ui.button.default_style(remove='color: green') button_c = ui.button('Button C') - assert button_c._style.get('color') is None, '"color" style was removed' - assert button_c._style.get('font-size') == '200%', 'other style are still there' + assert button_c.style.get('color') is None, '"color" style was removed' + assert button_c.style.get('font-size') == '200%', 'other style are still there' ui.input.default_style('font-weight: 300') input_a = ui.input() - assert input_a._style.get('font-weight') == '300' - assert input_a._style.get('font-size') is None, 'default style of ui.button does not affect ui.input' + assert input_a.style.get('font-weight') == '300' + assert input_a.style.get('font-size') is None, 'default style of ui.button does not affect ui.input' class MyButton(ui.button): pass MyButton.default_style('font-family: courier') button_d = MyButton() button_e = ui.button() - assert button_d._style.get('font-family') == 'courier' - assert button_d._style.get('font-size') == '200%', 'default style is inherited' - assert button_e._style.get('font-family') is None, 'default style of MyButton does not affect ui.button' - assert button_e._style.get('font-size') == '200%' + assert button_d.style.get('font-family') == 'courier' + assert button_d.style.get('font-size') == '200%', 'default style is inherited' + assert button_e.style.get('font-family') is None, 'default style of MyButton does not affect ui.button' + assert button_e.style.get('font-size') == '200%' ui.button.default_style('border: 2px').default_style('padding: 30px') button_f = ui.button() - assert button_f._style.get('border') == '2px' - assert button_f._style.get('padding') == '30px' + assert button_f.style.get('border') == '2px' + assert button_f.style.get('padding') == '30px' def test_invalid_tags(screen: Screen): diff --git a/tests/test_element_filter.py b/tests/test_element_filter.py index 0f3f92706..decf8c644 100644 --- a/tests/test_element_filter.py +++ b/tests/test_element_filter.py @@ -26,7 +26,7 @@ def test_find_all() -> None: assert len(elements) == 8 assert elements[0].tag == 'q-page-container' assert elements[1].tag == 'q-page' - assert elements[2]._classes == ['nicegui-content'] # pylint: disable=protected-access + assert elements[2].classes == ['nicegui-content'] assert elements[3].text == 'button A' # type: ignore assert elements[4].text == 'label A' # type: ignore assert elements[5].__class__ == ui.row @@ -180,7 +180,7 @@ async def test_setting_classes(user: User): await user.open('/') for label in user.find('label').elements: - assert label._classes == ['text-2xl'] # pylint: disable=protected-access + assert label.classes == ['text-2xl'] async def test_setting_style(user: User): @@ -191,7 +191,7 @@ async def test_setting_style(user: User): await user.open('/') for label in user.find('label').elements: - assert label._style['color'] == 'red' # pylint: disable=protected-access + assert label.style['color'] == 'red' async def test_setting_props(user: User): @@ -202,7 +202,7 @@ async def test_setting_props(user: User): await user.open('/') for button in user.find('button').elements: - assert button._props['flat'] # pylint: disable=protected-access + assert button.props['flat'] async def test_typing(user: User): diff --git a/tests/test_tailwind.py b/tests/test_tailwind.py index a7198123e..3719c20ba 100644 --- a/tests/test_tailwind.py +++ b/tests/test_tailwind.py @@ -31,4 +31,4 @@ def test_tailwind_apply(screen: Screen): def test_empty_values(nicegui_reset_globals): label = ui.label('A') label.tailwind.border_width('') - assert 'border' in label._classes + assert 'border' in label.classes diff --git a/tests/test_tree.py b/tests/test_tree.py index b59c44c42..b0320c01c 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -72,7 +72,7 @@ def test_select_deselect_node(screen: Screen): ui.button('Select', on_click=lambda: tree.select('2')) ui.button('Deselect', on_click=tree.deselect) - ui.label().bind_text_from(tree._props, 'selected', lambda x: f'Selected: {x}') + ui.label().bind_text_from(tree.props, 'selected', lambda x: f'Selected: {x}') screen.open('/') screen.click('Select') @@ -92,7 +92,7 @@ def test_tick_untick_node_or_nodes(screen: Screen): ui.button('Untick some', on_click=lambda: tree.untick(['1', 'B'])) ui.button('Tick all', on_click=tree.tick) ui.button('Untick all', on_click=tree.untick) - ui.label().bind_text_from(tree._props, 'ticked', lambda x: f'Ticked: {sorted(x)}') + ui.label().bind_text_from(tree.props, 'ticked', lambda x: f'Ticked: {sorted(x)}') screen.open('/') screen.should_contain('Ticked: []') diff --git a/website/documentation/content/mermaid_documentation.py b/website/documentation/content/mermaid_documentation.py index 6a1f84bcd..87dd791a5 100644 --- a/website/documentation/content/mermaid_documentation.py +++ b/website/documentation/content/mermaid_documentation.py @@ -11,7 +11,7 @@ def main_demo() -> None: A --> C; ''') # END OF DEMO - list(ui.context.client.elements.values())[-1]._props['config'] = {'securityLevel': 'loose'} # HACK: for click_demo + list(ui.context.client.elements.values())[-1].props['config'] = {'securityLevel': 'loose'} # HACK: for click_demo @doc.demo('Handle click events', ''' @@ -38,7 +38,7 @@ def error_demo() -> None: A -> C; ''').on('error', lambda e: print(e.args['message'])) # END OF DEMO - list(ui.context.client.elements.values())[-1]._props['config'] = {'securityLevel': 'loose'} # HACK: for click_demo + list(ui.context.client.elements.values())[-1].props['config'] = {'securityLevel': 'loose'} # HACK: for click_demo doc.reference(ui.mermaid) diff --git a/website/documentation/content/section_styling_appearance.py b/website/documentation/content/section_styling_appearance.py index dbc511233..6f52ea0cd 100644 --- a/website/documentation/content/section_styling_appearance.py +++ b/website/documentation/content/section_styling_appearance.py @@ -74,7 +74,9 @@ def handle_classes(e: events.ValueChangeEventArguments): ui.markdown("`')`") with ui.row().classes('items-center gap-0 w-full px-2'): def handle_props(e: events.ValueChangeEventArguments): - element._props = {'label': 'Button', 'color': 'primary'} + element.props.clear() + element.props['label'] = 'Button' + element.props['color'] = 'primary' try: element.props(e.value) except ValueError: