Building views

This section builds a small form, with its layout and its checks:

import enum
from typing import Optional

from attrs import Factory

from composeui.core.application import run
from composeui.core.view import (
    Align,
    ButtonView,
    FormView,
    HorizontalView,
    LabelRowCheckBoxView,
    LabelRowComboBoxView,
    LabelRowFloatInputView,
    LabelRowInputView,
    MainView,
    child,
    define_view,
)


class Material(enum.Enum):
    STEEL = "Steel"
    COPPER = "Copper"


@define_view
class NameRow(LabelRowInputView):
    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.set_validator(self._check_name, self.text_edited)
        self.run_validator()  # shows the error before the first edit

    def _check_name(self) -> Optional[str]:
        return "A name is required" if self.text == "" else None


@define_view
class PipeForm(FormView):
    name: NameRow = child(NameRow, label="Name")
    radius: LabelRowFloatInputView = child(LabelRowFloatInputView, label="Radius", minimum=0)
    material: LabelRowComboBoxView[Material] = child(
        LabelRowComboBoxView,
        label="Material",
        values={material.value: material for material in Material},
        value=Material.STEEL,
    )
    insulated: LabelRowCheckBoxView = child(LabelRowCheckBoxView, label="Insulated")


@define_view
class FormButtons(HorizontalView):
    align = Align.END

    save: ButtonView = child(ButtonView, text="Save")


@define_view
class PipeMainView(MainView):
    app_title = "Pipes"
    extension = ".pipe"

    form: PipeForm = Factory(PipeForm)
    buttons: FormButtons = Factory(FormButtons)

    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.buttons.save.clicked.connect(self._save)

    def _save(self) -> None:
        self.form.check()
        if self.form.is_valid:
            self.status_bar.show_message(f"Saved {self.form.name.text}")


if __name__ == "__main__":
    run(PipeMainView)

Define a view

  • @define_view makes an attrs class of a view. A field set with a view is a child view. A field set with a plain value, such as text or value, is state that the renderer shows. The fields are keyword-only.

  • A class variable configures the class, such as app_title, extension, or align. It is not a field, so the state does not save it.

  • __attrs_post_init__ wires the view once it is built. Call super().__attrs_post_init__() first, then set values and connect signals.

  • T("...") and self.tr("...") mark a text for translation. Later examples of this guide write their text so, for example Column("radius", T("Radius")). Import T from composeui.core.view. Until the app has catalogs, the text shows as written. See Translating the app.

child or Factory

Each view needs its own child views, so a view field takes a function that builds them:

  • Factory(PipeForm) builds the view with its defaults.

  • child(LabelRowInputView, label="Name") builds it with these fields. Each view gets its own copy of the arguments, so a list among them is never shared.

Use child to set a few fields where the view is declared. Use a subclass with an __attrs_post_init__, as NameRow does, when the view also needs code: signals, checks, or text built from other fields.

Lay out views

  • A main view, a page, and a form put their view fields one above the other, in the order of the fields.

  • A HorizontalView puts its view fields side by side. Each field takes a share of the width, from its STRETCH metadata: attrs.field(factory=TableView, metadata={STRETCH: 3}). A button keeps the width of its text (default_stretch = 0). When no view takes a share, align packs them at the start or the end of the row. On the web, a row narrower than 42rem stacks its views.

  • A VerticalView puts its view fields one above the other, for a column in a HorizontalView, for example a table above its buttons.

  • A GridView puts each view field in a cell, from its GRID_POSITION metadata: metadata={GRID_POSITION: (1, 0)}, or GridPosition(1, 0, column_span=2).

For panes that the user resizes, see Resizable panes.

Forms and their fields

A FormView shows its rows one above the other, with their labels aligned. A GroupFormView is a form in a box with a title. It can be a row of another form, as the main pipe and the incident pipe are in pipeform.py.

Each field has three views:

View

Shows

Example

InputView

Only the field, anywhere

A search field in a toolbar

RowInputView

A row of a form, without a label

A field under a heading

LabelRowInputView

A row of a form, with a label and an optional help

Most form fields

The fields:

Field

Its value

What the user does

InputView

text

Types a line of text

TextAreaView

text, with its lines (see Text area)

Types several lines of text

FloatInputView

value, a float or None, from minimum to maximum, with decimals

