mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.2.0] - Unreleased
|
||||
|
||||
## [0.1.15] - 2022-01-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -41,6 +41,7 @@ App > Screen {
|
||||
background: $primary-darken-2;
|
||||
color: $text-primary-darken-2 ;
|
||||
border-right: outer $primary-darken-3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#sidebar .user {
|
||||
@@ -48,19 +49,21 @@ App > Screen {
|
||||
background: $primary-darken-1;
|
||||
color: $text-primary-darken-1;
|
||||
border-right: outer $primary-darken-3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#sidebar .content {
|
||||
background: $primary;
|
||||
color: $text-primary;
|
||||
border-right: outer $primary-darken-3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#header {
|
||||
color: $text-primary-darken-1;
|
||||
background: $primary-darken-1;
|
||||
height: 3
|
||||
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#content {
|
||||
@@ -84,6 +87,7 @@ Tweet {
|
||||
border: wide $panel-darken-2;
|
||||
overflow-y: scroll;
|
||||
align-horizontal: center;
|
||||
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
@@ -152,6 +156,7 @@ TweetBody {
|
||||
background: $accent;
|
||||
height: 1;
|
||||
border-top: hkey $accent-darken-2;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +170,7 @@ OptionItem {
|
||||
transition: background 100ms linear;
|
||||
border-right: outer $primary-darken-2;
|
||||
border-left: hidden;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
OptionItem:hover {
|
||||
|
||||
@@ -66,7 +66,7 @@ class Tweet(Widget):
|
||||
|
||||
class OptionItem(Widget):
|
||||
def render(self) -> Text:
|
||||
return Align.center(Text("Option", justify="center"), vertical="middle")
|
||||
return Text("Option")
|
||||
|
||||
|
||||
class Error(Widget):
|
||||
@@ -95,10 +95,9 @@ class BasicApp(App):
|
||||
"""Build layout here."""
|
||||
self.mount(
|
||||
header=Static(
|
||||
Align.center(
|
||||
"[b]This is a [u]Textual[/u] app, running in the terminal",
|
||||
vertical="middle",
|
||||
)
|
||||
Text.from_markup(
|
||||
"[b]This is a [u]Textual[/u] app, running in the terminal"
|
||||
),
|
||||
),
|
||||
content=Widget(
|
||||
Tweet(
|
||||
@@ -110,8 +109,8 @@ class BasicApp(App):
|
||||
# ),
|
||||
),
|
||||
Widget(
|
||||
Static(Syntax(CODE, "python"), classes={"code"}),
|
||||
classes={"scrollable"},
|
||||
Static(Syntax(CODE, "python"), classes="code"),
|
||||
classes="scrollable",
|
||||
),
|
||||
Error(),
|
||||
Tweet(TweetBody()),
|
||||
@@ -121,12 +120,12 @@ class BasicApp(App):
|
||||
),
|
||||
footer=Widget(),
|
||||
sidebar=Widget(
|
||||
Widget(classes={"title"}),
|
||||
Widget(classes={"user"}),
|
||||
Widget(classes="title"),
|
||||
Widget(classes="user"),
|
||||
OptionItem(),
|
||||
OptionItem(),
|
||||
OptionItem(),
|
||||
Widget(classes={"content"}),
|
||||
Widget(classes="content"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -140,4 +139,7 @@ class BasicApp(App):
|
||||
self.panic(self.tree)
|
||||
|
||||
|
||||
BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log")
|
||||
app = BasicApp(css_file="basic.css", watch_css=True, log="textual.log")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
||||
0
sandbox/buttons.css
Normal file
0
sandbox/buttons.css
Normal file
24
sandbox/buttons.py
Normal file
24
sandbox/buttons.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
from textual.widgets import Button
|
||||
from textual import layout
|
||||
|
||||
|
||||
class ButtonsApp(App[str]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield layout.Vertical(
|
||||
Button("foo", id="foo"),
|
||||
Button("bar", id="bar"),
|
||||
Button("baz", id="baz"),
|
||||
)
|
||||
|
||||
def handle_pressed(self, event: Button.Pressed) -> None:
|
||||
self.app.bell()
|
||||
self.exit(event.button.id)
|
||||
|
||||
|
||||
app = ButtonsApp(log="textual.log")
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = app.run()
|
||||
print(repr(result))
|
||||
@@ -1,7 +1,7 @@
|
||||
#uber1 {
|
||||
layout: vertical;
|
||||
|
||||
background: dark_green;
|
||||
background: green;
|
||||
overflow: hidden auto;
|
||||
border: heavy white;
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ class BasicApp(App):
|
||||
Widget(id="uber2-child2"),
|
||||
)
|
||||
uber1 = Widget(
|
||||
Placeholder(id="child1", classes={"list-item"}),
|
||||
Placeholder(id="child2", classes={"list-item"}),
|
||||
Placeholder(id="child3", classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(id="child1", classes="list-item"),
|
||||
Placeholder(id="child2", classes="list-item"),
|
||||
Placeholder(id="child3", classes="list-item"),
|
||||
Placeholder(classes="list-item"),
|
||||
Placeholder(classes="list-item"),
|
||||
Placeholder(classes="list-item"),
|
||||
)
|
||||
self.mount(uber1=uber1)
|
||||
|
||||
@@ -37,7 +37,7 @@ class BasicApp(App):
|
||||
await self.dispatch_key(event)
|
||||
|
||||
def action_quit(self):
|
||||
self.panic(self.screen.tree)
|
||||
self.panic(self.app.tree)
|
||||
|
||||
def action_dump(self):
|
||||
self.panic(str(self.app.registry))
|
||||
@@ -56,4 +56,7 @@ class BasicApp(App):
|
||||
sys.stdout.write("abcdef")
|
||||
|
||||
|
||||
BasicApp.run(css_file="uber.css", log="textual.log", log_verbosity=1)
|
||||
app = BasicApp(css_file="uber.css", log="textual.log", log_verbosity=1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ANSI_COLOR_TO_RGB: dict[str, tuple[int, int, int]] = {
|
||||
COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int]] = {
|
||||
"black": (0, 0, 0),
|
||||
"red": (128, 0, 0),
|
||||
"green": (0, 128, 0),
|
||||
|
||||
40
src/textual/_layout.py
Normal file
40
src/textual/_layout.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import ClassVar, NamedTuple, TYPE_CHECKING
|
||||
|
||||
|
||||
from .geometry import Region, Offset, Size
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class WidgetPlacement(NamedTuple):
|
||||
"""The position, size, and relative order of a widget within its parent."""
|
||||
|
||||
region: Region
|
||||
widget: Widget | None = None # A widget of None means empty space
|
||||
order: int = 0
|
||||
|
||||
|
||||
class Layout(ABC):
|
||||
"""Responsible for arranging Widgets in a view and rendering them."""
|
||||
|
||||
name: ClassVar[str] = ""
|
||||
|
||||
@abstractmethod
|
||||
def arrange(
|
||||
self, parent: Widget, size: Size, scroll: Offset
|
||||
) -> tuple[list[WidgetPlacement], set[Widget]]:
|
||||
"""Generate a layout map that defines where on the screen the widgets will be drawn.
|
||||
|
||||
Args:
|
||||
parent (Widget): Parent widget.
|
||||
size (Size): Size of container.
|
||||
scroll (Offset): Offset to apply to the Widget placements.
|
||||
|
||||
Returns:
|
||||
Iterable[WidgetPlacement]: An iterable of widget location
|
||||
"""
|
||||
@@ -9,7 +9,7 @@ import warnings
|
||||
from asyncio import AbstractEventLoop
|
||||
from contextlib import redirect_stdout
|
||||
from time import perf_counter
|
||||
from typing import Any, Iterable, TextIO, Type, TYPE_CHECKING
|
||||
from typing import Any, Generic, Iterable, TextIO, Type, TypeVar, TYPE_CHECKING
|
||||
|
||||
import rich
|
||||
import rich.repr
|
||||
@@ -67,6 +67,8 @@ DEFAULT_COLORS = ColorSystem(
|
||||
dark_surface="#292929",
|
||||
)
|
||||
|
||||
ComposeResult = Iterable[Widget]
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
pass
|
||||
@@ -76,8 +78,11 @@ class ActionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
ReturnType = TypeVar("ReturnType")
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class App(DOMNode):
|
||||
class App(Generic[ReturnType], DOMNode):
|
||||
"""The base class for Textual Applications"""
|
||||
|
||||
css = ""
|
||||
@@ -159,6 +164,8 @@ class App(DOMNode):
|
||||
|
||||
self.devtools = DevtoolsClient()
|
||||
|
||||
self._return_value: ReturnType | None = None
|
||||
|
||||
super().__init__()
|
||||
|
||||
title: Reactive[str] = Reactive("Textual")
|
||||
@@ -166,6 +173,20 @@ class App(DOMNode):
|
||||
background: Reactive[str] = Reactive("black")
|
||||
dark = Reactive(False)
|
||||
|
||||
def exit(self, result: ReturnType | None = None) -> None:
|
||||
"""Exit the app, and return the supplied result.
|
||||
|
||||
Args:
|
||||
result (ReturnType | None, optional): Return value. Defaults to None.
|
||||
"""
|
||||
self._return_value = result
|
||||
self.close_messages_no_wait()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Yield child widgets for a container."""
|
||||
return
|
||||
yield
|
||||
|
||||
def get_css_variables(self) -> dict[str, str]:
|
||||
"""Get a mapping of variables used to pre-populate CSS.
|
||||
|
||||
@@ -284,27 +305,9 @@ class App(DOMNode):
|
||||
keys, action, description, show=show, key_display=key_display
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def run(
|
||||
cls,
|
||||
console: Console | None = None,
|
||||
screen: bool = True,
|
||||
driver: Type[Driver] | None = None,
|
||||
loop: AbstractEventLoop | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Run the app.
|
||||
|
||||
Args:
|
||||
console (Console, optional): Console object. Defaults to None.
|
||||
screen (bool, optional): Enable application mode. Defaults to True.
|
||||
driver (Type[Driver], optional): Driver class or None for default. Defaults to None.
|
||||
loop (AbstractEventLoop): Event loop to run the application on. If not specified, uvloop will be used.
|
||||
"""
|
||||
|
||||
def run(self, loop: AbstractEventLoop | None = None) -> ReturnType | None:
|
||||
async def run_app() -> None:
|
||||
app = cls(screen=screen, driver_class=driver, **kwargs)
|
||||
await app.process_messages()
|
||||
await self.process_messages()
|
||||
|
||||
if loop:
|
||||
asyncio.set_event_loop(loop)
|
||||
@@ -322,13 +325,15 @@ class App(DOMNode):
|
||||
finally:
|
||||
event_loop.close()
|
||||
|
||||
return self._return_value
|
||||
|
||||
async def _on_css_change(self) -> None:
|
||||
"""Called when the CSS changes (if watch_css is True)."""
|
||||
if self.css_file is not None:
|
||||
stylesheet = Stylesheet(variables=self.get_css_variables())
|
||||
|
||||
try:
|
||||
time = perf_counter()
|
||||
stylesheet.read(self.css_file)
|
||||
self.stylesheet.read(self.css_file)
|
||||
elapsed = (perf_counter() - time) * 1000
|
||||
self.log(f"loaded {self.css_file} in {elapsed:.0f}ms")
|
||||
except Exception as error:
|
||||
@@ -337,10 +342,12 @@ class App(DOMNode):
|
||||
self.log(error)
|
||||
else:
|
||||
self.reset_styles()
|
||||
self.stylesheet = stylesheet
|
||||
self.stylesheet.update(self)
|
||||
self.screen.refresh(layout=True)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return ""
|
||||
|
||||
def query(self, selector: str | None = None) -> DOMQuery:
|
||||
"""Get a DOM query in the current screen.
|
||||
|
||||
@@ -498,7 +505,9 @@ class App(DOMNode):
|
||||
if self.css_file is not None:
|
||||
self.stylesheet.read(self.css_file)
|
||||
if self.css is not None:
|
||||
self.stylesheet.parse(self.css, path=f"<{self.__class__.__name__}>")
|
||||
self.stylesheet.add_source(
|
||||
self.css, path=f"<{self.__class__.__name__}>"
|
||||
)
|
||||
except Exception as error:
|
||||
self.on_exception(error)
|
||||
self._print_error_renderables()
|
||||
@@ -548,6 +557,12 @@ class App(DOMNode):
|
||||
if self._log_file is not None:
|
||||
self._log_file.close()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
widgets = list(self.compose())
|
||||
if widgets:
|
||||
self.mount(*widgets)
|
||||
self.screen.refresh()
|
||||
|
||||
async def on_idle(self) -> None:
|
||||
"""Perform actions when there are no messages in the queue."""
|
||||
if self._require_styles_update:
|
||||
@@ -559,6 +574,7 @@ class App(DOMNode):
|
||||
parent.children._append(child)
|
||||
self.registry.add(child)
|
||||
child.set_parent(parent)
|
||||
child.on_register(self)
|
||||
child.start_messages()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -24,7 +24,7 @@ from rich.style import Style
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
from ._color_constants import ANSI_COLOR_TO_RGB
|
||||
from ._color_constants import COLOR_NAME_TO_RGB
|
||||
from .geometry import clamp
|
||||
|
||||
|
||||
@@ -123,6 +123,11 @@ class Color(NamedTuple):
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_transparent(self) -> bool:
|
||||
"""Check if the color is transparent, i.e. has 0 alpha."""
|
||||
return self.a == 0
|
||||
|
||||
@property
|
||||
def clamped(self) -> Color:
|
||||
"""Get a color with all components saturated to maximum and minimum values."""
|
||||
@@ -253,7 +258,9 @@ class Color(NamedTuple):
|
||||
"""
|
||||
if isinstance(color_text, Color):
|
||||
return color_text
|
||||
ansi_color = ANSI_COLOR_TO_RGB.get(color_text)
|
||||
if color_text == "transparent":
|
||||
return TRANSPARENT
|
||||
ansi_color = COLOR_NAME_TO_RGB.get(color_text)
|
||||
if ansi_color is not None:
|
||||
return cls(*ansi_color)
|
||||
color_match = RE_COLOR.match(color_text)
|
||||
@@ -329,6 +336,7 @@ class Color(NamedTuple):
|
||||
# Color constants
|
||||
WHITE = Color(255, 255, 255)
|
||||
BLACK = Color(0, 0, 0)
|
||||
TRANSPARENT = Color(0, 0, 0, 0)
|
||||
|
||||
|
||||
class ColorPair(NamedTuple):
|
||||
|
||||
@@ -31,7 +31,7 @@ from .transition import Transition
|
||||
from ..geometry import Spacing, SpacingDimensions, clamp
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..layout import Layout
|
||||
from .._layout import Layout
|
||||
from .styles import DockGroup, Styles, StylesBase
|
||||
|
||||
|
||||
|
||||
@@ -621,13 +621,18 @@ class StylesBuilder:
|
||||
f"invalid token {token_vertical!r}, expected {friendly_list(VALID_ALIGN_VERTICAL)}",
|
||||
)
|
||||
|
||||
self.styles._rules["align_horizontal"] = token_horizontal.value
|
||||
self.styles._rules["align_vertical"] = token_vertical.value
|
||||
name = name.replace("-", "_")
|
||||
self.styles._rules[f"{name}_horizontal"] = token_horizontal.value
|
||||
self.styles._rules[f"{name}_vertical"] = token_vertical.value
|
||||
|
||||
def process_align_horizontal(self, name: str, tokens: list[Token]) -> None:
|
||||
value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL)
|
||||
self.styles._rules["align_horizontal"] = value
|
||||
self.styles._rules[name.replace("-", "_")] = value
|
||||
|
||||
def process_align_vertical(self, name: str, tokens: list[Token]) -> None:
|
||||
value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL)
|
||||
self.styles._rules["align_vertical"] = value
|
||||
self.styles._rules[name.replace("-", "_")] = value
|
||||
|
||||
process_content_align = process_align
|
||||
process_content_align_horizontal = process_align_horizontal
|
||||
process_content_align_vertical = process_align_vertical
|
||||
|
||||
@@ -340,7 +340,7 @@ if __name__ == "__main__":
|
||||
console = Console()
|
||||
stylesheet = Stylesheet()
|
||||
try:
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
except StylesheetParseError as e:
|
||||
console.print(e.errors)
|
||||
print(stylesheet)
|
||||
|
||||
@@ -13,7 +13,7 @@ from rich.style import Style
|
||||
from .. import log
|
||||
from .._animator import Animation, EasingFunction
|
||||
from ..color import Color
|
||||
from ..geometry import Offset, Size, Spacing
|
||||
from ..geometry import Spacing
|
||||
from ._style_properties import (
|
||||
BorderProperty,
|
||||
BoxProperty,
|
||||
@@ -64,7 +64,7 @@ else:
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..dom import DOMNode
|
||||
from ..layout import Layout
|
||||
from .._layout import Layout
|
||||
|
||||
|
||||
class RulesMap(TypedDict, total=False):
|
||||
@@ -130,6 +130,9 @@ class RulesMap(TypedDict, total=False):
|
||||
align_horizontal: AlignHorizontal
|
||||
align_vertical: AlignVertical
|
||||
|
||||
content_align_horizontal: AlignHorizontal
|
||||
content_align_vertical: AlignVertical
|
||||
|
||||
|
||||
RULE_NAMES = list(RulesMap.__annotations__.keys())
|
||||
RULE_NAMES_SET = frozenset(RULE_NAMES)
|
||||
@@ -170,7 +173,7 @@ class StylesBase(ABC):
|
||||
layout = LayoutProperty()
|
||||
|
||||
color = ColorProperty(Color(255, 255, 255))
|
||||
background = ColorProperty(Color(0, 0, 0))
|
||||
background = ColorProperty(Color(0, 0, 0, 0))
|
||||
text_style = StyleFlagsProperty()
|
||||
|
||||
opacity = FractionalProperty()
|
||||
@@ -222,6 +225,9 @@ class StylesBase(ABC):
|
||||
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
||||
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||
|
||||
content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
||||
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||
|
||||
def __eq__(self, styles: object) -> bool:
|
||||
"""Check that Styles containts the same rules."""
|
||||
if not isinstance(styles, StylesBase):
|
||||
@@ -677,6 +683,18 @@ class Styles(StylesBase):
|
||||
elif has_rule("align_horizontal"):
|
||||
append_declaration("align-vertical", self.align_vertical)
|
||||
|
||||
if has_rule("content_align_horizontal") and has_rule("content_align_vertical"):
|
||||
append_declaration(
|
||||
"content-align",
|
||||
f"{self.content_align_horizontal} {self.content_align_vertical}",
|
||||
)
|
||||
elif has_rule("content_align_horizontal"):
|
||||
append_declaration(
|
||||
"content-align-horizontal", self.content_align_horizontal
|
||||
)
|
||||
elif has_rule("content_align_horizontal"):
|
||||
append_declaration("content-align-vertical", self.content_align_vertical)
|
||||
|
||||
lines.sort()
|
||||
return lines
|
||||
|
||||
|
||||
@@ -101,21 +101,25 @@ class StylesheetErrors:
|
||||
@rich.repr.auto
|
||||
class Stylesheet:
|
||||
def __init__(self, *, variables: dict[str, str] | None = None) -> None:
|
||||
self.rules: list[RuleSet] = []
|
||||
self._rules: list[RuleSet] = []
|
||||
self.variables = variables or {}
|
||||
self.source: list[tuple[str, str]] = []
|
||||
self.source: dict[str, str] = {}
|
||||
self._require_parse = False
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.rules
|
||||
|
||||
@property
|
||||
def css(self) -> str:
|
||||
return "\n\n".join(rule_set.css for rule_set in self.rules)
|
||||
def rules(self) -> list[RuleSet]:
|
||||
if self._require_parse:
|
||||
self.parse()
|
||||
self._require_parse = False
|
||||
assert self._rules is not None
|
||||
return self._rules
|
||||
|
||||
@property
|
||||
def any_errors(self) -> bool:
|
||||
"""Check if there are any errors."""
|
||||
return any(rule.errors for rule in self.rules)
|
||||
def css(self) -> str:
|
||||
return "\n\n".join(rule_set.css for rule_set in self.rules)
|
||||
|
||||
@property
|
||||
def error_renderable(self) -> StylesheetErrors:
|
||||
@@ -129,6 +133,28 @@ class Stylesheet:
|
||||
"""
|
||||
self.variables = variables
|
||||
|
||||
def _parse_rules(self, css: str, path: str) -> list[RuleSet]:
|
||||
"""Parse CSS and return rules.
|
||||
|
||||
|
||||
Args:
|
||||
css (str): String containing Textual CSS.
|
||||
path (str): Path to CSS or unique identifier
|
||||
|
||||
Raises:
|
||||
StylesheetError: If the CSS is invalid.
|
||||
|
||||
Returns:
|
||||
list[RuleSet]: List of RuleSets.
|
||||
"""
|
||||
try:
|
||||
rules = list(parse(css, path, variables=self.variables))
|
||||
except TokenizeError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise StylesheetError(f"failed to parse css; {error}")
|
||||
return rules
|
||||
|
||||
def read(self, filename: str) -> None:
|
||||
"""Read Textual CSS file.
|
||||
|
||||
@@ -146,19 +172,10 @@ class Stylesheet:
|
||||
path = os.path.abspath(filename)
|
||||
except Exception as error:
|
||||
raise StylesheetError(f"unable to read {filename!r}; {error}")
|
||||
try:
|
||||
rules = list(parse(css, path, variables=self.variables))
|
||||
except TokenizeError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise StylesheetError(f"failed to parse {filename!r}; {error!r}")
|
||||
else:
|
||||
self.source.append((css, path))
|
||||
self.rules.extend(rules)
|
||||
if self.any_errors:
|
||||
raise StylesheetParseError(self.error_renderable)
|
||||
self.source[path] = css
|
||||
self._require_parse = True
|
||||
|
||||
def parse(self, css: str, *, path: str = "") -> None:
|
||||
def add_source(self, css: str, path: str | None = None) -> None:
|
||||
"""Parse CSS from a string.
|
||||
|
||||
Args:
|
||||
@@ -169,26 +186,31 @@ class Stylesheet:
|
||||
StylesheetError: If the CSS could not be read.
|
||||
StylesheetParseError: If the CSS is invalid.
|
||||
"""
|
||||
try:
|
||||
rules = list(parse(css, path, variables=self.variables))
|
||||
except TokenizeError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise StylesheetError(f"failed to parse css; {error}")
|
||||
else:
|
||||
self.source.append((css, path))
|
||||
self.rules.extend(rules)
|
||||
if self.any_errors:
|
||||
raise StylesheetParseError(self.error_renderable)
|
||||
|
||||
def _clone(self, stylesheet: Stylesheet) -> None:
|
||||
"""Replace this stylesheet contents with another.
|
||||
if path is None:
|
||||
path = str(hash(css))
|
||||
if path in self.source and self.source[path] == css:
|
||||
# Path already in source, and CSS is identical
|
||||
return
|
||||
|
||||
Args:
|
||||
stylesheet (Stylesheet): A Stylesheet.
|
||||
self.source[path] = css
|
||||
self._require_parse = True
|
||||
|
||||
def parse(self) -> None:
|
||||
"""Parse the source in the stylesheet.
|
||||
|
||||
Raises:
|
||||
StylesheetParseError: If there are any CSS related errors.
|
||||
"""
|
||||
self.rules = stylesheet.rules.copy()
|
||||
self.source = stylesheet.source.copy()
|
||||
rules: list[RuleSet] = []
|
||||
add_rules = rules.extend
|
||||
for path, css in self.source.items():
|
||||
css_rules = self._parse_rules(css, path)
|
||||
if any(rule.errors for rule in css_rules):
|
||||
raise StylesheetParseError(self.error_renderable)
|
||||
add_rules(css_rules)
|
||||
self._rules = rules
|
||||
self._require_parse = False
|
||||
|
||||
def reparse(self) -> None:
|
||||
"""Re-parse source, applying new variables.
|
||||
@@ -200,9 +222,11 @@ class Stylesheet:
|
||||
"""
|
||||
# Do this in a fresh Stylesheet so if there are errors we don't break self.
|
||||
stylesheet = Stylesheet(variables=self.variables)
|
||||
for css, path in self.source:
|
||||
stylesheet.parse(css, path=path)
|
||||
self._clone(stylesheet)
|
||||
for path, css in self.source.items():
|
||||
stylesheet.add_source(css, path)
|
||||
stylesheet.parse()
|
||||
self.rules = stylesheet.rules
|
||||
self.source = stylesheet.source
|
||||
|
||||
@classmethod
|
||||
def _check_rule(cls, rule: RuleSet, node: DOMNode) -> Iterable[Specificity3]:
|
||||
@@ -401,7 +425,7 @@ if __name__ == "__main__":
|
||||
"""
|
||||
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(CSS)
|
||||
stylesheet.add_source(CSS)
|
||||
|
||||
print(stylesheet.css)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
from ._node_list import NodeList
|
||||
from .color import Color
|
||||
from .css._error_tools import friendly_list
|
||||
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
|
||||
from .css.errors import StyleValueError
|
||||
@@ -19,6 +20,7 @@ from .css.query import NoMatchingNodesError
|
||||
from .message_pump import MessagePump
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
from .css.query import DOMQuery
|
||||
from .screen import Screen
|
||||
|
||||
@@ -40,42 +42,44 @@ class DOMNode(MessagePump):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: set[str] | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
self._name = name
|
||||
self._id = id
|
||||
self._classes: set[str] = set() if classes is None else classes
|
||||
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.styles = RenderStyles(self, self._css_styles, self._inline_styles)
|
||||
self._default_styles = Styles.parse(self.DEFAULT_STYLES, f"{self.__class__}")
|
||||
self._default_styles = Styles()
|
||||
self._default_rules = self._default_styles.extract_rules((0, 0, 0))
|
||||
super().__init__()
|
||||
|
||||
def on_register(self, app: App) -> None:
|
||||
"""Called when the widget is registered
|
||||
|
||||
Args:
|
||||
app (App): Parent application.
|
||||
"""
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "name", self._name, None
|
||||
yield "id", self._id, None
|
||||
if self._classes:
|
||||
yield "classes", self._classes
|
||||
yield "classes", " ".join(self._classes)
|
||||
|
||||
@property
|
||||
def parent(self) -> DOMNode:
|
||||
def parent(self) -> DOMNode | None:
|
||||
"""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)
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
@@ -222,19 +226,6 @@ class DOMNode(MessagePump):
|
||||
f"expected {friendly_list(VALID_VISIBILITY)})"
|
||||
)
|
||||
|
||||
@property
|
||||
def rich_text_style(self) -> Style:
|
||||
"""Get the text style (added to parent style).
|
||||
|
||||
Returns:
|
||||
Style: Rich Style object.
|
||||
"""
|
||||
return (
|
||||
self.parent.rich_text_style + self.styles.rich_style
|
||||
if self.has_parent
|
||||
else self.styles.rich_style
|
||||
)
|
||||
|
||||
@property
|
||||
def tree(self) -> Tree:
|
||||
"""Get a Rich tree object which will recursively render the structure of the node tree.
|
||||
@@ -278,6 +269,48 @@ class DOMNode(MessagePump):
|
||||
add_children(tree, self)
|
||||
return tree
|
||||
|
||||
@property
|
||||
def rich_text_style(self) -> Style:
|
||||
"""Get the text style object.
|
||||
|
||||
A widget's style is influenced by its parent. For instance if a widgets background has an alpha,
|
||||
then its parent's background color will show through. Additionally, widgets will inherit their
|
||||
parent's text style (i.e. bold, italic etc).
|
||||
|
||||
Returns:
|
||||
Style: Rich Style object.
|
||||
"""
|
||||
|
||||
# TODO: Feels like there may be opportunity for caching here.
|
||||
|
||||
background = Color(0, 0, 0, 0)
|
||||
color = Color(255, 255, 255, 0)
|
||||
style = Style()
|
||||
for node in reversed(self.ancestors):
|
||||
styles = node.styles
|
||||
if styles.has_rule("background"):
|
||||
background += styles.background
|
||||
if styles.has_rule("color"):
|
||||
color = styles.color
|
||||
style += styles.text_style
|
||||
|
||||
style = Style(bgcolor=background.rich_color, color=color.rich_color) + style
|
||||
return style
|
||||
|
||||
@property
|
||||
def ancestors(self) -> list[DOMNode]:
|
||||
"""Get a list of Nodes by tracing ancestors all the way back to App."""
|
||||
|
||||
nodes: list[DOMNode] = [self]
|
||||
add_node = nodes.append
|
||||
node = self
|
||||
while True:
|
||||
node = node.parent
|
||||
if node is None:
|
||||
break
|
||||
add_node(node)
|
||||
return nodes
|
||||
|
||||
@property
|
||||
def displayed_children(self) -> list[DOMNode]:
|
||||
return [child for child in self.children if child.display]
|
||||
|
||||
@@ -1,41 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import ClassVar, NamedTuple, TYPE_CHECKING
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
from .geometry import Region, Offset, Size
|
||||
class Vertical(Widget):
|
||||
"""A container widget to align children vertically."""
|
||||
|
||||
CSS = """
|
||||
Vertical {
|
||||
layout: vertical;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .widget import Widget
|
||||
from .screen import Screen
|
||||
class Horizontal(Widget):
|
||||
"""A container widget to align children horizontally."""
|
||||
|
||||
|
||||
class WidgetPlacement(NamedTuple):
|
||||
"""The position, size, and relative order of a widget within its parent."""
|
||||
|
||||
region: Region
|
||||
widget: Widget | None = None # A widget of None means empty space
|
||||
order: int = 0
|
||||
|
||||
|
||||
class Layout(ABC):
|
||||
"""Responsible for arranging Widgets in a view and rendering them."""
|
||||
|
||||
name: ClassVar[str] = ""
|
||||
|
||||
@abstractmethod
|
||||
def arrange(
|
||||
self, parent: Widget, size: Size, scroll: Offset
|
||||
) -> tuple[list[WidgetPlacement], set[Widget]]:
|
||||
"""Generate a layout map that defines where on the screen the widgets will be drawn.
|
||||
|
||||
Args:
|
||||
parent (Widget): Parent widget.
|
||||
size (Size): Size of container.
|
||||
scroll (Offset): Offset to apply to the Widget placements.
|
||||
|
||||
Returns:
|
||||
Iterable[WidgetPlacement]: An iterable of widget location
|
||||
"""
|
||||
CSS = """
|
||||
Horizontal {
|
||||
layout: horizontal;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence
|
||||
from .._layout_resolve import layout_resolve
|
||||
from ..css.types import Edge
|
||||
from ..geometry import Offset, Region, Size
|
||||
from ..layout import Layout, WidgetPlacement
|
||||
from .._layout import Layout, WidgetPlacement
|
||||
from ..widget import Widget
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import sys
|
||||
|
||||
from .horizontal import HorizontalLayout
|
||||
from ..layout import Layout
|
||||
from .._layout import Layout
|
||||
from ..layouts.dock import DockLayout
|
||||
from ..layouts.grid import GridLayout
|
||||
from ..layouts.vertical import VerticalLayout
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Iterable, NamedTuple, TYPE_CHECKING
|
||||
|
||||
from .._layout_resolve import layout_resolve
|
||||
from ..geometry import Size, Offset, Region
|
||||
from ..layout import Layout, WidgetPlacement
|
||||
from .._layout import Layout, WidgetPlacement
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from typing import cast
|
||||
|
||||
from textual.geometry import Size, Offset, Region
|
||||
from textual.layout import Layout, WidgetPlacement
|
||||
from textual._layout import Layout, WidgetPlacement
|
||||
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import cast, TYPE_CHECKING
|
||||
from .. import log
|
||||
|
||||
from ..geometry import Offset, Region, Size
|
||||
from ..layout import Layout, WidgetPlacement
|
||||
from .._layout import Layout, WidgetPlacement
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
|
||||
@@ -174,6 +174,7 @@ class MessagePump:
|
||||
)
|
||||
|
||||
def close_messages_no_wait(self) -> None:
|
||||
"""Request the message queue to exit."""
|
||||
self._message_queue.put_nowait(MessagePriority(None))
|
||||
|
||||
async def close_messages(self) -> None:
|
||||
|
||||
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
|
||||
Reactable = Union[Widget, App]
|
||||
|
||||
|
||||
ReactiveType = TypeVar("ReactiveType")
|
||||
ReactiveType = TypeVar("ReactiveType", covariant=True)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -36,7 +36,7 @@ class Reactive(Generic[ReactiveType]):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default: ReactiveType,
|
||||
default: ReactiveType | Callable[[], ReactiveType],
|
||||
*,
|
||||
layout: bool = False,
|
||||
repaint: bool = True,
|
||||
@@ -58,7 +58,11 @@ class Reactive(Generic[ReactiveType]):
|
||||
|
||||
self.name = name
|
||||
self.internal_name = f"_reactive_{name}"
|
||||
setattr(owner, self.internal_name, self._default)
|
||||
setattr(
|
||||
owner,
|
||||
self.internal_name,
|
||||
self._default() if callable(self._default) else self._default,
|
||||
)
|
||||
|
||||
def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType:
|
||||
return getattr(obj, self.internal_name)
|
||||
|
||||
@@ -11,18 +11,21 @@ from .geometry import Offset, Region
|
||||
from ._compositor import Compositor
|
||||
from .reactive import Reactive
|
||||
from .widget import Widget
|
||||
from .renderables.gradient import VerticalGradient
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Screen(Widget):
|
||||
"""A widget for the root of the app."""
|
||||
|
||||
DEFAULT_STYLES = """
|
||||
|
||||
layout: dock;
|
||||
docks: _default=top;
|
||||
|
||||
CSS = """
|
||||
|
||||
Screen {
|
||||
layout: dock;
|
||||
docks: _default=top;
|
||||
background: $surface;
|
||||
color: $text-surface;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
dark = Reactive(False)
|
||||
@@ -35,12 +38,8 @@ class Screen(Widget):
|
||||
def watch_dark(self, dark: bool) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_transparent(self) -> bool:
|
||||
return False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return VerticalGradient("red", "blue")
|
||||
return self.app.render()
|
||||
|
||||
def get_offset(self, widget: Widget) -> Offset:
|
||||
"""Get the absolute offset of a given Widget.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
@@ -33,12 +33,13 @@ from .dom import DOMNode
|
||||
from .geometry import clamp, Offset, Region, Size, Spacing
|
||||
from .message import Message
|
||||
from . import messages
|
||||
from .layout import Layout
|
||||
from ._layout import Layout
|
||||
from .reactive import Reactive, watch
|
||||
from .renderables.opacity import Opacity
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App, ComposeResult
|
||||
from .scrollbar import (
|
||||
ScrollBar,
|
||||
ScrollTo,
|
||||
@@ -67,8 +68,7 @@ class Widget(DOMNode):
|
||||
|
||||
can_focus: bool = False
|
||||
|
||||
DEFAULT_STYLES = """
|
||||
|
||||
CSS = """
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -76,7 +76,7 @@ class Widget(DOMNode):
|
||||
*children: Widget,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: set[str] | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
|
||||
self._size = Size(0, 0)
|
||||
@@ -107,6 +107,26 @@ class Widget(DOMNode):
|
||||
show_vertical_scrollbar = Reactive(False, layout=True)
|
||||
show_horizontal_scrollbar = Reactive(False, layout=True)
|
||||
|
||||
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
|
||||
self.app.register(self, *anon_widgets, **widgets)
|
||||
self.screen.refresh()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Yield child widgets for a container."""
|
||||
return
|
||||
yield
|
||||
|
||||
def on_register(self, app: App) -> None:
|
||||
"""Called when the instance is registered.
|
||||
|
||||
Args:
|
||||
app (App): App instance.
|
||||
"""
|
||||
# Parser the Widget's CSS
|
||||
self.app.stylesheet.add_source(
|
||||
self.CSS, f"{__file__}:<{self.__class__.__name__}>"
|
||||
)
|
||||
|
||||
def get_box_model(self, container: Size, viewport: Size) -> BoxModel:
|
||||
"""Process the box model for this widget.
|
||||
|
||||
@@ -414,12 +434,18 @@ class Widget(DOMNode):
|
||||
"""
|
||||
|
||||
renderable = self.render()
|
||||
|
||||
styles = self.styles
|
||||
parent_styles = self.parent.styles
|
||||
|
||||
parent_text_style = self.parent.rich_text_style
|
||||
text_style = styles.rich_style
|
||||
|
||||
content_align = (styles.content_align_horizontal, styles.content_align_vertical)
|
||||
if content_align != ("left", "top"):
|
||||
horizontal, vertical = content_align
|
||||
renderable = Align(renderable, horizontal, vertical=vertical)
|
||||
|
||||
renderable_text_style = parent_text_style + text_style
|
||||
if renderable_text_style:
|
||||
renderable = Styled(renderable, renderable_text_style)
|
||||
@@ -478,8 +504,7 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
bool: ``True`` if there is background color, otherwise ``False``.
|
||||
"""
|
||||
return False
|
||||
return self.layout is not None
|
||||
return self.is_container and self.styles.background.is_transparent
|
||||
|
||||
@property
|
||||
def console(self) -> Console:
|
||||
@@ -615,8 +640,10 @@ class Widget(DOMNode):
|
||||
|
||||
# Default displays a pretty repr in the center of the screen
|
||||
|
||||
label = self.css_identifier_styled
|
||||
return Align.center(label, vertical="middle")
|
||||
if self.is_container:
|
||||
return ""
|
||||
|
||||
return self.css_identifier_styled
|
||||
|
||||
async def action(self, action: str, *params) -> None:
|
||||
await self.app.action(action, self)
|
||||
@@ -677,6 +704,12 @@ class Widget(DOMNode):
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
await self.dispatch_key(event)
|
||||
|
||||
def on_mount(self, event: events.Mount) -> None:
|
||||
widgets = list(self.compose())
|
||||
if widgets:
|
||||
self.mount(*widgets)
|
||||
self.screen.refresh()
|
||||
|
||||
def on_leave(self) -> None:
|
||||
self.mouse_over = False
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from ._footer import Footer
|
||||
from ._header import Header
|
||||
from ._button import Button, ButtonPressed
|
||||
from ._button import Button
|
||||
from ._placeholder import Placeholder
|
||||
from ._static import Static
|
||||
from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID
|
||||
@@ -8,7 +8,6 @@ from ._directory_tree import DirectoryTree, FileClick
|
||||
|
||||
__all__ = [
|
||||
"Button",
|
||||
"ButtonPressed",
|
||||
"DirectoryTree",
|
||||
"FileClick",
|
||||
"Footer",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.align import Align
|
||||
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
|
||||
from rich.style import StyleType
|
||||
from typing import cast
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from .. import events
|
||||
from ..message import Message
|
||||
@@ -10,58 +11,65 @@ from ..reactive import Reactive
|
||||
from ..widget import Widget
|
||||
|
||||
|
||||
class ButtonPressed(Message, bubble=True):
|
||||
pass
|
||||
class Button(Widget, can_focus=True):
|
||||
"""A simple clickable button."""
|
||||
|
||||
CSS = """
|
||||
|
||||
Button {
|
||||
width: auto;
|
||||
height: 3;
|
||||
padding: 0 2;
|
||||
background: $primary;
|
||||
color: $text-primary;
|
||||
content-align: center middle;
|
||||
border: tall $primary-lighten-3;
|
||||
|
||||
margin: 1 0;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
class Expand:
|
||||
def __init__(self, renderable: RenderableType) -> None:
|
||||
self.renderable = renderable
|
||||
Button:hover {
|
||||
background:$primary-darken-2;
|
||||
color: $text-primary-darken-2;
|
||||
border: tall $primary-lighten-1;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
width = options.max_width
|
||||
height = options.height or 1
|
||||
yield from console.render(
|
||||
self.renderable, options.update_dimensions(width, height)
|
||||
)
|
||||
class Pressed(Message, bubble=True):
|
||||
@property
|
||||
def button(self) -> Button:
|
||||
return cast(Button, self.sender)
|
||||
|
||||
|
||||
class ButtonRenderable:
|
||||
def __init__(self, label: RenderableType, style: StyleType = "") -> None:
|
||||
self.label = label
|
||||
self.style = style
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
width = options.max_width
|
||||
height = options.height or 1
|
||||
|
||||
yield Align.center(
|
||||
self.label, vertical="middle", style=self.style, width=width, height=height
|
||||
)
|
||||
|
||||
|
||||
class Button(Widget):
|
||||
def __init__(
|
||||
self,
|
||||
label: RenderableType,
|
||||
label: RenderableType | None = None,
|
||||
disabled: bool = False,
|
||||
*,
|
||||
name: str | None = None,
|
||||
style: StyleType = "white on dark_blue",
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
):
|
||||
super().__init__(name=name)
|
||||
self.name = name or str(label)
|
||||
self.button_style = style
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
|
||||
self.label = label
|
||||
self.label = self.css_identifier_styled if label is None else label
|
||||
self.disabled = disabled
|
||||
if disabled:
|
||||
self.add_class("-disabled")
|
||||
|
||||
label: Reactive[RenderableType] = Reactive("")
|
||||
|
||||
def validate_label(self, label: RenderableType) -> RenderableType:
|
||||
"""Parse markup for self.label"""
|
||||
if isinstance(label, str):
|
||||
return Text.from_markup(label)
|
||||
return label
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return ButtonRenderable(self.label, style=self.button_style)
|
||||
return self.label
|
||||
|
||||
async def on_click(self, event: events.Click) -> None:
|
||||
event.prevent_default().stop()
|
||||
await self.emit(ButtonPressed(self))
|
||||
event.stop()
|
||||
if not self.disabled:
|
||||
await self.emit(Button.Pressed(self))
|
||||
|
||||
@@ -14,7 +14,7 @@ class Static(Widget):
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: set[str] | None = None,
|
||||
classes: str | None = None,
|
||||
style: StyleType = "",
|
||||
padding: PaddingDimensions = 0,
|
||||
) -> None:
|
||||
|
||||
@@ -864,7 +864,7 @@ class TestParseLayout:
|
||||
css = "#some-widget { layout: dock; }"
|
||||
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
|
||||
styles = stylesheet.rules[0].styles
|
||||
assert isinstance(styles.layout, DockLayout)
|
||||
@@ -874,7 +874,8 @@ class TestParseLayout:
|
||||
|
||||
stylesheet = Stylesheet()
|
||||
with pytest.raises(StylesheetParseError) as ex:
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
stylesheet.parse()
|
||||
|
||||
assert ex.value.errors is not None
|
||||
|
||||
@@ -886,7 +887,7 @@ class TestParseText:
|
||||
}
|
||||
"""
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
|
||||
styles = stylesheet.rules[0].styles
|
||||
assert styles.color == Color.parse("green")
|
||||
@@ -897,7 +898,7 @@ class TestParseText:
|
||||
}
|
||||
"""
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
|
||||
styles = stylesheet.rules[0].styles
|
||||
assert styles.background == Color.parse("red")
|
||||
@@ -933,7 +934,7 @@ class TestParseOffset:
|
||||
}}
|
||||
"""
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
|
||||
styles = stylesheet.rules[0].styles
|
||||
|
||||
@@ -972,7 +973,7 @@ class TestParseOffset:
|
||||
}}
|
||||
"""
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
|
||||
styles = stylesheet.rules[0].styles
|
||||
|
||||
@@ -1002,7 +1003,7 @@ class TestParseTransition:
|
||||
}}
|
||||
"""
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
|
||||
styles = stylesheet.rules[0].styles
|
||||
|
||||
@@ -1017,7 +1018,7 @@ class TestParseTransition:
|
||||
def test_no_delay_specified(self):
|
||||
css = f"#some-widget {{ transition: offset-x 1 in_out_cubic; }}"
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
|
||||
styles = stylesheet.rules[0].styles
|
||||
|
||||
@@ -1032,9 +1033,11 @@ class TestParseTransition:
|
||||
|
||||
stylesheet = Stylesheet()
|
||||
with pytest.raises(StylesheetParseError) as ex:
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
stylesheet.parse()
|
||||
|
||||
stylesheet_errors = stylesheet.rules[0].errors
|
||||
rules = stylesheet._parse_rules(css, "foo")
|
||||
stylesheet_errors = rules[0].errors
|
||||
|
||||
assert len(stylesheet_errors) == 1
|
||||
assert stylesheet_errors[0][0].value == invalid_func_name
|
||||
@@ -1056,7 +1059,7 @@ class TestParseOpacity:
|
||||
def test_opacity_to_styles(self, css_value, styles_value):
|
||||
css = f"#some-widget {{ opacity: {css_value} }}"
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
|
||||
assert stylesheet.rules[0].styles.opacity == styles_value
|
||||
assert not stylesheet.rules[0].errors
|
||||
@@ -1066,15 +1069,17 @@ class TestParseOpacity:
|
||||
stylesheet = Stylesheet()
|
||||
|
||||
with pytest.raises(StylesheetParseError):
|
||||
stylesheet.parse(css)
|
||||
assert stylesheet.rules[0].errors
|
||||
stylesheet.add_source(css)
|
||||
stylesheet.parse()
|
||||
rules = stylesheet._parse_rules(css, "foo")
|
||||
assert rules[0].errors
|
||||
|
||||
|
||||
class TestParseMargin:
|
||||
def test_margin_partial(self):
|
||||
css = "#foo {margin: 1; margin-top: 2; margin-right: 3; margin-bottom: -1;}"
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
assert stylesheet.rules[0].styles.margin == Spacing(2, 3, -1, 1)
|
||||
|
||||
|
||||
@@ -1082,5 +1087,5 @@ class TestParsePadding:
|
||||
def test_padding_partial(self):
|
||||
css = "#foo {padding: 1; padding-top: 2; padding-right: 3; padding-bottom: -1;}"
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
assert stylesheet.rules[0].styles.padding == Spacing(2, 3, -1, 1)
|
||||
|
||||
@@ -42,7 +42,8 @@ def test_color_property_parsing(css_value, expectation, expected_color):
|
||||
)
|
||||
|
||||
with expectation:
|
||||
stylesheet.parse(css)
|
||||
stylesheet.add_source(css)
|
||||
stylesheet.parse()
|
||||
|
||||
if expected_color:
|
||||
css_rule = stylesheet.rules[0]
|
||||
|
||||
Reference in New Issue
Block a user