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_viewmakes anattrsclass of a view. A field set with a view is a child view. A field set with a plain value, such astextorvalue, is state that the renderer shows. The fields are keyword-only.A class variable configures the class, such as
app_title,extension, oralign. It is not a field, so the state does not save it.__attrs_post_init__wires the view once it is built. Callsuper().__attrs_post_init__()first, then set values and connect signals.T("...")andself.tr("...")mark a text for translation. Later examples of this guide write their text so, for exampleColumn("radius", T("Radius")). ImportTfromcomposeui.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
HorizontalViewputs its view fields side by side. Each field takes a share of the width, from itsSTRETCHmetadata:attrs.field(factory=TableView, metadata={STRETCH: 3}). A button keeps the width of its text (default_stretch = 0). When no view takes a share,alignpacks them at the start or the end of the row. On the web, a row narrower than 42rem stacks its views.A
VerticalViewputs its view fields one above the other, for a column in aHorizontalView, for example a table above its buttons.A
GridViewputs each view field in a cell, from itsGRID_POSITIONmetadata:metadata={GRID_POSITION: (1, 0)}, orGridPosition(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 |
|---|---|---|
|
Only the field, anywhere |
A search field in a toolbar |
|
A row of a form, without a label |
A field under a heading |
|
A row of a form, with a |
Most form fields |
The fields:
Field |
Its value |
What the user does |
|---|---|---|
|
|
Types a line of text |
|
|
Types several lines of text |
|
|
Types a decimal number |
|
|
Types a whole number |
|
|
Types or picks a date |
|
|
Types or picks a file |
|
|
Types or picks a folder |
|
|
Types or picks a file to save |
|
|
Checks or unchecks the box ( |
|
|
Picks one of a few choices |
|
|
Picks one choice in a drop-down list |
|
|
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_editedon each change that the user makes, andediting_finishedwhen 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 theiris_visibleat once, and again each time the box changes: as the user clicks it, as code setsis_checked, and as a study opens or New starts one.A choice maps each label to its value:
values = {"Steel": Material.STEEL}.valueisNoneor one ofvalues.value_changedreports the user’s choice. ANonevalue selects nothing, unlessNoneis one ofvalues: then it selects that choice, such as the “All” of a filter,{"All regions": None, "North": "north"}.A
LabelViewshows 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)runscheckeach time one of the signals fires, and shows its message under the row’s field. It does not run at once: callrun_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 ofrowsas invalid.Before an action.
form.check()runs every check of the form and its nested forms now. Thenform.is_validtells 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:
PageViewandRouterfor an app split into pages.TabViewfor tabs.SplitterViewfor resizable panes.ToolBarfor a toolbar.DialogViewfor dialogs.SidebarViewfor a sidebar.ListViewfor a list of views, andMenufor menus.TableView,RecordTableView, andSourceTableViewfor tables.
The main view¶
A subclass of MainView is the app. Its class variables configure it:
app_titleis the name of the app, in the title of the window.extensionis the file type of its studies, for example".pipe".has_file_menu = Falseleaves out the File menu, for an app without studies (see The File menu and picking a file).app_iconis the icon of the app (see The app’s icon).widthsets the default width of the web content, also in an app without pages (see Page width). Qt and Textual ignore it.translations_diris 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
timeoutseconds, 3 by default. With atimeoutof 0, it stays until the next message, or untilclear_message().A new message replaces the previous one, and its timeout starts again.
messageholds 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,SearchBarenables 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
Signalfield such assearched. 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,attrsandcattrsread the types of the fields from the module. A large component can have its own module. Callrunin the program’s module, underif __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:
Layout: the sizes of resizable panes, and whether the sidebar is retracted.
The app’s setup: class variables, the
iconof an action or a tree node, theextensionsof a file path input, and thecell_widgets,cell_styles,header_groups, androw_headersof 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
--languagesets.
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 adict, 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.