Skip to content

How-to: Build a Custom Two-Column Layout

This guide shows how to arrange form fields in two equal columns using a custom layout class that extends ControllerAwareLayout.

Goal

Given a form with six fields, render them in a two-column grid with Submit and Cancel buttons spanning the full width at the bottom.

┌─────────────────────┬─────────────────────┐
│  First name         │  Last name          │
│  [              ]   │  [              ]   │
│  Email              │  Phone              │
│  [              ]   │  [              ]   │
│  Company            │  Job title          │
│  [              ]   │  [              ]   │
├─────────────────────┴─────────────────────┤
│  [Submit]  [Cancel]                       │
└───────────────────────────────────────────┘

The form

two_column_demo.py
from textual_wtf import Form, StringField

class ContactForm(Form):
    title = "New Contact"

    first_name = StringField("First name", required=True)
    last_name  = StringField("Last name", required=True)
    email      = StringField("Email", required=True)
    phone      = StringField("Phone")
    company    = StringField("Company")
    job_title  = StringField("Job title")

The custom layout

Subclass ControllerAwareLayout and implement compose(). Use self.form.bound_fields to access all bound fields in declaration order, then split them into two columns.

two_column_demo.py
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Button, Label
from textual_wtf import ControllerAwareLayout

class TwoColumnLayout(ControllerAwareLayout):
    """Arrange fields in a two-column grid."""

    DEFAULT_CSS = """
    TwoColumnLayout {
        height: auto;
        padding: 1 2;
    }
    TwoColumnLayout .form-title {
        text-style: bold;
        margin-bottom: 1;
    }
    TwoColumnLayout .column {
        width: 1fr;
        height: auto;
        padding: 0 1;
    }
    TwoColumnLayout #buttons {
        height: auto;
        margin-top: 1;
    }
    TwoColumnLayout #buttons Button {
        margin-right: 1;
    }
    """

    def compose(self) -> ComposeResult:
        # Optional title
        title = getattr(self.form, "title", "")
        if title:
            yield Label(title, classes="form-title")

        # Split fields into two columns (even-indexed → left, odd-indexed → right)
        all_fields = list(self.form.bound_fields.values())
        left_fields  = all_fields[::2]   # 0, 2, 4, …
        right_fields = all_fields[1::2]  # 1, 3, 5, …

        with Horizontal():
            with Vertical(classes="column"):
                for bf in left_fields:
                    yield bf.simple_layout()

            with Vertical(classes="column"):
                for bf in right_fields:
                    yield bf.simple_layout()

        with Horizontal(id="buttons"):
            yield Button("Submit", id="submit", variant="primary")
            yield Button("Cancel", id="cancel")

Wiring it up

Assign the custom layout class to the form:

two_column_demo.py
class ContactForm(Form):
    layout_class = TwoColumnLayout   # ← use our layout
    title        = "New Contact"

    first_name = StringField("First name", required=True)
    last_name  = StringField("Last name", required=True)
    email      = StringField("Email", required=True)
    phone      = StringField("Phone")
    company    = StringField("Company")
    job_title  = StringField("Job title")

The full app

two_column_demo.py
from textual.app import App, ComposeResult, on
from textual.containers import Horizontal, Vertical
from textual.widgets import Button, Label
from textual_wtf import Form, StringField, ControllerAwareLayout


class TwoColumnLayout(ControllerAwareLayout):
    DEFAULT_CSS = """
    TwoColumnLayout {
        height: auto;
        padding: 1 2;
    }
    TwoColumnLayout .form-title {
        text-style: bold;
        margin-bottom: 1;
    }
    TwoColumnLayout .column {
        width: 1fr;
        height: auto;
        padding: 0 1;
    }
    TwoColumnLayout #buttons {
        height: auto;
        margin-top: 1;
    }
    """

    def compose(self) -> ComposeResult:
        title = getattr(self.form, "title", "")
        if title:
            yield Label(title, classes="form-title")

        all_fields   = list(self.form.bound_fields.values())
        left_fields  = all_fields[::2]
        right_fields = all_fields[1::2]

        with Horizontal():
            with Vertical(classes="column"):
                for bf in left_fields:
                    yield bf.simple_layout()
            with Vertical(classes="column"):
                for bf in right_fields:
                    yield bf.simple_layout()

        with Horizontal(id="buttons"):
            yield Button("Submit", id="submit", variant="primary")
            yield Button("Cancel", id="cancel")


class ContactForm(Form):
    layout_class = TwoColumnLayout
    title        = "New Contact"

    first_name = StringField("First name", required=True)
    last_name  = StringField("Last name", required=True)
    email      = StringField("Email", required=True)
    phone      = StringField("Phone")
    company    = StringField("Company")
    job_title  = StringField("Job title")


class ContactApp(App):
    CSS = """
    Screen {
        align: center middle;
    }
    TwoColumnLayout {
        width: 80;
        border: solid $primary;
    }
    """

    def compose(self) -> ComposeResult:
        self.form = ContactForm()
        yield self.form.layout()

    @on(ContactForm.Submitted)
    def on_submitted(self, event: ContactForm.Submitted) -> None:
        data = event.form.get_data()
        self.notify(
            f"Saved contact: {data['first_name']} {data['last_name']}",
            severity="information",
        )

    @on(ContactForm.Cancelled)
    def on_cancelled(self, event: ContactForm.Cancelled) -> None:
        self.app.exit()


if __name__ == "__main__":
    ContactApp().run()

Tips

Odd number of fields

If the form has an odd number of fields, left_fields will have one more field than right_fields. The right column will simply have an empty slot at the bottom. This is handled naturally by the list slicing.

Label style in two-column layouts

Consider using label_style = "above" (the default) or label_style = "beside" on the form. "beside" can make columns feel cramped at narrow widths; "above" generally works better in column arrangements.

Using bf() instead of simple_layout()

If you need complete control over individual field layout (for instance, to place the label and input in separate CSS grid cells), use bf() to get the raw widget. Since TwoColumnLayout extends ControllerAwareLayout, event routing to the controllers is handled automatically.