Merge pull request #2030 from Textualize/add-containers

Add containers
This commit is contained in:
Rodrigo Girão Serrão
2023-03-15 10:37:01 +00:00
committed by GitHub
49 changed files with 707 additions and 120 deletions

View File

@@ -11,6 +11,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Dropped "loading-indicator--dot" component style from LoadingIndicator https://github.com/Textualize/textual/pull/2050 - Dropped "loading-indicator--dot" component style from LoadingIndicator https://github.com/Textualize/textual/pull/2050
## Unreleased
### Changed
- Breaking change: changed default behaviour of `Vertical` (see `VerticalScroll`) https://github.com/Textualize/textual/issues/1957
- The default `overflow` style for `Horizontal` was changed to `hidden hidden` https://github.com/Textualize/textual/issues/1957
### Added
- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957
- Added `Center` https://github.com/Textualize/textual/issues/1957
- Added `Middle` https://github.com/Textualize/textual/issues/1957
- Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957
## [0.15.1] - 2023-03-14 ## [0.15.1] - 2023-03-14
### Fixed ### Fixed
@@ -22,6 +37,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed ### Fixed
- Fixed container not resizing when a widget is removed https://github.com/Textualize/textual/issues/2007
- Fixes issue where the horizontal scrollbar would be incorrectly enabled https://github.com/Textualize/textual/pull/2024
## [0.15.0] - 2023-03-13
### Changed
- Fixed container not resizing when a widget is removed https://github.com/Textualize/textual/issues/2007 - Fixed container not resizing when a widget is removed https://github.com/Textualize/textual/issues/2007
- Fixed issue where the horizontal scrollbar would be incorrectly enabled https://github.com/Textualize/textual/pull/2024 - Fixed issue where the horizontal scrollbar would be incorrectly enabled https://github.com/Textualize/textual/pull/2024
- Fixed `Pilot.click` not correctly creating the mouse events https://github.com/Textualize/textual/issues/2022 - Fixed `Pilot.click` not correctly creating the mouse events https://github.com/Textualize/textual/issues/2022

View File

