Merge branch 'css' into style-error-improvements

This commit is contained in:
Will McGugan
2022-04-29 10:56:27 +01:00
committed by GitHub
35 changed files with 735 additions and 498 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
View File

24
sandbox/buttons.py Normal file
View 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))

View File

@@ -1,6 +1,7 @@
#uber1 {
layout: vertical;
background: dark_green;
background: green;
overflow: hidden auto;
border: heavy white;
}

View File

@@ -30,12 +30,12 @@ class BasicApp(App):
)
first_child = Placeholder(id="child1", classes={"list-item"})
uber1 = Widget(
first_child,
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)
await first_child.focus()
@@ -44,7 +44,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))
@@ -83,4 +83,7 @@ class BasicApp(App):
self.focused.styles.border = ("solid", "red")
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()

View File

@@ -1,239 +1,172 @@
from __future__ import annotations
ANSI_COLOR_TO_RGB: dict[str, tuple[int, int, int]] = {
COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int] | tuple[int, int, int, float]] = {
# Let's start with a specific pseudo-color::
"transparent": (0, 0, 0, 0),
# Then, the 16 common ANSI colors:
"ansi_black": (0, 0, 0),
"ansi_red": (128, 0, 0),
"ansi_green": (0, 128, 0),
"ansi_yellow": (128, 128, 0),
"ansi_blue": (0, 0, 128),
"ansi_magenta": (128, 0, 128),
"ansi_cyan": (0, 128, 128),
"ansi_white": (192, 192, 192),
"ansi_bright_black": (128, 128, 128),
"ansi_bright_red": (255, 0, 0),
"ansi_bright_green": (0, 255, 0),
"ansi_bright_yellow": (255, 255, 0),
"ansi_bright_blue": (0, 0, 255),
"ansi_bright_magenta": (255, 0, 255),
"ansi_bright_cyan": (0, 255, 255),
"ansi_bright_white": (255, 255, 255),
# And then, Web color keywords: (up to CSS Color Module Level 4)
"black": (0, 0, 0),
"red": (128, 0, 0),
"silver": (192, 192, 192),
"gray": (128, 128, 128),
"white": (255, 255, 255),
"maroon": (128, 0, 0),
"red": (255, 0, 0),
"purple": (128, 0, 128),
"fuchsia": (255, 0, 255),
"green": (0, 128, 0),
"yellow": (128, 128, 0),
"blue": (0, 0, 128),
"magenta": (128, 0, 128),
"cyan": (0, 128, 128),
"white": (192, 192, 192),
"bright_black": (128, 128, 128),
"bright_red": (255, 0, 0),
"bright_green": (0, 255, 0),
"bright_yellow": (255, 255, 0),
"bright_blue": (0, 0, 255),
"bright_magenta": (255, 0, 255),
"bright_cyan": (0, 255, 255),
"bright_white": (255, 255, 255),
"grey0": (0, 0, 0),
"gray0": (0, 0, 0),
"navy_blue": (0, 0, 95),
"dark_blue": (0, 0, 135),
"blue3": (0, 0, 215),
"blue1": (0, 0, 255),
"dark_green": (0, 95, 0),
"deep_sky_blue4": (0, 95, 175),
"dodger_blue3": (0, 95, 215),
"dodger_blue2": (0, 95, 255),
"green4": (0, 135, 0),
"spring_green4": (0, 135, 95),
"turquoise4": (0, 135, 135),
"deep_sky_blue3": (0, 135, 215),
"dodger_blue1": (0, 135, 255),
"green3": (0, 215, 0),
"spring_green3": (0, 215, 95),
"dark_cyan": (0, 175, 135),
"light_sea_green": (0, 175, 175),
"deep_sky_blue2": (0, 175, 215),
"deep_sky_blue1": (0, 175, 255),
"spring_green2": (0, 255, 95),
"cyan3": (0, 215, 175),
"dark_turquoise": (0, 215, 215),
"turquoise2": (0, 215, 255),
"green1": (0, 255, 0),
"spring_green1": (0, 255, 135),
"medium_spring_green": (0, 255, 175),
"cyan2": (0, 255, 215),
"cyan1": (0, 255, 255),
"dark_red": (135, 0, 0),
"deep_pink4": (175, 0, 95),
"purple4": (95, 0, 175),
"purple3": (95, 0, 215),
"blue_violet": (95, 0, 255),
"orange4": (135, 95, 0),
"grey37": (95, 95, 95),
"gray37": (95, 95, 95),
"medium_purple4": (95, 95, 135),
"slate_blue3": (95, 95, 215),
"royal_blue1": (95, 95, 255),
"chartreuse4": (95, 135, 0),
"dark_sea_green4": (95, 175, 95),
"pale_turquoise4": (95, 135, 135),
"steel_blue": (95, 135, 175),
"steel_blue3": (95, 135, 215),
"cornflower_blue": (95, 135, 255),
"chartreuse3": (95, 215, 0),
"cadet_blue": (95, 175, 175),
"sky_blue3": (95, 175, 215),
"steel_blue1": (95, 215, 255),
"pale_green3": (135, 215, 135),
"sea_green3": (95, 215, 135),
"aquamarine3": (95, 215, 175),
"medium_turquoise": (95, 215, 215),
"chartreuse2": (135, 215, 0),
"sea_green2": (95, 255, 95),
"sea_green1": (95, 255, 175),
"aquamarine1": (135, 255, 215),
"dark_slate_gray2": (95, 255, 255),
"dark_magenta": (135, 0, 175),
"dark_violet": (175, 0, 215),
"purple": (175, 0, 255),
"light_pink4": (135, 95, 95),
"plum4": (135, 95, 135),
"medium_purple3": (135, 95, 215),
"slate_blue1": (135, 95, 255),
"yellow4": (135, 175, 0),
"wheat4": (135, 135, 95),
"grey53": (135, 135, 135),
"gray53": (135, 135, 135),
"light_slate_grey": (135, 135, 175),
"light_slate_gray": (135, 135, 175),
"medium_purple": (135, 135, 215),
"light_slate_blue": (135, 135, 255),
"dark_olive_green3": (175, 215, 95),
"dark_sea_green": (135, 175, 135),
"light_sky_blue3": (135, 175, 215),
"sky_blue2": (135, 175, 255),
"dark_sea_green3": (175, 215, 135),
"dark_slate_gray3": (135, 215, 215),
"sky_blue1": (135, 215, 255),
"chartreuse1": (135, 255, 0),
"light_green": (135, 255, 135),
"pale_green1": (175, 255, 135),
"dark_slate_gray1": (135, 255, 255),
"red3": (215, 0, 0),
"medium_violet_red": (175, 0, 135),
"magenta3": (215, 0, 215),
"dark_orange3": (215, 95, 0),
"indian_red": (215, 95, 95),
"hot_pink3": (215, 95, 135),
"medium_orchid3": (175, 95, 175),
"medium_orchid": (175, 95, 215),
"medium_purple2": (175, 135, 215),
"dark_goldenrod": (175, 135, 0),
"light_salmon3": (215, 135, 95),
"rosy_brown": (175, 135, 135),
"grey63": (175, 135, 175),
"gray63": (175, 135, 175),
"medium_purple1": (175, 135, 255),
"gold3": (215, 175, 0),
"dark_khaki": (175, 175, 95),
"navajo_white3": (175, 175, 135),
"grey69": (175, 175, 175),
"gray69": (175, 175, 175),
"light_steel_blue3": (175, 175, 215),
"light_steel_blue": (175, 175, 255),
"yellow3": (215, 215, 0),
"dark_sea_green2": (175, 255, 175),
"light_cyan3": (175, 215, 215),
"light_sky_blue1": (175, 215, 255),
"green_yellow": (175, 255, 0),
"dark_olive_green2": (175, 255, 95),
"dark_sea_green1": (215, 255, 175),
"pale_turquoise1": (175, 255, 255),
"deep_pink3": (215, 0, 135),
"magenta2": (255, 0, 215),
"hot_pink2": (215, 95, 175),
"orchid": (215, 95, 215),
"medium_orchid1": (255, 95, 255),
"orange3": (215, 135, 0),
"light_pink3": (215, 135, 135),
"pink3": (215, 135, 175),
"plum3": (215, 135, 215),
"violet": (215, 135, 255),
"light_goldenrod3": (215, 175, 95),
"tan": (215, 175, 135),
"misty_rose3": (215, 175, 175),
"thistle3": (215, 175, 215),
"plum2": (215, 175, 255),
"khaki3": (215, 215, 95),
"light_goldenrod2": (255, 215, 135),
"light_yellow3": (215, 215, 175),
"grey84": (215, 215, 215),
"gray84": (215, 215, 215),
"light_steel_blue1": (215, 215, 255),
"yellow2": (215, 255, 0),
"dark_olive_green1": (215, 255, 135),
"honeydew2": (215, 255, 215),
"light_cyan1": (215, 255, 255),
"red1": (255, 0, 0),
"deep_pink2": (255, 0, 95),
"deep_pink1": (255, 0, 175),
"magenta1": (255, 0, 255),
"orange_red1": (255, 95, 0),
"indian_red1": (255, 95, 135),
"hot_pink": (255, 95, 215),
"dark_orange": (255, 135, 0),
"salmon1": (255, 135, 95),
"light_coral": (255, 135, 135),
"pale_violet_red1": (255, 135, 175),
"orchid2": (255, 135, 215),
"orchid1": (255, 135, 255),
"orange1": (255, 175, 0),
"sandy_brown": (255, 175, 95),
"light_salmon1": (255, 175, 135),
"light_pink1": (255, 175, 175),
"pink1": (255, 175, 215),
"plum1": (255, 175, 255),
"gold1": (255, 215, 0),
"navajo_white1": (255, 215, 175),
"misty_rose1": (255, 215, 215),
"thistle1": (255, 215, 255),
"yellow1": (255, 255, 0),
"light_goldenrod1": (255, 255, 95),
"khaki1": (255, 255, 135),
"wheat1": (255, 255, 175),
"cornsilk1": (255, 255, 215),
"grey100": (255, 255, 255),
"gray100": (255, 255, 255),
"grey3": (8, 8, 8),
"gray3": (8, 8, 8),
"grey7": (18, 18, 18),
"gray7": (18, 18, 18),
"grey11": (28, 28, 28),
"gray11": (28, 28, 28),
"grey15": (38, 38, 38),
"gray15": (38, 38, 38),
"grey19": (48, 48, 48),
"gray19": (48, 48, 48),
"grey23": (58, 58, 58),
"gray23": (58, 58, 58),
"grey27": (68, 68, 68),
"gray27": (68, 68, 68),
"grey30": (78, 78, 78),
"gray30": (78, 78, 78),
"grey35": (88, 88, 88),
"gray35": (88, 88, 88),
"grey39": (98, 98, 98),
"gray39": (98, 98, 98),
"grey42": (108, 108, 108),
"gray42": (108, 108, 108),
"grey46": (118, 118, 118),
"gray46": (118, 118, 118),
"grey50": (128, 128, 128),
"gray50": (128, 128, 128),
"grey54": (138, 138, 138),
"gray54": (138, 138, 138),
"grey58": (148, 148, 148),
"gray58": (148, 148, 148),
"grey62": (158, 158, 158),
"gray62": (158, 158, 158),
"grey66": (168, 168, 168),
"gray66": (168, 168, 168),
"grey70": (178, 178, 178),
"gray70": (178, 178, 178),
"grey74": (188, 188, 188),
"gray74": (188, 188, 188),
"grey78": (198, 198, 198),
"gray78": (198, 198, 198),
"grey82": (208, 208, 208),
"gray82": (208, 208, 208),
"grey85": (218, 218, 218),
"gray85": (218, 218, 218),
"grey89": (228, 228, 228),
"gray89": (228, 228, 228),
"grey93": (238, 238, 238),
"gray93": (238, 238, 238),
"lime": (0, 255, 0),
"olive": (128, 128, 0),
"yellow": (255, 255, 0),
"navy": (0, 0, 128),
"blue": (0, 0, 255),
"teal": (0, 128, 128),
"aqua": (0, 255, 255),
"orange": (255, 165, 0),
"aliceblue": (240, 248, 255),
"antiquewhite": (250, 235, 215),
"aquamarine": (127, 255, 212),
"azure": (240, 255, 255),
"beige": (245, 245, 220),
"bisque": (255, 228, 196),
"blanchedalmond": (255, 235, 205),
"blueviolet": (138, 43, 226),
"brown": (165, 42, 42),
"burlywood": (222, 184, 135),
"cadetblue": (95, 158, 160),
"chartreuse": (127, 255, 0),
"chocolate": (210, 105, 30),
"coral": (255, 127, 80),
"cornflowerblue": (100, 149, 237),
"cornsilk": (255, 248, 220),
"crimson": (220, 20, 60),
"cyan": (0, 255, 255),
"darkblue": (0, 0, 139),
"darkcyan": (0, 139, 139),
"darkgoldenrod": (184, 134, 11),
"darkgray": (169, 169, 169),
"darkgreen": (0, 100, 0),
"darkgrey": (169, 169, 169),
"darkkhaki": (189, 183, 107),
"darkmagenta": (139, 0, 139),
"darkolivegreen": (85, 107, 47),
"darkorange": (255, 140, 0),
"darkorchid": (153, 50, 204),
"darkred": (139, 0, 0),
"darksalmon": (233, 150, 122),
"darkseagreen": (143, 188, 143),
"darkslateblue": (72, 61, 139),
"darkslategray": (47, 79, 79),
"darkslategrey": (47, 79, 79),
"darkturquoise": (0, 206, 209),
"darkviolet": (148, 0, 211),
"deeppink": (255, 20, 147),
"deepskyblue": (0, 191, 255),
"dimgray": (105, 105, 105),
"dimgrey": (105, 105, 105),
"dodgerblue": (30, 144, 255),
"firebrick": (178, 34, 34),
"floralwhite": (255, 250, 240),
"forestgreen": (34, 139, 34),
"gainsboro": (220, 220, 220),
"ghostwhite": (248, 248, 255),
"gold": (255, 215, 0),
"goldenrod": (218, 165, 32),
"greenyellow": (173, 255, 47),
"grey": (128, 128, 128),
"honeydew": (240, 255, 240),
"hotpink": (255, 105, 180),
"indianred": (205, 92, 92),
"indigo": (75, 0, 130),
"ivory": (255, 255, 240),
"khaki": (240, 230, 140),
"lavender": (230, 230, 250),
"lavenderblush": (255, 240, 245),
"lawngreen": (124, 252, 0),
"lemonchiffon": (255, 250, 205),
"lightblue": (173, 216, 230),
"lightcoral": (240, 128, 128),
"lightcyan": (224, 255, 255),
"lightgoldenrodyellow": (250, 250, 210),
"lightgray": (211, 211, 211),
"lightgreen": (144, 238, 144),
"lightgrey": (211, 211, 211),
"lightpink": (255, 182, 193),
"lightsalmon": (255, 160, 122),
"lightseagreen": (32, 178, 170),
"lightskyblue": (135, 206, 250),
"lightslategray": (119, 136, 153),
"lightslategrey": (119, 136, 153),
"lightsteelblue": (176, 196, 222),
"lightyellow": (255, 255, 224),
"limegreen": (50, 205, 50),
"linen": (250, 240, 230),
"magenta": (255, 0, 255),
"mediumaquamarine": (102, 205, 170),
"mediumblue": (0, 0, 205),
"mediumorchid": (186, 85, 211),
"mediumpurple": (147, 112, 219),
"mediumseagreen": (60, 179, 113),
"mediumslateblue": (123, 104, 238),
"mediumspringgreen": (0, 250, 154),
"mediumturquoise": (72, 209, 204),
"mediumvioletred": (199, 21, 133),
"midnightblue": (25, 25, 112),
"mintcream": (245, 255, 250),
"mistyrose": (255, 228, 225),
"moccasin": (255, 228, 181),
"navajowhite": (255, 222, 173),
"oldlace": (253, 245, 230),
"olivedrab": (107, 142, 35),
"orangered": (255, 69, 0),
"orchid": (218, 112, 214),
"palegoldenrod": (238, 232, 170),
"palegreen": (152, 251, 152),
"paleturquoise": (175, 238, 238),
"palevioletred": (219, 112, 147),
"papayawhip": (255, 239, 213),
"peachpuff": (255, 218, 185),
"peru": (205, 133, 63),
"pink": (255, 192, 203),
"plum": (221, 160, 221),
"powderblue": (176, 224, 230),
"rosybrown": (188, 143, 143),
"royalblue": (65, 105, 225),
"saddlebrown": (139, 69, 19),
"salmon": (250, 128, 114),
"sandybrown": (244, 164, 96),
"seagreen": (46, 139, 87),
"seashell": (255, 245, 238),
"sienna": (160, 82, 45),
"skyblue": (135, 206, 235),
"slateblue": (106, 90, 205),
"slategray": (112, 128, 144),
"slategrey": (112, 128, 144),
"snow": (255, 250, 250),
"springgreen": (0, 255, 127),
"steelblue": (70, 130, 180),
"tan": (210, 180, 140),
"thistle": (216, 191, 216),
"tomato": (255, 99, 71),
"turquoise": (64, 224, 208),
"violet": (238, 130, 238),
"wheat": (245, 222, 179),
"whitesmoke": (245, 245, 245),
"yellowgreen": (154, 205, 50),
"rebeccapurple": (102, 51, 153),
}