Types a decimal number

IntInputView

value, an int or None (see Integer input)

Types a whole number

DateInputView

value, a datetime.date or None (see Date input)

Types or picks a date

FilePathInputView

text, and path (see File path input)

Types or picks a file

FolderPathInputView

text, and path (see Folder path input)

Types or picks a folder

SavePathInputView

text, and path (see Save path input)

Types or picks a file to save

CheckBoxView

is_checked, with its text beside the box

Checks or unchecks the box (toggled)

RadioGroupView

value, one of values

Picks one of a few choices

ComboBoxView

value, one of values

Picks one choice in a drop-down list

SliderView

value, from minimum to maximum by step (see Slider)

Drags a handle

Each field has its row views too, for example RowFloatInputView and LabelRowFloatInputView, or LabelRowDateInputView for a date. The widget catalog lists every row view.

A row shows in any view, such as a SidebarView or a VerticalView, as a form shows it: its label, its field, and its message. A FormView adds what concerns its rows together: on Qt, it lines up their labels, and its checks compare their values.

A form shows its other views too, such as a table or a HorizontalView of buttons. Each one shows in its place between the rows, as wide as the form. The checks of the form run on its rows only.

  • The text fields emit text_edited on each change that the user makes, and editing_finished when the user is done with the field, for example on Enter, or as a text area loses the focus.

  • A check box shows other views only while it is checked, with shows: self.show_raw_data.shows(self.table). It sets their is_visible at once, and again each time the box changes: as the user clicks it, as code sets is_checked, and as a study opens or New starts one.

  • A choice maps each label to its value: values = {"Steel": Material.STEEL}. value is None or one of values. value_changed reports the user’s choice. A None value selects nothing, unless None is one of values: then it selects that choice, such as the “All” of a filter, {"All regions": None, "North": "north"}.

  • A LabelView shows a line of read-only text, for example a result.

  • The status bar of the main view shows a short message, for example the result of a form.

Check a form

A check is a function without arguments. It returns an error message, or None when the value is good:

  • A row’s own check. row.set_validator(check, *signals) runs check each time one of the signals fires, and shows its message under the row’s field. It does not run at once: call run_validator() to show the error before the first edit.

  • A check of several rows. form.add_validator(check, *signals, rows=(...)) checks the values of several rows together. The form shows the messages of the failing checks at its end, and marks the fields of rows as invalid.

  • Before an action. form.check() runs every check of the form and its nested forms now. Then form.is_valid tells whether they all pass.

In a form dialog, OK stays disabled while a check fails. src/examples/pipeform.py checks its name field, and has two checks of several rows.

Compose views from mixins

You can compose a view by mixing traits. For example, LabelRowFloatInputView combines FormLabelRowView and FloatInputMixin. The mixins in composeui/core/view.py are the available building blocks: InputMixin, FloatInputMixin, IntInputMixin, DateInputMixin, FilePathInputMixin, SliderMixin, CheckBoxMixin, ValuesGroupMixin, TableMixin, LogMixin, LabelMixin, and MarkdownMixin.

Larger views have sections of their own:

The main view

A subclass of MainView is the app. Its class variables configure it:

  • app_title is the name of the app, in the title of the window.

  • extension is the file type of its studies, for example ".pipe".

  • has_file_menu = False leaves out the File menu, for an app without studies (see The File menu and picking a file).

  • app_icon is the icon of the app (see The app’s icon).

  • width sets the default width of the web content, also in an app without pages (see Page width). Qt and Textual ignore it.

  • translations_dir is the directory of its catalogs (see Translating the app).

Its view fields are the content of the window, one above the other. Its status_bar shows short messages, and its jobs run work in threads.

A dashboard’s main view

DashboardView, from composeui.core.dashboard, is a MainView for a dashboard. Its width is WIDE, and it has no File menu:

from composeui.core.dashboard import DashboardView
from composeui.core.view import ChartView, LabelView, child, define_view


@define_view
class SalesDashboard(DashboardView):
    app_title = "Sales"

    status: LabelView = child(LabelView)
    sales: ChartView = child(ChartView, title="Sales by month")

A dashboard that saves studies sets has_file_menu = True and its extension. The Uber pickups example does, so that a study keeps the rows that it read.

The status bar

