From 6161dffbfed4b590f6433fbbe70a7ef55f215622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 4 Mar 2024 15:32:17 +0000 Subject: [PATCH] Add support for pseudo-classes in nested TCSS. To be able to disambiguate between selector:pseudo-class and declaration:rule-value in nested TCSS (see #4039 for the original issue and #4163 for a first attempt at solving this) we establish that selectors with widget type names always start with an upper case letter A-Z or an underscore _ whereas declarations always start with a lower case letter a-z. When a user creates a widget subclass that doesn't conform to this, we issue a 'SyntaxWarning' to let the user know. Because we do this with the standard module 'warnings', the warning can easily be supressed if the user has a good reason to create a widget subclass with a name starting with a lower case letter (which is valid Python, just unhelpful to Textual). --- src/textual/css/tokenize.py | 31 ++++++++++++++++++++++--------- src/textual/message_pump.py | 21 +++++++++++++++++---- src/textual/widget.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index b029fad550..4193cc578b 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -30,6 +30,19 @@ VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+" IDENTIFIER = r"[a-zA-Z_\-][a-zA-Z0-9_\-]*" +SELECTOR_TYPE_NAME = r"[A-Z_][a-zA-Z0-9_]*" +"""Selectors representing Widget type names should start with upper case or '_'. + +The fact that a selector starts with an upper case letter or '_' is relevant in the +context of nested CSS to help determine whether xxx:yyy is a declaration + value or a +selector + pseudo-class.""" +DECLARATION_NAME = r"[a-z][a-zA-Z0-9_\-]*" +"""Declaration of TCSS rules start with lowercase. + +The fact that a declaration starts with a lower case letter is relevant in the context +of nested CSS to help determine whether xxx:yyy is a declaration + value or a selector ++ pseudo-class. +""" # Values permitted in variable and rule declarations. DECLARATION_VALUES = { @@ -54,7 +67,7 @@ selector_start_id=r"\#" + IDENTIFIER, selector_start_class=r"\." + IDENTIFIER, selector_start_universal=r"\*", - selector_start=IDENTIFIER, + selector_start=SELECTOR_TYPE_NAME, variable_name=rf"{VARIABLE_REF}:", declaration_set_end=r"\}", ).expect_eof(True) @@ -64,11 +77,11 @@ whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, - declaration_name=r"[a-zA-Z_\-]+\:", + declaration_name=DECLARATION_NAME + r"\:", selector_start_id=r"\#" + IDENTIFIER, selector_start_class=r"\." + IDENTIFIER, selector_start_universal=r"\*", - selector_start=IDENTIFIER, + selector_start=SELECTOR_TYPE_NAME, variable_name=rf"{VARIABLE_REF}:", declaration_set_end=r"\}", nested=r"\&", @@ -98,10 +111,10 @@ comment_start=COMMENT_START, comment_line=COMMENT_LINE, pseudo_class=r"\:[a-zA-Z_-]+", - selector_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*", - selector_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*", + selector_id=r"\#" + IDENTIFIER, + selector_class=r"\." + IDENTIFIER, selector_universal=r"\*", - selector=IDENTIFIER, + selector=SELECTOR_TYPE_NAME, combinator_child=">", new_selector=r",", declaration_set_start=r"\{", @@ -117,13 +130,13 @@ whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, - declaration_name=r"[a-zA-Z_\-]+\:", + declaration_name=DECLARATION_NAME + r"\:", declaration_set_end=r"\}", # selector_start_id=r"\#" + IDENTIFIER, selector_start_class=r"\." + IDENTIFIER, selector_start_universal=r"\*", - selector_start=IDENTIFIER, + selector_start=SELECTOR_TYPE_NAME, ) expect_declaration_solo = Expect( @@ -131,7 +144,7 @@ whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, - declaration_name=r"[a-zA-Z_\-]+\:", + declaration_name=DECLARATION_NAME + r"\:", declaration_set_end=r"\}", ).expect_eof(True) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index b0dc922f58..7c9ef51b0e 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -16,7 +16,17 @@ from asyncio import CancelledError, Queue, QueueEmpty, Task, create_task from contextlib import contextmanager from functools import partial -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Iterable, cast +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Generator, + Iterable, + Type, + TypeVar, + cast, +) from weakref import WeakSet from . import Logger, events, log, messages @@ -52,18 +62,21 @@ class MessagePumpClosed(Exception): pass +_MessagePumpMetaSub = TypeVar("_MessagePumpMetaSub", bound="_MessagePumpMeta") + + class _MessagePumpMeta(type): """Metaclass for message pump. This exists to populate a Message inner class of a Widget with the parent classes' name. """ def __new__( - cls, + cls: Type[_MessagePumpMetaSub], name: str, bases: tuple[type, ...], class_dict: dict[str, Any], - **kwargs, - ): + **kwargs: Any, + ) -> _MessagePumpMetaSub: namespace = camel_to_snake(name) isclass = inspect.isclass handlers: dict[ diff --git a/src/textual/widget.py b/src/textual/widget.py index d0976c6edf..a28312a57e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4,6 +4,7 @@ from __future__ import annotations +import warnings from asyncio import Lock, create_task, wait from collections import Counter from contextlib import asynccontextmanager @@ -12,6 +13,7 @@ from types import TracebackType from typing import ( TYPE_CHECKING, + Any, AsyncGenerator, Awaitable, ClassVar, @@ -20,6 +22,7 @@ Iterable, NamedTuple, Sequence, + Type, TypeVar, cast, overload, @@ -72,6 +75,7 @@ ) from .layouts.vertical import VerticalLayout from .message import Message +from .message_pump import _MessagePumpMeta from .messages import CallbackType from .notifications import Notification, SeverityLevel from .reactive import Reactive @@ -243,8 +247,35 @@ def __get__(self, obj: Widget, objtype: type[Widget] | None = None) -> str | Non return title.markup +_WidgetMetaSub = TypeVar("_WidgetMetaSub", bound="_WidgetMeta") + + +class _WidgetMeta(_MessagePumpMeta): + """Metaclass for widgets. + + Used to issue a warning if a widget subclass is created with naming that's + incompatible with TCSS/querying. + """ + + def __new__( + mcs: Type[_WidgetMetaSub], + name: str, + *args: Any, + **kwargs: Any, + ) -> _WidgetMetaSub: + """Hook into widget subclass creation to check the subclass name.""" + if not name[0].isupper() and not name.startswith("_"): + warnings.warn( + SyntaxWarning( + f"Widget subclass {name!r} should be capitalised or start with '_'." + ), + stacklevel=2, + ) + return super().__new__(mcs, name, *args, **kwargs) + + @rich.repr.auto -class Widget(DOMNode): +class Widget(DOMNode, metaclass=_WidgetMeta): """ A Widget is the base class for Textual widgets.