40
src/textual/_layout.py Normal file
View 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
"""

View File

@@ -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()
@@ -521,6 +530,7 @@ class App(DOMNode):
mount_event = events.Mount(sender=self)
await self.dispatch_message(mount_event)
# TODO: don't override `self.console` here
self.console = Console(file=sys.__stdout__)
self.title = self._title
self.refresh()
@@ -547,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:
@@ -558,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

View File

@@ -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,9 +258,9 @@ class Color(NamedTuple):
"""
if isinstance(color_text, Color):
return color_text
ansi_color = ANSI_COLOR_TO_RGB.get(color_text)
if ansi_color is not None:
return cls(*ansi_color)
color_from_name = COLOR_NAME_TO_RGB.get(color_text)
if color_from_name is not None:
return cls(*color_from_name)
color_match = RE_COLOR.match(color_text)
if color_match is None:
raise ColorParseError(f"failed to parse {color_text!r} as a color")
@@ -329,6 +334,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):

View File

@@ -43,7 +43,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
from .types import EdgeType

View File

@@ -686,31 +686,37 @@ class StylesBuilder:
elif token_horizontal.value not in VALID_ALIGN_VERTICAL:
align_error(name, token_horizontal)
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:
try:
value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL)
self.styles._rules["align_horizontal"] = value
self.styles._rules[name.replace("-", "_")] = value
except StyleValueError:
self.error(
name,
tokens[0],
string_enum_help_text(
"align-horizontal", VALID_ALIGN_HORIZONTAL, context="css"
name, VALID_ALIGN_HORIZONTAL, context="css"
),
)
def process_align_vertical(self, name: str, tokens: list[Token]) -> None:
try:
value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL)
self.styles._rules["align_vertical"] = value
self.styles._rules[name.replace("-", "_")] = value
except StyleValueError:
self.error(
name,
tokens[0],
string_enum_help_text(
"align-vertical", VALID_ALIGN_VERTICAL, context="css"
name, VALID_ALIGN_VERTICAL, context="css"
),
)
process_content_align = process_align
process_content_align_horizontal = process_align_horizontal
process_content_align_vertical = process_align_vertical

