diff --git a/CHANGELOG.md b/CHANGELOG.md index f66641495..97f76dde6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sandbox/basic.css b/sandbox/basic.css index 5d2446655..a2ced4868 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -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 { diff --git a/sandbox/basic.py b/sandbox/basic.py index e89f54034..0778a9eed 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -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() diff --git a/sandbox/buttons.css b/sandbox/buttons.css new file mode 100644 index 000000000..e69de29bb diff --git a/sandbox/buttons.py b/sandbox/buttons.py new file mode 100644 index 000000000..8ff5a72f8 --- /dev/null +++ b/sandbox/buttons.py @@ -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)) diff --git a/sandbox/uber.css b/sandbox/uber.css index 3407e53f3..fbbfcb261 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -1,6 +1,7 @@ #uber1 { layout: vertical; - background: dark_green; + background: green; + overflow: hidden auto; border: heavy white; } diff --git a/sandbox/uber.py b/sandbox/uber.py index f3d80b48a..ae44da30d 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -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() diff --git a/src/textual/_color_constants.py b/src/textual/_color_constants.py index 4b1a1e89b..cedab5673 100644 --- a/src/textual/_color_constants.py +++ b/src/textual/_color_constants.py @@ -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), } diff --git a/src/textual/_layout.py b/src/textual/_layout.py new file mode 100644 index 000000000..ea459a3e0 --- /dev/null +++ b/src/textual/_layout.py @@ -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 + """ diff --git a/src/textual/app.py b/src/textual/app.py index 8060be90e..319415142 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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 diff --git a/src/textual/color.py b/src/textual/color.py index 22949cf8c..f6e5be1c3 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -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): diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 4b647fabf..01b64b001 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -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 diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index a3d473561..fa52a6aec 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -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 + diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index d934a2109..6cb47076a 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -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) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index dc6c9adad..d7c78f24b 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -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 diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 70a4049ec..03a6bc3bd 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -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) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 3ccc8166f..7d5dbe3a0 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -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_\-]+" diff --git a/src/textual/dom.py b/src/textual/dom.py index 7c0c0ba54..f24368bb8 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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. diff --git a/src/textual/layout.py b/src/textual/layout.py index 9914ad78c..4c900a593 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -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; + } + """ diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index b507bbc67..f2ff41431 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -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: diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index c8550162b..0ea00d641 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -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 diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index f616d6512..f925d1b4c 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -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 diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 36fef9bd7..d0ff64c92 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -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) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 2752c4445..67834ed49 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -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) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index c426cb2bd..0d9fb0dfb 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -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: diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 27bfdaedb..049ceef76 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -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) diff --git a/src/textual/screen.py b/src/textual/screen.py index e348416fe..57722a25e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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. diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 5633f0d0e..62f05185c 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index e6f7805fc..29cefa18a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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 diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 79955a396..0646452be 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -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", diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 770edb197..331b6bdde 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -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)) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 455ebe1a1..733986e2f 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -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: diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index b5b3179fd..ccf7a75f4 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -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) diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py new file mode 100644 index 000000000..4544d1e1d --- /dev/null +++ b/tests/css/test_stylesheet.py @@ -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 diff --git a/tests/layouts/test_common_layout_features.py b/tests/layouts/test_common_layout_features.py new file mode 100644 index 000000000..e8cc5ee19 --- /dev/null +++ b/tests/layouts/test_common_layout_features.py @@ -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