Skip to content

Commit

Permalink
Provide public API for accessing _props, _classes and _style. (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
falkoschindler committed Aug 29, 2024
1 parent ed6a955 commit 392e0d0
Show file tree
Hide file tree
Showing 20 changed files with 378 additions and 311 deletions.
45 changes: 45 additions & 0 deletions nicegui/classes.py
Original file line number Diff line number Diff line change
@@ -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 <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
Removing or replacing classes can be helpful if predefined classes are not desired.
:param add: whitespace-delimited string of classes
:param remove: whitespace-delimited string of classes to remove from the element
:param replace: whitespace-delimited string of classes to use instead of existing ones
"""
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
180 changes: 39 additions & 141 deletions nicegui/element.py
Original file line number Diff line number Diff line change
@@ -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]'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
Removing or replacing classes can be helpful if predefined classes are not desired.
:param add: whitespace-delimited string of classes
:param remove: whitespace-delimited string of classes to remove from the element
:param replace: whitespace-delimited string of classes to use instead of existing ones
"""
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,
Expand All @@ -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,
Expand All @@ -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 <https://quasar.dev/>`_ 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,
Expand All @@ -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

Expand Down
13 changes: 6 additions & 7 deletions nicegui/element_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -105,19 +104,19 @@ 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,
) if content]
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())
Expand Down
4 changes: 2 additions & 2 deletions nicegui/elements/carousel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}')
Expand Down
17 changes: 5 additions & 12 deletions nicegui/elements/menu.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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):

Expand Down
2 changes: 1 addition & 1 deletion nicegui/elements/mixins/visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 392e0d0

Please sign in to comment.