View File

@@ -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)

View File

@@ -61,7 +61,7 @@ else:
if TYPE_CHECKING:
from ..dom import DOMNode
from ..layout import Layout
from .._layout import Layout
class RulesMap(TypedDict, total=False):
@@ -126,6 +126,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)
@@ -166,7 +169,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()
@@ -207,9 +210,9 @@ class StylesBase(ABC):
rich_style = StyleProperty()
scrollbar_color = ColorProperty("bright_magenta")
scrollbar_color_hover = ColorProperty("yellow")
scrollbar_color_active = ColorProperty("bright_yellow")
scrollbar_color = ColorProperty("ansi_bright_magenta")
scrollbar_color_hover = ColorProperty("ansi_yellow")
scrollbar_color_active = ColorProperty("ansi_bright_yellow")
scrollbar_background = ColorProperty("#555555")
scrollbar_background_hover = ColorProperty("#444444")
@@ -218,6 +221,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 contains the same rules."""
if not isinstance(styles, StylesBase):
@@ -673,6 +679,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

View File

@@ -120,21 +120,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:
@@ -148,6 +152,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.
@@ -165,19 +191,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:
@@ -188,26 +205,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.
@@ -219,9 +241,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]:
@@ -420,7 +444,7 @@ if __name__ == "__main__":
"""
stylesheet = Stylesheet()
stylesheet.parse(CSS)
stylesheet.add_source(CSS)
print(stylesheet.css)

