mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
35
sandbox/will/calculator.css
Normal file
35
sandbox/will/calculator.css
Normal file
@@ -0,0 +1,35 @@
|
||||
Screen {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#calculator {
|
||||
layout: table;
|
||||
table-size: 4;
|
||||
table-gutter: 1 2;
|
||||
table-columns: 1fr;
|
||||
table-rows: 2fr 1fr 1fr 1fr 1fr 1fr;
|
||||
margin: 1 2;
|
||||
min-height:26;
|
||||
min-width: 50;
|
||||
}
|
||||
|
||||
Button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.display {
|
||||
column-span: 4;
|
||||
content-align: right middle;
|
||||
padding: 0 1;
|
||||
height: 100%;
|
||||
background: $panel-darken-2;
|
||||
}
|
||||
|
||||
.special {
|
||||
tint: $text-panel 20%;
|
||||
}
|
||||
|
||||
.zero {
|
||||
column-span: 2;
|
||||
}
|
||||
36
sandbox/will/calculator.py
Normal file
36
sandbox/will/calculator.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from textual.app import App
|
||||
|
||||
from textual.layout import Container
|
||||
from textual.widgets import Button, Static
|
||||
|
||||
|
||||
class CalculatorApp(App):
|
||||
def compose(self):
|
||||
yield Container(
|
||||
Static("0", classes="display"),
|
||||
Button("AC", classes="special"),
|
||||
Button("+/-", classes="special"),
|
||||
Button("%", classes="special"),
|
||||
Button("÷", variant="warning"),
|
||||
Button("7"),
|
||||
Button("8"),
|
||||
Button("9"),
|
||||
Button("×", variant="warning"),
|
||||
Button("4"),
|
||||
Button("5"),
|
||||
Button("6"),
|
||||
Button("-", variant="warning"),
|
||||
Button("1"),
|
||||
Button("2"),
|
||||
Button("3"),
|
||||
Button("+", variant="warning"),
|
||||
Button("0", classes="operator zero"),
|
||||
Button("."),
|
||||
Button("=", variant="warning"),
|
||||
id="calculator",
|
||||
)
|
||||
|
||||
|
||||
app = CalculatorApp(css_path="calculator.css")
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
12
sandbox/will/spacing.css
Normal file
12
sandbox/will/spacing.css
Normal file
@@ -0,0 +1,12 @@
|
||||
Screen {
|
||||
|
||||
overflow: auto;
|
||||
|
||||
}
|
||||
|
||||
Static {
|
||||
background: blue 20%;
|
||||
height: 100%;
|
||||
margin: 2 4;
|
||||
min-width: 30;
|
||||
}
|
||||
10
sandbox/will/spacing.py
Normal file
10
sandbox/will/spacing.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from textual.app import App
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class SpacingApp(App):
|
||||
def compose(self):
|
||||
yield Static()
|
||||
|
||||
|
||||
app = SpacingApp(css_path="spacing.css")
|
||||
23
sandbox/will/table_layout.css
Normal file
23
sandbox/will/table_layout.css
Normal file
@@ -0,0 +1,23 @@
|
||||
Screen {
|
||||
layout: table;
|
||||
table-columns: 2fr 1fr 1fr;
|
||||
table-rows: 1fr 1fr;
|
||||
table-gutter: 1 2;
|
||||
}
|
||||
|
||||
Static {
|
||||
border: solid white;
|
||||
background: blue 20%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#foo {
|
||||
row-span: 2;
|
||||
}
|
||||
|
||||
#last {
|
||||
column-span: 3;
|
||||
margin: 1;
|
||||
|
||||
}
|
||||
19
sandbox/will/table_layout.py
Normal file
19
sandbox/will/table_layout.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from textual.app import App
|
||||
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class TableLayoutApp(App):
|
||||
def compose(self):
|
||||
yield Static("foo", id="foo")
|
||||
yield Static("bar")
|
||||
yield Static("baz")
|
||||
|
||||
yield Static("foo")
|
||||
yield Static("bar")
|
||||
yield Static("baz", id="last")
|
||||
|
||||
|
||||
app = TableLayoutApp(css_path="table_layout.css")
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -48,7 +48,7 @@ def arrange(
|
||||
_WidgetPlacement = WidgetPlacement
|
||||
top_z = TOP_Z
|
||||
scroll_spacing = Spacing()
|
||||
|
||||
null_spacing = Spacing()
|
||||
get_dock = attrgetter("styles.dock")
|
||||
|
||||
for widgets in dock_layers.values():
|
||||
@@ -94,7 +94,9 @@ def arrange(
|
||||
(widget_width, widget_height), size
|
||||
)
|
||||
dock_region = dock_region.shrink(margin).translate(align_offset)
|
||||
add_placement(_WidgetPlacement(dock_region, dock_widget, top_z, True))
|
||||
add_placement(
|
||||
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
|
||||
)
|
||||
|
||||
dock_spacing = Spacing(top, right, bottom, left)
|
||||
region = size.region.shrink(dock_spacing)
|
||||
@@ -109,9 +111,9 @@ def arrange(
|
||||
if placement_offset:
|
||||
layout_placements = [
|
||||
_WidgetPlacement(
|
||||
_region + placement_offset, layout_widget, order, fixed
|
||||
_region + placement_offset, margin, layout_widget, order, fixed
|
||||
)
|
||||
for _region, layout_widget, order, fixed in layout_placements
|
||||
for _region, margin, layout_widget, order, fixed in layout_placements
|
||||
]
|
||||
|
||||
placements.extend(layout_placements)
|
||||
|
||||
@@ -400,17 +400,16 @@ class Compositor:
|
||||
get_layer_index = layers_to_index.get
|
||||
|
||||
# Add all the widgets
|
||||
for sub_region, sub_widget, z, fixed in placements:
|
||||
for sub_region, margin, sub_widget, z, fixed in placements:
|
||||
# Combine regions with children to calculate the "virtual size"
|
||||
if fixed:
|
||||
widget_region = sub_region + placement_offset
|
||||
else:
|
||||
total_region = total_region.union(sub_region.grow(spacing))
|
||||
total_region = total_region.union(
|
||||
sub_region.grow(spacing + margin)
|
||||
)
|
||||
widget_region = sub_region + placement_scroll_offset
|
||||
|
||||
if sub_widget is None:
|
||||
continue
|
||||
|
||||
widget_order = order + (get_layer_index(sub_widget.layer, 0), z)
|
||||
|
||||
add_widget(
|
||||
|
||||
@@ -25,7 +25,8 @@ 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
|
||||
margin: Spacing
|
||||
widget: Widget
|
||||
order: int = 0
|
||||
fixed: bool = False
|
||||
|
||||
|
||||
73
src/textual/_resolve.py
Normal file
73
src/textual/_resolve.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
from itertools import accumulate
|
||||
from typing import cast, Sequence
|
||||
|
||||
from .css.scalar import Scalar
|
||||
from .geometry import Size
|
||||
|
||||
|
||||
def resolve(
|
||||
dimensions: Sequence[Scalar],
|
||||
total: int,
|
||||
gutter: int,
|
||||
size: Size,
|
||||
viewport: Size,
|
||||
) -> list[tuple[int, int]]:
|
||||
"""Resolve a list of dimensions.
|
||||
|
||||
Args:
|
||||
dimensions (Sequence[Scalar]): Scalars for column / row sizes.
|
||||
total (int): Total space to divide.
|
||||
gutter (int): Gutter between rows / columns.
|
||||
size (Size): Size of container.
|
||||
viewport (Size): Size of viewport.
|
||||
|
||||
Returns:
|
||||
list[tuple[int, int]]: List of (<OFFSET>, <LENGTH>)
|
||||
"""
|
||||
|
||||
resolved: list[tuple[Scalar, Fraction | None]] = [
|
||||
(
|
||||
(scalar, None)
|
||||
if scalar.is_fraction
|
||||
else (scalar, scalar.resolve_dimension(size, viewport))
|
||||
)
|
||||
for scalar in dimensions
|
||||
]
|
||||
|
||||
from_float = Fraction.from_float
|
||||
total_fraction = from_float(
|
||||
sum(scalar.value for scalar, fraction in resolved if fraction is None)
|
||||
)
|
||||
|
||||
if total_fraction:
|
||||
total_gutter = gutter * (len(dimensions) - 1)
|
||||
consumed = sum(fraction for _, fraction in resolved if fraction is not None)
|
||||
remaining = max(Fraction(0), Fraction(total - total_gutter) - consumed)
|
||||
fraction_unit = Fraction(remaining, total_fraction)
|
||||
resolved_fractions = [
|
||||
from_float(scalar.value) * fraction_unit if fraction is None else fraction
|
||||
for scalar, fraction in resolved
|
||||
]
|
||||
else:
|
||||
resolved_fractions = cast(
|
||||
"list[Fraction]", [fraction for _, fraction in resolved]
|
||||
)
|
||||
|
||||
fraction_gutter = Fraction(gutter)
|
||||
offsets = [0] + [
|
||||
int(fraction)
|
||||
for fraction in accumulate(
|
||||
value
|
||||
for fraction in resolved_fractions
|
||||
for value in (fraction, fraction_gutter)
|
||||
)
|
||||
]
|
||||
results = [
|
||||
(offset1, offset2 - offset1)
|
||||
for offset1, offset2 in zip(offsets[::2], offsets[1::2])
|
||||
]
|
||||
|
||||
return results
|
||||
@@ -608,7 +608,29 @@ def scrollbar_size_single_axis_help_text(property_name: str) -> HelpText:
|
||||
summary=f"Invalid value for [i]{property_name}[/]",
|
||||
bullets=[
|
||||
Bullet(
|
||||
markup=f"The [i]{property_name}[/] property can only be set to a positive integer, greather than zero",
|
||||
markup=f"The [i]{property_name}[/] property can only be set to a positive integer, greater than zero",
|
||||
examples=[
|
||||
Example(f"{property_name}: 2;"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def integer_help_text(property_name: str) -> HelpText:
|
||||
"""Help text to show when the user supplies an invalid integer value.
|
||||
|
||||
Args:
|
||||
property_name (str): The name of the property
|
||||
|
||||
Returns:
|
||||
HelpText: Renderable for displaying the help text for this property
|
||||
"""
|
||||
return HelpText(
|
||||
summary=f"Invalid value for [i]{property_name}[/]",
|
||||
bullets=[
|
||||
Bullet(
|
||||
markup=f"An integer value is expected here",
|
||||
examples=[
|
||||
Example(f"{property_name}: 2;"),
|
||||
],
|
||||
@@ -732,3 +754,12 @@ def style_flags_property_help_text(
|
||||
).get_by_context(context),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def table_rows_or_columns_help_text(
|
||||
property_name: str, value: str, context: StylingContext
|
||||
):
|
||||
property_name = _contextualize_property_name(property_name, context)
|
||||
return HelpText(
|
||||
summary=f"Invalid value '{value}' in [i]{property_name}[/] property"
|
||||
)
|
||||
|
||||
@@ -194,6 +194,43 @@ class ScalarProperty:
|
||||
obj.refresh(layout=True)
|
||||
|
||||
|
||||
class ScalarListProperty:
|
||||
def __set_name__(self, owner: Styles, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
def __get__(
|
||||
self, obj: StylesBase, objtype: type[StylesBase] | None = None
|
||||
) -> tuple[Scalar, ...] | None:
|
||||
value = obj.get_rule(self.name)
|
||||
return value
|
||||
|
||||
def __set__(
|
||||
self, obj: StylesBase, value: str | Iterable[str | float] | None
|
||||
) -> None:
|
||||
if value is None:
|
||||
obj.clear_rule(self.name)
|
||||
obj.refresh(layout=True)
|
||||
return
|
||||
parse_values: Iterable[str | float]
|
||||
if isinstance(value, str):
|
||||
parse_values = value.split()
|
||||
else:
|
||||
parse_values = value
|
||||
|
||||
scalars = []
|
||||
for parse_value in parse_values:
|
||||
if isinstance(parse_value, (int, float)):
|
||||
scalars.append(Scalar.from_number(parse_value))
|
||||
else:
|
||||
scalars.append(
|
||||
Scalar.parse(parse_value)
|
||||
if isinstance(parse_value, str)
|
||||
else parse_value
|
||||
)
|
||||
if obj.set_rule(self.name, tuple(scalars)):
|
||||
obj.refresh(layout=True)
|
||||
|
||||
|
||||
class BoxProperty:
|
||||
"""Descriptor for getting and setting outlines and borders along a single edge.
|
||||
For example "border-right", "outline-bottom", etc.
|
||||
|
||||
@@ -8,22 +8,24 @@ import rich.repr
|
||||
from ._error_tools import friendly_list
|
||||
from ._help_renderables import HelpText
|
||||
from ._help_text import (
|
||||
spacing_invalid_value_help_text,
|
||||
spacing_wrong_number_of_values_help_text,
|
||||
scalar_help_text,
|
||||
color_property_help_text,
|
||||
string_enum_help_text,
|
||||
align_help_text,
|
||||
border_property_help_text,
|
||||
layout_property_help_text,
|
||||
color_property_help_text,
|
||||
dock_property_help_text,
|
||||
fractional_property_help_text,
|
||||
align_help_text,
|
||||
integer_help_text,
|
||||
layout_property_help_text,
|
||||
offset_property_help_text,
|
||||
offset_single_axis_help_text,
|
||||
style_flags_property_help_text,
|
||||
property_invalid_value_help_text,
|
||||
scalar_help_text,
|
||||
scrollbar_size_property_help_text,
|
||||
scrollbar_size_single_axis_help_text,
|
||||
spacing_invalid_value_help_text,
|
||||
spacing_wrong_number_of_values_help_text,
|
||||
string_enum_help_text,
|
||||
style_flags_property_help_text,
|
||||
table_rows_or_columns_help_text,
|
||||
text_align_help_text,
|
||||
)
|
||||
from .constants import (
|
||||
@@ -841,6 +843,102 @@ class StylesBuilder:
|
||||
self.error(name, token, scrollbar_size_single_axis_help_text(name))
|
||||
self.styles._rules["scrollbar_size_horizontal"] = value
|
||||
|
||||
def _process_table_rows_or_columns(self, name: str, tokens: list[Token]) -> None:
|
||||
scalars: list[Scalar] = []
|
||||
for token in tokens:
|
||||
if token.name == "number":
|
||||
scalars.append(Scalar.from_number(float(token.value)))
|
||||
elif token.name == "scalar":
|
||||
scalars.append(
|
||||
Scalar.parse(
|
||||
token.value,
|
||||
percent_unit=Unit.WIDTH if name == "rows" else Unit.HEIGHT,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.error(
|
||||
name,
|
||||
token,
|
||||
table_rows_or_columns_help_text(name, token.value, context="css"),
|
||||
)
|
||||
self.styles._rules[name.replace("-", "_")] = scalars
|
||||
|
||||
process_table_rows = _process_table_rows_or_columns
|
||||
process_table_columns = _process_table_rows_or_columns
|
||||
|
||||
def _process_integer(self, name: str, tokens: list[Token]) -> None:
|
||||
if not tokens:
|
||||
return
|
||||
if len(tokens) != 1:
|
||||
self.error(name, tokens[0], integer_help_text(name))
|
||||
else:
|
||||
token = tokens[0]
|
||||
if token.name != "number" or not token.value.isdigit():
|
||||
self.error(name, token, integer_help_text(name))
|
||||
value = int(token.value)
|
||||
if value == 0:
|
||||
self.error(name, token, integer_help_text(name))
|
||||
self.styles._rules[name.replace("-", "_")] = value
|
||||
|
||||
process_table_gutter_horizontal = _process_integer
|
||||
process_table_gutter_vertical = _process_integer
|
||||
process_column_span = _process_integer
|
||||
process_row_span = _process_integer
|
||||
process_table_size_columns = _process_integer
|
||||
process_table_size_rows = _process_integer
|
||||
|
||||
def process_table_gutter(self, name: str, tokens: list[Token]) -> None:
|
||||
if not tokens:
|
||||
return
|
||||
if len(tokens) == 1:
|
||||
token = tokens[0]
|
||||
if token.name != "number":
|
||||
self.error(name, token, integer_help_text(name))
|
||||
value = max(0, int(token.value))
|
||||
self.styles._rules["table_gutter_horizontal"] = value
|
||||
self.styles._rules["table_gutter_vertical"] = value
|
||||
|
||||
elif len(tokens) == 2:
|
||||
token = tokens[0]
|
||||
if token.name != "number":
|
||||
self.error(name, token, integer_help_text(name))
|
||||
value = max(0, int(token.value))
|
||||
self.styles._rules["table_gutter_horizontal"] = value
|
||||
token = tokens[1]
|
||||
if token.name != "number":
|
||||
self.error(name, token, integer_help_text(name))
|
||||
value = max(0, int(token.value))
|
||||
self.styles._rules["table_gutter_vertical"] = value
|
||||
|
||||
else:
|
||||
self.error(name, tokens[0], "expected two integers here")
|
||||
|
||||
def process_table_size(self, name: str, tokens: list[Token]) -> None:
|
||||
if not tokens:
|
||||
return
|
||||
if len(tokens) == 1:
|
||||
token = tokens[0]
|
||||
if token.name != "number":
|
||||
self.error(name, token, integer_help_text(name))
|
||||
value = max(0, int(token.value))
|
||||
self.styles._rules["table_size_columns"] = value
|
||||
self.styles._rules["table_size_rows"] = 0
|
||||
|
||||
elif len(tokens) == 2:
|
||||
token = tokens[0]
|
||||
if token.name != "number":
|
||||
self.error(name, token, integer_help_text(name))
|
||||
value = max(0, int(token.value))
|
||||
self.styles._rules["table_size_columns"] = value
|
||||
token = tokens[1]
|
||||
if token.name != "number":
|
||||
self.error(name, token, integer_help_text(name))
|
||||
value = max(0, int(token.value))
|
||||
self.styles._rules["table_size_rows"] = value
|
||||
|
||||
else:
|
||||
self.error(name, tokens[0], "expected two integers here")
|
||||
|
||||
def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
|
||||
"""
|
||||
Returns a valid CSS property "Python" name, or None if no close matches could be found.
|
||||
|
||||
@@ -32,7 +32,7 @@ VALID_BORDER: Final[set[EdgeType]] = {
|
||||
"wide",
|
||||
}
|
||||
VALID_EDGE: Final = {"top", "right", "bottom", "left"}
|
||||
VALID_LAYOUT: Final = {"vertical", "horizontal", "center"}
|
||||
VALID_LAYOUT: Final = {"vertical", "horizontal", "center", "table"}
|
||||
|
||||
VALID_BOX_SIZING: Final = {"border-box", "content-box"}
|
||||
VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"}
|
||||
|
||||
@@ -27,6 +27,7 @@ from ._style_properties import (
|
||||
NameListProperty,
|
||||
NameProperty,
|
||||
OffsetProperty,
|
||||
ScalarListProperty,
|
||||
ScalarProperty,
|
||||
SpacingProperty,
|
||||
StringEnumProperty,
|
||||
@@ -144,6 +145,16 @@ class RulesMap(TypedDict, total=False):
|
||||
content_align_horizontal: AlignHorizontal
|
||||
content_align_vertical: AlignVertical
|
||||
|
||||
table_size_rows: int
|
||||
table_size_columns: int
|
||||
table_gutter_horizontal: int
|
||||
table_gutter_vertical: int
|
||||
table_rows: tuple[Scalar, ...]
|
||||
table_columns: tuple[Scalar, ...]
|
||||
|
||||
row_span: int
|
||||
column_span: int
|
||||
|
||||
text_align: TextAlign
|
||||
|
||||
|
||||
@@ -253,6 +264,17 @@ class StylesBase(ABC):
|
||||
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||
content_align = AlignProperty()
|
||||
|
||||
table_rows = ScalarListProperty()
|
||||
table_columns = ScalarListProperty()
|
||||
|
||||
table_size_columns = IntegerProperty(default=1, layout=True)
|
||||
table_size_rows = IntegerProperty(default=0, layout=True)
|
||||
table_gutter_horizontal = IntegerProperty(default=0, layout=True)
|
||||
table_gutter_vertical = IntegerProperty(default=0, layout=True)
|
||||
|
||||
row_span = IntegerProperty(default=1, layout=True)
|
||||
column_span = IntegerProperty(default=1, layout=True)
|
||||
|
||||
text_align = StringEnumProperty(VALID_TEXT_ALIGN, "start")
|
||||
|
||||
def __eq__(self, styles: object) -> bool:
|
||||
@@ -780,6 +802,30 @@ class Styles(StylesBase):
|
||||
)
|
||||
elif has_rule("content_align_vertical"):
|
||||
append_declaration("content-align-vertical", self.content_align_vertical)
|
||||
elif has_rule("table_columns"):
|
||||
append_declaration(
|
||||
"table-columns",
|
||||
" ".join(str(scalar) for scalar in self.table_columns or ()),
|
||||
)
|
||||
elif has_rule("table_rows"):
|
||||
append_declaration(
|
||||
"table-rows",
|
||||
" ".join(str(scalar) for scalar in self.table_rows or ()),
|
||||
)
|
||||
elif has_rule("table_size_columns"):
|
||||
append_declaration("table-size-columns", str(self.table_size_columns))
|
||||
elif has_rule("table_size_rows"):
|
||||
append_declaration("table-size-rows", str(self.table_size_rows))
|
||||
elif has_rule("table_gutter_horizontal"):
|
||||
append_declaration(
|
||||
"table-gutter-horizontal", str(self.table_gutter_horizontal)
|
||||
)
|
||||
elif has_rule("table_gutter_vertical"):
|
||||
append_declaration("table-gutter-vertical", str(self.table_gutter_vertical))
|
||||
elif has_rule("row_span"):
|
||||
append_declaration("row-span", str(self.row_span))
|
||||
elif has_rule("column_span"):
|
||||
append_declaration("column-span", str(self.column_span))
|
||||
|
||||
lines.sort()
|
||||
return lines
|
||||
|
||||
@@ -551,6 +551,19 @@ class Region(NamedTuple):
|
||||
height + expand_height * 2,
|
||||
)
|
||||
|
||||
def clip_size(self, size: tuple[int, int]) -> Region:
|
||||
"""Clip the size to fit within minimum values.
|
||||
|
||||
Args:
|
||||
size (tuple[int, int]): Maximum width and height.
|
||||
|
||||
Returns:
|
||||
Region: No region, not bigger than size.
|
||||
"""
|
||||
x, y, width, height = self
|
||||
max_width, max_height = size
|
||||
return Region(x, y, min(width, max_width), min(height, max_height))
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def overlaps(self, other: Region) -> bool:
|
||||
"""Check if another region overlaps this region.
|
||||
|
||||
@@ -17,7 +17,6 @@ class CenterLayout(Layout):
|
||||
) -> ArrangeResult:
|
||||
|
||||
placements: list[WidgetPlacement] = []
|
||||
total_regions: list[Region] = []
|
||||
|
||||
parent_size = parent.outer_size
|
||||
container_width, container_height = size
|
||||
@@ -32,8 +31,6 @@ class CenterLayout(Layout):
|
||||
x = margin.left + max(0, (container_width - margin_width) // 2)
|
||||
y = margin.top + max(0, (container_height - margin_height) // 2)
|
||||
region = Region(x, y, int(width), int(height))
|
||||
total_regions.append(region.grow(margin))
|
||||
placements.append(WidgetPlacement(region, widget, 0))
|
||||
placements.append(WidgetPlacement(region, margin, widget, 0))
|
||||
|
||||
placements.append(WidgetPlacement(Region.from_union(total_regions), None, 0))
|
||||
return placements, set(children)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .._layout import Layout
|
||||
from .horizontal import HorizontalLayout
|
||||
from .vertical import VerticalLayout
|
||||
from .center import CenterLayout
|
||||
|
||||
from .horizontal import HorizontalLayout
|
||||
from .table import TableLayout
|
||||
from .vertical import VerticalLayout
|
||||
|
||||
LAYOUT_MAP: dict[str, type[Layout]] = {
|
||||
"vertical": VerticalLayout,
|
||||
"horizontal": HorizontalLayout,
|
||||
"center": CenterLayout,
|
||||
"horizontal": HorizontalLayout,
|
||||
"table": TableLayout,
|
||||
"vertical": VerticalLayout,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
# TODO: This is deprecated (and probably doesn't work any more)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from itertools import cycle, product
|
||||
from logging import getLogger
|
||||
from operator import itemgetter
|
||||
from typing import Iterable, NamedTuple, TYPE_CHECKING
|
||||
|
||||
from .._layout_resolve import layout_resolve
|
||||
from ..geometry import Size, Offset, Region
|
||||
from .._layout import Layout, WidgetPlacement
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
from ..screen import Screen
|
||||
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from typing import Literal
|
||||
else:
|
||||
from typing_extensions import Literal
|
||||
|
||||
|
||||
log = getLogger("rich")
|
||||
|
||||
GridAlign = Literal["start", "end", "center", "stretch"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GridOptions:
|
||||
name: str
|
||||
size: int | None = None
|
||||
fraction: int = 1
|
||||
min_size: int = 1
|
||||
max_size: int | None = None
|
||||
|
||||
|
||||
class GridArea(NamedTuple):
|
||||
col_start: str
|
||||
col_end: str
|
||||
row_start: str
|
||||
row_end: str
|
||||
|
||||
|
||||
class GridLayout(Layout):
|
||||
def __init__(
|
||||
self,
|
||||
gap: tuple[int, int] | int | None = None,
|
||||
gutter: tuple[int, int] | int | None = None,
|
||||
align: tuple[GridAlign, GridAlign] | None = None,
|
||||
) -> None:
|
||||
self.columns: list[GridOptions] = []
|
||||
self.rows: list[GridOptions] = []
|
||||
self.areas: dict[str, GridArea] = {}
|
||||
self.widgets: dict[Widget, str | None] = {}
|
||||
self.column_gap = 0
|
||||
self.row_gap = 0
|
||||
self.column_repeat = False
|
||||
self.row_repeat = False
|
||||
self.column_align: GridAlign = "start"
|
||||
self.row_align: GridAlign = "start"
|
||||
self.column_gutter: int = 0
|
||||
self.row_gutter: int = 0
|
||||
self.hidden_columns: set[str] = set()
|
||||
self.hidden_rows: set[str] = set()
|
||||
|
||||
if gap is not None:
|
||||
if isinstance(gap, tuple):
|
||||
self.set_gap(*gap)
|
||||
else:
|
||||
self.set_gap(gap)
|
||||
|
||||
if gutter is not None:
|
||||
if isinstance(gutter, tuple):
|
||||
self.set_gutter(*gutter)
|
||||
else:
|
||||
self.set_gutter(gutter)
|
||||
|
||||
if align is not None:
|
||||
self.set_align(*align)
|
||||
|
||||
super().__init__()
|
||||
|
||||
def is_row_visible(self, row_name: str) -> bool:
|
||||
return row_name not in self.hidden_rows
|
||||
|
||||
def is_column_visible(self, column_name: str) -> bool:
|
||||
return column_name not in self.hidden_columns
|
||||
|
||||
def show_row(self, row_name: str, visible: bool = True) -> bool:
|
||||
changed = (row_name in self.hidden_rows) == visible
|
||||
if visible:
|
||||
self.hidden_rows.discard(row_name)
|
||||
else:
|
||||
self.hidden_rows.add(row_name)
|
||||
if changed:
|
||||
self.require_update()
|
||||
return True
|
||||
return False
|
||||
|
||||
def show_column(self, column_name: str, visible: bool = True) -> bool:
|
||||
changed = (column_name in self.hidden_columns) == visible
|
||||
if visible:
|
||||
self.hidden_columns.discard(column_name)
|
||||
else:
|
||||
self.hidden_columns.add(column_name)
|
||||
if changed:
|
||||
self.require_update()
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_column(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
size: int | None = None,
|
||||
fraction: int = 1,
|
||||
min_size: int = 1,
|
||||
max_size: int | None = None,
|
||||
repeat: int = 1,
|
||||
) -> None:
|
||||
names = (
|
||||
[name]
|
||||
if repeat == 1
|
||||
else [f"{name}{count}" for count in range(1, repeat + 1)]
|
||||
)
|
||||
append = self.columns.append
|
||||
for name in names:
|
||||
append(
|
||||
GridOptions(
|
||||
name,
|
||||
size=size,
|
||||
fraction=fraction,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
)
|
||||
)
|
||||
self.require_update()
|
||||
|
||||
def add_row(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
size: int | None = None,
|
||||
fraction: int = 1,
|
||||
min_size: int = 1,
|
||||
max_size: int | None = None,
|
||||
repeat: int = 1,
|
||||
) -> None:
|
||||
names = (
|
||||
[name]
|
||||
if repeat == 1
|
||||
else [f"{name}{count}" for count in range(1, repeat + 1)]
|
||||
)
|
||||
append = self.rows.append
|
||||
for name in names:
|
||||
append(
|
||||
GridOptions(
|
||||
name,
|
||||
size=size,
|
||||
fraction=fraction,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
)
|
||||
)
|
||||
self.require_update()
|
||||
|
||||
def _add_area(
|
||||
self, name: str, columns: str | tuple[str, str], rows: str | tuple[str, str]
|
||||
) -> None:
|
||||
if isinstance(columns, str):
|
||||
column_start = f"{columns}-start"
|
||||
column_end = f"{columns}-end"
|
||||
else:
|
||||
column_start, column_end = columns
|
||||
|
||||
if isinstance(rows, str):
|
||||
row_start = f"{rows}-start"
|
||||
row_end = f"{rows}-end"
|
||||
else:
|
||||
row_start, row_end = rows
|
||||
|
||||
self.areas[name] = GridArea(column_start, column_end, row_start, row_end)
|
||||
|
||||
def add_areas(self, **areas: str) -> None:
|
||||
for name, area in areas.items():
|
||||
area = area.replace(" ", "")
|
||||
column, _, row = area.partition(",")
|
||||
|
||||
column_start, column_sep, column_end = column.partition("|")
|
||||
row_start, row_sep, row_end = row.partition("|")
|
||||
|
||||
self._add_area(
|
||||
name,
|
||||
(column_start, column_end) if column_sep else column,
|
||||
(row_start, row_end) if row_sep else row,
|
||||
)
|
||||
self.require_update()
|
||||
|
||||
def set_gap(self, column: int, row: int | None = None) -> None:
|
||||
self.column_gap = column
|
||||
self.row_gap = column if row is None else row
|
||||
self.require_update()
|
||||
|
||||
def set_gutter(self, column: int, row: int | None = None) -> None:
|
||||
self.column_gutter = column
|
||||
self.row_gutter = column if row is None else row
|
||||
self.require_update()
|
||||
|
||||
def add_widget(self, widget: Widget, area: str | None = None) -> Widget:
|
||||
self.widgets[widget] = area
|
||||
self.require_update()
|
||||
return widget
|
||||
|
||||
def place(self, *auto_widgets: Widget, **area_widgets: Widget) -> None:
|
||||
widgets = self.widgets
|
||||
for area, widget in area_widgets.items():
|
||||
widgets[widget] = area
|
||||
for widget in auto_widgets:
|
||||
widgets[widget] = None
|
||||
self.require_update()
|
||||
|
||||
def set_repeat(self, column: bool | None = None, row: bool | None = None) -> None:
|
||||
if column is not None:
|
||||
self.column_repeat = column
|
||||
if row is not None:
|
||||
self.row_repeat = row
|
||||
self.require_update()
|
||||
|
||||
def set_align(self, column: GridAlign | None = None, row: GridAlign | None = None):
|
||||
if column is not None:
|
||||
self.column_align = column
|
||||
if row is not None:
|
||||
self.row_align = row
|
||||
self.require_update()
|
||||
|
||||
@classmethod
|
||||
def _align(
|
||||
cls,
|
||||
region: Region,
|
||||
grid_size: Size,
|
||||
container: Size,
|
||||
col_align: GridAlign,
|
||||
row_align: GridAlign,
|
||||
) -> Region:
|
||||
def align(size: int, container: int, align: GridAlign) -> int:
|
||||
offset = 0
|
||||
if align == "end":
|
||||
offset = container - size
|
||||
elif align == "center":
|
||||
offset = (container - size) // 2
|
||||
return offset
|
||||
|
||||
offset = Offset(
|
||||
align(grid_size.width, container.width, col_align),
|
||||
align(grid_size.height, container.height, row_align),
|
||||
)
|
||||
|
||||
region = region.translate(offset)
|
||||
return region
|
||||
|
||||
def get_widgets(self) -> Iterable[Widget]:
|
||||
return self.widgets.keys()
|
||||
|
||||
def arrange(
|
||||
self, view: Screen, size: Size, scroll: Offset
|
||||
) -> Iterable[WidgetPlacement]:
|
||||
"""Generate a map that associates widgets with their location on screen.
|
||||
|
||||
Args:
|
||||
width (int): [description]
|
||||
height (int): [description]
|
||||
offset (Point, optional): [description]. Defaults to Point(0, 0).
|
||||
|
||||
Returns:
|
||||
dict[Widget, OrderedRegion]: [description]
|
||||
"""
|
||||
width, height = size
|
||||
|
||||
def resolve(
|
||||
size: int, edges: list[GridOptions], gap: int, repeat: bool
|
||||
) -> Iterable[tuple[int, int]]:
|
||||
total_gap = gap * (len(edges) - 1)
|
||||
tracks: Iterable[int]
|
||||
tracks = [
|
||||
track if edge.max_size is None else min(edge.max_size, track)
|
||||
for track, edge in zip(layout_resolve(size - total_gap, edges), edges)
|
||||
]
|
||||
if repeat:
|
||||
tracks = cycle(tracks)
|
||||
total = 0
|
||||
edge_count = len(edges)
|
||||
for index, track in enumerate(tracks):
|
||||
if total + track >= size and index >= edge_count:
|
||||
break
|
||||
yield total, total + track
|
||||
total += track + gap
|
||||
|
||||
def resolve_tracks(
|
||||
grid: list[GridOptions], size: int, gap: int, repeat: bool
|
||||
) -> tuple[list[str], dict[str, tuple[int, int]], int, int]:
|
||||
spans = [
|
||||
(options.name, span)
|
||||
for options, span in zip(cycle(grid), resolve(size, grid, gap, repeat))
|
||||
]
|
||||
|
||||
max_size = 0
|
||||
tracks: dict[str, tuple[int, int]] = {}
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
if repeat:
|
||||
names = []
|
||||
for index, (name, (start, end)) in enumerate(spans):
|
||||
max_size = max(max_size, end)
|
||||
counts[name] += 1
|
||||
count = counts[name]
|
||||
names.append(f"{name}-{count}")
|
||||
tracks[f"{name}-{count}-start"] = (index, start)
|
||||
tracks[f"{name}-{count}-end"] = (index, end)
|
||||
else:
|
||||
names = [name for name, _span in spans]
|
||||
for index, (name, (start, end)) in enumerate(spans):
|
||||
max_size = max(max_size, end)
|
||||
tracks[f"{name}-start"] = (index, start)
|
||||
tracks[f"{name}-end"] = (index, end)
|
||||
|
||||
return names, tracks, len(spans), max_size
|
||||
|
||||
container = Size(width - self.column_gutter * 2, height - self.row_gutter * 2)
|
||||
column_names, column_tracks, column_count, column_size = resolve_tracks(
|
||||
[
|
||||
options
|
||||
for options in self.columns
|
||||
if options.name not in self.hidden_columns
|
||||
],
|
||||
container.width,
|
||||
self.column_gap,
|
||||
self.column_repeat,
|
||||
)
|
||||
row_names, row_tracks, row_count, row_size = resolve_tracks(
|
||||
[options for options in self.rows if options.name not in self.hidden_rows],
|
||||
container.height,
|
||||
self.row_gap,
|
||||
self.row_repeat,
|
||||
)
|
||||
grid_size = Size(column_size, row_size)
|
||||
|
||||
widget_areas = (
|
||||
(widget, area)
|
||||
for widget, area in self.widgets.items()
|
||||
if area and widget.display
|
||||
)
|
||||
|
||||
free_slots = {
|
||||
(col, row) for col, row in product(range(column_count), range(row_count))
|
||||
}
|
||||
order = 1
|
||||
from_corners = Region.from_corners
|
||||
gutter = Offset(self.column_gutter, self.row_gutter)
|
||||
for widget, area in widget_areas:
|
||||
column_start, column_end, row_start, row_end = self.areas[area]
|
||||
try:
|
||||
col1, x1 = column_tracks[column_start]
|
||||
col2, x2 = column_tracks[column_end]
|
||||
row1, y1 = row_tracks[row_start]
|
||||
row2, y2 = row_tracks[row_end]
|
||||
except (KeyError, IndexError):
|
||||
continue
|
||||
|
||||
free_slots.difference_update(
|
||||
product(range(col1, col2 + 1), range(row1, row2 + 1))
|
||||
)
|
||||
|
||||
region = self._align(
|
||||
from_corners(x1, y1, x2, y2),
|
||||
grid_size,
|
||||
container,
|
||||
self.column_align,
|
||||
self.row_align,
|
||||
)
|
||||
yield WidgetPlacement(region + gutter, widget, (0, order))
|
||||
order += 1
|
||||
|
||||
# Widgets with no area assigned.
|
||||
auto_widgets = (widget for widget, area in self.widgets.items() if area is None)
|
||||
|
||||
grid_slots = sorted(
|
||||
(
|
||||
slot
|
||||
for slot in product(range(column_count), range(row_count))
|
||||
if slot in free_slots
|
||||
),
|
||||
key=itemgetter(1, 0), # TODO: other orders
|
||||
)
|
||||
|
||||
for widget, (col, row) in zip(auto_widgets, grid_slots):
|
||||
|
||||
col_name = column_names[col]
|
||||
row_name = row_names[row]
|
||||
_col1, x1 = column_tracks[f"{col_name}-start"]
|
||||
_col2, x2 = column_tracks[f"{col_name}-end"]
|
||||
|
||||
_row1, y1 = row_tracks[f"{row_name}-start"]
|
||||
_row2, y2 = row_tracks[f"{row_name}-end"]
|
||||
|
||||
region = self._align(
|
||||
from_corners(x1, y1, x2, y2),
|
||||
grid_size,
|
||||
container,
|
||||
self.column_align,
|
||||
self.row_align,
|
||||
)
|
||||
yield WidgetPlacement(region + gutter, widget, (0, order))
|
||||
order += 1
|
||||
|
||||
return map
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
layout = GridLayout()
|
||||
|
||||
layout.add_column(size=20, name="a")
|
||||
layout.add_column(size=10, name="b")
|
||||
|
||||
layout.add_row(fraction=1, name="top")
|
||||
layout.add_row(fraction=2, name="bottom")
|
||||
|
||||
layout.add_areas(center="a-start|b-end,top")
|
||||
# layout.set_repeat(True)
|
||||
|
||||
from ..widgets import Placeholder
|
||||
|
||||
layout.place(center=Placeholder())
|
||||
|
||||
from rich import print
|
||||
|
||||
print(layout.widgets)
|
||||
|
||||
map = layout.generate_map(100, 80)
|
||||
print(map)
|
||||
@@ -61,11 +61,8 @@ class HorizontalLayout(Layout):
|
||||
max_height = max(
|
||||
max_height, content_height + offset_y + box_model.margin.bottom
|
||||
)
|
||||
add_placement(WidgetPlacement(region, widget, 0))
|
||||
add_placement(WidgetPlacement(region, box_model.margin, widget, 0))
|
||||
x = next_x + margin
|
||||
max_width = x
|
||||
|
||||
total_region = Region(0, 0, int(max_width), int(max_height))
|
||||
add_placement(WidgetPlacement(total_region, None, 0))
|
||||
|
||||
return placements, set(displayed_children)
|
||||
|
||||
160
src/textual/layouts/table.py
Normal file
160
src/textual/layouts/table.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
from .._layout import ArrangeResult, Layout, WidgetPlacement
|
||||
from .._resolve import resolve
|
||||
from ..css.scalar import Scalar
|
||||
from ..geometry import Region, Size, Spacing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
|
||||
|
||||
class TableLayout(Layout):
|
||||
"""Used to layout Widgets in to a table."""
|
||||
|
||||
name = "table"
|
||||
|
||||
def arrange(
|
||||
self, parent: Widget, children: list[Widget], size: Size
|
||||
) -> ArrangeResult:
|
||||
styles = parent.styles
|
||||
row_scalars = styles.table_rows or [Scalar.parse("1fr")]
|
||||
column_scalars = styles.table_columns or [Scalar.parse("1fr")]
|
||||
gutter_horizontal = styles.table_gutter_horizontal
|
||||
gutter_vertical = styles.table_gutter_vertical
|
||||
table_size_columns = max(1, styles.table_size_columns)
|
||||
table_size_rows = styles.table_size_rows
|
||||
viewport = parent.screen.size
|
||||
|
||||
def cell_coords(column_count: int) -> Iterable[tuple[int, int]]:
|
||||
"""Iterate over table coordinates ad infinitum.
|
||||
|
||||
Args:
|
||||
column_count (int): Number of columns
|
||||
|
||||
"""
|
||||
row = 0
|
||||
while True:
|
||||
for column in range(column_count):
|
||||
yield (column, row)
|
||||
row += 1
|
||||
|
||||
def widget_coords(
|
||||
column_start: int, row_start: int, columns: int, rows: int
|
||||
) -> set[tuple[int, int]]:
|
||||
"""Get coords occupied by a cell.
|
||||
|
||||
Args:
|
||||
column_start (int): Start column.
|
||||
row_start (int): Start_row.
|
||||
columns (int): Number of columns.
|
||||
rows (int): Number of rows.
|
||||
|
||||
Returns:
|
||||
set[tuple[int, int]]: Set of coords.
|
||||
"""
|
||||
return {
|
||||
(column, row)
|
||||
for column in range(column_start, column_start + columns)
|
||||
for row in range(row_start, row_start + rows)
|
||||
}
|
||||
|
||||
def repeat_scalars(scalars: Iterable[Scalar], count: int) -> list[Scalar]:
|
||||
"""Repeat an iterable of scalars as many times as required to return
|
||||
a list of `count` values.
|
||||
|
||||
Args:
|
||||
scalars (Iterable[T]): Iterable of values.
|
||||
count (int): Number of values to return.
|
||||
|
||||
Returns:
|
||||
list[T]: A list of values.
|
||||
"""
|
||||
limited_values = list(scalars)[:]
|
||||
while len(limited_values) < count:
|
||||
limited_values.extend(scalars)
|
||||
return limited_values[:count]
|
||||
|
||||
cell_map: dict[tuple[int, int], tuple[Widget, bool]] = {}
|
||||
cell_size_map: dict[Widget, tuple[int, int, int, int]] = {}
|
||||
|
||||
column_count = table_size_columns
|
||||
next_coord = iter(cell_coords(column_count)).__next__
|
||||
cell_coord = (0, 0)
|
||||
column = row = 0
|
||||
|
||||
for child in children:
|
||||
child_styles = child.styles
|
||||
column_span = child_styles.column_span or 1
|
||||
row_span = child_styles.row_span or 1
|
||||
# Find a slot where this cell fits
|
||||
# A cell on a previous row may have a row span
|
||||
while True:
|
||||
column, row = cell_coord
|
||||
coords = widget_coords(column, row, column_span, row_span)
|
||||
if cell_map.keys().isdisjoint(coords):
|
||||
for coord in coords:
|
||||
cell_map[coord] = (child, coord == cell_coord)
|
||||
cell_size_map[child] = (
|
||||
column,
|
||||
row,
|
||||
column_span - 1,
|
||||
row_span - 1,
|
||||
)
|
||||
break
|
||||
else:
|
||||
cell_coord = next_coord()
|
||||
continue
|
||||
cell_coord = next_coord()
|
||||
|
||||
# Resolve columns / rows
|
||||
columns = resolve(
|
||||
repeat_scalars(column_scalars, table_size_columns),
|
||||
size.width,
|
||||
gutter_vertical,
|
||||
size,
|
||||
viewport,
|
||||
)
|
||||
rows = resolve(
|
||||
repeat_scalars(
|
||||
row_scalars, table_size_rows if table_size_rows else row + 1
|
||||
),
|
||||
size.height,
|
||||
gutter_horizontal,
|
||||
size,
|
||||
viewport,
|
||||
)
|
||||
|
||||
placements: list[WidgetPlacement] = []
|
||||
add_placement = placements.append
|
||||
fraction_unit = Fraction(1)
|
||||
widgets: list[Widget] = []
|
||||
add_widget = widgets.append
|
||||
max_column = len(columns) - 1
|
||||
max_row = len(rows) - 1
|
||||
margin = Spacing()
|
||||
for widget, (column, row, column_span, row_span) in cell_size_map.items():
|
||||
x = columns[column][0]
|
||||
if row > max_row:
|
||||
break
|
||||
y = rows[row][0]
|
||||
x2, cell_width = columns[min(max_column, column + column_span)]
|
||||
y2, cell_height = rows[min(max_row, row + row_span)]
|
||||
cell_size = Size(cell_width + x2 - x, cell_height + y2 - y)
|
||||
width, height, margin = widget._get_box_model(
|
||||
cell_size,
|
||||
viewport,
|
||||
fraction_unit,
|
||||
)
|
||||
region = (
|
||||
Region(x, y, int(width), int(height))
|
||||
.shrink(margin)
|
||||
.clip_size(cell_size)
|
||||
)
|
||||
add_placement(WidgetPlacement(region, margin, widget))
|
||||
add_widget(widget)
|
||||
|
||||
return (placements, set(widgets))
|
||||
@@ -54,10 +54,7 @@ class VerticalLayout(Layout):
|
||||
)
|
||||
next_y = y + content_height
|
||||
region = Region(offset_x, int(y), int(content_width), int(next_y) - int(y))
|
||||
add_placement(WidgetPlacement(region, widget, 0))
|
||||
add_placement(WidgetPlacement(region, box_model.margin, widget, 0))
|
||||
y = next_y + margin
|
||||
|
||||
total_region = Region(0, 0, size.width, int(y))
|
||||
add_placement(WidgetPlacement(total_region, None, 0))
|
||||
|
||||
return placements, set(children)
|
||||
|
||||
@@ -4,16 +4,16 @@ from math import ceil
|
||||
|
||||
import rich.repr
|
||||
from rich.color import Color
|
||||
from rich.console import ConsoleOptions, RenderResult, RenderableType
|
||||
from rich.console import ConsoleOptions, RenderableType, RenderResult
|
||||
from rich.segment import Segment, Segments
|
||||
from rich.style import Style, StyleType
|
||||
|
||||
from textual.reactive import Reactive
|
||||
from textual.renderables.blank import Blank
|
||||
from . import events
|
||||
from ._types import MessageTarget
|
||||
from .geometry import Offset
|
||||
from .message import Message
|
||||
from .reactive import Reactive
|
||||
from .renderables.blank import Blank
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ class ScrollBarRender:
|
||||
virtual_size: float = 50,
|
||||
window_size: float = 20,
|
||||
position: float = 0,
|
||||
ascii_only: bool = False,
|
||||
thickness: int = 1,
|
||||
vertical: bool = True,
|
||||
back_color: Color = Color.parse("#555555"),
|
||||
@@ -94,15 +93,9 @@ class ScrollBarRender:
|
||||
) -> Segments:
|
||||
|
||||
if vertical:
|
||||
if ascii_only:
|
||||
bars = ["|", "|", "|", "|", "|", "|", "|", "|"]
|
||||
else:
|
||||
bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
||||
bars = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
||||
else:
|
||||
if ascii_only:
|
||||
bars = ["-", "-", "-", "-", "-", "-", "-", "-"]
|
||||
else:
|
||||
bars = ["█", "▉", "▊", "▋", "▌", "▍", "▎", "▏"]
|
||||
bars = ["█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", " "]
|
||||
|
||||
back = back_color
|
||||
bar = bar_color
|
||||
@@ -117,11 +110,11 @@ class ScrollBarRender:
|
||||
if window_size and size and virtual_size and size != virtual_size:
|
||||
step_size = virtual_size / size
|
||||
|
||||
start = int(position / step_size * 8)
|
||||
end = start + max(8, int(ceil(window_size / step_size * 8)))
|
||||
start = int(position / step_size * 9)
|
||||
end = start + max(9, int(ceil(window_size / step_size * 9)))
|
||||
|
||||
start_index, start_bar = divmod(start, 8)
|
||||
end_index, end_bar = divmod(end, 8)
|
||||
start_index, start_bar = divmod(start, 9)
|
||||
end_index, end_bar = divmod(end, 9)
|
||||
|
||||
upper = {"@click": "scroll_up"}
|
||||
lower = {"@click": "scroll_down"}
|
||||
@@ -138,14 +131,14 @@ class ScrollBarRender:
|
||||
|
||||
if start_index < len(segments):
|
||||
segments[start_index] = _Segment(
|
||||
bars[7 - start_bar] * width_thickness,
|
||||
bars[8 - start_bar] * width_thickness,
|
||||
_Style(bgcolor=back, color=bar, meta=foreground_meta)
|
||||
if vertical
|
||||
else _Style(bgcolor=bar, color=back, meta=foreground_meta),
|
||||
)
|
||||
if end_index < len(segments):
|
||||
segments[end_index] = _Segment(
|
||||
bars[7 - end_bar] * width_thickness,
|
||||
bars[8 - end_bar] * width_thickness,
|
||||
_Style(bgcolor=bar, color=back, meta=foreground_meta)
|
||||
if vertical
|
||||
else _Style(bgcolor=back, color=bar, meta=foreground_meta),
|
||||
@@ -296,6 +289,7 @@ class ScrollBarCorner(Widget):
|
||||
super().__init__(name=name)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
assert self.parent is not None
|
||||
styles = self.parent.styles
|
||||
color = styles.scrollbar_corner_color
|
||||
return Blank(color)
|
||||
|
||||
@@ -185,7 +185,7 @@ class Widget(DOMNode):
|
||||
|
||||
@property
|
||||
def _allow_scroll(self) -> bool:
|
||||
"""Check if both axes may be scrolled.
|
||||
"""Check if both axis may be scrolled.
|
||||
|
||||
Returns:
|
||||
bool: True if horizontal and vertical scrolling is enabled.
|
||||
|
||||
@@ -24,14 +24,10 @@ def test_arrange_dock_top():
|
||||
container, [child, header], Size(80, 24), Size(80, 24)
|
||||
)
|
||||
assert placements == [
|
||||
WidgetPlacement(Region(0, 0, 80, 1), header, order=TOP_Z, fixed=True),
|
||||
WidgetPlacement(Region(0, 1, 80, 23), child, order=0, fixed=False),
|
||||
WidgetPlacement(
|
||||
region=Region(x=0, y=1, width=80, height=23),
|
||||
widget=None,
|
||||
order=0,
|
||||
fixed=False,
|
||||
Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
|
||||
),
|
||||
WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False),
|
||||
]
|
||||
assert widgets == {child, header}
|
||||
assert spacing == Spacing(1, 0, 0, 0)
|
||||
@@ -48,14 +44,10 @@ def test_arrange_dock_left():
|
||||
container, [child, header], Size(80, 24), Size(80, 24)
|
||||
)
|
||||
assert placements == [
|
||||
WidgetPlacement(Region(0, 0, 10, 24), header, order=TOP_Z, fixed=True),
|
||||
WidgetPlacement(Region(10, 0, 70, 24), child, order=0, fixed=False),
|
||||
WidgetPlacement(
|
||||
region=Region(x=10, y=0, width=70, height=24),
|
||||
widget=None,
|
||||
order=0,
|
||||
fixed=False,
|
||||
Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
|
||||
),
|
||||
WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False),
|
||||
]
|
||||
assert widgets == {child, header}
|
||||
assert spacing == Spacing(0, 0, 0, 10)
|
||||
@@ -72,14 +64,10 @@ def test_arrange_dock_right():
|
||||
container, [child, header], Size(80, 24), Size(80, 24)
|
||||
)
|
||||
assert placements == [
|
||||
WidgetPlacement(Region(70, 0, 10, 24), header, order=TOP_Z, fixed=True),
|
||||
WidgetPlacement(Region(0, 0, 70, 24), child, order=0, fixed=False),
|
||||
WidgetPlacement(
|
||||
region=Region(x=0, y=0, width=70, height=24),
|
||||
widget=None,
|
||||
order=0,
|
||||
fixed=False,
|
||||
Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
|
||||
),
|
||||
WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False),
|
||||
]
|
||||
assert widgets == {child, header}
|
||||
assert spacing == Spacing(0, 10, 0, 0)
|
||||
@@ -96,14 +84,10 @@ def test_arrange_dock_bottom():
|
||||
container, [child, header], Size(80, 24), Size(80, 24)
|
||||
)
|
||||
assert placements == [
|
||||
WidgetPlacement(Region(0, 23, 80, 1), header, order=TOP_Z, fixed=True),
|
||||
WidgetPlacement(Region(0, 0, 80, 23), child, order=0, fixed=False),
|
||||
WidgetPlacement(
|
||||
region=Region(x=0, y=0, width=80, height=23),
|
||||
widget=None,
|
||||
order=0,
|
||||
fixed=False,
|
||||
Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
|
||||
),
|
||||
WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False),
|
||||
]
|
||||
assert widgets == {child, header}
|
||||
assert spacing == Spacing(0, 0, 1, 0)
|
||||
|
||||
@@ -195,6 +195,10 @@ def test_crop_size():
|
||||
assert Region(10, 20, 100, 200).crop_size((500, 40)) == Region(10, 20, 100, 40)
|
||||
|
||||
|
||||
def test_clip_size():
|
||||
assert Region(10, 10, 100, 80).clip_size((50, 100)) == Region(10, 10, 50, 80)
|
||||
|
||||
|
||||
def test_region_overlaps():
|
||||
assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20))
|
||||
assert not Region(10, 10, 5, 5).overlaps(Region(15, 15, 20, 20))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from textual._layout import WidgetPlacement
|
||||
from textual.geometry import Region, Size
|
||||
from textual.geometry import Region, Size, Spacing
|
||||
from textual.layouts.center import CenterLayout
|
||||
from textual.widget import Widget
|
||||
|
||||
@@ -19,15 +19,10 @@ def test_center_layout():
|
||||
expected = [
|
||||
WidgetPlacement(
|
||||
region=Region(x=25, y=7, width=10, height=5),
|
||||
margin=Spacing(),
|
||||
widget=child,
|
||||
order=0,
|
||||
fixed=False,
|
||||
),
|
||||
WidgetPlacement(
|
||||
region=Region(x=25, y=7, width=10, height=5),
|
||||
widget=None,
|
||||
order=0,
|
||||
fixed=False,
|
||||
),
|
||||
]
|
||||
assert placements == expected
|
||||
|
||||
58
tests/test_resolve.py
Normal file
58
tests/test_resolve.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
|
||||
from textual.geometry import Size
|
||||
from textual.css.scalar import Scalar
|
||||
from textual._resolve import resolve
|
||||
|
||||
|
||||
def test_resolve_empty():
|
||||
assert resolve([], 10, 1, Size(20, 10), Size(80, 24)) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scalars,total,gutter,result",
|
||||
[
|
||||
(["10"], 100, 0, [(0, 10)]),
|
||||
(
|
||||
["10", "20"],
|
||||
100,
|
||||
0,
|
||||
[(0, 10), (10, 20)],
|
||||
),
|
||||
(
|
||||
["10", "20"],
|
||||
100,
|
||||
1,
|
||||
[(0, 10), (11, 20)],
|
||||
),
|
||||
(
|
||||
["10", "1fr"],
|
||||
100,
|
||||
1,
|
||||
[(0, 10), (11, 89)],
|
||||
),
|
||||
(
|
||||
["1fr", "1fr"],
|
||||
100,
|
||||
0,
|
||||
[(0, 50), (50, 50)],
|
||||
),
|
||||
(
|
||||
["3", "1fr", "1fr", "1"],
|
||||
100,
|
||||
1,
|
||||
[(0, 3), (4, 46), (51, 47), (99, 1)],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_resolve(scalars, total, gutter, result):
|
||||
assert (
|
||||
resolve(
|
||||
[Scalar.parse(scalar) for scalar in scalars],
|
||||
total,
|
||||
gutter,
|
||||
Size(40, 20),
|
||||
Size(80, 24),
|
||||
)
|
||||
== result
|
||||
)
|
||||
Reference in New Issue
Block a user