The status_bar of the main view is a line at the bottom of the window. It shows a short message to the user, for example “Saved”, or why an edit was refused:

main_view.status_bar.show_message("Saved")
  • The message goes after timeout seconds, 3 by default. With a timeout of 0, it stays until the next message, or until clear_message().

  • A new message replaces the previous one, and its timeout starts again.

  • message holds the message shown, or "".

ComposeUI writes in the status bar too, for example when a table refuses an edit or a study fails to open. Qt shows a QStatusBar, Textual a line at the bottom of the screen, and the web a footer at the bottom of the page. On the web, the page clears the message itself after its timeout.

The app’s icon

A MainView shows its app_icon, an image file, as the app’s icon. The default is ComposeUI’s icon. Set your own in the subclass, or set None for no icon:

from pathlib import Path


@define_view
class PipeMainView(MainView):
    app_title = "Pipe Form"
    app_icon = Path(__file__).parent / "images" / "pipe.svg"
  • Qt shows it as the window’s icon.

  • The web shows it in the browser tab and in the page’s header, before the title. Without an icon, the header shows the first letter of the title in a badge.

  • Textual shows no icon.

Command-line options of your own

A MainView adds command-line options with its add_arguments classmethod. It gets their values in apply_arguments, once it is wired. On the web, this happens for every session.

import argparse
from pathlib import Path


@define_view
class MarkdownMainView(MainView):
    @classmethod
    def add_arguments(cls, parser: argparse.ArgumentParser) -> None:
        parser.add_argument("--markdown", type=Path, metavar="FILE", help="Show the Markdown FILE.")

    def apply_arguments(self, args: argparse.Namespace) -> None:
        if args.markdown is not None:
            self.open_markdown(args.markdown)  # a method of the app

src/examples/markdownviewer.py adds --markdown.

Structure an app

Split a larger app into components: views that each do one part of the work, such as a search bar or a history. Each component wires its own views. The main view connects the components:

from attrs import Factory

from composeui.core.application import run
from composeui.core.signal import Signal
from composeui.core.view import (
    ButtonView,
    HorizontalView,
    InputView,
    LabelView,
    MainView,
    child,
    define_view,
)


@define_view
class SearchBar(HorizontalView):
    query: InputView = child(InputView, placeholder="Search...")
    search: ButtonView = child(ButtonView, text="Search", is_enabled=False)
    searched: Signal[str] = Factory(Signal[str])

    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.query.text_edited.connect(self._on_query_edited)
        self.search.clicked.connect(self._on_search_clicked)

    def _on_query_edited(self, text: str) -> None:
        self.search.is_enabled = text != ""

    def _on_search_clicked(self) -> None:
        self.searched.emit(self.query.text)


@define_view
class LastSearch(LabelView):
    def show_search(self, query: str) -> None:
        self.text = f"Last search: {query}"


@define_view
class SearchMainView(MainView):
    app_title = "Search"
    has_file_menu = False

    search_bar: SearchBar = Factory(SearchBar)
    last_search: LastSearch = child(LastSearch, text="No search yet")

    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.search_bar.searched.connect(self.last_search.show_search)


if __name__ == "__main__":
    run(SearchMainView)
  • A component owns its views and their callbacks. It connects the signals of its views and sets their validators in its __attrs_post_init__. For example, SearchBar enables its button when the query has text. Keep in the component the state that only it reads.

  • A component tells the app with a signal of its own, a Signal field such as searched. The component then needs no other component. The state does not save a signal.

  • The main view connects the components and does the work of the whole app: the callback of loaded, the menus, the toolbars, the dialogs, and the work that reads several components. It does not hold the callback of each button.

  • A callback that reaches another component asks for main_view (see Signals inject arguments). Never keep a parent or another component in a view field: the app reads each view field as a child view, and a view that holds its parent makes the app loop forever as it starts.

  • Keep the state in the views and in the model, not in a module or class variable. On the web, each session builds its own main view, but all the users share a module or class variable.

  • Keep the data and the slow work out of the views. Do not import a view in the model or in the functions of the jobs.

  • Define each view at module level, not in a function. With from __future__ import annotations, attrs and cattrs read the types of the fields from the module. A large component can have its own module. Call run in the program’s module, under if __name__ == "__main__":.

