diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83496f9f2..3db42cba3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,12 @@ 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/).
-## Unreleased
+## [0.22.0] - Unreleased
+
+### Fixed
+
+- Fixed broken fr units when there is a min or max dimension https://github.com/Textualize/textual/issues/2378
+- Fixed plain text in Markdown code blocks with no syntax being difficult to read https://github.com/Textualize/textual/issues/2400
### Added
@@ -15,9 +20,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- All `textual.containers` are now `1fr` in relevant dimensions by default https://github.com/Textualize/textual/pull/2386
-### Fixed
-
-- Fixed plain text in Markdown code blocks with no syntax being difficult to read https://github.com/Textualize/textual/issues/2400
## [0.21.0] - 2023-04-26
diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py
index cc826a298..e9dedf961 100644
--- a/src/textual/_resolve.py
+++ b/src/textual/_resolve.py
@@ -2,12 +2,13 @@ from __future__ import annotations
from fractions import Fraction
from itertools import accumulate
-from typing import TYPE_CHECKING, Sequence, cast
+from typing import TYPE_CHECKING, Iterable, Sequence, cast
from typing_extensions import Literal
from .box_model import BoxModel
from .css.scalar import Scalar
+from .css.styles import RenderStyles
from .geometry import Size
if TYPE_CHECKING:
@@ -78,13 +79,105 @@ def resolve(
return results
+def resolve_fraction_unit(
+ widget_styles: Iterable[RenderStyles],
+ size: Size,
+ viewport_size: Size,
+ remaining_space: Fraction,
+ resolve_dimension: Literal["width", "height"] = "width",
+) -> Fraction:
+ """Calculate the fraction
+
+ Args:
+ widget_styles: Styles for widgets with fraction units.
+ size: Container size.
+ viewport_size: Viewport size.
+ remaining_space: Remaining space for fr units.
+ resolve_dimension: Which dimension to resolve.
+
+ Returns:
+ The value of 1fr.
+ """
+ if not remaining_space or not widget_styles:
+ return Fraction(1)
+
+ def resolve_scalar(
+ scalar: Scalar | None, fraction_unit: Fraction = Fraction(1)
+ ) -> Fraction | None:
+ """Resolve a scalar if it is not None.
+
+ Args:
+ scalar: Optional scalar to resolve.
+ fraction_unit: Size of 1fr.
+
+ Returns:
+ Fraction if resolved, otherwise None.
+ """
+ return (
+ None
+ if scalar is None
+ else scalar.resolve(size, viewport_size, fraction_unit)
+ )
+
+ resolve: list[tuple[Scalar, Fraction | None, Fraction | None]] = []
+
+ if resolve_dimension == "width":
+ resolve = [
+ (
+ cast(Scalar, styles.width),
+ resolve_scalar(styles.min_width),
+ resolve_scalar(styles.max_width),
+ )
+ for styles in widget_styles
+ ]
+ else:
+ resolve = [
+ (
+ cast(Scalar, styles.height),
+ resolve_scalar(styles.min_height),
+ resolve_scalar(styles.max_height),
+ )
+ for styles in widget_styles
+ ]
+
+ resolved: list[Fraction | None] = [None] * len(resolve)
+ remaining_fraction = Fraction(sum(scalar.value for scalar, _, _ in resolve))
+
+ while True:
+ remaining_space_changed = False
+ resolve_fraction = Fraction(remaining_space, remaining_fraction)
+ for index, (scalar, min_value, max_value) in enumerate(resolve):
+ value = resolved[index]
+ if value is None:
+ resolved_scalar = scalar.resolve(size, viewport_size, resolve_fraction)
+ if min_value is not None and resolved_scalar < min_value:
+ remaining_space -= min_value
+ remaining_fraction -= Fraction(scalar.value)
+ resolved[index] = min_value
+ remaining_space_changed = True
+ elif max_value is not None and resolved_scalar > max_value:
+ remaining_space -= max_value
+ remaining_fraction -= Fraction(scalar.value)
+ resolved[index] = max_value
+ remaining_space_changed = True
+
+ if not remaining_space_changed:
+ break
+
+ return (
+ Fraction(remaining_space, remaining_fraction)
+ if remaining_space
+ else Fraction(1)
+ )
+
+
def resolve_box_models(
dimensions: list[Scalar | None],
widgets: list[Widget],
size: Size,
viewport_size: Size,
margin: Size,
- dimension: Literal["width", "height"] = "width",
+ resolve_dimension: Literal["width", "height"] = "width",
) -> list[BoxModel]:
"""Resolve box models for a list of dimensions
@@ -94,7 +187,7 @@ def resolve_box_models(
size: Size of container.
viewport_size: Viewport size.
margin: Total space occupied by margin
- dimensions: Which dimension to resolve.
+ resolve_dimension: Which dimension to resolve.
Returns:
List of resolved box models.
@@ -106,6 +199,7 @@ def resolve_box_models(
margin_size = size - margin
+ # Fixed box models
box_models: list[BoxModel | None] = [
(
None
@@ -117,30 +211,46 @@ def resolve_box_models(
for (_dimension, widget) in zip(dimensions, widgets)
]
- if dimension == "width":
- total_remaining = sum([width for width, _, _ in filter(None, box_models)])
- remaining_space = max(0, size.width - total_remaining - margin_width)
- else:
- total_remaining = sum([height for _, height, _ in filter(None, box_models)])
- remaining_space = max(0, size.height - total_remaining - margin_height)
+ if None not in box_models:
+ # No fr units, so we're done
+ return cast("list[BoxModel]", box_models)
- fraction_unit = Fraction(
- remaining_space,
- int(
- sum(
- [
- dimension.value
- for dimension in dimensions
- if dimension and dimension.is_fraction
- ]
- )
+ # If all box models have been calculated
+ widget_styles = [widget.styles for widget in widgets]
+ if resolve_dimension == "width":
+ total_remaining = int(
+ sum([width for width, _, _ in filter(None, box_models)], start=Fraction())
+ )
+ remaining_space = int(max(0, size.width - total_remaining - margin_width))
+ fraction_unit = resolve_fraction_unit(
+ [
+ styles
+ for styles in widget_styles
+ if styles.width is not None and styles.width.is_fraction
+ ],
+ size,
+ viewport_size,
+ Fraction(remaining_space),
+ resolve_dimension,
)
- or 1,
- )
- if dimension == "width":
width_fraction = fraction_unit
height_fraction = Fraction(margin_size.height)
else:
+ total_remaining = int(
+ sum([height for _, height, _ in filter(None, box_models)], start=Fraction())
+ )
+ remaining_space = int(max(0, size.height - total_remaining - margin_height))
+ fraction_unit = resolve_fraction_unit(
+ [
+ styles
+ for styles in widget_styles
+ if styles.height is not None and styles.height.is_fraction
+ ],
+ size,
+ viewport_size,
+ Fraction(remaining_space),
+ resolve_dimension,
+ )
width_fraction = Fraction(margin_size.width)
height_fraction = fraction_unit
diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py
index ff09e19eb..a69d94207 100644
--- a/src/textual/css/_style_properties.py
+++ b/src/textual/css/_style_properties.py
@@ -187,7 +187,7 @@ class ScalarProperty:
new_value = Scalar.parse(value)
except ScalarParseError:
raise StyleValueError(
- "unable to parse scalar from {value!r}",
+ f"unable to parse scalar from {value!r}",
help_text=scalar_help_text(
property_name=self.name, context="inline"
),
diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py
index 4455ea20c..5b5c8fdae 100644
--- a/src/textual/layouts/horizontal.py
+++ b/src/textual/layouts/horizontal.py
@@ -53,7 +53,7 @@ class HorizontalLayout(Layout):
size,
parent.app.size,
resolve_margin,
- dimension="width",
+ resolve_dimension="width",
)
margins = [
diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py
index b498fd637..148c1854f 100644
--- a/src/textual/layouts/vertical.py
+++ b/src/textual/layouts/vertical.py
@@ -50,7 +50,7 @@ class VerticalLayout(Layout):
size,
parent.app.size,
resolve_margin,
- dimension="height",
+ resolve_dimension="height",
)
margins = [
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 2e72c2380..dd8e6b7dc 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -2699,6 +2699,8 @@ class Widget(DOMNode):
or self.virtual_size != virtual_size
or self._container_size != container_size
):
+ if self._size != size:
+ self._set_dirty()
self._size = size
if layout:
self.virtual_size = virtual_size
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 41651754e..d63cb210f 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -14891,6 +14891,166 @@
'''
# ---
+# name: test_fr_unit_with_min
+ '''
+
+
+ '''
+# ---
# name: test_fr_units
'''