diff --git a/sandbox/align.css b/sandbox/align.css index f61901c48..0ca179814 100644 --- a/sandbox/align.css +++ b/sandbox/align.css @@ -27,6 +27,7 @@ Widget { /* outline: heavy blue; */ height: 10; padding: 1 2; + box-sizing: border-box; max-height: 100vh; @@ -41,5 +42,7 @@ Widget { height: 10; margin: 1; background:blue; + color: white 50%; + border: white; align-horizontal: center; } diff --git a/sandbox/auto_test.css b/sandbox/auto_test.css new file mode 100644 index 000000000..25bc21868 --- /dev/null +++ b/sandbox/auto_test.css @@ -0,0 +1,16 @@ +Vertical { + background: red 50%; +} + +.test { + width: auto; + height: auto; + + background: white 50%; + border:solid green; + padding: 0; + margin:3; + + align: center middle; + box-sizing: border-box; +} \ No newline at end of file diff --git a/sandbox/auto_test.py b/sandbox/auto_test.py new file mode 100644 index 000000000..c592a8155 --- /dev/null +++ b/sandbox/auto_test.py @@ -0,0 +1,20 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static +from textual.layout import Vertical + +from rich.text import Text + +TEXT = Text.from_markup(" ".join(str(n) * 5 for n in range(10))) + + +class AutoApp(App): + def compose(self) -> ComposeResult: + yield Vertical( + Static(TEXT, classes="test"), Static(TEXT, id="test", classes="test") + ) + + +app = AutoApp(css_path="auto_test.css") + +if __name__ == "__main__": + app.run() diff --git a/sandbox/basic.css b/sandbox/basic.css index 3934f521c..bff9b08d4 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -75,7 +75,7 @@ App > Screen { Tweet { - height: 12; + height: auto; width: 80; margin: 1 3; @@ -85,7 +85,7 @@ Tweet { /* border: outer $primary; */ padding: 1; border: wide $panel-darken-2; - overflow-y: scroll; + overflow-y: auto; align-horizontal: center; } @@ -223,4 +223,4 @@ Success { .horizontal { layout: horizontal -} +} \ No newline at end of file diff --git a/sandbox/horizontal.css b/sandbox/horizontal.css new file mode 100644 index 000000000..7a836108e --- /dev/null +++ b/sandbox/horizontal.css @@ -0,0 +1,18 @@ +Horizontal { + background: red 50%; + overflow-x: auto; + width: auto +} + +.test { + width: auto; + height: auto; + + background: white 50%; + border:solid green; + padding: 0; + margin:3; + + align: center middle; + box-sizing: border-box; +} \ No newline at end of file diff --git a/sandbox/horizontal.py b/sandbox/horizontal.py new file mode 100644 index 000000000..041e03a8d --- /dev/null +++ b/sandbox/horizontal.py @@ -0,0 +1,26 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static +from textual import layout + +from rich.text import Text + +TEXT = Text.from_markup(" ".join(str(n) * 5 for n in range(10))) + + +class AutoApp(App): + def on_mount(self) -> None: + self.bind("t", "tree") + + def compose(self) -> ComposeResult: + yield layout.Horizontal( + Static(TEXT, classes="test"), Static(TEXT, id="test", classes="test") + ) + + def action_tree(self): + self.log(self.screen.tree) + + +app = AutoApp(css_path="horizontal.css") + +if __name__ == "__main__": + app.run() diff --git a/sandbox/uber.css b/sandbox/uber.css index daa40a36d..edfdebf80 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -1,13 +1,14 @@ App.-show-focus *:focus { - tint: #8bc34a 50%; + tint: #8bc34a 20%; } #uber1 { layout: vertical; background: green; overflow: hidden auto; - border: heavy white; + border: heavy white; text-style: underline; + /* box-sizing: content-box; */ } #uber1:focus-within { @@ -16,11 +17,12 @@ App.-show-focus *:focus { #child2 { text-style: underline; - background: red; + background: red 10%; } .list-item { - height: 20; + height: 10; + /* display: none; */ color: #12a0; background: #ffffff00; } diff --git a/src/textual/_border.py b/src/textual/_border.py index a7c8a72e8..56dfa1277 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -5,7 +5,7 @@ from functools import lru_cache from rich.console import Console, ConsoleOptions, RenderResult, RenderableType import rich.repr from rich.segment import Segment, SegmentLines -from rich.style import Style, StyleType +from rich.style import Style from .color import Color from .css.types import EdgeStyle, EdgeType @@ -210,8 +210,8 @@ class Border: if new_height >= 1: render_options = options.update_dimensions(width, new_height) else: - render_options = options - has_top = has_bottom = False + render_options = options.update_width(width) + # has_top = has_bottom = False lines = console.render_lines(self.renderable, render_options) diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 6ba50370a..937510d4d 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -41,3 +41,27 @@ class Layout(ABC): Returns: Iterable[WidgetPlacement]: An iterable of widget location """ + + def get_content_width( + self, parent: Widget, container_size: Size, viewport_size: Size + ) -> int: + width: int | None = None + for child in parent.displayed_children: + if not child.is_container: + child_width = child.get_content_width(container_size, viewport_size) + width = child_width if width is None else max(width, child_width) + if width is None: + width = container_size.width + return width + + def get_content_height( + self, parent: Widget, container_size: Size, viewport_size: Size, width: int + ) -> int: + if not parent.displayed_children: + height = container_size.height + else: + placements, widgets = self.arrange( + parent, Size(width, container_size.height), Offset(0, 0) + ) + height = max(placement.region.y_max for placement in placements) + return height diff --git a/src/textual/app.py b/src/textual/app.py index 05c743418..b43e53633 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -415,7 +415,7 @@ class App(Generic[ReturnType], DOMNode): output = " ".join(str(arg) for arg in objects) if kwargs: key_values = " ".join( - f"{key}={value}" for key, value in kwargs.items() + f"{key}={value!r}" for key, value in kwargs.items() ) output = f"{output} {key_values}" if output else key_values if self._log_console is not None: @@ -425,7 +425,7 @@ class App(Generic[ReturnType], DOMNode): DevtoolsLog(output, caller=_textual_calling_frame) ) except Exception: - pass + self.console.bell() def bind( self, @@ -470,14 +470,17 @@ class App(Generic[ReturnType], DOMNode): try: time = perf_counter() - self.stylesheet.read(self.css_path) + stylesheet = self.stylesheet.copy() + stylesheet.read(self.css_path) + stylesheet.parse() elapsed = (perf_counter() - time) * 1000 - self.log(f"loaded {self.css_path} in {elapsed:.0f}ms") + self.log(f" loaded {self.css_path!r} in {elapsed:.0f} ms") except Exception as error: # TODO: Catch specific exceptions self.console.bell() self.log(error) else: + self.stylesheet = stylesheet self.reset_styles() self.stylesheet.update(self) self.screen.refresh(layout=True) diff --git a/src/textual/box_model.py b/src/textual/box_model.py index fd6c3ed5b..bbafadfcf 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -17,8 +17,8 @@ def get_box_model( styles: StylesBase, container: Size, viewport: Size, - get_content_width: Callable[[Size, Size], int], - get_content_height: Callable[[Size, Size, int], int], + get_content_width: Callable[[Size, Size], int | None], + get_content_height: Callable[[Size, Size, int], int | None], ) -> BoxModel: """Resolve the box model for this Styles. @@ -36,34 +36,26 @@ def get_box_model( has_rule = styles.has_rule width, height = container is_content_box = styles.box_sizing == "content-box" + is_border_box = styles.box_sizing == "border-box" gutter = styles.padding + styles.border.spacing + is_auto_width = styles.width and styles.width.is_auto + is_auto_height = styles.height and styles.height.is_auto + if not has_rule("width"): width = container.width - elif styles.width.is_auto: + elif is_auto_width: # When width is auto, we want enough space to always fit the content - width = get_content_width(container, viewport) - if not is_content_box: - # If box sizing is border box we want to enlarge the width so that it - # can accommodate padding + border - width += gutter.width + width = get_content_width( + (container - gutter.totals if is_border_box else container) + - styles.margin.totals, + viewport, + ) + # width = min(container.width, width) + else: width = styles.width.resolve_dimension(container, viewport) - if not has_rule("height"): - height = container.height - elif styles.height.is_auto: - height = get_content_height(container, viewport, width) - if not is_content_box: - height += gutter.height - else: - height = styles.height.resolve_dimension(container, viewport) - - if is_content_box: - gutter_width, gutter_height = gutter.totals - width += gutter_width - height += gutter_height - if has_rule("min_width"): min_width = styles.min_width.resolve_dimension(container, viewport) width = max(width, min_width) @@ -72,6 +64,17 @@ def get_box_model( max_width = styles.max_width.resolve_dimension(container, viewport) width = min(width, max_width) + if not has_rule("height"): + height = container.height + elif styles.height.is_auto: + height = get_content_height( + container - gutter.totals if is_border_box else container, viewport, width + ) + if is_border_box: + height += gutter.height + else: + height = styles.height.resolve_dimension(container, viewport) + if has_rule("min_height"): min_height = styles.min_height.resolve_dimension(container, viewport) height = max(height, min_height) @@ -80,7 +83,16 @@ def get_box_model( max_height = styles.max_height.resolve_dimension(container, viewport) height = min(height, max_height) + if is_border_box and is_auto_width: + width += gutter.width + + if is_content_box: + width += gutter.width + height += gutter.height + size = Size(width, height) margin = styles.margin - return BoxModel(size, margin) + model = BoxModel(size, margin) + + return model diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 8ce6cb6f2..2054af7d9 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -82,7 +82,9 @@ class Scalar(NamedTuple): percent_unit: Unit def __str__(self) -> str: - value, _unit, _ = self + value, unit, _ = self + if unit == Unit.AUTO: + return "auto" return f"{int(value) if value.is_integer() else value}{self.symbol}" @property diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index ebeb96970..23fbc4f5a 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -141,6 +141,11 @@ class Stylesheet: def css(self) -> str: return "\n\n".join(rule_set.css for rule_set in self.rules) + def copy(self) -> Stylesheet: + stylesheet = Stylesheet(variables=self.variables.copy()) + stylesheet.source = self.source.copy() + return stylesheet + def set_variables(self, variables: dict[str, str]) -> None: """Set CSS variables. diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 689abb96b..d5f8ba98b 100644 --- a/src/textual/devtools/client.py +++ b/src/textual/devtools/client.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio import base64 -import datetime +from time import time import inspect import json import pickle @@ -207,7 +207,7 @@ class DevtoolsClient: { "type": "client_log", "payload": { - "timestamp": int(datetime.datetime.utcnow().timestamp()), + "timestamp": int(time()), "path": getattr(log.caller, "filename", ""), "line_number": getattr(log.caller, "lineno", 0), "encoded_segments": encoded_segments, @@ -242,6 +242,6 @@ class DevtoolsClient: Returns: str: The Segment list pickled with pickle protocol v3, then base64 encoded """ - pickled = pickle.dumps(segments, protocol=3) + pickled = pickle.dumps(segments, protocol=pickle.HIGHEST_PROTOCOL) encoded = base64.b64encode(pickled) return str(encoded, encoding="utf-8") diff --git a/src/textual/devtools/renderables.py b/src/textual/devtools/renderables.py index 8cba84d39..c94c9e8b7 100644 --- a/src/textual/devtools/renderables.py +++ b/src/textual/devtools/renderables.py @@ -72,19 +72,13 @@ class DevConsoleLog: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: - local_time = ( - datetime.fromtimestamp(self.unix_timestamp) - .replace(tzinfo=timezone.utc) - .astimezone(tz=datetime.now().astimezone().tzinfo) - ) - timezone_name = local_time.tzname() + local_time = datetime.fromtimestamp(self.unix_timestamp) + table = Table.grid(expand=True) - table.add_column() - table.add_column() file_link = escape(f"file://{Path(self.path).absolute()}") file_and_line = escape(f"{Path(self.path).name}:{self.line_number}") table.add_row( - f"[dim]{local_time.time()} {timezone_name}", + f"[dim]{local_time.time()}", Align.right( Text(f"{file_and_line}", style=Style(dim=True, link=file_link)) ), diff --git a/src/textual/devtools/service.py b/src/textual/devtools/service.py index e70fb06a9..8f94f51a5 100644 --- a/src/textual/devtools/service.py +++ b/src/textual/devtools/service.py @@ -179,7 +179,7 @@ class ClientHandler: and message_time - last_message_time > 1 ): # Print a rule if it has been longer than a second since the last message - self.service.console.rule("") + self.service.console.rule() self.service.console.print( DevConsoleLog( segments=segments, diff --git a/src/textual/dom.py b/src/textual/dom.py index 23b51dbaa..d06846abf 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -270,7 +270,7 @@ class DOMNode(MessagePump): return tree @property - def rich_text_style(self) -> Style: + def 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, @@ -283,19 +283,25 @@ class DOMNode(MessagePump): # 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): + style += node.styles.text_style + + return style + + @property + def colors(self) -> tuple[tuple[Color, Color], tuple[Color, Color]]: + base_background = background = Color(0, 0, 0, 0) + base_color = color = Color(255, 255, 255, 0) for node in reversed(self.ancestors): styles = node.styles if styles.has_rule("background"): + base_background = 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 + base_color = color + color += styles.color + return (base_background, base_color), (background, color) @property def ancestors(self) -> list[DOMNode]: diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index d0ff64c92..c313521c2 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -50,8 +50,6 @@ class HorizontalLayout(Layout): x += region.width + margin max_width = x - max_width += margins[-1] if margins else 0 - total_region = Region(0, 0, max_width, max_height) add_placement(WidgetPlacement(total_region, None, 0)) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 21c81e2a5..ea987c844 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -47,9 +47,17 @@ class VerticalLayout(Layout): y += region.height + margin max_height = y - max_height += margins[-1] if margins else 0 + # max_height += margins[-1] if margins else 0 total_region = Region(0, 0, max_width, max_height) add_placement(WidgetPlacement(total_region, None, 0)) return placements, set(displayed_children) + + # def get_content_width( + # self, parent: Widget, container_size: Size, viewport_size: Size + # ) -> int: + # width = super().get_content_width(parent, container_size, viewport_size) + # width = min(width, container_size.width) + # print("get_content_width", parent, container_size, width) + # return width diff --git a/src/textual/widget.py b/src/textual/widget.py index 09b315eb0..981599003 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -70,6 +70,7 @@ class Widget(DOMNode): can_focus_children: bool = True CSS = """ + """ def __init__( @@ -111,6 +112,14 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) + def watch_show_horizontal_scrollbar(self, value: bool) -> None: + if not value: + self.scroll_to(0, 0, animate=False) + + def watch_show_vertical_scrollbar(self, value: bool) -> None: + if not value: + self.scroll_to(0, 0, animate=False) + def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: self.app.register(self, *anon_widgets, **widgets) self.screen.refresh() @@ -160,10 +169,15 @@ class Widget(DOMNode): Returns: int: The optimal width of the content. """ + if self.is_container: + return self.layout.get_content_width(self, container_size, viewport_size) console = self.app.console renderable = self.render(self.styles.rich_style) - measurement = Measurement.get(console, console.options, renderable) - return measurement.maximum + measurement = Measurement.get( + console, console.options.update_width(container_size.width), renderable + ) + width = measurement.maximum + return width def get_content_height( self, container_size: Size, viewport_size: Size, width: int @@ -178,11 +192,20 @@ class Widget(DOMNode): Returns: int: The height of the content. """ - renderable = self.render(self.styles.rich_style) - options = self.console.options.update_width(width) - segments = self.console.render(renderable, options) - # Cheaper than counting the lines returned from render_lines! - height = sum(text.count("\n") for text, _, _ in segments) + + if self.is_container: + assert self.layout is not None + return self.layout.get_content_height( + self, container_size, viewport_size, width + ) + else: + renderable = self.render(self.styles.rich_style) + options = self.console.options.update_width(width).update(highlight=False) + + segments = self.console.render(renderable, options) + # # Cheaper than counting the lines returned from render_lines! + # print(list(segments)) + height = sum(text.count("\n") for text, _, _ in segments) return height async def watch_scroll_x(self, new_value: float) -> None: @@ -556,32 +579,28 @@ class Widget(DOMNode): Returns: RenderableType: A new renderable. """ - renderable = self.render(self.styles.rich_style) + renderable = self.render(self.text_style) + (base_background, base_color), (background, color) = self.colors 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 = Padding(renderable, styles.padding) - - renderable_text_style = parent_text_style + text_style - if renderable_text_style: - style = Style.from_color(text_style.color, text_style.bgcolor) - renderable = Styled(renderable, style) + renderable = Padding( + renderable, + styles.padding, + style=Style.from_color(color.rich_color, background.rich_color), + ) if styles.border: renderable = Border( renderable, styles.border, - inner_color=styles.background, - outer_color=Color.from_rich_color(parent_text_style.bgcolor), + inner_color=background, + outer_color=base_background, ) if styles.outline: @@ -589,7 +608,7 @@ class Widget(DOMNode): renderable, styles.outline, inner_color=styles.background, - outer_color=parent_styles.background, + outer_color=base_background, outline=True, ) @@ -706,6 +725,8 @@ class Widget(DOMNode): self.horizontal_scrollbar.window_virtual_size = virtual_size.width self.horizontal_scrollbar.window_size = width + self.scroll_x = self.validate_scroll_x(self.scroll_x) + self.scroll_y = self.validate_scroll_y(self.scroll_y) self.refresh(layout=True) self.call_later(self.scroll_to, self.scroll_x, self.scroll_y) else: @@ -865,10 +886,12 @@ class Widget(DOMNode): def on_focus(self, event: events.Focus) -> None: self.emit_no_wait(events.DescendantFocus(self)) self.has_focus = True + self.refresh() def on_blur(self, event: events.Blur) -> None: self.emit_no_wait(events.DescendantBlur(self)) self.has_focus = False + self.refresh() def on_descendant_focus(self, event: events.DescendantFocus) -> None: self.descendant_has_focus = True @@ -880,13 +903,13 @@ class Widget(DOMNode): def on_mouse_scroll_down(self, event) -> None: if self.is_container: - self.scroll_down(animate=False) - event.stop() + if self.scroll_down(animate=False): + event.stop() def on_mouse_scroll_up(self, event) -> None: if self.is_container: - self.scroll_up(animate=False) - event.stop() + if self.scroll_up(animate=False): + event.stop() def handle_scroll_to(self, message: ScrollTo) -> None: if self.is_container: