mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' into style-error-improvements
This commit is contained in:
@@ -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,6 +1,7 @@
|
||||
#uber1 {
|
||||
layout: vertical;
|
||||
background: dark_green;
|
||||
background: green;
|
||||
overflow: hidden auto;
|
||||
border: heavy white;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
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()
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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_\-]+"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -50,10 +50,9 @@ 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)
|
||||
groups[child.styles.dock].append(child)
|
||||
docks: list[Dock] = []
|
||||
append_dock = docks.append
|
||||
for name, edge, z in parent.styles.docks:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
55
tests/css/test_stylesheet.py
Normal file
55
tests/css/test_stylesheet.py
Normal 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
|
||||
30
tests/layouts/test_common_layout_features.py
Normal file
30
tests/layouts/test_common_layout_features.py
Normal 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
|
||||
Reference in New Issue
Block a user