CSS inheritance

This commit is contained in:
Will McGugan
2022-06-02 17:20:03 +01:00
parent 57dec90cc4
commit 39dde3c3cb
13 changed files with 160 additions and 91 deletions

View File

@@ -3,16 +3,18 @@ from textual.widgets import Button
class ButtonApp(App): class ButtonApp(App):
CSS = """ CSS = """
Button { Button {
width: 100%; width: 100%;
} }
""" """
def compose(self): def compose(self):
yield Button("Light", id="light") yield Button("Lights off")
yield Button("Dark", id="dark")
def handle_pressed(self, event): def handle_pressed(self, event):
self.dark = event.button.id == "dark" self.dark = not self.dark
event.button.label = "Lights ON" if self.dark else "Lights OFF"

View File

@@ -11,19 +11,21 @@
scrollbar-background-hover: $panel-darken-3; scrollbar-background-hover: $panel-darken-3;
scrollbar-color: $system; scrollbar-color: $system;
scrollbar-color-active: $accent-darken-1; scrollbar-color-active: $accent-darken-1;
scrollbar-size-horizontal: 1;
scrollbar-size-vertical: 2;
} }
App > Screen { App > Screen {
layout: dock; layout: dock;
docks: side=left/1; docks: side=left/1;
background: $surface; background: $surface;
color: $text-surface; color: $text-surface;
} }
#sidebar { #sidebar {
color: $text-primary; color: $text-primary;
background: $primary; background: $primary-background;
dock: side; dock: side;
width: 30; width: 30;
offset-x: -100%; offset-x: -100%;
@@ -37,7 +39,7 @@ App > Screen {
#sidebar .title { #sidebar .title {
height: 3; height: 3;
background: $primary-darken-2; background: $primary-background-darken-2;
color: $text-primary-darken-2 ; color: $text-primary-darken-2 ;
border-right: outer $primary-darken-3; border-right: outer $primary-darken-3;
content-align: center middle; content-align: center middle;
@@ -45,16 +47,16 @@ App > Screen {
#sidebar .user { #sidebar .user {
height: 8; height: 8;
background: $primary-darken-1; background: $primary-background-darken-1;
color: $text-primary-darken-1; color: $text-primary-darken-1;
border-right: outer $primary-darken-3; border-right: outer $primary-background-darken-3;
content-align: center middle; content-align: center middle;
} }
#sidebar .content { #sidebar .content {
background: $primary; background: $primary-background;
color: $text-primary; color: $text-primary-background;
border-right: outer $primary-darken-3; border-right: outer $primary-background-darken-3;
content-align: center middle; content-align: center middle;
} }
@@ -90,14 +92,6 @@ Tweet {
box-sizing: border-box; box-sizing: border-box;
} }
Tweet.scrollbar-size-custom {
scrollbar-size-vertical: 2;
}
Tweet.scroll-horizontal {
scrollbar-size-horizontal: 2;
}
.scrollable { .scrollable {
width: 80; width: 80;
@@ -178,8 +172,8 @@ Tweet.scroll-horizontal TweetBody {
OptionItem { OptionItem {
height: 3; height: 3;
background: $primary; background: $primary-background;
border-right: outer $primary-darken-2; border-right: outer $primary-background-darken-2;
border-left: blank; border-left: blank;
content-align: center middle; content-align: center middle;
} }
@@ -187,7 +181,7 @@ OptionItem {
OptionItem:hover { OptionItem:hover {
height: 3; height: 3;
color: $accent; color: $accent;
background: $primary-darken-1; background: $primary-background-darken-1;
/* border-top: hkey $accent2-darken-3; /* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */ border-bottom: hkey $accent2-darken-3; */
text-style: bold; text-style: bold;

View File

@@ -17,8 +17,6 @@ class Hr(Widget):
class Info(Widget): class Info(Widget):
DEFAULT_STYLES = "height: 2;"
def __init__(self, text: str) -> None: def __init__(self, text: str) -> None:
super().__init__() super().__init__()
self.text = text self.text = text

View File

@@ -48,6 +48,7 @@ from ._context import active_app
from ._event_broker import extract_handler_actions, NoHandler from ._event_broker import extract_handler_actions, NoHandler
from .binding import Bindings, NoBinding from .binding import Bindings, NoBinding
from .css.stylesheet import Stylesheet from .css.stylesheet import Stylesheet
from .css.styles import RenderStyles
from .css.query import NoMatchingNodesError from .css.query import NoMatchingNodesError
from .design import ColorSystem from .design import ColorSystem
from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog
@@ -61,6 +62,7 @@ from .layouts.dock import Dock
from .message_pump import MessagePump from .message_pump import MessagePump
from .reactive import Reactive from .reactive import Reactive
from .renderables.blank import Blank from .renderables.blank import Blank
from .screen import Screen from .screen import Screen
from .widget import Widget from .widget import Widget
@@ -109,9 +111,9 @@ class App(Generic[ReturnType], DOMNode):
"""The base class for Textual Applications""" """The base class for Textual Applications"""
CSS = """ CSS = """
$WIDGET { App {
background: $surface; background: $surface;
color: $text-surface; color: $text-surface;
} }
""" """
@@ -144,6 +146,7 @@ class App(Generic[ReturnType], DOMNode):
# this will create some first references to an asyncio loop. # this will create some first references to an asyncio loop.
_init_uvloop() _init_uvloop()
super().__init__()
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
self.console = Console( self.console = Console(
@@ -206,10 +209,10 @@ class App(Generic[ReturnType], DOMNode):
else None else None
) )
super().__init__() def __init_subclass__(
cls, css_path: str | None = None, inherit_css: bool = True
def __init_subclass__(cls, css_path: str | None = None) -> None: ) -> None:
super().__init_subclass__() super().__init_subclass__(inherit_css=inherit_css)
cls.CSS_PATH = css_path cls.CSS_PATH = css_path
title: Reactive[str] = Reactive("Textual") title: Reactive[str] = Reactive("Textual")
@@ -340,7 +343,15 @@ class App(Generic[ReturnType], DOMNode):
def watch_dark(self, dark: bool) -> None: def watch_dark(self, dark: bool) -> None:
"""Watches the dark bool.""" """Watches the dark bool."""
self.screen.dark = dark self.screen.dark = dark
if dark:
self.add_class("-dark-mode")
self.remove_class("-light-mode")
else:
self.remove_class("-dark-mode")
self.add_class("-light-mode")
self.refresh_css() self.refresh_css()
def get_driver_class(self) -> Type[Driver]: def get_driver_class(self) -> Type[Driver]:
@@ -364,6 +375,18 @@ class App(Generic[ReturnType], DOMNode):
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield "title", self.title yield "title", self.title
yield "id", self.id, None
if self.name:
yield "name", self.name
if self.classes:
yield "classes", set(self.classes)
pseudo_classes = self.pseudo_classes
if pseudo_classes:
yield "pseudo_classes", set(pseudo_classes)
@property
def is_transparent(self) -> bool:
return True
@property @property
def animator(self) -> Animator: def animator(self) -> Animator:
@@ -373,10 +396,6 @@ class App(Generic[ReturnType], DOMNode):
def screen(self) -> Screen: def screen(self) -> Screen:
return self._screen_stack[-1] return self._screen_stack[-1]
@property
def css_type(self) -> str:
return "app"
@property @property
def size(self) -> Size: def size(self) -> Size:
return Size(*self.console.size) return Size(*self.console.size)
@@ -680,10 +699,6 @@ class App(Generic[ReturnType], DOMNode):
error (Exception): An exception instance. error (Exception): An exception instance.
""" """
if "tb" in self.features:
self.fatal_error()
return
if hasattr(error, "__rich__"): if hasattr(error, "__rich__"):
# Exception has a rich method, so we can defer to that for the rendering # Exception has a rich method, so we can defer to that for the rendering
self.panic(error) self.panic(error)
@@ -725,13 +740,8 @@ class App(Generic[ReturnType], DOMNode):
try: try:
if self.css_path is not None: if self.css_path is not None:
self.stylesheet.read(self.css_path) self.stylesheet.read(self.css_path)
if self.CSS is not None: for path, css in self.css:
css_code = string.Template(self.CSS).safe_substitute( self.stylesheet.add_source(css, path=path)
{"WIDGET": self.css_type}
)
self.stylesheet.add_source(
css_code, path=f"<{self.__class__.__name__}>"
)
except Exception as error: except Exception as error:
self.on_exception(error) self.on_exception(error)
self._print_error_renderables() self._print_error_renderables()

View File

@@ -86,8 +86,10 @@ class Selector:
return node.has_pseudo_class(*self.pseudo_classes) return node.has_pseudo_class(*self.pseudo_classes)
def _check_type(self, node: DOMNode) -> bool: def _check_type(self, node: DOMNode) -> bool:
if node.css_type != self._name_lower: if self._name_lower not in node.css_type_names:
return False return False
# if node.css_type != self._name_lower:
# return False
if self.pseudo_classes and not node.has_pseudo_class(*self.pseudo_classes): if self.pseudo_classes and not node.has_pseudo_class(*self.pseudo_classes):
return False return False
return True return True

View File

@@ -49,7 +49,9 @@ class DOMQuery:
if selector is not None: if selector is not None:
selector_set = parse_selectors(selector) selector_set = parse_selectors(selector)
print(selector_set)
self._nodes = [_node for _node in self._nodes if match(selector_set, _node)] self._nodes = [_node for _node in self._nodes if match(selector_set, _node)]
print(self._nodes)
def __len__(self) -> int: def __len__(self) -> int:
return len(self._nodes) return len(self._nodes)

View File

@@ -115,7 +115,7 @@ class StylesheetErrors:
) )
@rich.repr.auto @rich.repr.auto(angular=True)
class Stylesheet: class Stylesheet:
def __init__(self, *, variables: dict[str, str] | None = None) -> None: def __init__(self, *, variables: dict[str, str] | None = None) -> None:
self._rules: list[RuleSet] = [] self._rules: list[RuleSet] = []
@@ -124,7 +124,7 @@ class Stylesheet:
self._require_parse = False self._require_parse = False
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield self.rules yield list(self.source.keys())
@property @property
def rules(self) -> list[RuleSet]: def rules(self) -> list[RuleSet]:

View File

@@ -188,6 +188,8 @@ class ColorSystem:
COLORS = [ COLORS = [
("primary", primary), ("primary", primary),
("secondary", secondary), ("secondary", secondary),
("primary-background", primary),
("secondary-background", secondary),
("background", background), ("background", background),
("panel", panel), ("panel", panel),
("surface", surface), ("surface", surface),
@@ -199,7 +201,7 @@ class ColorSystem:
] ]
# Colors names that have a dark variant # Colors names that have a dark variant
DARK_SHADES = {"primary", "secondary"} DARK_SHADES = {"primary-background", "secondary-background"}
for name, color in COLORS: for name, color in COLORS:
is_dark_shade = dark and name in DARK_SHADES is_dark_shade = dark and name in DARK_SHADES

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Iterable, Iterator, TYPE_CHECKING from inspect import getfile
from typing import ClassVar, Iterable, Iterator, Type, TYPE_CHECKING
import rich.repr import rich.repr
from rich.highlighter import ReprHighlighter from rich.highlighter import ReprHighlighter
@@ -38,8 +39,10 @@ class DOMNode(MessagePump):
""" """
DEFAULT_STYLES = "" CSS: ClassVar[str] = ""
INLINE_STYLES = ""
inherit_css: ClassVar[bool] = True
css_type_names: ClassVar[frozenset[str]] = frozenset()
def __init__( def __init__(
self, self,
@@ -53,14 +56,38 @@ class DOMNode(MessagePump):
self._classes: set[str] = set() if classes is None else set(classes.split()) self._classes: set[str] = set() if classes is None else set(classes.split())
self.children = NodeList() self.children = NodeList()
self._css_styles: Styles = Styles(self) self._css_styles: Styles = Styles(self)
self._inline_styles: Styles = Styles.parse( self._inline_styles: Styles = Styles(self)
self.INLINE_STYLES, repr(self), node=self
)
self.styles = RenderStyles(self, self._css_styles, self._inline_styles) self.styles = RenderStyles(self, self._css_styles, self._inline_styles)
self._default_styles = Styles() self._default_styles = Styles()
self._default_rules = self._default_styles.extract_rules((0, 0, 0)) self._default_rules = self._default_styles.extract_rules((0, 0, 0))
super().__init__() super().__init__()
def __init_subclass__(cls, inherit_css: bool = True) -> None:
super().__init_subclass__()
cls.inherit_css = inherit_css
css_type_names: set[str] = set()
for base in cls._css_bases(cls):
css_type_names.add(base.__name__.lower())
cls.css_type_names = frozenset(css_type_names)
@property
def _node_bases(self) -> Iterator[Type[DOMNode]]:
return self._css_bases(self.__class__)
@classmethod
def _css_bases(cls, base: Type[DOMNode]) -> Iterator[Type[DOMNode]]:
_class = base
while True:
yield _class
if not _class.inherit_css:
break
for _base in _class.__bases__:
if issubclass(_base, DOMNode):
_class = _base
break
else:
break
def on_register(self, app: App) -> None: def on_register(self, app: App) -> None:
"""Called when the widget is registered """Called when the widget is registered
@@ -74,6 +101,25 @@ class DOMNode(MessagePump):
if self._classes: if self._classes:
yield "classes", " ".join(self._classes) yield "classes", " ".join(self._classes)
@property
def css(self) -> list[tuple[str, str]]:
"""Combined CSS from base classes"""
css_stack: list[tuple[str, str]] = []
def get_path(base: Type[DOMNode]) -> str:
try:
return f"{getfile(base)}:{base.__name__}"
except TypeError:
return f"{base.__name__}"
for base in self._node_bases:
css = base.CSS.strip()
if css:
css_stack.append((get_path(base), css))
return css_stack
@property @property
def parent(self) -> DOMNode | None: def parent(self) -> DOMNode | None:
"""Get the parent node. """Get the parent node.
@@ -158,15 +204,6 @@ class DOMNode(MessagePump):
pseudo_classes = frozenset({*self.get_pseudo_classes()}) pseudo_classes = frozenset({*self.get_pseudo_classes()})
return pseudo_classes return pseudo_classes
@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 @property
def css_path_nodes(self) -> list[DOMNode]: def css_path_nodes(self) -> list[DOMNode]:
"""A list of nodes from the root to this node, forming a "path". """A list of nodes from the root to this node, forming a "path".
@@ -238,20 +275,29 @@ class DOMNode(MessagePump):
from rich.console import Group from rich.console import Group
from rich.panel import Panel from rich.panel import Panel
highlighter = ReprHighlighter() from .widget import Widget
tree = Tree(highlighter(repr(self)))
def add_children(tree, node): def render_info(node: DOMNode) -> Columns:
for child in node.children: if isinstance(node, Widget):
info = Columns( info = Columns(
[ [
Pretty(child), Pretty(node),
highlighter(f"region={child.region!r}"), highlighter(f"region={node.region!r}"),
highlighter( highlighter(
f"virtual_size={child.virtual_size!r}", f"virtual_size={node.virtual_size!r}",
), ),
] ]
) )
else:
info = Columns([Pretty(node)])
return info
highlighter = ReprHighlighter()
tree = Tree(render_info(self))
def add_children(tree, node):
for child in node.children:
info = render_info(child)
css = child.styles.css css = child.styles.css
if css: if css:
info = Group( info = Group(

View File

@@ -30,7 +30,7 @@ class Screen(Widget):
"""A widget for the root of the app.""" """A widget for the root of the app."""
CSS = """ CSS = """
$WIDGET { Screen {
layout: vertical; layout: vertical;
overflow-y: auto; overflow-y: auto;
} }

View File

@@ -67,9 +67,6 @@ class RenderCache(NamedTuple):
@rich.repr.auto @rich.repr.auto
class Widget(DOMNode): class Widget(DOMNode):
CSS = """
"""
can_focus: bool = False can_focus: bool = False
can_focus_children: bool = True can_focus_children: bool = True
@@ -177,11 +174,9 @@ class Widget(DOMNode):
Args: Args:
app (App): App instance. app (App): App instance.
""" """
css_code = string.Template(self.CSS).safe_substitute({"WIDGET": self.css_type}) # Parse the Widget's CSS
# Parser the Widget's CSS for path, css in self.css:
self.app.stylesheet.add_source( self.app.stylesheet.add_source(css, path=path)
css_code, f"{__file__}:<{self.__class__.__name__}>"
)
def get_box_model( def get_box_model(
self, container: Size, viewport: Size, fraction_unit: Fraction self, container: Size, viewport: Size, fraction_unit: Fraction
@@ -536,12 +531,16 @@ class Widget(DOMNode):
def scroll_page_left(self, *, animate: bool = True) -> bool: def scroll_page_left(self, *, animate: bool = True) -> bool:
return self.scroll_to( return self.scroll_to(
x=self.scroll_target_x - self.container_size.width, animate=animate x=self.scroll_target_x - self.container_size.width,
animate=animate,
duration=0.3,
) )
def scroll_page_right(self, *, animate: bool = True) -> bool: def scroll_page_right(self, *, animate: bool = True) -> bool:
return self.scroll_to( return self.scroll_to(
x=self.scroll_target_x + self.container_size.width, animate=animate x=self.scroll_target_x + self.container_size.width,
animate=animate,
duration=0.3,
) )
def scroll_to_widget(self, widget: Widget, *, animate: bool = True) -> bool: def scroll_to_widget(self, widget: Widget, *, animate: bool = True) -> bool:
@@ -589,9 +588,12 @@ class Widget(DOMNode):
) )
def __init_subclass__( def __init_subclass__(
cls, can_focus: bool = True, can_focus_children: bool = True cls,
can_focus: bool = True,
can_focus_children: bool = True,
inherit_css: bool = True,
) -> None: ) -> None:
super().__init_subclass__() super().__init_subclass__(inherit_css=inherit_css)
cls.can_focus = can_focus cls.can_focus = can_focus
cls.can_focus_children = can_focus_children cls.can_focus_children = can_focus_children
@@ -1019,7 +1021,7 @@ class Widget(DOMNode):
def handle_scroll_to(self, message: ScrollTo) -> None: def handle_scroll_to(self, message: ScrollTo) -> None:
if self.is_container: if self.is_container:
self.scroll_to(message.x, message.y, animate=message.animate) self.scroll_to(message.x, message.y, animate=message.animate, duration=0.1)
message.stop() message.stop()
def handle_scroll_up(self, event: ScrollUp) -> None: def handle_scroll_up(self, event: ScrollUp) -> None:

View File

@@ -17,25 +17,38 @@ class Button(Widget, can_focus=True):
CSS = """ CSS = """
$WIDGET {
Button {
width: auto; width: auto;
height: 3; height: 3;
background: $primary; background: $primary;
color: $text-primary; color: $text-primary;
content-align: center middle; content-align: center middle;
border: tall $primary-lighten-3; border: tall $primary-lighten-3;
margin: 1 0; margin: 1 0;
align: center middle; align: center middle;
text-style: bold; text-style: bold;
} }
$WIDGET:hover { .-dark-mode Button {
border: tall white $primary-lighten-2;
color: $primary-lighten-2;
background: $background;
}
.-dark-mode Button:hover {
background: $surface;
}
Button:hover {
background:$primary-darken-2; background:$primary-darken-2;
color: $text-primary-darken-2; color: $text-primary-darken-2;
border: tall $primary-lighten-1; border: tall $primary-lighten-1;
} }
App.-show-focus $WIDGET:focus { App.-show-focus Button:focus {
tint: $accent 20%; tint: $accent 20%;
} }

View File

@@ -191,8 +191,6 @@ class Tabs(Widget):
that character. that character.
""" """
DEFAULT_STYLES = "height: 2;"
_active_tab_name: Reactive[str | None] = Reactive("") _active_tab_name: Reactive[str | None] = Reactive("")
_bar_offset: Reactive[float] = Reactive(0.0) _bar_offset: Reactive[float] = Reactive(0.0)