Split by the work that a component does, not by its number of lines. When a callback of the main view only calls a method of a component, move the connection into the component too. To test a component alone, see Test a component.

Saving and loading state

Save and Open in the File menu save and open the study, in a file with the extension of the main view. -f FILE on the command line opens a study at the start. Without a model, the study holds the state of the views. A model can add its data to the study (see A SQLite model).

MainView.save_state(path) writes the state of the whole view tree as JSON, with cattrs, and MainView.load_state(path) reads it back. They skip the Signal and Renderer fields. A load changes the views in place, so the renderers and the signal connections stay.

The CONVERTER of the views writes a datetime.date or a datetime.datetime as ISO 8601 text. A datetime keeps its UTC offset and its microseconds. A field of these types needs no hook of its own.

The text that the user reads rather than enters (labels, titles, headers, button and menu text) is tagged UI_TEXT, and is skipped too. This text comes from the app’s code, in its language, not from a study. Tag a text field of your own views the same way: attrs.field(default="", metadata={UI_TEXT: True}).

A study holds what the user enters and what the app computes from it: the values of the fields, the rows and records of a table with its sort and its filters, and the series of a chart. Saving, opening, and New leave the rest alone:

  • Navigation: the page and the tab shown, and an open dialog.

  • Layout: the sizes of resizable panes, and whether the sidebar is retracted.

  • The app’s setup: class variables, the icon of an action or a tree node, the extensions of a file path input, and the cell_widgets, cell_styles, header_groups, and row_headers of a table.

  • What the app gives again: the source and the rows of a large table, and the views of a list of views, which belong to the model.

  • The language shown, which the main view or --language sets.

The main view emits loaded once the views and the model’s data are in place: once as the app starts, after its setup, with the study that -f FILE opens or with a new one, then after each New and Open. On the web, each session emits it as it starts. Connect loaded to give again what a study doesn’t hold, from the fields that it does hold, such as a filter:

from composeui.core.view import (
    ListSource,
    MainView,
    SliderView,
    SourceTableView,
    child,
    define_view,
)


@define_view
class ReadingsMainView(MainView):
    extension = ".readings"

    threshold: SliderView = child(SliderView, maximum=10)
    above: SourceTableView = child(SourceTableView)  # the readings above the threshold

    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.loaded.connect(self._show_above)

    def _show_above(self, *, model: ReadingsModel) -> None:
        records = [r for r in model.readings if r.value > self.threshold.value]
        self.above.source = ListSource(Reading, {"sensor": "Sensor"}, records)

A load sets the fields without emitting their signals, such as the slider’s value_changed, so a callback connected only to those doesn’t run. On New and Open, a model reacts in Model.loaded, which runs first, so a callback of loaded reads the data of the study. The app’s own command-line options apply after loaded (see Command-line options of your own).

Versions of the state

A study file holds the state of the views, and two versions: the version of ComposeUI’s file format, and the app’s state_version, 1 by default.

A file opens in any version of the app while the views can read its fields. When it has a field that the views don’t have, the views skip that field. When it doesn’t have a field of the views, that field keeps its value. So most changes need no new version:

  • No new version for a new field, or a field that you remove. Older and newer versions of the app open each other’s files.

  • A new version for a renamed or moved field, or a field whose value changes its form. Without it, an older file loses the field’s value with no message, or Open refuses the file.

Raise state_version only for a change of the second kind. A raised version has two effects:

  • Older versions of the app refuse the new files, and the status bar says why. An older version can’t read the changed field. It would show the study without that value, and a Save would then lose the value for good. Open also refuses a file of a newer ComposeUI file format.

  • The new version upgrades the older files in MainView.migrate_state, which you override. It gets the state of an older file, as a dict, and the version that saved it. It returns the state as the views save it now:

from typing import Any

from attrs import Factory

from composeui.core.view import InputView, MainView, define_view


@define_view
class PipeMainView(MainView):
    extension = ".pipe"
    state_version = 2  # version 1 named this field `name`

    label: InputView = Factory(InputView)

    def migrate_state(self, data: Any, version: int) -> Any:
        if version < 2:
            data["label"] = data.pop("name")
        return data

When the upgrade fails, for example on a key that the file doesn’t have, Open refuses the file. A file saved before ComposeUI wrote the versions opens as version 1.