Last active
April 25, 2025 11:48
-
-
Save ddkasa/0a60dcb1bd13e67bc79cc6f3b144aa8d to your computer and use it in GitHub Desktop.
Example Of Textual Flexbox Layout
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from __future__ import annotations | |
from operator import itemgetter | |
from typing import TYPE_CHECKING, get_args | |
import random | |
from textual.app import App, ComposeResult | |
from textual.box_model import BoxModel | |
from textual.css.styles import RenderStyles | |
from textual._resolve import resolve_box_models | |
from textual.geometry import ( | |
NULL_OFFSET, | |
NULL_SIZE, | |
Region, | |
Size, | |
Spacing, | |
) | |
from textual.layout import Layout, WidgetPlacement | |
from textual.widget import Widget | |
from textual.widgets import Label | |
from textual.widgets._label import LabelVariant | |
from math import floor | |
if TYPE_CHECKING: | |
from textual.layout import ArrangeResult | |
class FlexBoxLayout(Layout): | |
name = "flexbox" | |
def arrange( | |
self, | |
parent: Widget, | |
children: list[Widget], | |
size: Size, | |
) -> ArrangeResult: | |
"""Generate a layout map that defines where the widgets will be drawn. | |
Args: | |
parent: Parent widget. | |
children: A list of widgets to be placed. | |
size: Size of container. | |
Returns: | |
An iterable of widget locations. | |
""" | |
parent.pre_layout(self) | |
viewport = parent.app.size | |
styles = [child.styles for child in children] | |
placements = [ | |
( | |
style.get_rule("position") == "absolute", | |
style.overlay == "screen", | |
) | |
for style in styles | |
] | |
margins: list[Spacing] = [ | |
style.margin for style, (overlay, _) in zip(styles, placements) if overlay | |
] | |
NewSize = Size | |
if margins: | |
resolve_margin = NewSize( | |
sum( | |
[ | |
max(margin1[1], margin2[3]) | |
for margin1, margin2 in zip(margins, margins[1:]) | |
] | |
) | |
+ (margins[0].left + margins[-1].right), | |
max( | |
[ | |
margin_top + margin_bottom | |
for margin_top, _, margin_bottom, _ in margins | |
] | |
), | |
) | |
else: | |
resolve_margin = NULL_SIZE | |
regions = self._resolve_regions( | |
parent, | |
children, | |
viewport, | |
size, | |
resolve_margin, | |
placements, | |
styles, | |
) | |
NewPlacement = WidgetPlacement | |
return [ | |
NewPlacement( | |
region=region, | |
offset=style.offset.resolve( | |
NewSize(region.width, region.height), | |
viewport, | |
) | |
if style.has_rule("offset") | |
else NULL_OFFSET, | |
margin=box_model.margin, | |
widget=widget, | |
order=i, | |
overlay=is_overlay, | |
absolute=is_abs, | |
) | |
for i, ( | |
widget, | |
(region, box_model), | |
(is_abs, is_overlay), | |
style, | |
) in enumerate(zip(children, regions, placements, styles, strict=False)) | |
] | |
def _resolve_regions( | |
self, | |
parent: Widget, | |
children: list[Widget], | |
viewport: Size, | |
size: Size, | |
margin: Size, | |
placements: list[tuple[bool, bool]], | |
styles: list[RenderStyles], | |
) -> list[tuple[Region, BoxModel]]: | |
"""Generate regions balanced rows from provided widgets. | |
Args: | |
parent: The parent container widget primarily for styles. | |
children: Widgets to be layed out. | |
viewport: Size of the of parent viewport. | |
size: Given size of the container. | |
margin: Initial size of margin for resolving box models. | |
placements: Booleans for screening and absolute placement setting. | |
styles: Styles of children expanded out beforehand. | |
Returns: | |
Tuples of regions and box models ready to be placed. | |
""" | |
margin_width, margin_height = margin | |
pwidth, pheight = viewport - margin | |
sizes = [(style.width, style.height) for style in styles] | |
parent_width = size.width | |
resolved = resolve_box_models( | |
list(map(itemgetter(0), sizes)), | |
children, | |
size, | |
viewport, | |
margin, | |
resolve_dimension="width", | |
) | |
row_sizes: list[tuple[int, int, int, int]] = [] | |
"""Tuples of row indexes. 1. Index 2. row_width 3. row-y-pos, 4. row_height""" | |
add_row = row_sizes.append | |
row_width, row_pos, max_height = 0, 0, 0 | |
for i, ( | |
widget, | |
(width, height, box_margin), | |
(is_abs, is_overlay), | |
) in enumerate(zip(children, resolved, placements, strict=True)): | |
if is_abs or is_overlay: | |
continue | |
box_width = floor(width + box_margin.width) | |
next_width = floor(row_width + box_width) | |
if next_width > parent_width: | |
add_row((i, row_width, row_pos, max_height)) | |
next_width = box_width | |
row_pos += max_height | |
max_height = 0 | |
next_height = floor(height + box_margin.height) | |
max_height = max_height if max_height >= next_height else next_height | |
row_width = next_width | |
add_row((None, row_width, row_pos, max_height)) | |
halign, valign = parent.styles.align | |
center_aligned = halign == "center" | |
right_aligned = halign == "right" | |
NewRegion = Region | |
box_models: list[Region] = [] | |
add_region = box_models.append | |
prev_index = None | |
for i in range(len(row_sizes)): | |
index, row_width, row_pos, max_height = row_sizes[i] | |
if center_aligned: | |
x = floor((parent_width - row_width) / 2) | |
elif right_aligned: | |
x = floor(parent_width - row_width) | |
else: | |
x = 0 | |
for widget, (width, height, box_margin), (is_abs, is_overlay) in zip( | |
children[prev_index:index], | |
resolved[prev_index:index], | |
placements[prev_index:index], | |
strict=True, | |
): | |
add_region( | |
NewRegion( | |
x + box_margin.left, | |
row_pos + box_margin.top, | |
width := floor(width), | |
floor(height), | |
) | |
) | |
if not is_abs and not is_overlay: | |
x += width + box_margin.width | |
prev_index = index | |
return zip(box_models, resolved) | |
class FlexBoxContainer(Widget): | |
"""Container widget with predefined flexbox layout.""" | |
def __init__( | |
self, | |
*children: Widget, | |
name: str | None = None, | |
id: str | None = None, | |
classes: str | None = None, | |
disabled: bool = False, | |
markup: bool = True, | |
) -> None: | |
super().__init__( | |
*children, | |
name=name, | |
id=id, | |
classes=classes, | |
disabled=disabled, | |
markup=markup, | |
) | |
self._layout = FlexBoxLayout() | |
@property | |
def layout(self) -> FlexBoxLayout: | |
return self._layout | |
class FlexBoxApp(App[None]): | |
DEFAULT_CSS = """\ | |
Label { | |
padding: 1 2; | |
margin: 1 2; | |
} | |
FlexBoxContainer { | |
overflow: hidden auto; | |
align-horizontal: left; | |
} | |
""" | |
def compose(self) -> ComposeResult: | |
variants = get_args(LabelVariant) | |
with FlexBoxContainer(): | |
for i in range(200): | |
yield (lbl := Label(str(i), variant=random.choice(variants))) | |
lbl.styles.width = random.randint(10, 20) | |
if __name__ == "__main__": | |
random.seed(0) | |
FlexBoxApp().run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment