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):
CSS = """
Button {
width: 100%;
}
"""
def compose(self):
yield Button("Light", id="light")
yield Button("Dark", id="dark")
yield Button("Lights off")
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-color: $system;
scrollbar-color-active: $accent-darken-1;
scrollbar-size-horizontal: 1;
scrollbar-size-vertical: 2;
}
App > Screen {
layout: dock;
docks: side=left/1;
background: $surface;
color: $text-surface;
color: $text-surface;
}
#sidebar {
color: $text-primary;
background: $primary;
background: $primary-background;
dock: side;
width: 30;
offset-x: -100%;
@@ -37,7 +39,7 @@ App > Screen {
#sidebar .title {
height: 3;
background: $primary-darken-2;
background: $primary-background-darken-2;
color: $text-primary-darken-2 ;
border-right: outer $primary-darken-3;
content-align: center middle;
@@ -45,16 +47,16 @@ App > Screen {
#sidebar .user {
height: 8;
background: $primary-darken-1;
background: $primary-background-darken-1;
color: $text-primary-darken-1;
border-right: outer $primary-darken-3;
border-right: outer $primary-background-darken-3;
content-align: center middle;
}
#sidebar .content {
background: $primary;
color: $text-primary;
border-right: outer $primary-darken-3;
background: $primary-background;
color: $text-primary-background;
border-right: outer $primary-background-darken-3;
content-align: center middle;
}
@@ -90,14 +92,6 @@ Tweet {
box-sizing: border-box;
}
Tweet.scrollbar-size-custom {
scrollbar-size-vertical: 2;
}
Tweet.scroll-horizontal {
scrollbar-size-horizontal: 2;
}
.scrollable {
width: 80;
@@ -178,8 +172,8 @@ Tweet.scroll-horizontal TweetBody {
OptionItem {
height: 3;
background: $primary;
border-right: outer $primary-darken-2;
background: $primary-background;
border-right: outer $primary-background-darken-2;
border-left: blank;
content-align: center middle;
}
@@ -187,7 +181,7 @@ OptionItem {
OptionItem:hover {
height: 3;
color: $accent;
background: $primary-darken-1;
background: $primary-background-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
text-style: bold;

View File

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

View File

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

View File

@@ -86,8 +86,10 @@ class Selector:
return node.has_pseudo_class(*self.pseudo_classes)
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
# if node.css_type != self._name_lower:
# return False
if self.pseudo_classes and not node.has_pseudo_class(*self.pseudo_classes):
return False
return True

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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
from rich.highlighter import ReprHighlighter
@@ -38,8 +39,10 @@ class DOMNode(MessagePump):
"""
DEFAULT_STYLES = ""
INLINE_STYLES = ""
CSS: ClassVar[str] = ""
inherit_css: ClassVar[bool] = True
css_type_names: ClassVar[frozenset[str]] = frozenset()
def __init__(
self,
@@ -53,14 +56,38 @@ class DOMNode(MessagePump):
self._classes: set[str] = set() if classes is None else set(classes.split())
self.children = NodeList()
self._css_styles: Styles = Styles(self)
self._inline_styles: Styles = Styles.parse(
self.INLINE_STYLES, repr(self), node=self
)
self._inline_styles: Styles = Styles(self)
self.styles = RenderStyles(self, self._css_styles, self._inline_styles)
self._default_styles = Styles()
self._default_rules = self._default_styles.extract_rules((0, 0, 0))
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:
"""Called when the widget is registered
@@ -74,6 +101,25 @@ class DOMNode(MessagePump):
if 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
def parent(self) -> DOMNode | None:
"""Get the parent node.
@@ -158,15 +204,6 @@ class DOMNode(MessagePump):
pseudo_classes = frozenset({*self.get_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
def css_path_nodes(self) -> list[DOMNode]:
"""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.panel import Panel
highlighter = ReprHighlighter()
tree = Tree(highlighter(repr(self)))
from .widget import Widget
def add_children(tree, node):
for child in node.children:
def render_info(node: DOMNode) -> Columns:
if isinstance(node, Widget):
info = Columns(
[
Pretty(child),
highlighter(f"region={child.region!r}"),
Pretty(node),
highlighter(f"region={node.region!r}"),
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
if css:
info = Group(

View File

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

View File

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

View File

@@ -17,25 +17,38 @@ class Button(Widget, can_focus=True):
CSS = """
$WIDGET {
Button {
width: auto;
height: 3;
background: $primary;
color: $text-primary;
content-align: center middle;
border: tall $primary-lighten-3;
margin: 1 0;
align: center middle;
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;
color: $text-primary-darken-2;
border: tall $primary-lighten-1;
}
App.-show-focus $WIDGET:focus {
App.-show-focus Button:focus {
tint: $accent 20%;
}

View File

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