Skip to content

Commit 1c62e1e

Browse files
authored
Merge pull request #6013 from Textualize/stream-layout
Stream layout
2 parents 7c2b65f + 0c701d5 commit 1c62e1e

File tree

9 files changed

+276
-3
lines changed

9 files changed

+276
-3
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ lib/
2929
lib64/
3030
parts/
3131
sdist/
32+
dist/
3233
var/
3334
wheels/
3435
*.egg-info/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## Unreleased
9+
10+
### Added
11+
12+
- Added a 'stream' layout, which is a lot like vertical but with fewer supported rules (which is why it is faster), will remain undocumented for now. https://github.com/Textualize/textual/pull/6013
13+
814
## [5.1.1] - 2025-07-21
915

1016
### Fixed

src/textual/css/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"wide",
2929
}
3030
VALID_EDGE: Final = {"top", "right", "bottom", "left", "none"}
31-
VALID_LAYOUT: Final = {"vertical", "horizontal", "grid"}
31+
VALID_LAYOUT: Final = {"vertical", "horizontal", "grid", "stream"}
3232

3333
VALID_BOX_SIZING: Final = {"border-box", "content-box"}
3434
VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"}

src/textual/css/styles.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ class StylesBase:
284284
layout = LayoutProperty()
285285
"""Set the layout of the widget, defining how it's children are laid out.
286286
287-
Valid values are "grid", "horizontal", and "vertical" or None to clear any layout
287+
Valid values are "grid", "stream", "horizontal", or "vertical" or None to clear any layout
288288
that was set at runtime.
289289
290290
Raises:

src/textual/layouts/factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
from textual.layout import Layout
44
from textual.layouts.grid import GridLayout
55
from textual.layouts.horizontal import HorizontalLayout
6+
from textual.layouts.stream import StreamLayout
67
from textual.layouts.vertical import VerticalLayout
78

89
LAYOUT_MAP: dict[str, type[Layout]] = {
910
"horizontal": HorizontalLayout,
1011
"grid": GridLayout,
1112
"vertical": VerticalLayout,
13+
"stream": StreamLayout,
1214
}
1315

1416

src/textual/layouts/stream.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from textual.geometry import NULL_OFFSET, Region, Size
6+
from textual.layout import ArrangeResult, Layout, WidgetPlacement
7+
8+
if TYPE_CHECKING:
9+
10+
from textual.widget import Widget
11+
12+
13+
class StreamLayout(Layout):
14+
"""A cut down version of the vertical layout.
15+
16+
The stream layout is faster, but has a few limitations compared to the vertical layout.
17+
18+
- All widgets are the full width (as if their widget is `1fr`).
19+
- All widgets have an effective height of `auto`.
20+
- `max-height` is supported, but only if it is a units value, all other extrema rules are ignored.
21+
- No absolute positioning.
22+
- No overlay: screen.
23+
- Layers are ignored.
24+
25+
The primary use of `layout: stream` is for a long list of widgets in a scrolling container, such as
26+
what you might expect from a LLM chat-bot. The speed improvement will only be significant with a lot of
27+
child widgets, so stick to vertical layouts unless you see any slowdown.
28+
29+
"""
30+
31+
name = "stream"
32+
33+
def arrange(
34+
self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True
35+
) -> ArrangeResult:
36+
parent.pre_layout(self)
37+
if not children:
38+
return []
39+
viewport = parent.app.size
40+
41+
_Region = Region
42+
_WidgetPlacement = WidgetPlacement
43+
44+
placements: list[WidgetPlacement] = []
45+
width = size.width
46+
first_child_styles = children[0].styles
47+
y = 0
48+
previous_margin = first_child_styles.margin.top
49+
null_offset = NULL_OFFSET
50+
51+
for widget in children:
52+
styles = widget.styles.base
53+
margin = styles.margin
54+
gutter_width, gutter_height = styles.gutter.totals
55+
top, right, bottom, left = margin
56+
y += max(top, previous_margin)
57+
previous_margin = bottom
58+
height = (
59+
widget.get_content_height(size, viewport, width - gutter_width)
60+
+ gutter_height
61+
)
62+
if (max_height := styles.max_height) is not None and max_height.is_cells:
63+
height = min(height, int(max_height.value))
64+
placements.append(
65+
_WidgetPlacement(
66+
_Region(left, y, width - (left + right), height),
67+
null_offset,
68+
margin,
69+
widget,
70+
0,
71+
False,
72+
False,
73+
False,
74+
)
75+
)
76+
y += height
77+
78+
return placements

src/textual/widget.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,7 @@ def anchor(self, anchor: bool = True) -> None:
721721
"""
722722
self._anchored = anchor
723723
if anchor:
724-
self.scroll_end()
724+
self.scroll_end(immediate=True, animate=False)
725725

726726
def release_anchor(self) -> None:
727727
"""Release the [anchor][textual.widget.Widget].
Lines changed: 150 additions & 0 deletions
Loading

tests/snapshot_tests/test_snapshots.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4502,3 +4502,39 @@ def compose(self) -> ComposeResult:
45024502
pass
45034503

45044504
assert snap_compare(EmptyApp())
4505+
4506+
4507+
def test_stream_layout(snap_compare):
4508+
"""Test stream layout.
4509+
4510+
You should see 3 blue labels.
4511+
The topmost should be a single line.
4512+
The middle should be two lines.
4513+
The last should be three lines.
4514+
There will be a one character margin between them.
4515+
4516+
"""
4517+
4518+
class StreamApp(App):
4519+
CSS = """
4520+
VerticalScroll {
4521+
layout: stream;
4522+
Label {
4523+
background: blue;
4524+
margin: 1;
4525+
}
4526+
#many-lines {
4527+
max-height: 3;
4528+
}
4529+
}
4530+
"""
4531+
4532+
def compose(self) -> ComposeResult:
4533+
with VerticalScroll():
4534+
yield Label("Hello")
4535+
yield Label("foo\nbar")
4536+
yield Label(
4537+
"\n".join(["Only 3 lines should be visible"] * 100), id="many-lines"
4538+
)
4539+
4540+
assert snap_compare(StreamApp())

0 commit comments

Comments
 (0)