@@ -1,9 +1,9 @@
from random import randint
import time import time
from random import randint
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.color import Color from textual.color import Color
from textual.containers import Grid, Vertical from textual.containers import Grid, VerticalScroll
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Footer, Label from textual.widgets import Footer, Label
@@ -28,7 +28,7 @@ class MyApp(App[None]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Grid( yield Grid(
ColourChanger(), ColourChanger(),
Vertical(id="log"), VerticalScroll(id="log"),
) )
yield Footer() yield Footer()

View File

@@ -1,10 +1,10 @@
import asyncio import asyncio
from random import randint
import time import time
from random import randint
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.color import Color from textual.color import Color
from textual.containers import Grid, Vertical from textual.containers import Grid, VerticalScroll
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Footer, Label from textual.widgets import Footer, Label
@@ -29,7 +29,7 @@ class MyApp(App[None]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Grid( yield Grid(
ColourChanger(), ColourChanger(),
Vertical(id="log"), VerticalScroll(id="log"),
) )
yield Footer() yield Footer()

View File

@@ -1,10 +1,10 @@
import asyncio import asyncio
from random import randint
import time import time
from random import randint
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.color import Color from textual.color import Color
from textual.containers import Grid, Vertical from textual.containers import Grid, VerticalScroll
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Footer, Label from textual.widgets import Footer, Label
@@ -29,7 +29,7 @@ class MyApp(App[None]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Grid( yield Grid(
ColourChanger(), ColourChanger(),
Vertical(id="log"), VerticalScroll(id="log"),
) )
yield Footer() yield Footer()

View File

@@ -6,9 +6,10 @@ except ImportError:
raise ImportError("Please install httpx with 'pip install httpx' ") raise ImportError("Please install httpx with 'pip install httpx' ")
from rich.json import JSON from rich.json import JSON
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Static, Input from textual.widgets import Input, Static
class DictionaryApp(App): class DictionaryApp(App):
@@ -18,7 +19,7 @@ class DictionaryApp(App):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Input(placeholder="Search for a word") yield Input(placeholder="Search for a word")
yield Vertical(Static(id="results"), id="results-container") yield VerticalScroll(Static(id="results"), id="results-container")
async def on_input_changed(self, message: Input.Changed) -> None: async def on_input_changed(self, message: Input.Changed) -> None:
"""A coroutine to handle a text changed message.""" """A coroutine to handle a text changed message."""

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical from textual.containers import Container, Horizontal, VerticalScroll
from textual.widgets import Header, Static from textual.widgets import Header, Static
@@ -9,7 +9,7 @@ class CombiningLayoutsExample(App):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
with Container(id="app-grid"): with Container(id="app-grid"):
with Vertical(id="left-pane"): with VerticalScroll(id="left-pane"):
for number in range(15): for number in range(15):
yield Static(f"Vertical layout, child {number}") yield Static(f"Vertical layout, child {number}")
with Horizontal(id="top-right"): with Horizontal(id="top-right"):

View File

@@ -1,6 +1,6 @@
from textual.app import App from textual.app import App
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Placeholder, Label, Static from textual.widgets import Label, Placeholder, Static
class Ruler(Static): class Ruler(Static):
@@ -11,7 +11,7 @@ class Ruler(Static):
class HeightComparisonApp(App): class HeightComparisonApp(App):
def compose(self): def compose(self):
yield Vertical( yield VerticalScroll(
Placeholder(id="cells"), # (1)! Placeholder(id="cells"), # (1)!
Placeholder(id="percent"), Placeholder(id="percent"),
Placeholder(id="w"), Placeholder(id="w"),

View File

@@ -1,11 +1,11 @@
from textual.app import App from textual.app import App
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Placeholder from textual.widgets import Placeholder
class MaxWidthApp(App): class MaxWidthApp(App):
def compose(self): def compose(self):
yield Vertical( yield VerticalScroll(
Placeholder("max-width: 50h", id="p1"), Placeholder("max-width: 50h", id="p1"),
Placeholder("max-width: 999", id="p2"), Placeholder("max-width: 999", id="p2"),
Placeholder("max-width: 50%", id="p3"), Placeholder("max-width: 50%", id="p3"),

View File

@@ -1,4 +1,4 @@
Vertical { VerticalScroll {
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow-x: auto; overflow-x: auto;
@@ -10,7 +10,8 @@ Placeholder {
} }
#p1 { #p1 {
min-width: 25%; /* (1)! */ min-width: 25%;
/* (1)! */
} }
#p2 { #p2 {

View File

@@ -1,11 +1,11 @@
from textual.app import App from textual.app import App
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Placeholder from textual.widgets import Placeholder
class MinWidthApp(App): class MinWidthApp(App):
def compose(self): def compose(self):
yield Vertical( yield VerticalScroll(
Placeholder("min-width: 25%", id="p1"), Placeholder("min-width: 25%", id="p1"),
Placeholder("min-width: 75%", id="p2"), Placeholder("min-width: 75%", id="p2"),
Placeholder("min-width: 100", id="p3"), Placeholder("min-width: 100", id="p3"),

View File

@@ -3,7 +3,7 @@ Screen {
color: black; color: black;
} }
Vertical { VerticalScroll {
width: 1fr; width: 1fr;
} }

View File

@@ -1,6 +1,6 @@
from textual.app import App from textual.app import App
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Static from textual.widgets import Static
from textual.containers import Horizontal, Vertical
TEXT = """I must not fear. TEXT = """I must not fear.
Fear is the mind-killer. Fear is the mind-killer.
@@ -14,8 +14,8 @@ Where the fear has gone there will be nothing. Only I will remain."""
class OverflowApp(App): class OverflowApp(App):
def compose(self): def compose(self):
yield Horizontal( yield Horizontal(
Vertical(Static(TEXT), Static(TEXT), Static(TEXT), id="left"), VerticalScroll(Static(TEXT), Static(TEXT), Static(TEXT), id="left"),
Vertical(Static(TEXT), Static(TEXT), Static(TEXT), id="right"), VerticalScroll(Static(TEXT), Static(TEXT), Static(TEXT), id="right"),
) )

View File

@@ -1,11 +1,11 @@
from textual.app import App from textual.app import App
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Placeholder from textual.widgets import Placeholder
class VisibilityContainersApp(App): class VisibilityContainersApp(App):
def compose(self): def compose(self):
yield Vertical( yield VerticalScroll(
Horizontal( Horizontal(
Placeholder(), Placeholder(),
Placeholder(), Placeholder(),

View File

@@ -2,7 +2,7 @@ Button {
margin: 1 2; margin: 1 2;
} }
Horizontal > Vertical { Horizontal > VerticalScroll {
width: 24; width: 24;
} }

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Button, Static from textual.widgets import Button, Static
@@ -8,7 +8,7 @@ class ButtonsApp(App[str]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Horizontal( yield Horizontal(
Vertical( VerticalScroll(
Static("Standard Buttons", classes="header"), Static("Standard Buttons", classes="header"),
Button("Default"), Button("Default"),
Button("Primary!", variant="primary"), Button("Primary!", variant="primary"),
@@ -16,7 +16,7 @@ class ButtonsApp(App[str]):
Button.warning("Warning!"), Button.warning("Warning!"),
Button.error("Error!"), Button.error("Error!"),
), ),
Vertical( VerticalScroll(
Static("Disabled Buttons", classes="header"), Static("Disabled Buttons", classes="header"),
Button("Default", disabled=True), Button("Default", disabled=True),
Button("Primary!", variant="primary", disabled=True), Button("Primary!", variant="primary", disabled=True),

View File

@@ -2,7 +2,7 @@ Screen {
align: center middle; align: center middle;
} }
Vertical { VerticalScroll {
width: auto; width: auto;
height: auto; height: auto;
border: solid $primary; border: solid $primary;

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Checkbox from textual.widgets import Checkbox
@@ -7,7 +7,7 @@ class CheckboxApp(App[None]):
CSS_PATH = "checkbox.css" CSS_PATH = "checkbox.css"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(): with VerticalScroll():
yield Checkbox("Arrakis :sweat:") yield Checkbox("Arrakis :sweat:")
yield Checkbox("Caladan") yield Checkbox("Caladan")
yield Checkbox("Chusuk") yield Checkbox("Chusuk")

View File

@@ -1,14 +1,13 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical from textual.containers import Container, Horizontal, VerticalScroll
from textual.widgets import Placeholder from textual.widgets import Placeholder
class PlaceholderApp(App): class PlaceholderApp(App):
CSS_PATH = "placeholder.css" CSS_PATH = "placeholder.css"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Vertical( yield VerticalScroll(
Container( Container(
Placeholder("This is a custom label for p1.", id="p1"), Placeholder("This is a custom label for p1.", id="p1"),
Placeholder("Placeholder p2 here!", id="p2"), Placeholder("Placeholder p2 here!", id="p2"),

View File

@@ -1,4 +1,4 @@
Vertical { VerticalScroll {
align: center middle; align: center middle;
} }

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Label, RadioButton, RadioSet from textual.widgets import Label, RadioButton, RadioSet
@@ -7,7 +7,7 @@ class RadioSetChangedApp(App[None]):
CSS_PATH = "radio_set_changed.css" CSS_PATH = "radio_set_changed.css"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(): with VerticalScroll():
with Horizontal(): with Horizontal():
with RadioSet(): with RadioSet():
yield RadioButton("Battlestar Galactica") yield RadioButton("Battlestar Galactica")

View File

@@ -134,8 +134,8 @@ exceeds the available horizontal space in the parent container.
## Utility containers ## Utility containers
Textual comes with several "container" widgets. Textual comes with [several "container" widgets][textual.containers].
These are [Vertical][textual.containers.Vertical], [Horizontal][textual.containers.Horizontal], and [Grid][textual.containers.Grid] which have the corresponding layout. Among them, we have [Vertical][textual.containers.Vertical], [Horizontal][textual.containers.Horizontal], and [Grid][textual.containers.Grid] which have the corresponding layout.
The example below shows how we can combine these containers to create a simple 2x2 grid. The example below shows how we can combine these containers to create a simple 2x2 grid.
Inside a single `Horizontal` container, we place two `Vertical` containers. Inside a single `Horizontal` container, we place two `Vertical` containers.
@@ -163,8 +163,8 @@ However, Textual comes with a more powerful mechanism for achieving this known a
## Composing with context managers ## Composing with context managers
In the previous section we've show how you add children to a container (such as `Horizontal` and `Vertical`) using positional arguments. In the previous section, we've shown how you add children to a container (such as `Horizontal` and `Vertical`) using positional arguments.
It's fine to do it this way, but Textual offers a simplified syntax using [context managers](https://docs.python.org/3/reference/datamodel.html#context-managers) which is generally easier to write and edit. It's fine to do it this way, but Textual offers a simplified syntax using [context managers](https://docs.python.org/3/reference/datamodel.html#context-managers), which is generally easier to write and edit.
When composing a widget, you can introduce a container using Python's `with` statement. When composing a widget, you can introduce a container using Python's `with` statement.
Any widgets yielded within that block are added as a child of the container. Any widgets yielded within that block are added as a child of the container.
@@ -202,7 +202,7 @@ Let's update the [utility containers](#utility-containers) example to use the co
```{.textual path="docs/examples/guide/layout/utility_containers_using_with.py"} ```{.textual path="docs/examples/guide/layout/utility_containers_using_with.py"}
``` ```
Note how the end result is the same, but the code with context managers is a little easer to read. It is up to you which method you want to use, and you can mix context managers with positional arguments if you like! Note how the end result is the same, but the code with context managers is a little easier to read. It is up to you which method you want to use, and you can mix context managers with positional arguments if you like!
## Grid ## Grid

View File

@@ -61,8 +61,8 @@ Open the CSS file tab to see the comments that explain how each height is comput
1. This sets the height to 2 lines. 1. This sets the height to 2 lines.
2. This sets the height to 12.5% of the space made available by the container. The container is 24 lines tall, so 12.5% of 24 is 3. 2. This sets the height to 12.5% of the space made available by the container. The container is 24 lines tall, so 12.5% of 24 is 3.
3. This sets the height to 5% of the width of the direct container, which is the `Vertical` container. Because it expands to fit all of the terminal, the width of the `Vertical` is 80 and 5% of 80 is 4. 3. This sets the height to 5% of the width of the direct container, which is the `VerticalScroll` container. Because it expands to fit all of the terminal, the width of the `VerticalScroll` is 80 and 5% of 80 is 4.
4. This sets the height to 12.5% of the height of the direct container, which is the `Vertical` container. Because it expands to fit all of the terminal, the height of the `Vertical` is 24 and 12.5% of 24 is 3. 4. This sets the height to 12.5% of the height of the direct container, which is the `VerticalScroll` container. Because it expands to fit all of the terminal, the height of the `VerticalScroll` is 24 and 12.5% of 24 is 3.
5. This sets the height to 6.25% of the viewport width, which is 80. 6.25% of 80 is 5. 5. This sets the height to 6.25% of the viewport width, which is 80. 6.25% of 80 is 5.
6. This sets the height to 12.5% of the viewport height, which is 24. 12.5% of 24 is 3. 6. This sets the height to 12.5% of the viewport height, which is 24. 12.5% of 24 is 3.
7. This sets the height of the placeholder to be the optimal size that fits the content without scrolling. 7. This sets the height of the placeholder to be the optimal size that fits the content without scrolling.

View File

@@ -25,7 +25,7 @@ The default setting for containers is `overflow: auto auto`.
!!! warning !!! warning
Some built-in containers like `Horizontal` and `Vertical` override these defaults. Some built-in containers like `Horizontal` and `VerticalScroll` override these defaults.
## Example ## Example

View File

@@ -14,7 +14,7 @@ from rich.traceback import Traceback
from textual import events from textual import events
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Container, Vertical from textual.containers import Container, VerticalScroll
from textual.reactive import var from textual.reactive import var
from textual.widgets import DirectoryTree, Footer, Header, Static from textual.widgets import DirectoryTree, Footer, Header, Static
@@ -40,7 +40,7 @@ class CodeBrowser(App):
yield Header() yield Header()
with Container(): with Container():
yield DirectoryTree(path, id="tree-view") yield DirectoryTree(path, id="tree-view")
with Vertical(id="code-view"): with VerticalScroll(id="code-view"):
yield Static(id="code", expand=True) yield Static(id="code", expand=True)
yield Footer() yield Footer()

View File

@@ -192,11 +192,10 @@ def align_lines(
style: Background style. style: Background style.
size: Size of container. size: Size of container.
horizontal: Horizontal alignment. horizontal: Horizontal alignment.
vertical: Vertical alignment vertical: Vertical alignment.
Returns: Returns:
Aligned lines. Aligned lines.
""" """
width, height = size width, height = size

View File

@@ -1,6 +1,6 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.constants import BORDERS from textual.constants import BORDERS
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Button, Label from textual.widgets import Button, Label
TEXT = """I must not fear. TEXT = """I must not fear.
@@ -12,7 +12,7 @@ And when it has gone past, I will turn the inner eye to see its path.
Where the fear has gone there will be nothing. Only I will remain.""" Where the fear has gone there will be nothing. Only I will remain."""
class BorderButtons(Vertical): class BorderButtons(VerticalScroll):
DEFAULT_CSS = """ DEFAULT_CSS = """
BorderButtons { BorderButtons {
dock: left; dock: left;

View File

@@ -1,11 +1,11 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, VerticalScroll
from textual.design import ColorSystem from textual.design import ColorSystem
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Button, Footer, Label, Static from textual.widgets import Button, Footer, Label, Static
class ColorButtons(Vertical): class ColorButtons(VerticalScroll):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
for border in ColorSystem.COLOR_NAMES: for border in ColorSystem.COLOR_NAMES:
if border: if border:
@@ -20,15 +20,15 @@ class ColorItem(Horizontal):
pass pass
class ColorGroup(Vertical): class ColorGroup(VerticalScroll):
pass pass
class Content(Vertical): class Content(VerticalScroll):
pass pass
class ColorsView(Vertical): class ColorsView(VerticalScroll):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
LEVELS = [ LEVELS = [
"darken-3", "darken-3",

View File

@@ -5,7 +5,7 @@ from rich.console import RenderableType
from textual._easing import EASING from textual._easing import EASING
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.cli.previews.borders import TEXT from textual.cli.previews.borders import TEXT
from textual.containers import Container, Horizontal, Vertical from textual.containers import Container, Horizontal, VerticalScroll
from textual.reactive import reactive, var from textual.reactive import reactive, var
from textual.scrollbar import ScrollBarRender from textual.scrollbar import ScrollBarRender
from textual.widget import Widget from textual.widget import Widget
@@ -73,7 +73,7 @@ class EasingApp(App):
) )
yield EasingButtons() yield EasingButtons()
with Vertical(): with VerticalScroll():
with Horizontal(id="inputs"): with Horizontal(id="inputs"):
yield Label("Animation Duration:", id="label") yield Label("Animation Duration:", id="label")
yield duration_input yield duration_input

View File

@@ -14,11 +14,23 @@ class Container(Widget):
class Vertical(Widget): class Vertical(Widget):
"""A container widget which aligns children vertically.""" """A container which arranges children vertically."""
DEFAULT_CSS = """ DEFAULT_CSS = """
Vertical { Vertical {
height: 1fr; width: 1fr;
layout: vertical;
overflow: hidden hidden;
}
"""
class VerticalScroll(Widget):
"""A container which arranges children vertically, with an automatic vertical scrollbar."""
DEFAULT_CSS = """
VerticalScroll {
width: 1fr;
layout: vertical; layout: vertical;
overflow-y: auto; overflow-y: auto;
} }
@@ -26,19 +38,55 @@ class Vertical(Widget):
class Horizontal(Widget): class Horizontal(Widget):
"""A container widget which aligns children horizontally.""" """A container which arranges children horizontally."""
DEFAULT_CSS = """ DEFAULT_CSS = """
Horizontal { Horizontal {
height: 1fr; height: 1fr;
layout: horizontal; layout: horizontal;
overflow-x: hidden; overflow: hidden hidden;
}
"""
class HorizontalScroll(Widget):
"""A container which arranges children horizontally, with an automatic horizontal scrollbar."""
DEFAULT_CSS = """
HorizontalScroll {
height: 1fr;
layout: horizontal;
overflow-x: auto;
}
"""
class Center(Widget):
"""A container which centers children horizontally."""
DEFAULT_CSS = """
Center {
align-horizontal: center;
height: auto;
width: 1fr;
}
"""
class Middle(Widget):
"""A container which aligns children vertically in the middle."""
DEFAULT_CSS = """
Middle {
align-vertical: middle;
width: auto;
height: 1fr;
} }
""" """
class Grid(Widget): class Grid(Widget):
"""A container widget with grid alignment.""" """A container with grid alignment."""
DEFAULT_CSS = """ DEFAULT_CSS = """
Grid { Grid {
@@ -52,7 +100,7 @@ class Content(Widget, can_focus=True, can_focus_children=False):
"""A container for content such as text.""" """A container for content such as text."""
DEFAULT_CSS = """ DEFAULT_CSS = """
Vertical { VerticalScroll {
height: 1fr; height: 1fr;
layout: vertical; layout: vertical;
overflow-y: auto; overflow-y: auto;

View File

@@ -4,7 +4,7 @@ from typing import ClassVar, Optional
from textual.await_remove import AwaitRemove from textual.await_remove import AwaitRemove
from textual.binding import Binding, BindingType from textual.binding import Binding, BindingType
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.geometry import clamp from textual.geometry import clamp
from textual.message import Message from textual.message import Message
from textual.reactive import reactive from textual.reactive import reactive
@@ -12,7 +12,7 @@ from textual.widget import AwaitMount, Widget
from textual.widgets._list_item import ListItem from textual.widgets._list_item import ListItem
class ListView(Vertical, can_focus=True, can_focus_children=False): class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
"""A vertical list view widget. """A vertical list view widget.
Displays a vertical list of `ListItem`s which can be highlighted and Displays a vertical list of `ListItem`s which can be highlighted and

View File

@@ -10,7 +10,7 @@ from rich.text import Text
from typing_extensions import TypeAlias from typing_extensions import TypeAlias
from ..app import ComposeResult from ..app import ComposeResult
from ..containers import Horizontal, Vertical from ..containers import Horizontal, VerticalScroll
from ..message import Message from ..message import Message
from ..reactive import reactive, var from ..reactive import reactive, var
from ..widget import Widget from ..widget import Widget
@@ -266,7 +266,7 @@ class MarkdownBulletList(MarkdownList):
width: 1fr; width: 1fr;
} }
MarkdownBulletList Vertical { MarkdownBulletList VerticalScroll {
height: auto; height: auto;
width: 1fr; width: 1fr;
} }
@@ -277,7 +277,7 @@ class MarkdownBulletList(MarkdownList):
if isinstance(block, MarkdownListItem): if isinstance(block, MarkdownListItem):
bullet = MarkdownBullet() bullet = MarkdownBullet()
bullet.symbol = block.bullet bullet.symbol = block.bullet
yield Horizontal(bullet, Vertical(*block._blocks)) yield Horizontal(bullet, VerticalScroll(*block._blocks))
self._blocks.clear() self._blocks.clear()
@@ -295,7 +295,7 @@ class MarkdownOrderedList(MarkdownList):
width: 1fr; width: 1fr;
} }
MarkdownOrderedList Vertical { MarkdownOrderedList VerticalScroll {
height: auto; height: auto;
width: 1fr; width: 1fr;
} }
@@ -311,7 +311,7 @@ class MarkdownOrderedList(MarkdownList):
if isinstance(block, MarkdownListItem): if isinstance(block, MarkdownListItem):
bullet = MarkdownBullet() bullet = MarkdownBullet()
bullet.symbol = block.bullet.rjust(symbol_size + 1) bullet.symbol = block.bullet.rjust(symbol_size + 1)
yield Horizontal(bullet, Vertical(*block._blocks)) yield Horizontal(bullet, VerticalScroll(*block._blocks))
self._blocks.clear() self._blocks.clear()
@@ -410,7 +410,7 @@ class MarkdownListItem(MarkdownBlock):
height: auto; height: auto;
} }
MarkdownListItem > Vertical { MarkdownListItem > VerticalScroll {
width: 1fr; width: 1fr;
height: auto; height: auto;
} }
@@ -761,7 +761,7 @@ class MarkdownTableOfContents(Widget, can_focus_children=True):
) )
class MarkdownViewer(Vertical, can_focus=True, can_focus_children=True): class MarkdownViewer(VerticalScroll, can_focus=True, can_focus_children=True):
"""A Markdown viewer widget.""" """A Markdown viewer widget."""
DEFAULT_CSS = """ DEFAULT_CSS = """

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,29 @@
"""
App to test alignment containers.
"""
from textual.app import App, ComposeResult
from textual.containers import Center, Middle
from textual.widgets import Button
class AlignContainersApp(App[None]):
CSS = """
Center {
tint: $primary 10%;
}
Middle {
tint: $secondary 10%;
}
"""
def compose(self) -> ComposeResult:
with Center():
yield Button.success("center")
with Middle():
yield Button.error("middle")
app = AlignContainersApp()
if __name__ == "__main__":
app.run()

View File

@@ -1,10 +1,9 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Header, Footer, Label, Input from textual.widgets import Header, Footer, Label, Input
class InputWidthAutoApp(App[None]): class InputWidthAutoApp(App[None]):
CSS = """ CSS = """
Input.auto { Input.auto {
width: auto; width: auto;

View File

@@ -2,6 +2,7 @@ from textual.app import App, ComposeResult
from textual.containers import Horizontal from textual.containers import Horizontal
from textual.widgets import Button from textual.widgets import Button
class WidgetDisableTestApp(App[None]): class WidgetDisableTestApp(App[None]):
CSS = """ CSS = """
Horizontal { Horizontal {
@@ -28,5 +29,6 @@ class WidgetDisableTestApp(App[None]):
yield Button(variant="warning") yield Button(variant="warning")
yield Button(variant="error") yield Button(variant="error")
if __name__ == "__main__": if __name__ == "__main__":
WidgetDisableTestApp().run() WidgetDisableTestApp().run()

View File

@@ -1,7 +1,7 @@
from rich.text import Text from rich.text import Text
from textual.app import App, ComposeResult, RenderResult from textual.app import App, ComposeResult, RenderResult
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Header, Footer from textual.widgets import Header, Footer
from textual.widget import Widget from textual.widget import Widget
@@ -32,7 +32,7 @@ class Tester(Widget, can_focus=True):
class StyleBugApp(App[None]): class StyleBugApp(App[None]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
with Vertical(): with VerticalScroll():
for n in range(40): for n in range(40):
yield Tester(n) yield Tester(n)
yield Footer() yield Footer()

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Static from textual.widgets import Static
@@ -8,7 +8,6 @@ class StaticText(Static):
class FRApp(App): class FRApp(App):
CSS = """ CSS = """
StaticText { StaticText {
height: 1fr; height: 1fr;
@@ -39,7 +38,7 @@ class FRApp(App):
""" """
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Vertical( yield VerticalScroll(
StaticText("HEADER", id="header"), StaticText("HEADER", id="header"),
Horizontal( Horizontal(
StaticText("foo", id="foo"), StaticText("foo", id="foo"),

View File

@@ -1,7 +1,7 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Label from textual.widgets import Header, Footer, Label
from textual.containers import Vertical, Container from textual.containers import VerticalScroll, Container
class Overlay(Container): class Overlay(Container):
@@ -9,12 +9,12 @@ class Overlay(Container):
yield Label("This should float over the top") yield Label("This should float over the top")
class Body1(Vertical): class Body1(VerticalScroll):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Label("My God! It's full of stars! " * 300) yield Label("My God! It's full of stars! " * 300)
class Body2(Vertical): class Body2(VerticalScroll):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Label("My God! It's full of stars! " * 300) yield Label("My God! It's full of stars! " * 300)
@@ -36,7 +36,6 @@ class Bad(Screen):
class Layers(App[None]): class Layers(App[None]):
CSS = """ CSS = """
Screen { Screen {
layers: base higher; layers: base higher;

View File

@@ -0,0 +1,56 @@
"""
App to test layout containers.
"""
from typing import Iterable
from textual.app import App, ComposeResult
from textual.containers import (
Grid,
Horizontal,
HorizontalScroll,
Vertical,
VerticalScroll,
)
from textual.widget import Widget
from textual.widgets import Button, Input, Label
def sub_compose() -> Iterable[Widget]:
yield Button.success("Accept")
yield Button.error("Decline")
yield Input()
yield Label("\n\n".join([str(n * 1_000_000) for n in range(10)]))
class MyApp(App[None]):
CSS = """
Grid {
grid-size: 2 2;
grid-rows: 1fr;
grid-columns: 1fr;
}
Grid > Widget {
width: 100%;
height: 100%;
}
Input {
width: 80;
}
"""
def compose(self) -> ComposeResult:
with Grid():
with Horizontal():
yield from sub_compose()
with HorizontalScroll():
yield from sub_compose()
with Vertical():
yield from sub_compose()
with VerticalScroll():
yield from sub_compose()
app = MyApp()
if __name__ == "__main__":
app.run()

View File

@@ -1,7 +1,7 @@
from rich.text import Text from rich.text import Text
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import TextLog from textual.widgets import TextLog
@@ -27,26 +27,26 @@ class ScrollViewApp(App):
height:10; height:10;
} }
Vertical{ VerticalScroll {
width:13; width:13;
height: 10; height: 10;
overflow: scroll; overflow: scroll;
overflow-x: auto; overflow-x: auto;
} }
MyWidget { MyWidget {
width:13; width:13;
height:auto; height:auto;
} }
""" """
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield TextLog() yield TextLog()
yield Vertical(MyWidget()) yield VerticalScroll(MyWidget())
def on_ready(self) -> None: def on_ready(self) -> None:
self.query_one(TextLog).write("\n".join(f"{n} 0123456789" for n in range(20))) self.query_one(TextLog).write("\n".join(f"{n} 0123456789" for n in range(20)))
self.query_one(Vertical).scroll_end(animate=False) self.query_one(VerticalScroll).scroll_end(animate=False)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Static from textual.widgets import Static
@@ -42,8 +42,8 @@ class NestedAutoApp(App[None]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
self._static = Static("", id="my-static") self._static = Static("", id="my-static")
yield Vertical( yield VerticalScroll(
Vertical( VerticalScroll(
self._static, self._static,
id="my-static-wrapper", id="my-static-wrapper",
), ),

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Label, Static from textual.widgets import Label, Static
@@ -18,7 +18,6 @@ class Box(Static):
class OffsetsApp(App): class OffsetsApp(App):
CSS = """ CSS = """
#box1 { #box1 {

View File

@@ -1,5 +1,5 @@
from textual.app import App from textual.app import App
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import Static from textual.widgets import Static
@@ -10,7 +10,7 @@ class Visibility(App):
Screen { Screen {
layout: horizontal; layout: horizontal;
} }
Vertical { VerticalScroll {
width: 1fr; width: 1fr;
border: solid red; border: solid red;
} }
@@ -30,13 +30,12 @@ class Visibility(App):
""" """
def compose(self): def compose(self):
yield VerticalScroll(
yield Vertical(
Static("foo"), Static("foo"),
Static("float", classes="float"), Static("float", classes="float"),
id="container1", id="container1",
) )
yield Vertical( yield VerticalScroll(
Static("bar"), Static("bar"),
Static("float", classes="float"), Static("float", classes="float"),
id="container2", id="container2",

View File

@@ -47,6 +47,14 @@ def test_dock_layout_sidebar(snap_compare):
assert snap_compare(LAYOUT_EXAMPLES_DIR / "dock_layout2_sidebar.py") assert snap_compare(LAYOUT_EXAMPLES_DIR / "dock_layout2_sidebar.py")
def test_layout_containers(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "layout_containers.py")
def test_alignment_containers(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "alignment_containers.py")
# --- Widgets - rendering and basic interactions --- # --- Widgets - rendering and basic interactions ---
# Each widget should have a canonical example that is display in the docs. # Each widget should have a canonical example that is display in the docs.
# When adding a new widget, ideally we should also create a snapshot test # When adding a new widget, ideally we should also create a snapshot test

100
tests/test_containers.py Normal file
View File

@@ -0,0 +1,100 @@
"""Test basic functioning of some containers."""
from textual.app import App, ComposeResult
from textual.containers import (
Center,
Horizontal,
HorizontalScroll,
Middle,
Vertical,
VerticalScroll,
)
from textual.widgets import Label
async def test_horizontal_vs_horizontalscroll_scrolling():
"""Check the default scrollbar behaviours for `Horizontal` and `HorizontalScroll`."""
class HorizontalsApp(App[None]):
CSS = """
Screen {
layout: vertical;
}
"""
def compose(self) -> ComposeResult:
with Horizontal():
for _ in range(10):
yield Label("How is life going? " * 3 + " | ")
with HorizontalScroll():
for _ in range(10):
yield Label("How is life going? " * 3 + " | ")
WIDTH = 80
HEIGHT = 24
app = HorizontalsApp()
async with app.run_test(size=(WIDTH, HEIGHT)):
horizontal = app.query_one(Horizontal)
horizontal_scroll = app.query_one(HorizontalScroll)
assert horizontal.size.height == horizontal_scroll.size.height
assert horizontal.scrollbars_enabled == (False, False)
assert horizontal_scroll.scrollbars_enabled == (False, True)
async def test_vertical_vs_verticalscroll_scrolling():
"""Check the default scrollbar behaviours for `Vertical` and `VerticalScroll`."""
class VerticalsApp(App[None]):
CSS = """
Screen {
layout: horizontal;
}
"""
def compose(self) -> ComposeResult:
with Vertical():
for _ in range(10):
yield Label("How is life going?\n" * 3 + "\n\n")
with VerticalScroll():
for _ in range(10):
yield Label("How is life going?\n" * 3 + "\n\n")
WIDTH = 80
HEIGHT = 24
app = VerticalsApp()
async with app.run_test(size=(WIDTH, HEIGHT)):
vertical = app.query_one(Vertical)
vertical_scroll = app.query_one(VerticalScroll)
assert vertical.size.width == vertical_scroll.size.width
assert vertical.scrollbars_enabled == (False, False)
assert vertical_scroll.scrollbars_enabled == (True, False)
async def test_center_container():
"""Check the size of the container `Center`."""
class CenterApp(App[None]):
def compose(self) -> ComposeResult:
with Center():
yield Label("<>\n<>\n<>")
app = CenterApp()
async with app.run_test():
center = app.query_one(Center)
assert center.size.width == app.size.width
assert center.size.height == 3
async def test_middle_container():
"""Check the size of the container `Middle`."""
class MiddleApp(App[None]):
def compose(self) -> ComposeResult:
with Middle():
yield Label("1234")
app = MiddleApp()
async with app.run_test():
middle = app.query_one(Middle)
assert middle.size.width == 4
assert middle.size.height == app.size.height

View File

@@ -1,7 +1,7 @@
"""Test Widget.disabled.""" """Test Widget.disabled."""
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widgets import ( from textual.widgets import (
Button, Button,
DataTable, DataTable,
@@ -21,7 +21,7 @@ class DisableApp(App[None]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Compose the child widgets.""" """Compose the child widgets."""
yield Vertical( yield VerticalScroll(
Button(), Button(),
DataTable(), DataTable(),
DirectoryTree("."), DirectoryTree("."),
@@ -56,7 +56,7 @@ async def test_enabled_widgets_have_enabled_pseudo_class() -> None:
async def test_all_individually_disabled() -> None: async def test_all_individually_disabled() -> None:
"""Post-disable all widgets should report being disabled.""" """Post-disable all widgets should report being disabled."""
async with DisableApp().run_test() as pilot: async with DisableApp().run_test() as pilot:
for node in pilot.app.screen.query("Vertical > *"): for node in pilot.app.screen.query("VerticalScroll > *"):
node.disabled = True node.disabled = True
assert all( assert all(
node.disabled for node in pilot.app.screen.query("#test-container > *") node.disabled for node in pilot.app.screen.query("#test-container > *")
@@ -77,7 +77,7 @@ async def test_disabled_widgets_have_disabled_pseudo_class() -> None:
async def test_disable_via_container() -> None: async def test_disable_via_container() -> None:
"""All child widgets should appear (to CSS) as disabled by a container being disabled.""" """All child widgets should appear (to CSS) as disabled by a container being disabled."""
async with DisableApp().run_test() as pilot: async with DisableApp().run_test() as pilot:
pilot.app.screen.query_one("#test-container", Vertical).disabled = True pilot.app.screen.query_one("#test-container", VerticalScroll).disabled = True
assert all( assert all(
node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled") node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled")
for node in pilot.app.screen.query("#test-container > *") for node in pilot.app.screen.query("#test-container > *")

View File

@@ -151,11 +151,11 @@ def test_focus_next_and_previous_with_type_selector_without_self():
screen = app.screen screen = app.screen
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Button, Input, Switch from textual.widgets import Button, Input, Switch
screen._add_children( screen._add_children(
Vertical( VerticalScroll(
Horizontal( Horizontal(
Input(id="w3"), Input(id="w3"),
Switch(id="w4"), Switch(id="w4"),

View File

@@ -1,19 +1,18 @@
"""Regression test for #1616 https://github.com/Textualize/textual/issues/1616""" """Regression test for #1616 https://github.com/Textualize/textual/issues/1616"""
import pytest
from textual.app import App from textual.app import App
from textual.containers import Vertical from textual.containers import VerticalScroll
async def test_overflow_change_updates_virtual_size_appropriately(): async def test_overflow_change_updates_virtual_size_appropriately():
class MyApp(App): class MyApp(App):
def compose(self): def compose(self):
yield Vertical() yield VerticalScroll()
app = MyApp() app = MyApp()
async with app.run_test() as pilot: async with app.run_test() as pilot:
vertical = app.query_one(Vertical) vertical = app.query_one(VerticalScroll)
height = vertical.virtual_size.height height = vertical.virtual_size.height

View File

@@ -1,7 +1,7 @@
"""See https://github.com/Textualize/textual/issues/1355 as the motivation for these tests.""" """See https://github.com/Textualize/textual/issues/1355 as the motivation for these tests."""
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Vertical from textual.containers import VerticalScroll
from textual.widget import Widget from textual.widget import Widget
@@ -18,7 +18,7 @@ class VisibleTester(App[None]):
""" """
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Vertical( yield VerticalScroll(
Widget(id="keep"), Widget(id="hide-via-code"), Widget(id="hide-via-css") Widget(id="keep"), Widget(id="hide-via-code"), Widget(id="hide-via-css")
) )