Skip to content

Instantly share code, notes, and snippets.

@ddkasa
Last active April 25, 2025 11:48
Show Gist options
  • Save ddkasa/0a60dcb1bd13e67bc79cc6f3b144aa8d to your computer and use it in GitHub Desktop.
Save ddkasa/0a60dcb1bd13e67bc79cc6f3b144aa8d to your computer and use it in GitHub Desktop.
Example Of Textual Flexbox Layout
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