# 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: ```python 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`](../../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. ```python 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: ```python 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](../../src/examples/uberpickups.py) 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: ```python 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](#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`](../../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`: ```python 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`](../../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: ```python 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: ```python 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`](../../src/examples/pipecatalogue.py) lists its pipes this way, each with a Delete button. ## Menus A `Menu` groups actions under a title in the window's menu bar, for example the commands that act on a pipe. Its `ActionView` and `SeparatorAction` fields are its entries, in order. Make the menu a field of the main view: ```python from attrs import Factory from composeui.core.view import ActionView, MainView, Menu, define_view @define_view class PipeMenu(Menu): reset_dimensions: ActionView = Factory(ActionView) def __attrs_post_init__(self) -> None: super().__attrs_post_init__() self.title = "Pipe" self.reset_dimensions.text = "Reset Dimensions" self.reset_dimensions.shortcut = "Ctrl+r" @define_view class AppMainView(MainView): pipe_menu: PipeMenu = Factory(PipeMenu) def __attrs_post_init__(self) -> None: super().__attrs_post_init__() self.pipe_menu.reset_dimensions.triggered.connect(self.reset_state) ``` - `triggered` emits when the user picks the entry, or presses its `shortcut`. - A checkable entry (`is_checkable`) changes `is_checked` before `triggered` emits, so the callbacks see the new state. Qt shows the menu in the menu bar of the window, as a `QMenu`. The web shows it as a drop-down list in the page header. Browsers keep the Ctrl shortcuts, so a shortcut of Ctrl and a letter or a digit becomes Alt+Shift: Ctrl+r becomes Alt+Shift+R. The web has no equivalent for other shortcuts, such as Ctrl+Shift+s. Textual has no menu bar: an entry with a `shortcut` is a key binding, which the footer lists. On Textual, the user can't reach an entry without a shortcut. The File menu is built in (see [The File menu and picking a file](widgets.md#the-file-menu-and-picking-a-file)), and a [`LanguageMenu`](translation.md) lets the user switch languages. In [`src/examples/pipeform.py`](../../src/examples/pipeform.py), the Pipe menu resets the dimensions. ## 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: ```python 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](running-the-app.md#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. ## Sidebar A `SidebarView` is a column of views beside the pages. It shows on every page, for example a form that creates a record. Its view fields are its content, under its `title`. Make it a field of the main view, not of its router: ```python from attrs import Factory from composeui.core.view import ButtonView, MainView, SidebarView, child, define_view @define_view class NewRecordSidebar(SidebarView): form: NewRecordForm = Factory(NewRecordForm) # a FormView create: ButtonView = child(ButtonView, text="Create") @define_view class AppMainView(MainView): sidebar: NewRecordSidebar = child(NewRecordSidebar, title="New record") pages: Pages = Factory(Pages) ``` Qt docks the sidebar on the left of the window, where the user can't close or move it. Textual shows it on the left of the content, and the web as a column on the left of the pages. The user retracts the sidebar and expands it again with a button that `toggle_text` names: - On Qt and Textual, the button is an arrow in the header of the sidebar. A retracted sidebar leaves a narrow strip. - On the web, the button is in the page header. Code does the same with `is_expanded`, and reacts to the user's toggles through `toggled`. Whether the sidebar is retracted is layout, not study data. On a phone, the web shows the sidebar as a drawer over the page. The button in the header opens it. A tap beside it, or Escape, closes it. In [`src/examples/pipecatalogue.py`](../../src/examples/pipecatalogue.py), the form of the sidebar adds pipes to the catalogue beside it. ## 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. ```python 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 ``, 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`](../../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]`: ```python 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`](../../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`: ```python 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 ``, 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`: ```python 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: ```python # `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`](../../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: ```python 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 ``. In [`src/examples/studyinputs.py`](../../src/examples/studyinputs.py), "Save summary" writes the table's rows to the file of the field beside it.