mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
CSS inheritance
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
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 {
|
||||
@@ -23,7 +25,7 @@ App > Screen {
|
||||
|
||||
#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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +111,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
"""The base class for Textual Applications"""
|
||||
|
||||
CSS = """
|
||||
$WIDGET {
|
||||
App {
|
||||
background: $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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -30,7 +30,7 @@ class Screen(Widget):
|
||||
"""A widget for the root of the app."""
|
||||
|
||||
CSS = """
|
||||
$WIDGET {
|
||||
Screen {
|
||||
layout: vertical;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user