View File

@@ -11,7 +11,7 @@ DURATION = r"\d+\.?\d*(?:ms|s)"
NUMBER = r"\-?\d+\.?\d*"
COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|rgb\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)|rgba\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)"
KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
TOKEN = "[a-zA-Z_-]+"
TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*"
STRING = r"\".*?\""
VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+"

View File

@@ -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,52 @@ 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]
def get_pseudo_classes(self) -> Iterable[str]:
"""Get any pseudo classes applicable to this Node, e.g. hover, focus.

View File

@@ -1,41 +1,21 @@
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
from .screen import Screen
class WidgetPlacement(NamedTuple):
"""The position, size, and relative order of a widget within its parent."""
class Vertical(Widget):
"""A container widget to align children vertically."""
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 = """
Vertical {
layout: vertical;
}
"""
class Horizontal(Widget):
"""A container widget to align children horizontally."""
CSS = """
Horizontal {
layout: horizontal;
}
"""

View File

@@ -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):
@@ -50,9 +50,8 @@ class DockLayout(Layout):
def get_docks(self, parent: Widget) -> list[Dock]:
groups: dict[str, list[Widget]] = defaultdict(list)
for child in parent.children:
for child in parent.displayed_children:
assert isinstance(child, Widget)
if child.display:
groups[child.styles.dock].append(child)
docks: list[Dock] = []
append_dock = docks.append

View File

@@ -1,5 +1,5 @@
from .horizontal import HorizontalLayout
from ..layout import Layout
from .._layout import Layout
from ..layouts.dock import DockLayout
from ..layouts.vertical import VerticalLayout

View File

@@ -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

View File

@@ -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
@@ -39,7 +39,9 @@ class HorizontalLayout(Layout):
x = box_models[0].margin.left if box_models else 0
for widget, box_model, margin in zip(parent.children, box_models, margins):
displayed_children = parent.displayed_children
for widget, box_model, margin in zip(displayed_children, box_models, margins):
content_width, content_height = box_model.size
offset_y = widget.styles.align_height(content_height, parent_size.height)
region = Region(x, offset_y, content_width, content_height)
@@ -53,4 +55,4 @@ class HorizontalLayout(Layout):
total_region = Region(0, 0, max_width, max_height)
add_placement(WidgetPlacement(total_region, None, 0))
return placements, set(parent.children)
return placements, set(displayed_children)

View File

@@ -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
@@ -40,10 +40,13 @@ class VerticalLayout(Layout):
y = box_models[0].margin.top if box_models else 0
for widget, box_model, margin in zip(parent.children, box_models, margins):
displayed_children = parent.displayed_children
for widget, box_model, margin in zip(displayed_children, box_models, margins):
content_width, content_height = box_model.size
offset_x = widget.styles.align_width(content_width, parent_size.width)
region = Region(offset_x, y, content_width, content_height)
# TODO: it seems that `max_height` is not used?
max_height = max(max_height, content_height)
add_placement(WidgetPlacement(region, widget, 0))
y += region.height + margin
@@ -54,4 +57,4 @@ class VerticalLayout(Layout):
total_region = Region(0, 0, max_width, max_height)
add_placement(WidgetPlacement(total_region, None, 0))
return placements, set(parent.children)
return placements, set(displayed_children)

View File

@@ -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:

View File

@@ -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)

View File

@@ -11,17 +11,20 @@ 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 = """
CSS = """
Screen {
layout: dock;
docks: _default=top;
background: $surface;
color: $text-surface;
}
"""
@@ -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.

