Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide public API for accessing _props, _classes and _style. #3588

Merged
merged 10 commits into from
Aug 29, 2024
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
Loading