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).WIDEgives room for views side by side, such as tables (at most 72rem).FULLtakes 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_itemshows 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
TypedFormDialogViewneedsform=..., 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 ofFormDialogViewwith its ownformworks too, but a type checker reads itsformas aFormView.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_enabledfollows 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.checkruns all the checks, andis_validtells whether they pass.The answer comes through
acceptedorrejected.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
VerticalViewof a table and buttons, tabs, or a chart. The dialog’stextshows above it. AContentDialogViewneedscontent=..., unless its subclass gives a default, as above. A type checker then readscontentas aFolderView.OK follows the app. The app sets
is_accept_enabled. While it isFalse, the user can’t press OK, andaccept()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 callssuper().accept()only to close the dialog. Above, OK on a folder shows that folder.The answer comes through
acceptedorrejected. 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.
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)callswrite(path)at once when no file is at the field’s path. Otherwise it asks “report.txt already exists. Replace it?”, and callswriteonly 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_filewrites at once. The agreement is lost once the field holds another path, for example typed since.is_replace_agreedtells whether the user agreed.The app writes the file.
write_fileonly 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
writethe data as it is when the user saves, asfunctools.partialdoes above. The answer comes later, and the views may have changed by then.The texts are the field’s:
browse_textas the title,replace_question_textandreplace_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.