View File

@@ -69,7 +69,7 @@ class ScrollBarRender:
position: float = 0,
thickness: int = 1,
vertical: bool = True,
style: StyleType = "bright_magenta on #555555",
style: StyleType = "ansi_bright_magenta on #555555",
) -> None:
self.virtual_size = virtual_size
self.window_size = window_size
@@ -89,7 +89,7 @@ class ScrollBarRender:
thickness: int = 1,
vertical: bool = True,
back_color: Color = Color.parse("#555555"),
bar_color: Color = Color.parse("bright_magenta"),
bar_color: Color = Color.parse("ansi_bright_magenta"),
) -> Segments:
if vertical:
@@ -181,7 +181,7 @@ class ScrollBarRender:
vertical=self.vertical,
thickness=thickness,
back_color=_style.bgcolor or Color.parse("#555555"),
bar_color=_style.color or Color.parse("bright_magenta"),
bar_color=_style.color or Color.parse("ansi_bright_magenta"),
)
yield bar

View File

@@ -30,12 +30,13 @@ from .dom import DOMNode
from .geometry import clamp, Offset, Region, Size
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,
@@ -64,8 +65,7 @@ class Widget(DOMNode):
can_focus: bool = False
DEFAULT_STYLES = """
CSS = """
"""
def __init__(
@@ -73,7 +73,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)
@@ -104,6 +104,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.
@@ -411,12 +431,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)
@@ -475,8 +501,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:
@@ -612,8 +637,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)
@@ -674,6 +701,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

View File

@@ -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",

View File

@@ -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 = """
class Expand:
def __init__(self, renderable: RenderableType) -> None:
self.renderable = renderable
Button {
width: auto;
height: 3;
padding: 0 2;
background: $primary;
color: $text-primary;
content-align: center middle;
border: tall $primary-lighten-3;
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)
)
margin: 1 0;
text-style: bold;
}
Button:hover {
background:$primary-darken-2;
color: $text-primary-darken-2;
border: tall $primary-lighten-1;
}
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
class Pressed(Message, bubble=True):
@property
def button(self) -> Button:
return cast(Button, self.sender)
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))

