diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d9655531..c603f6c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Allowed border_title and border_subtitle to accept Text objects - Added additional line around titles +- When a container is auto, relative dimensions in children stretch the container. https://github.com/Textualize/textual/pull/2221 ### Fixed +- Fixed margin not being respected when width or height is "auto" https://github.com/Textualize/textual/issues/2220 - Fixed issue which prevent scroll_visible from working https://github.com/Textualize/textual/issues/2181 ## [0.18.0] - 2023-04-04 diff --git a/docs/examples/widgets/content_switcher.css b/docs/examples/widgets/content_switcher.css index b8546b117..da47d9c57 100644 --- a/docs/examples/widgets/content_switcher.css +++ b/docs/examples/widgets/content_switcher.css @@ -1,9 +1,9 @@ Screen { align: center middle; + padding: 1; } #buttons { - margin-top: 1; height: 3; width: auto; } @@ -12,7 +12,7 @@ ContentSwitcher { background: $panel; border: round $primary; width: 90%; - height: 80%; + height: 1fr; } DataTable { diff --git a/src/textual/_layout.py b/src/textual/_layout.py index a2b42a0c3..9054435bf 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -136,4 +136,5 @@ class Layout(ABC): # Use a height of zero to ignore relative heights arrangement = widget._arrange(Size(width, 0)) height = arrangement.total_region.bottom + return height diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index 0a6671377..f8f7efe90 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -33,7 +33,6 @@ def resolve( Returns: List of (, ) """ - resolved: list[tuple[Scalar, Fraction | None]] = [ ( (scalar, None) @@ -84,6 +83,7 @@ def resolve_box_models( widgets: list[Widget], size: Size, parent_size: Size, + margin: Size, dimension: Literal["width", "height"] = "width", ) -> list[BoxModel]: """Resolve box models for a list of dimensions @@ -93,14 +93,19 @@ def resolve_box_models( widgets: Widgets in resolve. size: Size of container. parent_size: Size of parent. + margin: Total space occupied by margin dimensions: Which dimension to resolve. Returns: List of resolved box models. """ + margin_width, margin_height = margin + + fraction_width = Fraction(max(0, size.width - margin_width)) + fraction_height = Fraction(max(0, size.height - margin_height)) + + margin_size = size - margin - fraction_width = Fraction(size.width) - fraction_height = Fraction(size.height) box_models: list[BoxModel | None] = [ ( None @@ -116,12 +121,12 @@ def resolve_box_models( total_remaining = sum( box_model.width for box_model in box_models if box_model is not None ) - remaining_space = max(0, size.width - total_remaining) + remaining_space = max(0, size.width - total_remaining - margin_width) else: total_remaining = sum( box_model.height for box_model in box_models if box_model is not None ) - remaining_space = max(0, size.height - total_remaining) + remaining_space = max(0, size.height - total_remaining - margin_height) fraction_unit = Fraction( remaining_space, @@ -136,9 +141,9 @@ def resolve_box_models( ) if dimension == "width": width_fraction = fraction_unit - height_fraction = Fraction(size.height) + height_fraction = Fraction(margin_size.height) else: - width_fraction = Fraction(size.width) + width_fraction = Fraction(margin_size.width) height_fraction = fraction_unit box_models = [ diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 85f57d9ca..eec3b9f1b 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -1,10 +1,9 @@ from __future__ import annotations from fractions import Fraction -from typing import Callable, NamedTuple +from typing import NamedTuple -from .css.styles import StylesBase -from .geometry import Size, Spacing +from .geometry import Spacing class BoxModel(NamedTuple): @@ -14,119 +13,3 @@ class BoxModel(NamedTuple): width: Fraction height: Fraction margin: Spacing # Additional margin - - -def get_box_model( - styles: StylesBase, - container: Size, - viewport: Size, - width_fraction: Fraction, - height_fraction: Fraction, - get_content_width: Callable[[Size, Size], int], - get_content_height: Callable[[Size, Size, int], int], -) -> BoxModel: - """Resolve the box model for this Styles. - - Args: - styles: Styles object. - container: The size of the widget container. - viewport: The viewport size. - width_fraction: A fraction used for 1 `fr` unit on the width dimension. - height_fraction: A fraction used for 1 `fr` unit on the height dimension. - get_content_width: A callable which accepts container size and parent size and returns a width. - get_content_height: A callable which accepts container size and parent size and returns a height. - - Returns: - A tuple with the size of the content area and margin. - """ - _content_width, _content_height = container - content_width = Fraction(_content_width) - content_height = Fraction(_content_height) - is_border_box = styles.box_sizing == "border-box" - gutter = styles.gutter - margin = styles.margin - - is_auto_width = styles.width and styles.width.is_auto - is_auto_height = styles.height and styles.height.is_auto - - # Container minus padding and border - content_container = container - gutter.totals - # The container including the content - sizing_container = content_container if is_border_box else container - - if styles.width is None: - # No width specified, fill available space - content_width = Fraction(content_container.width - margin.width) - elif is_auto_width: - # When width is auto, we want enough space to always fit the content - content_width = Fraction( - get_content_width(content_container - styles.margin.totals, viewport) - ) - if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto": - content_width += styles.scrollbar_size_vertical - else: - # An explicit width - styles_width = styles.width - content_width = styles_width.resolve( - sizing_container - styles.margin.totals, viewport, width_fraction - ) - if is_border_box and styles_width.excludes_border: - content_width -= gutter.width - - if styles.min_width is not None: - # Restrict to minimum width, if set - min_width = styles.min_width.resolve( - content_container, viewport, width_fraction - ) - content_width = max(content_width, min_width) - - if styles.max_width is not None: - # Restrict to maximum width, if set - max_width = styles.max_width.resolve( - content_container, viewport, width_fraction - ) - if is_border_box: - max_width -= gutter.width - content_width = min(content_width, max_width) - - content_width = max(Fraction(0), content_width) - - if styles.height is None: - # No height specified, fill the available space - content_height = Fraction(content_container.height - margin.height) - elif is_auto_height: - # Calculate dimensions based on content - content_height = Fraction( - get_content_height(content_container, viewport, int(content_width)) - ) - if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto": - content_height += styles.scrollbar_size_horizontal - else: - styles_height = styles.height - # Explicit height set - content_height = styles_height.resolve( - sizing_container - styles.margin.totals, viewport, height_fraction - ) - if is_border_box and styles_height.excludes_border: - content_height -= gutter.height - - if styles.min_height is not None: - # Restrict to minimum height, if set - min_height = styles.min_height.resolve( - content_container, viewport, height_fraction - ) - content_height = max(content_height, min_height) - - if styles.max_height is not None: - # Restrict maximum height, if set - max_height = styles.max_height.resolve( - content_container, viewport, height_fraction - ) - content_height = min(content_height, max_height) - - content_height = max(Fraction(0), content_height) - - model = BoxModel( - content_width + gutter.width, content_height + gutter.height, margin - ) - return model diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 087cc3211..1a4b246b6 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -384,6 +384,21 @@ class StylesBase(ABC): has_rule("height") and self.height.is_auto # type: ignore ) + @property + def is_relative_width(self) -> bool: + """Does the node have a relative width?""" + width = self.width + return width is not None and width.unit in ( + Unit.FRACTION, + Unit.PERCENT, + ) + + @property + def is_relative_height(self) -> bool: + """Does the node have a relative width?""" + height = self.height + return height is not None and height.unit in (Unit.FRACTION, Unit.PERCENT) + @abstractmethod def has_rule(self, rule: str) -> bool: """Check if a rule is set on this Styles object. diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 7ed1820ef..cde91b6dd 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -23,11 +23,28 @@ class HorizontalLayout(Layout): x = max_height = Fraction(0) parent_size = parent.outer_size + child_styles = [child.styles for child in children] + box_margins = [styles.margin for styles in child_styles] + if box_margins: + resolve_margin = Size( + ( + sum( + max(margin1.right, margin2.left) + for margin1, margin2 in zip(box_margins, box_margins[1:]) + ) + + (box_margins[0].left + box_margins[-1].right) + ), + max(margin.height for margin in box_margins), + ) + else: + resolve_margin = Size(0, 0) + box_models = resolve_box_models( - [child.styles.width for child in children], + [styles.width for styles in child_styles], children, size, parent_size, + resolve_margin, dimension="width", ) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index d0235092c..62a5bb968 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -23,11 +23,28 @@ class VerticalLayout(Layout): add_placement = placements.append parent_size = parent.outer_size + child_styles = [child.styles for child in children] + box_margins = [styles.margin for styles in child_styles] + if box_margins: + resolve_margin = Size( + max([margin.width for margin in box_margins]), + ( + sum( + max(margin1.bottom, margin2.top) + for margin1, margin2 in zip(box_margins, box_margins[1:]) + ) + + (box_margins[0].top + box_margins[-1].bottom) + ), + ) + else: + resolve_margin = Size(0, 0) + box_models = resolve_box_models( - [child.styles.height for child in children], + [styles.height for styles in child_styles], children, size, parent_size, + resolve_margin, dimension="height", ) diff --git a/src/textual/widget.py b/src/textual/widget.py index 3c929ffd8..2223c30b6 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -50,7 +50,7 @@ from ._styles_cache import StylesCache from .actions import SkipAction from .await_remove import AwaitRemove from .binding import Binding -from .box_model import BoxModel, get_box_model +from .box_model import BoxModel from .css.query import NoMatches, WrongType from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen @@ -316,6 +316,7 @@ class Widget(DOMNode): self._rich_style_cache: dict[str, tuple[Style, Style]] = {} self._stabilize_scrollbar: tuple[Size, str, str] | None = None """Used to prevent scrollbar logic getting stuck in an infinite loop.""" + self._lock = Lock() super().__init__( @@ -889,16 +890,110 @@ class Widget(DOMNode): Returns: The size and margin for this widget. """ - box_model = get_box_model( - self.styles, - container, - viewport, - width_fraction, - height_fraction, - self.get_content_width, - self.get_content_height, + styles = self.styles + _content_width, _content_height = container + content_width = Fraction(_content_width) + content_height = Fraction(_content_height) + is_border_box = styles.box_sizing == "border-box" + gutter = styles.gutter + margin = styles.margin + + is_auto_width = styles.width and styles.width.is_auto + is_auto_height = styles.height and styles.height.is_auto + + # Container minus padding and border + content_container = container - gutter.totals + # The container including the content + sizing_container = content_container if is_border_box else container + + if styles.width is None: + # No width specified, fill available space + content_width = Fraction(content_container.width - margin.width) + elif is_auto_width: + # When width is auto, we want enough space to always fit the content + content_width = Fraction( + self.get_content_width( + content_container - styles.margin.totals, viewport + ) + ) + if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto": + content_width += styles.scrollbar_size_vertical + if ( + content_width < content_container.width + and self._has_relative_children_width + ): + content_width = Fraction(content_container.width) + else: + # An explicit width + styles_width = styles.width + content_width = styles_width.resolve( + sizing_container - styles.margin.totals, viewport, width_fraction + ) + if is_border_box and styles_width.excludes_border: + content_width -= gutter.width + + if styles.min_width is not None: + # Restrict to minimum width, if set + min_width = styles.min_width.resolve( + content_container, viewport, width_fraction + ) + content_width = max(content_width, min_width) + + if styles.max_width is not None: + # Restrict to maximum width, if set + max_width = styles.max_width.resolve( + content_container, viewport, width_fraction + ) + if is_border_box: + max_width -= gutter.width + content_width = min(content_width, max_width) + + content_width = max(Fraction(0), content_width) + + if styles.height is None: + # No height specified, fill the available space + content_height = Fraction(content_container.height - margin.height) + elif is_auto_height: + # Calculate dimensions based on content + content_height = Fraction( + self.get_content_height(content_container, viewport, int(content_width)) + ) + if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto": + content_height += styles.scrollbar_size_horizontal + if ( + content_height < content_container.height + and self._has_relative_children_height + ): + content_height = Fraction(content_container.height) + else: + styles_height = styles.height + # Explicit height set + content_height = styles_height.resolve( + sizing_container - styles.margin.totals, viewport, height_fraction + ) + if is_border_box and styles_height.excludes_border: + content_height -= gutter.height + + if styles.min_height is not None: + # Restrict to minimum height, if set + min_height = styles.min_height.resolve( + content_container, viewport, height_fraction + ) + content_height = max(content_height, min_height) + + if styles.max_height is not None: + # Restrict maximum height, if set + max_height = styles.max_height.resolve( + content_container, viewport, height_fraction + ) + content_height = min(content_height, max_height) + + content_height = max(Fraction(0), content_height) + + model = BoxModel( + content_width + gutter.width, content_height + gutter.height, margin ) - return box_model + return model def get_content_width(self, container: Size, viewport: Size) -> int: """Called by textual to get the width of the content area. May be overridden in a subclass. @@ -1366,6 +1461,20 @@ class Widget(DOMNode): """ return active_app.get().console + @property + def _has_relative_children_width(self) -> bool: + """Do any children have a relative width?""" + if not self.is_container: + return False + return any(widget.styles.is_relative_width for widget in self.children) + + @property + def _has_relative_children_height(self) -> bool: + """Do any children have a relative height?""" + if not self.is_container: + return False + return any(widget.styles.is_relative_height for widget in self.children) + def animate( self, attribute: str, diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 00ec1d61b..58db7fff9 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -160,6 +160,170 @@ ''' # --- +# name: test_auto_fr + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FRApp + + + + + + + + + + ────────────────────────────────────────────────────────────────────────────── + ──────────────────────────── + Hello one line + ────────────────────────── + Widget#child + + + + + + + + + + + + + + ────────────────────────── + + Two + Lines with 1x2 margin + + ──────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + + + + + ''' +# --- # name: test_auto_table ''' @@ -1574,139 +1738,139 @@ font-weight: 700; } - .terminal-2964420743-matrix { + .terminal-2222688117-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2964420743-title { + .terminal-2222688117-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2964420743-r1 { fill: #e1e1e1 } - .terminal-2964420743-r2 { fill: #c5c8c6 } - .terminal-2964420743-r3 { fill: #454a50 } - .terminal-2964420743-r4 { fill: #e2e3e3;font-weight: bold } - .terminal-2964420743-r5 { fill: #000000 } - .terminal-2964420743-r6 { fill: #004578 } - .terminal-2964420743-r7 { fill: #dde6ed;font-weight: bold } - .terminal-2964420743-r8 { fill: #dde6ed } - .terminal-2964420743-r9 { fill: #211505 } - .terminal-2964420743-r10 { fill: #e2e3e3 } + .terminal-2222688117-r1 { fill: #c5c8c6 } + .terminal-2222688117-r2 { fill: #e1e1e1 } + .terminal-2222688117-r3 { fill: #454a50 } + .terminal-2222688117-r4 { fill: #e2e3e3;font-weight: bold } + .terminal-2222688117-r5 { fill: #000000 } + .terminal-2222688117-r6 { fill: #004578 } + .terminal-2222688117-r7 { fill: #dde6ed;font-weight: bold } + .terminal-2222688117-r8 { fill: #dde6ed } + .terminal-2222688117-r9 { fill: #211505 } + .terminal-2222688117-r10 { fill: #e2e3e3 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ContentSwitcherApp + ContentSwitcherApp - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - DataTableMarkdown - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ────────────────────────────────────────────────────────────────────── -  Book                                 Year  -  Dune                                 1965  -  Dune Messiah                         1969  -  Children of Dune                     1976  -  God Emperor of Dune                  1981  -  Heretics of Dune                     1984  -  Chapterhouse: Dune                   1985  - - - - - - - - - - - ────────────────────────────────────────────────────────────────────── + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DataTableMarkdown + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ──────────────────────────────────────────────────────────────────── +  Book                                 Year  +  Dune                                 1965  +  Dune Messiah                         1969  +  Children of Dune                     1976  +  God Emperor of Dune                  1981  +  Heretics of Dune                     1984  +  Chapterhouse: Dune                   1985  + + + + + + + + + + + ──────────────────────────────────────────────────────────────────── @@ -1737,247 +1901,246 @@ font-weight: 700; } - .terminal-703122315-matrix { + .terminal-1921368926-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-703122315-title { + .terminal-1921368926-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-703122315-r1 { fill: #e1e1e1 } - .terminal-703122315-r2 { fill: #c5c8c6 } - .terminal-703122315-r3 { fill: #454a50 } - .terminal-703122315-r4 { fill: #e2e3e3;font-weight: bold } - .terminal-703122315-r5 { fill: #24292f;font-weight: bold } - .terminal-703122315-r6 { fill: #000000 } - .terminal-703122315-r7 { fill: #004578 } - .terminal-703122315-r8 { fill: #121212 } - .terminal-703122315-r9 { fill: #e2e3e3 } - .terminal-703122315-r10 { fill: #0053aa } - .terminal-703122315-r11 { fill: #dde8f3;font-weight: bold } - .terminal-703122315-r12 { fill: #ffff00;font-weight: bold } - .terminal-703122315-r13 { fill: #24292f } - .terminal-703122315-r14 { fill: #14191f } + .terminal-1921368926-r1 { fill: #c5c8c6 } + .terminal-1921368926-r2 { fill: #e1e1e1 } + .terminal-1921368926-r3 { fill: #454a50 } + .terminal-1921368926-r4 { fill: #e2e3e3;font-weight: bold } + .terminal-1921368926-r5 { fill: #24292f;font-weight: bold } + .terminal-1921368926-r6 { fill: #000000 } + .terminal-1921368926-r7 { fill: #004578 } + .terminal-1921368926-r8 { fill: #121212 } + .terminal-1921368926-r9 { fill: #e2e3e3 } + .terminal-1921368926-r10 { fill: #0053aa } + .terminal-1921368926-r11 { fill: #dde8f3;font-weight: bold } + .terminal-1921368926-r12 { fill: #ffff00;font-weight: bold } + .terminal-1921368926-r13 { fill: #24292f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ContentSwitcherApp + ContentSwitcherApp - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - DataTableMarkdown - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ─────────────────────────────────────────── - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Three Flavours Cornetto - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - The Three Flavours Cornetto  - trilogy is an anthology series of - Britishcomedic genre films  - directed by Edgar Wright. - - Shaun of the Dead - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - UK  - Release  - FlavourDateDirector -  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  - Strawber…2004-04-…Edgar  - Wright - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - Hot Fuzz - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - UK Release - FlavourDateDirector -  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  - Classico2007-02-17Edgar ▇▇ - Wright - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - The World's End - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ─────────────────────────────────────────── - - + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DataTableMarkdown + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ───────────────────────────────────────── + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Three Flavours Cornetto + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + The Three Flavours Cornetto  + trilogy is an anthology series  + of Britishcomedic genre films  + directed by Edgar Wright. + + Shaun of the Dead + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + UK  + Release  + FlavourDateDirector +  ━━━━━━━━━━━━━━━━━━━━━━━━━━━  + Strawbe…2004-04…Edgar  + Wright + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + Hot Fuzz + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + UK  + Release  + FlavourDateDirector +  ━━━━━━━━━━━━━━━━━━━━━━━━━━━  + Classico2007-02…Edgar  + Wright + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + The World's End + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + UK  + Release  + FlavourDateDirector + ───────────────────────────────────────── @@ -14090,6 +14253,167 @@ ''' # --- +# name: test_fr_margins + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TestApp + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Hello + + + + + + + World + + + + + + + !! + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + ''' +# --- # name: test_fr_units ''' diff --git a/tests/snapshot_tests/snapshot_apps/auto_fr.py b/tests/snapshot_tests/snapshot_apps/auto_fr.py new file mode 100644 index 000000000..1f6f128d8 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/auto_fr.py @@ -0,0 +1,40 @@ +from textual.app import App, ComposeResult +from textual.widget import Widget +from textual.widgets import Label + + +class FRApp(App): + CSS = """ + Screen { + align: center middle; + border: solid cyan; + } + + #container { + width: 30; + height: auto; + border: solid green; + overflow-y: auto; + } + + #child { + height: 1fr; + border: solid red; + } + + #bottom { + margin: 1 2; + background: $primary; + } + """ + + def compose(self) -> ComposeResult: + with Widget(id="container"): + yield Label("Hello one line", id="top") + yield Widget(id="child") + yield Label("Two\nLines with 1x2 margin", id="bottom") + + +if __name__ == "__main__": + app = FRApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/fr_margins.py b/tests/snapshot_tests/snapshot_apps/fr_margins.py new file mode 100644 index 000000000..204e6154f --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/fr_margins.py @@ -0,0 +1,35 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label +from textual.containers import Container + + +# Test fr dimensions and margins work in an auto container +# https://github.com/Textualize/textual/issues/2220 +class TestApp(App): + CSS = """ + Container { + background: green 20%; + border: heavy green; + width: auto; + height: auto; + overflow: hidden; + } + + Label { + background: green 20%; + width: 1fr; + height: 1fr; + margin: 2 2; + } + """ + + def compose(self) -> ComposeResult: + with Container(): + yield Label("Hello") + yield Label("World") + yield Label("!!") + + +if __name__ == "__main__": + app = TestApp() + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index deafb4a7c..1ab64f8ea 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -388,6 +388,16 @@ def test_dock_scroll(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "dock_scroll.py", terminal_size=(80, 25)) +def test_auto_fr(snap_compare): + # https://github.com/Textualize/textual/issues/2220 + assert snap_compare(SNAPSHOT_APPS_DIR / "auto_fr.py", terminal_size=(80, 25)) + + +def test_fr_margins(snap_compare): + # https://github.com/Textualize/textual/issues/2220 + assert snap_compare(SNAPSHOT_APPS_DIR / "fr_margins.py", terminal_size=(80, 25)) + + def test_scroll_visible(snap_compare): # https://github.com/Textualize/textual/issues/2181 assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_visible.py", press=["t"]) diff --git a/tests/test_box_model.py b/tests/test_box_model.py index c66f5a5b8..135eb97b3 100644 --- a/tests/test_box_model.py +++ b/tests/test_box_model.py @@ -2,206 +2,190 @@ from __future__ import annotations from fractions import Fraction -from textual.box_model import BoxModel, get_box_model +from textual.box_model import BoxModel from textual.css.styles import Styles from textual.geometry import Size, Spacing +from textual.widget import Widget def test_content_box(): - styles = Styles() - styles.width = 10 - styles.height = 8 - styles.padding = 1 - styles.border = ("solid", "red") - one = Fraction(1) + class TestWidget(Widget): + def get_content_width(self, container: Size, parent: Size) -> int: + assert False, "must not be called" + + def get_content_height(self, container: Size, parent: Size) -> int: + assert False, "must not be called" + + widget = TestWidget() + # border-box is default - assert styles.box_sizing == "border-box" + assert widget.styles.box_sizing == "border-box" - def get_auto_width(container: Size, parent: Size) -> int: - assert False, "must not be called" + widget.styles.width = 10 + widget.styles.height = 8 + widget.styles.padding = 1 + widget.styles.border = ("solid", "red") - def get_auto_height(container: Size, parent: Size) -> int: - assert False, "must not be called" - - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height + box_model = widget._get_box_model( + Size(60, 20), + Size(80, 24), + one, + one, ) # Size should be inclusive of padding / border assert box_model == BoxModel(Fraction(10), Fraction(8), Spacing(0, 0, 0, 0)) # Switch to content-box - styles.box_sizing = "content-box" + widget.styles.box_sizing = "content-box" - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) # width and height have added padding / border to accommodate content assert box_model == BoxModel(Fraction(14), Fraction(12), Spacing(0, 0, 0, 0)) def test_width(): """Test width settings.""" - styles = Styles() + one = Fraction(1) - def get_auto_width(container: Size, parent: Size) -> int: - return 10 + class TestWidget(Widget): + def get_content_width(self, container: Size, parent: Size) -> int: + return 10 - def get_auto_height(container: Size, parent: Size, width: int) -> int: - return 10 + def get_content_height(self, container: Size, parent: Size, width: int) -> int: + return 10 - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + widget = TestWidget() + styles = widget.styles + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(60), Fraction(20), Spacing(0, 0, 0, 0)) # Add a margin and check that it is reported styles.margin = (1, 2, 3, 4) - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) # Set width to auto-detect styles.width = "auto" - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) # Setting width to auto should call get_auto_width assert box_model == BoxModel(Fraction(10), Fraction(16), Spacing(1, 2, 3, 4)) # Set width to 100 vw which should make it the width of the parent styles.width = "100vw" - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(80), Fraction(16), Spacing(1, 2, 3, 4)) # Set the width to 100% should make it fill the container size styles.width = "100%" - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) styles.width = "100vw" styles.max_width = "50%" - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(30), Fraction(16), Spacing(1, 2, 3, 4)) def test_height(): """Test height settings.""" - styles = Styles() + one = Fraction(1) - def get_auto_width(container: Size, parent: Size) -> int: - return 10 + class TestWidget(Widget): + def get_content_width(self, container: Size, parent: Size) -> int: + return 10 - def get_auto_height(container: Size, parent: Size, width: int) -> int: - return 10 + def get_content_height(self, container: Size, parent: Size, width: int) -> int: + return 10 - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + widget = TestWidget() + styles = widget.styles + + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(60), Fraction(20), Spacing(0, 0, 0, 0)) # Add a margin and check that it is reported styles.margin = (1, 2, 3, 4) - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) # Set height to 100 vw which should make it the height of the parent styles.height = "100vh" - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(54), Fraction(24), Spacing(1, 2, 3, 4)) # Set the height to 100% should make it fill the container size styles.height = "100%" - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) styles.height = "auto" styles.margin = 2 - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) + print(box_model) assert box_model == BoxModel(Fraction(56), Fraction(10), Spacing(2, 2, 2, 2)) styles.margin = 1, 2, 3, 4 styles.height = "100vh" styles.max_height = "50%" - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(54), Fraction(10), Spacing(1, 2, 3, 4)) - # Set height to auto and set content height to 0 to check if box collapses. - styles.height = "auto" - - box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, lambda *_: 0 - ) - assert box_model == BoxModel(Fraction(54), Fraction(0), Spacing(1, 2, 3, 4)) - def test_max(): """Check that max_width and max_height are respected.""" - styles = Styles() + one = Fraction(1) + + class TestWidget(Widget): + def get_content_width(self, container: Size, parent: Size) -> int: + assert False, "must not be called" + + def get_content_height(self, container: Size, parent: Size, width: int) -> int: + assert False, "must not be called" + + widget = TestWidget() + styles = widget.styles + styles.width = 100 styles.height = 80 styles.max_width = 40 styles.max_height = 30 - one = Fraction(1) - def get_auto_width(container: Size, parent: Size) -> int: - assert False, "must not be called" - - def get_auto_height(container: Size, parent: Size) -> int: - assert False, "must not be called" - - box_model = get_box_model( - styles, Size(40, 30), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(40, 30), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(40), Fraction(30), Spacing(0, 0, 0, 0)) def test_min(): """Check that min_width and min_height are respected.""" - styles = Styles() + + one = Fraction(1) + + class TestWidget(Widget): + def get_content_width(self, container: Size, parent: Size) -> int: + assert False, "must not be called" + + def get_content_height(self, container: Size, parent: Size, width: int) -> int: + assert False, "must not be called" + + widget = TestWidget() + styles = widget.styles styles.width = 10 styles.height = 5 styles.min_width = 40 styles.min_height = 30 - one = Fraction(1) - def get_auto_width(container: Size, parent: Size) -> int: - assert False, "must not be called" - - def get_auto_height(container: Size, parent: Size) -> int: - assert False, "must not be called" - - box_model = get_box_model( - styles, Size(40, 30), Size(80, 24), one, one, get_auto_width, get_auto_height - ) + box_model = widget._get_box_model(Size(40, 30), Size(80, 24), one, one) assert box_model == BoxModel(Fraction(40), Fraction(30), Spacing(0, 0, 0, 0))