From 2635f58e7c3d10b161ee69a15ebfe6499ac26daa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Jan 2022 15:22:43 +0000 Subject: [PATCH] docstrings and tidy --- src/textual/_border.py | 2 +- src/textual/css/_style_properties.py | 23 ++++++ src/textual/css/_styles_builder.py | 14 +++- src/textual/css/box.py | 45 ------------ src/textual/css/match.py | 9 +++ src/textual/css/query.py | 31 ++++++++ src/textual/css/styles.py | 10 +++ src/textual/dom.py | 42 +++++++++++ src/textual/geometry.py | 9 +++ src/textual/view.py | 10 +-- src/textual/widget.py | 102 ++++++++++++++++----------- 11 files changed, 198 insertions(+), 99 deletions(-) delete mode 100644 src/textual/css/box.py diff --git a/src/textual/_border.py b/src/textual/_border.py index f81d1e23c..c950f0684 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -2,7 +2,7 @@ from __future__ import annotations from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.segment import Segment, SegmentLines -from rich.style import Style +from rich.style import Style, StyleType from .css.types import EdgeStyle diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index d56fcbe85..975562482 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -1,3 +1,12 @@ +""" +Style properties are descriptors which allow the Styles object to accept different types when +setting attributes. This gives the developer more freedom in how to express style information. + +Descriptors also play nicely with Mypy, which is aware that attributes can have different types +when setting and getting. + +""" + from __future__ import annotations from typing import Iterable, NamedTuple, Sequence, TYPE_CHECKING @@ -125,6 +134,20 @@ class Edges(NamedTuple): if left[0]: yield "left", left + def spacing(self) -> tuple[int, int, int, int]: + """Get spacing created by borders. + + Returns: + tuple[int, int, int, int]: Spacing for top, right, bottom, and left. + """ + top, right, bottom, left = self + return ( + 1 if top[0] else 0, + 1 if right[0] else 0, + 1 if bottom[0] else 0, + 1 if left[0] else 0, + ) + class BorderProperty: def __set_name__(self, owner: Styles, name: str) -> None: diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 5f93c006e..e70bf06da 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -1,16 +1,24 @@ +""" + +The StylesBuilder object takes tokens parsed from the CSS and converts +to the appropriate internal types. + + +""" + from __future__ import annotations from typing import cast, Iterable, NoReturn import rich.repr -from rich.color import ANSI_COLOR_NAMES, Color +from rich.color import Color from rich.style import Style from .constants import VALID_BORDER, VALID_EDGE, VALID_DISPLAY, VALID_VISIBILITY -from .errors import DeclarationError, StyleValueError +from .errors import DeclarationError from ._error_tools import friendly_list from .._easing import EASING -from ..geometry import Offset, Spacing, SpacingDimensions +from ..geometry import Spacing, SpacingDimensions from .model import Declaration from .scalar import Scalar, ScalarOffset, Unit, ScalarError from .styles import DockGroup, Styles diff --git a/src/textual/css/box.py b/src/textual/css/box.py deleted file mode 100644 index ec5abac02..000000000 --- a/src/textual/css/box.py +++ /dev/null @@ -1,45 +0,0 @@ -from dataclasses import dataclass, field - - -@dataclass(frozen=True) -class Space: - top: int = 0 - right: int = 0 - bottom: int = 0 - left: int = 0 - - def __str__(self) -> str: - return f"{self.top}, {self.right}, {self.bottom}, {self.left}" - - -@dataclass(frozen=True) -class Edge: - line: str = "none" - style: str = "default" - - -@dataclass(frozen=True) -class Border: - top: Edge = field(default_factory=Edge) - right: Edge = field(default_factory=Edge) - bottom: Edge = field(default_factory=Edge) - left: Edge = field(default_factory=Edge) - - -@dataclass -class Box: - padding: Space = field(default_factory=Space) - margin: Space = field(default_factory=Space) - border: Border = field(default_factory=Border) - outline: Border = field(default_factory=Border) - dispay: bool = True - visible: bool = True - text: str = "" - opacity: float = 1.0 - - -if __name__ == "__main__": - from rich import print - - box = Box() - print(box) diff --git a/src/textual/css/match.py b/src/textual/css/match.py index 94b3d989c..6b79b7a44 100644 --- a/src/textual/css/match.py +++ b/src/textual/css/match.py @@ -9,6 +9,15 @@ if TYPE_CHECKING: def match(selector_sets: Iterable[SelectorSet], node: DOMNode) -> bool: + """Check if a given selector matches any of the given selector sets. + + Args: + selector_sets (Iterable[SelectorSet]): Iterable of selector sets. + node (DOMNode): DOM node. + + Returns: + bool: True if the node matches the selector, otherwise False. + """ return any( _check_selectors(selector_set.selectors, node) for selector_set in selector_sets ) diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 72f8506cd..6bb390242 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -1,3 +1,16 @@ +""" +A DOMQuery is a set of DOM nodes associated with a given CSS selector. + +This set of nodes may be further filtered with the filter method. Additional methods apply +actions to the nodes in the query. + +If this sounds like JQuery, a (once) popular JS library, it is no coincidence. + +DOMQuery objects are typically created by Widget.filter method. + +""" + + from __future__ import annotations @@ -38,6 +51,7 @@ class DOMQuery: return len(self._nodes) def __bool__(self) -> bool: + """True if non-empty, otherwise False.""" return bool(self._nodes) def __iter__(self) -> Iterator[DOMNode]: @@ -47,22 +61,39 @@ class DOMQuery: yield self._nodes def filter(self, selector: str) -> DOMQuery: + """Filter this set by the given CSS selector. + + Args: + selector (str): A CSS selector. + + Returns: + DOMQuery: New DOM Query. + """ selector_set = parse_selectors(selector) query = DOMQuery() query._nodes = [_node for _node in self._nodes if match(selector_set, _node)] return query def first(self) -> DOMNode: + """Get the first matched node. + + Returns: + DOMNode: A DOM Node. + """ + # TODO: Better response to empty query than an IndexError return self._nodes[0] def add_class(self, *class_names: str) -> None: + """Add the given class name(s) to nodes.""" for node in self._nodes: node.add_class(*class_names) def remove_class(self, *class_names: str) -> None: + """Remove the given class names from the nodes.""" for node in self._nodes: node.remove_class(*class_names) def toggle_class(self, *class_names: str) -> None: + """Toggle the given class names from matched nodes.""" for node in self._nodes: node.toggle_class(*class_names) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index d70b89790..3554c12bf 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -154,6 +154,16 @@ class Styles: "min_height", } + @property + def gutter(self) -> Spacing: + """Get the gutter (additional space reserved for margin / padding / border). + + Returns: + Spacing: [description] + """ + gutter = self.margin + self.padding + self.border.spacing + return gutter + @classmethod @lru_cache(maxsize=1024) def parse(cls, css: str, path: str) -> Styles: diff --git a/src/textual/dom.py b/src/textual/dom.py index 9477d1679..caffdfe1d 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -50,6 +50,14 @@ class DOMNode(MessagePump): @property def parent(self) -> DOMNode: + """Get the parent node. + + Raises: + NoParent: If this is the root node. + + Returns: + DOMNode: The node which is the direct parent of this node. + """ if self._parent is None: raise NoParent(f"{self} has no parent") assert isinstance(self._parent, DOMNode) @@ -57,10 +65,24 @@ class DOMNode(MessagePump): @property def id(self) -> str | None: + """The ID of this node, or None if the node has no ID. + + Returns: + (str | None): A Node ID or None. + """ return self._id @id.setter def id(self, new_id: str) -> str: + """Sets the ID (may only be done once). + + Args: + new_id (str): ID for this node. + + Raises: + ValueError: If the ID has already been set. + + """ if self._id is not None: raise ValueError( "Node 'id' attribute may not be changed once set (current id={self._id!r})" @@ -83,10 +105,20 @@ class DOMNode(MessagePump): @property def css_type(self) -> str: + """Gets the CSS type, used by the CSS. + + Returns: + str: A type used in CSS (lower cased class name). + """ return self.__class__.__name__.lower() @property def css_path(self) -> list[DOMNode]: + """A list of nodes from the root to this node, forming a "path". + + Returns: + list[DOMNode]: List of Nodes, starting with the root and ending with this node. + """ result: list[DOMNode] = [self] append = result.append @@ -137,6 +169,11 @@ class DOMNode(MessagePump): @property def tree(self) -> Tree: + """Get a Rich tree object which will recursively render the structure of the node tree. + + Returns: + Tree: A Rich object which may be printed. + """ highlighter = ReprHighlighter() tree = Tree(highlighter(repr(self))) @@ -163,6 +200,11 @@ class DOMNode(MessagePump): pass def add_child(self, node: DOMNode) -> None: + """Add a new child node. + + Args: + node (DOMNode): A DOM node. + """ self.children._append(node) node.set_parent(self) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index ca1fe2382..beb926959 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -507,5 +507,14 @@ class Spacing(NamedTuple): return cls(top, right, bottom, left) raise ValueError(f"1, 2 or 4 integers required for spacing; {len(pad)} given") + def __add__(self, other: object) -> Spacing: + if isinstance(other, tuple): + top1, right1, bottom1, left1 = self + top2, right2, bottom2, left2 = other + return Spacing( + top1 + top2, right1 + right2, bottom1 + bottom2, left1 + left2 + ) + return NotImplemented + NULL_OFFSET = Offset(0, 0) diff --git a/src/textual/view.py b/src/textual/view.py index 2b32be09d..6793e3b74 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -84,18 +84,12 @@ class View(Widget): def scroll(self) -> Offset: return Offset(self.scroll_x, self.scroll_y) - # def __rich_console__( - # self, console: Console, options: ConsoleOptions - # ) -> RenderResult: - # return - # yield - def __rich_repr__(self) -> rich.repr.Result: yield "name", self.name - def __getitem__(self, widget_name: str) -> Widget: + def __getitem__(self, widget_id: str) -> Widget: try: - return self.get_child(widget_name) + return self.get_child_by_id(widget_id) except errors.MissingWidget as error: raise KeyError(str(error)) diff --git a/src/textual/widget.py b/src/textual/widget.py index a8984f695..90072034e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -65,12 +65,12 @@ class Widget(DOMNode): """ def __init__(self, name: str | None = None, id: str | None = None) -> None: - # if name is None: - # class_name = self.__class__.__name__ - # Widget._counts.setdefault(class_name, 0) - # Widget._counts[class_name] += 1 - # _count = self._counts[class_name] - # name = f"{class_name}{_count}" + if name is None: + class_name = self.__class__.__name__ + Widget._counts.setdefault(class_name, 0) + Widget._counts[class_name] += 1 + _count = self._counts[class_name] + name = f"{class_name}{_count}" self._size = Size(0, 0) self._repaint_required = False @@ -82,26 +82,6 @@ class Widget(DOMNode): super().__init__(name=name, id=id) - # visible: Reactive[bool] = Reactive(True, layout=True) - layout_size: Reactive[int | None] = Reactive(None, layout=True) - layout_fraction: Reactive[int] = Reactive(1, layout=True) - layout_min_size: Reactive[int] = Reactive(1, layout=True) - # layout_offset_x: Reactive[float] = Reactive(0.0, layout=True) - # layout_offset_y: Reactive[float] = Reactive(0.0, layout=True) - - # style: Reactive[str | None] = Reactive(None) - padding: Reactive[Spacing | None] = Reactive(None, layout=True) - margin: Reactive[Spacing | None] = Reactive(None, layout=True) - border: Reactive[str] = Reactive("none", layout=True) - border_style: Reactive[str] = Reactive("green") - border_title: Reactive[TextType] = Reactive("") - - def validate_padding(self, padding: SpacingDimensions) -> Spacing: - return Spacing.unpack(padding) - - def validate_margin(self, margin: SpacingDimensions) -> Spacing: - return Spacing.unpack(margin) - def __init_subclass__(cls, can_focus: bool = True) -> None: super().__init_subclass__() cls.can_focus = can_focus @@ -117,16 +97,43 @@ class Widget(DOMNode): renderable = self.render_styled() return renderable - def get_child(self, name: str | None = None, id: str | None = None) -> Widget: - if name is not None: - for widget in self.children: - if widget.name == name: - return cast(Widget, widget) - if id is not None: - for widget in self.children: - if widget.id == id: - return cast(Widget, widget) - raise errors.MissingWidget(f"Widget named {name!r} was not found in {self}") + def get_child_by_id(self, id: str) -> Widget: + """Get a child with a given id. + + Args: + id (str): A Widget id. + + Raises: + errors.MissingWidget: If the widget was not found. + + Returns: + Widget: A child widget. + """ + + for widget in self.children: + if widget.id == id: + return cast(Widget, widget) + raise errors.MissingWidget(f"Widget with id=={id!r} was not found in {self}") + + def get_child_by_name(self, name: str) -> Widget: + """Get a child widget with a given name. + + Args: + name (str): A name. Defaults to None. + + Raises: + errors.MissingWidget: If no Widget is found. + + Returns: + Widget: A Widget with the given name. + """ + + for widget in self.children: + if widget.name == name: + return cast(Widget, widget) + raise errors.MissingWidget( + f"Widget with name=={name!r} was not found in {self}" + ) def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: watch(self, attribute_name, callback) @@ -196,12 +203,12 @@ class Widget(DOMNode): @property def gutter(self) -> Spacing: - mt, mr, mb, bl = self.margin or (0, 0, 0, 0) - pt, pr, pb, pl = self.padding or (0, 0, 0, 0) - border = 1 if self.border else 0 - gutter = Spacing( - mt + pt + border, mr + pr + border, mb + pb + border, bl + pl + border - ) + """Get additional space reserved by margin / padding / border. + + Returns: + Spacing: [description] + """ + gutter = self.styles.gutter return gutter def on_style_change(self) -> None: @@ -312,9 +319,20 @@ class Widget(DOMNode): await self.app.set_focus(self) async def capture_mouse(self, capture: bool = True) -> None: + """Capture (or release) the mouse. + + When captured, all mouse coordinates will go to this widget even when the pointer is not directly over the widget. + + Args: + capture (bool, optional): True to capture or False to release. Defaults to True. + """ await self.app.capture_mouse(self if capture else None) async def release_mouse(self) -> None: + """Release the mouse. + + Mouse events will only be sent when the mouse is over the widget. + """ await self.app.capture_mouse(None) async def broker_event(self, event_name: str, event: events.Event) -> bool: