Organizing the window

These views organize the window: pages, tabs, panes, a list of views, menus, toolbars, a sidebar, and dialogs.

Pages

A Router shows one page at a time. It adds a toolbar with an entry per page. Each page is a PageView with:

  • a route: a unique name made of letters, digits, _, and -;

  • a page_title: its entry in the toolbar.

The view fields of a page are its content, laid out in a column:

from attrs import Factory

from composeui.core.view import MainView, PageView, Router, TableView, child, define_view


@define_view
class ResultsPage(PageView):
    route = "results"
    page_title = "Results"

    summary: TableView = child(TableView, headers=["Pipe", "Radius"])


@define_view
class Pages(Router):
    form: FormPage = Factory(FormPage)  # another PageView; the first one shows at first
    results: ResultsPage = Factory(ResultsPage)


@define_view
class AppMainView(MainView):
    pages: Pages = Factory(Pages)

A click on a toolbar entry triggers the page’s action, which shows the page. Code does the same with pages.navigate("results"), and reacts to it through pages.route_changed.

The page shown is navigation, not study data: saving, opening, and New leave it alone.

Qt docks the toolbar as a QToolBar. Textual shows a row of buttons at the top. The web puts the toolbar in the page header, with a web address per page.

Qt builds a page the first time it shows, and the web sends only the shown page. So an app with many pages starts as fast as one with a few. A page shows every change that code made to its views before it was built. Its dialogs open from any page. src/examples/pipeform.py is an app with three pages: Inputs, Results, and Help.

Page width

On the web, the page shown sets how wide the content grows. A subclass sets its width, a PageWidth:

  • NARROW, the default, keeps a column of fields easy to read (at most 48rem).

  • WIDE gives room for views side by side, such as tables (at most 72rem).

  • FULL takes the whole window.

from composeui.core.view import PageWidth


@define_view
class ResultsPage(PageView):
    route = "results"
    width = PageWidth.WIDE

An app without pages sets width on its MainView subclass:

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


@define_view
class WideMainView(MainView):
    width = PageWidth.WIDE

The width of the main view is the default of its pages, NARROW unless it sets one. A page shown with WIDE or FULL replaces this default, and a NARROW page keeps it. The Uber pickups example has a wide main view without pages.

On Qt and Textual, the window sets the width, and width changes nothing.

Tabs

A TabView shows its views one tab at a time. The user picks a tab from a row of tabs, for example the details of a record and its history. Its TabPage fields are the tabs, in order. Each tab shows its title and has its view fields as its content, in a column:

from composeui.core.view import LabelView, TabPage, TabView, child, define_view


@define_view
class DetailsTab(TabPage):
    length: LabelView = child(LabelView, text="Length: 12 m")


@define_view
class PipeTabs(TabView):
    details: DetailsTab = child(DetailsTab, title="Details")
    history: HistoryTab = child(HistoryTab, title="History")  # another TabPage

The view starts on the first tab. Code shows another tab with current_index, counted from 0. current_tab is the tab shown. current_changed reports the user’s picks.

  • The tab shown is navigation, not study data, as for pages.

  • A hidden tab page hides its tab. The user can’t pick a disabled tab page.

  • Tabs are not pages: tabs live inside a page or a form, and have no address of their own.

Qt shows a QTabWidget, Textual a TabbedContent, and the web a row of tabs. On the web, the arrow keys move between the tabs. The web sends only the shown tab’s views: a tab’s views arrive when the user picks it. Qt builds a tab’s views the first time the tab shows, as it does a page. The title and state of a tab show on its tab at once. src/examples/pipechart.py shows its chart on one tab, and the measurements plotted on it, as a table, on another tab.

Resizable panes

A SplitterView lays out its view fields side by side, as panes. The user drags the border between two panes to resize them, for example a study tree beside its result. Each pane starts with its share of the width. The STRETCH metadata of its field sets the share, as in a HorizontalView:

import attrs

from composeui.core.view import STRETCH, SplitterView, define_view


@define_view
class StudySplitter(SplitterView):
    study: StudyTree = attrs.Factory(StudyTree)  # a TreeView
    result: ResultTabs = attrs.field(factory=ResultTabs, metadata={STRETCH: 3})

sizes holds the shares of the panes (here [1, 3]). To share the width differently, code sets sizes.

The user drags a border, or moves it with the arrow keys once the border has the focus. After that, sizes holds the panes shown, in percent of their width. resized reports them once the user drops the border. It never reports them when code sets sizes.

  • A pane never shrinks to nothing. A hidden pane leaves its width to the other panes.

  • The width of the panes is layout, not study data: it is never saved, loaded, or reset.

Qt shows a QSplitter. Textual shows a row of panes with a │ border between two panes. The web shows an ARIA window splitter. Side by side, each pane is at least 12rem wide, so that its text stays readable: the border stops there. When a splitter is narrower than 42rem, the web stacks its panes without borders. It stacks the views of a row in the same way. src/examples/pipecatalogue.py shows its tree of pipes by material in a splitter, beside the expanders of the pipes.

Panes one above the other

A subclass that sets orientation to Orientation.VERTICAL lays out its panes one above the other instead. The panes then share its height, for example a chart above its table:

from composeui.core.view import Orientation


@define_view
class ResultSplitter(SplitterView):
    orientation = Orientation.VERTICAL

    chart: CurveChart = attrs.field(factory=CurveChart, metadata={STRETCH: 2})  # a ChartView
    measurements: Measurements = attrs.Factory(Measurements)  # a TableView

Its sizes are then shares of its height. The user drags a horizontal border, or moves it with the up and down keys.

  • Qt shares the spare height of the window.

  • Textual shares a height of 24 rows, and the web a height of 32rem. Each pane scrolls within its share.

The web never stacks the panes of a vertical splitter any other way: it keeps their borders on a narrow screen.

A list of views

A ListView shows a view per record, one above the other. For example, it shows an expander per record, with the details of the record and its Delete button. A subclass builds the view of a record. Set items to the records, for example the model’s, to show them:

from composeui.core.view import ExpanderView, LabelView, ListView, View, child, define_view


@define_view
class PipeExpander(ExpanderView):
    length: LabelView = child(LabelView)


@define_view
class PipeList(ListView[Pipe]):  # Pipe: an attrs record
    def build_item(self, item: Pipe) -> View:
        view = PipeExpander(title=item.name)
        self.update_item(view, item)
        return view

    def item_key(self, item: Pipe) -> str:
        return item.name

    def update_item(self, view: View, item: Pipe) -> bool:
        view.length.text = f"Length: {item.length:g} m"
        return True

item_key tells the records apart. By default, the key is the record itself.

  • A new record gets a view of its own.

  • A record that is gone takes its view along.

  • A record that is still there keeps its view.

  • A changed record keeps its view if update_item shows the change in the view and returns True. The view then keeps what the user did to it: an expander stays expanded. Otherwise, the record gets a new view.

A view built later is set up like the other views. Its signals pass main_view and model to the callbacks that ask for them, and it shows the main view’s language.

The records and their views belong to the model. They are not study data: saving, opening, and New leave them alone.

On the web, a change swaps only the views of the records that it adds or removes, never the other views, unless it reorders them.

The list stays compact, however many records there are. On Qt, it scrolls within itself and takes the spare height of the page. Expanders in a list join in one box on the web, and are a line apart on Textual.

src/examples/pipecatalogue.py lists its pipes this way, each with a Delete button.

Toolbars

A ToolBar is a row of the actions that the user runs most, docked at the top of the window. Its ActionView and SeparatorAction fields are its entries, in order. Make the toolbar a field of the main view:

from attrs import Factory

from composeui.core.view import ActionView, MainView, ToolBar, define_view


@define_view
class RunToolBar(ToolBar):
    run: ActionView = Factory(ActionView)

    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.title = "Run"  # names it, e.g. for a screen reader
        self.run.text = "Run"
        self.run.shortcut = "Ctrl+r"


@define_view
class AppMainView(MainView):
    toolbar: RunToolBar = Factory(RunToolBar)
  • A click on an entry triggers its action.

  • A checkable entry shows as pressed while it is checked.

  • When code disables the toolbar, its entries are disabled too.

Qt docks the toolbar as a QToolBar. Textual shows a row of buttons at the top, above the buttons of a router. The web puts its buttons in the page header, before the menus.

The icon of an action is the path of an image file. Qt and the web show it, and a terminal shows the text. On the web, an SVG icon takes the color of the text (see How the web differs from a window). The app’s code sets an icon, so an icon is not study data: it is never saved, loaded, or reset.

Dialogs

A DialogView is a modal message, or a confirmation with an accept button and a reject button (kind=DialogKind.CONFIRM). It starts closed, and open() shows it.

Nothing waits for the answer, because a web request can’t wait. Connect to accepted and rejected instead. They fire once the dialog is closed.

from attrs import Factory

from composeui.core.view import DialogKind, DialogView, MainView, define_view


@define_view
class ClearDialog(DialogView):
    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.kind = DialogKind.CONFIRM
        self.title = "Clear the form"
        self.text = "Every field will be emptied."
        self.accept_text = "Clear"  # "OK" otherwise; the other button is `reject_text`


@define_view
class AppMainView(MainView):
    confirm_clear: ClearDialog = Factory(ClearDialog)

    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.confirm_clear.accepted.connect(self.reset_state)
        # ... and something calls `self.confirm_clear.open()`, e.g. an action

Escape, or closing the window of the dialog, rejects a confirmation. It accepts a message, which has only one answer.

A dialog is not study data: saving, opening, and New leave it alone.

Qt shows a QMessageBox, Textual a modal screen, and the web a modal <dialog>, wherever the view is in the tree. On macOS, a message box has no title: Qt shows the title as its bold text, and the text under it. In src/examples/pipeform.py, Reset Dimensions in the Pipe menu asks for a confirmation first.

Form dialogs

A form dialog holds a form with OK and Cancel, for example to edit a record from its Edit button. Its text shows above the form. A FormDialogView holds an empty FormView by default. For a form of your own, subclass TypedFormDialogView[PipeForm]:

from attrs import Factory

from composeui.core.view import (
    FormView, LabelRowInputView, TypedFormDialogView, child, define_view,
)


@define_view
class PipeForm(FormView):
    name: LabelRowInputView = child(LabelRowInputView, label="Name")


@define_view
class EditPipeDialog(TypedFormDialogView[PipeForm]):
    form: PipeForm = Factory(PipeForm)


dialog = EditPipeDialog()
dialog.form.name.text = "Main pipe"
  • The form’s type. A TypedFormDialogView needs form=..., unless its subclass gives a default, as above. A type checker then refuses a form of another type, in the constructor or in an assignment. A subclass of FormDialogView with its own form works too, but a type checker reads its form as a FormView.

  • The same dialog. Both classes have the same renderers and the same behavior.

  • OK follows the checks. The user can’t press OK while a check of the form fails. is_accept_enabled follows the checks as they run.

  • OK runs every check. Accepting runs every check first, even a check that the user hasn’t triggered yet. The dialog stays open while a check fails. FormView.check runs all the checks, and is_valid tells whether they pass.

  • The answer comes through accepted or rejected.

  • The form can hold other views. A table or a button in the form shows in the dialog. A click on such a button leaves the dialog open, for example to show the files of another folder in the table.

  • The form keeps its values. The app reads them once the user accepts, or fills them before open().

In src/examples/pipecatalogue.py, the Edit button of each pipe changes the pipe in a form dialog.

Content dialogs

A content dialog holds any view with OK and Cancel, for example a table to pick a file from. Use it when the dialog shows something other than a form. Subclass ContentDialogView[YourView], and give it its content:

from attrs import Factory

from composeui.core.view import (
    ButtonView, ContentDialogView, TableView, VerticalView, child, define_view,
)


@define_view
class FolderView(VerticalView):
    up: ButtonView = child(ButtonView, text="Parent folder")
    entries: TableView = child(TableView, headers=["Name"])


@define_view
class OpenStudyDialog(ContentDialogView[FolderView]):
    content: FolderView = Factory(FolderView)

    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.title = "Open a study"
        self.is_accept_enabled = False  # until the user selects a row
        self.content.entries.selection_changed.connect(self._follow_selection)

    def _follow_selection(self, rows: list[int]) -> None:
        self.is_accept_enabled = len(rows) == 1

    def accept(self) -> None:
        # `selects_a_folder` and `show_selected_folder` are the app's own methods
        if self.is_accept_enabled and self.selects_a_folder():
            self.show_selected_folder()  # the dialog stays open
            return
        super().accept()  # closes the dialog, then `accepted` fires
  • The content. Any view: a VerticalView of a table and buttons, tabs, or a chart. The dialog’s text shows above it. A ContentDialogView needs content=..., unless its subclass gives a default, as above. A type checker then reads content as a FolderView.

  • OK follows the app. The app sets is_accept_enabled. While it is False, the user can’t press OK, and accept() does nothing.

  • The buttons of the content act without closing the dialog, for example to show the parent folder in the table.

  • OK can keep the dialog open. A subclass overrides accept(), and calls super().accept() only to close the dialog. Above, OK on a folder shows that folder.

  • The answer comes through accepted or rejected. Escape, Cancel, or closing the window of the dialog rejects it.

  • The content is not study data. As for any dialog, saving, opening, and New leave it alone, the rows of a table too.

Each backend shows a content dialog as it shows a form dialog: Qt a QDialog, Textual a modal screen, and the web a modal <dialog>, with the view above the OK and Cancel buttons.

Buttons and checks of a dialog

A form or content dialog can have buttons of its own between Cancel and OK, for example Apply, which saves the changes and leaves the dialog open. It can also check any view it shows, not only a form. Declare each button as a DialogButtonView field of the dialog, and add each check with add_check:

from typing import Optional

from attrs import Factory

from composeui.core.view import (
    ContentDialogView, DialogButtonView, InputView, VerticalView, child, define_view,
)


@define_view
class SettingsPanel(VerticalView):
    folder: InputView = child(InputView, text="results")


@define_view
class SettingsDialog(ContentDialogView[SettingsPanel]):
    content: SettingsPanel = Factory(SettingsPanel)
    apply: DialogButtonView = child(DialogButtonView, text="Apply")

    def __attrs_post_init__(self) -> None:
        super().__attrs_post_init__()
        self.accept_text = "Save"
        self.add_check(self._has_folder, self.content.folder.text_changed)

    def _has_folder(self) -> Optional[str]:
        return None if self.content.folder.text else "Choose an output folder."


dialog = SettingsDialog()
dialog.apply.clicked.connect(lambda: print("Applied"))  # the app's own work
dialog.accepted.connect(lambda: print("Saved"))
  • The buttons. Each DialogButtonView field shows between Cancel and OK, in the order of the fields (buttons). A click fires its clicked, and the dialog stays open.

  • A button that closes. With closes=True, clicked fires, then the dialog closes. Neither accepted nor rejected fires, for example for “Close without saving”.

  • A button that checks. By default (checks=True), a click runs every check first, as OK does. While a check fails, the button does nothing, and the user can’t press it (is_blocked). With checks=False, it acts whatever the checks say.

  • The checks. A check returns the message to show, or None when it passes. It runs again whenever one of the signals given to add_check fires, and validate runs the checks now. A value set by code emits no signal, but OK and the checking buttons run the checks anyway.

  • The message stays in the dialog. While a check fails, its message shows above the buttons (log_view), and the user can’t press OK. The message goes once the value is fixed: the dialog doesn’t close and open again.

  • With a form. In a form dialog, the dialog’s checks come in addition to the form’s. Use them for what the form can’t know, for example a name that another record already has. The form shows its own messages.

The model can still refuse what the checks let through, for example a value that only the database checks. Do the work in the dialog’s refusing, rather than in the view’s, and connect it to accepting for OK:

# `save_settings` is the app's own work: the model raises `RefusedError` to refuse it
def save(dialog: SettingsDialog) -> None:
    with dialog.refusing("Can't save the settings: {error}."):
        save_settings(dialog.content.folder.text)


dialog.accepting.connect(lambda: save(dialog))
dialog.apply.clicked.connect(lambda: save(dialog))
  • accepting fires when the user presses OK and every check passes, while the dialog is still open. Then the dialog closes, and accepted fires.

  • A refusal keeps the dialog open. In accepting, or in the clicked of one of the dialog’s buttons, a RefusedError in refusing shows its message above the buttons, as a failing check’s. The dialog stays open, with the user’s values. The next click, or the next opening, takes the message away.

  • Say a success in the dialog while it is open. The status bar is out of sight behind the open dialog. After Apply, call dialog.show_message("Saved"): the message shows above the buttons as information, not as an error, and doesn’t stop OK. It goes on the next click, the next run of a check, or the next opening. After OK, show “Saved” in the status bar from accepted, once the dialog is closed.

Qt shows the buttons in the dialog’s QDialogButtonBox, Textual and the web in the row of the dialog’s buttons. In src/examples/pipecatalogue.py, Apply in the Edit dialog saves the pipe, says so in the dialog, and leaves the dialog open. The dialog also checks that no other pipe has the name, and shows why the database refuses a pipe, for example a wall thicker than the radius.

Replacing a file

The save path input’s “…” button asks before the user picks a file that exists. A path typed in the field never goes through the picker. When the user saves, write the file with the main view’s write_file, which asks first:

import functools
from pathlib import Path

from composeui.core.view import ButtonView, MainView, SavePathInputView, child, define_view


def write_report(path: Path, text: str) -> None:
    path.write_text(text, encoding="utf-8")


@define_view
class ReportMainView(MainView):
    report: SavePathInputView = child(SavePathInputView, extensions={"Text": (".txt",)})
    save: ButtonView = child(ButtonView, text="Save")

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

    def save_report(self) -> None:
        text = "..."  # what the user sees now, e.g. built from the views
        self.write_file(self.report, functools.partial(write_report, text=text))
  • write_file(field, write) calls write(path) at once when no file is at the field’s path. Otherwise it asks “report.txt already exists. Replace it?”, and calls write only on Replace. Cancel, or Escape, writes nothing: the file stays as it is. While the field is empty, nothing happens.

  • One question per file. When the user already agreed in the field’s picker to replace the file, write_file writes at once. The agreement is lost once the field holds another path, for example typed since. is_replace_agreed tells whether the user agreed.

  • The app writes the file. write_file only decides when. The app chooses how, for example through a temporary file that then replaces the old one.

  • The data written is the data the user saw. Give write the data as it is when the user saves, as functools.partial does above. The answer comes later, and the views may have changed by then.

  • The texts are the field’s: browse_text as the title, replace_question_text and replace_text. A path rather than a field, write_file(path, write), asks with the main view’s texts, in its language.

Each backend shows the question as any confirmation: Qt a QMessageBox, Textual a modal screen, and the web a modal <dialog>. In src/examples/studyinputs.py, “Save summary” writes the table’s rows to the file of the field beside it.