View File

@@ -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:

View File

@@ -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)

View File

@@ -0,0 +1,55 @@
from contextlib import nullcontext as does_not_raise
import pytest
from textual.color import Color
from textual.css.stylesheet import Stylesheet, StylesheetParseError
from textual.css.tokenizer import TokenizeError
@pytest.mark.parametrize(
"css_value,expectation,expected_color",
[
# Valid values:
["transparent", does_not_raise(), Color(0, 0, 0, 0)],
["ansi_red", does_not_raise(), Color(128, 0, 0)],
["ansi_bright_magenta", does_not_raise(), Color(255, 0, 255)],
["red", does_not_raise(), Color(255, 0, 0)],
["lime", does_not_raise(), Color(0, 255, 0)],
["coral", does_not_raise(), Color(255, 127, 80)],
["aqua", does_not_raise(), Color(0, 255, 255)],
["deepskyblue", does_not_raise(), Color(0, 191, 255)],
["rebeccapurple", does_not_raise(), Color(102, 51, 153)],
["#ffcc00", does_not_raise(), Color(255, 204, 0)],
["#ffcc0033", does_not_raise(), Color(255, 204, 0, 0.2)],
["rgb(200,90,30)", does_not_raise(), Color(200, 90, 30)],
["rgba(200,90,30,0.3)", does_not_raise(), Color(200, 90, 30, 0.3)],
# Some invalid ones:
["coffee", pytest.raises(StylesheetParseError), None], # invalid color name
["ansi_dark_cyan", pytest.raises(StylesheetParseError), None],
["red 4", pytest.raises(StylesheetParseError), None], # space in it
["1", pytest.raises(StylesheetParseError), None], # invalid value
["()", pytest.raises(TokenizeError), None], # invalid tokens
# TODO: implement hex colors with 3 chars? @link https://devdocs.io/css/color_value
["#09f", pytest.raises(TokenizeError), None],
# TODO: allow spaces in rgb/rgba expressions?
["rgb(200, 90, 30)", pytest.raises(TokenizeError), None],
["rgba(200,90,30, 0.4)", pytest.raises(TokenizeError), None],
],
)
def test_color_property_parsing(css_value, expectation, expected_color):
stylesheet = Stylesheet()
css = """
* {
background: ${COLOR};
}
""".replace(
"${COLOR}", css_value
)
with expectation:
stylesheet.add_source(css)
stylesheet.parse()
if expected_color:
css_rule = stylesheet.rules[0]
assert css_rule.styles.background == expected_color

View File

@@ -0,0 +1,30 @@
import pytest
from textual.screen import Screen
from textual.widget import Widget
@pytest.mark.parametrize(
"layout,display,expected_in_displayed_children",
[
("dock", "block", True),
("horizontal", "block", True),
("vertical", "block", True),
("dock", "none", False),
("horizontal", "none", False),
("vertical", "none", False),
],
)
def test_nodes_take_display_property_into_account_when_they_display_their_children(
layout: str, display: str, expected_in_displayed_children: bool
):
widget = Widget(name="widget that might not be visible 🥷 ")
widget.styles.display = display
screen = Screen()
screen.styles.layout = layout
screen.add_child(widget)
displayed_children = screen.displayed_children
assert isinstance(displayed_children, list)
assert (widget in screen.displayed_children) is expected_in_displayed_children