Skip to content

Commit

Permalink
Add support for pseudo-classes in nested TCSS.
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
rodrigogiraoserrao committed Mar 4, 2024
1 parent da56de9 commit 6161dff
Show file tree
Hide file tree
Showing 3 changed files with 71 additions and 14 deletions.
31 changes: 22 additions & 9 deletions src/textual/css/tokenize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)
Expand All @@ -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"\&",
Expand Down Expand Up @@ -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"\{",
Expand All @@ -117,21 +130,21 @@
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(
"rule declaration",
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)

Expand Down
21 changes: 17 additions & 4 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[
Expand Down
33 changes: 32 additions & 1 deletion src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,6 +13,7 @@
from types import TracebackType
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Awaitable,
ClassVar,
Expand All @@ -20,6 +22,7 @@
Iterable,
NamedTuple,
Sequence,
Type,
TypeVar,
cast,
overload,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit 6161dff

Please sign in to comment.