Skip to content

Screens & mounting

Screen

from situ.declui import Screen, zones

ISSUE = Screen(
    model=Issue,
    zones={
        "list":   zones(main=["subject", "status", "priority"]),
        "detail": zones(header=["subject", "status"], body=["body", "owner", "notes"]),
        "form":   zones(main=["subject", "body"]),
    },
    object_editable="not suspended",     # optional whole-object predicate
)
Parameter Meaning
model the typed class (any supported source)
zones per-screen layout (see below)
object_editable a predicate AND-ed into every field's editability
rules the rule sheet: selector→property rules resolved at mount time (override + AND-chained predicates)
row_links server-tracker per-row navigation links (RowLink(...)), optionally gated by a predicate

Zones

zones(name=[fields...], ...) lays fields into named <div class="declui-zone--name"> slots, in declaration order, an explicit compile-time layout. Each zone renders a role="group" with a visible title. For a list screen, zones["list"] selects and orders the columns.

Zones are explicit

A field placed in no zone of a screen is not rendered on that screen, so the layout is the layout. Referencing an unplaced field from a predicate gives a precise error. Unknown field names and non-identifier zone names fail closed.

mount_model and the screen modes

from situ.declui import mount_model

A form (the default)

mount_model(path="/sample", screen=SAMPLE)                    # screens=("form",)

A list

mount_model(path="/tasks", screen=TASKS, screens=("list",),
            rows=[Task(...), Task(...)])

A client-seeded :each: one read-only cell per non-hidden scalar column (booleans render Yes/No, enums by value; long text cells get hover title tooltips). Zero network after load.

A table

mount_model(path="/invoices", screen=INVOICES, screens=("table",), rows=ROWS)

The same client-seeded columns render as the kit's <DataGrid> in place of a :each list. The rows then get sort (click a header, shift-click for a secondary key), a global filter box, pagination with a page-size selector, a column-visibility picker, and drag-to-resize columns, all client-side and free. Each column's presentation comes from its Field: cell_format= a display formatter, filter=True a per-column filter, a numeric field right-aligns and sorts numerically, a date sorts chronologically, and declui emits only the typed <thead> while the runtime builds the body.

Master-detail

mount_model(path="/contacts", screen=CONTACTS, screens=("list", "detail"), rows=ROWS)

This screen puts a list and a detail pane in one mount. A row click copies the row into per-field detail signals and reveals the pane, so selection is entirely client-side.

The server tracker

mount_model(path="/issues", screen=ISSUE,
            service_type=IssueService,     # from your Dishka container (mount_model is the Litestar mount)
            facade=Issues,                 # wraps the service for the handlers
            context=context)               # (service, view) -> the rows the region renders

The full round trip runs like this. The region is server-rendered from context() on every command, and the model's async @actions become per-row command buttons. zones["detail"] adds a server-rendered detail pane that walks relationships from the object graph: a reference field shows the target's label, a list[SubModel] renders as a nested list; Field(search=True) adds a zero-network client filter over the server-rendered rows; selection survives command swaps.

The server tracker requires an id field on the model (the command key t.id).

mount_model(new_link=("+ New", "/issues/new/")) adds a "New" link to the tracker header, the create affordance a list needs, pointing at the sibling create form. It's a server-tracker option (a write form / pure-client mount has no header for it, and says so).

A row's controls (@action buttons + RowLinks) ride one trailing cell with a matching header cell, so a row with actions keeps its columns aligned with the (action-less) header. mount_model(row_actions="menu", actions_label="Menu") collapses them behind a single ••• trigger, the shape a production list needs once @action(visible=) / RowLink(visible=) make each row's control count vary: one popup open at a time, Escape or an outside click dismisses it, no new client runtime. pager_label="Rows {first}–{last} of {total}" overrides the pager's English count line (with page_size=) for a non-English UI.

Write forms: create & edit

A server mount whose screens is ("create",) or ("edit",) generates a write form: the magic form wrapped in a <form> whose submit is a server command. This is the write half of CRUD (the C and field-level U) from the same model that drives the read screens.

# create: a blank form; submit creates the record, then navigates back to the list
mount_model(path="/tickets/new", screen=SCR, screens=("create",),
            service_type=TicketService, facade=Tickets, after="/tickets")

# edit: an entity path pre-fills the form from the record; submit updates it
mount_model(path="/tickets/{id:int}/edit", screen=SCR, screens=("edit",),
            service_type=TicketService, facade=Tickets, context=edit_context, after="/tickets")

The generated submit awaits the facade verb: facade.create(**fields) for create, facade.update(id, **fields) for edit, with the field values riding the POST by name (situ's with channel; no name= attributes). On success it navigates to after= (a same-origin path; omit it to stay on a cleared form).

  • Create seeds each control from the field default; context= is optional (it defaults to empty).
  • Edit is an entity mount: the {id:int} path converter names the record, context() loads it, and the controls pre-fill from it. Command and island URLs compile relative, so one island serves any id, the same mechanism the Flask entity mounts use.
  • The submit gate. Field(required=True) and valid= disable the submit button until the form is valid, client-side and with zero network. The server re-validates on the facade; the button is a hint.
  • References submit the target's FK key; the facade receives it.

Fail-closed: an edit mount needs an {id:int}-style path converter (the record key threads the update); a write form rejects the tracker-only kwargs (theme= / page_size= / Screen(row_links=...)).

Typing your context()

Annotate context()'s return with a TypedDict and situ verifies, at mount, that it declares every name the generated region reads: a forgotten key or a misspelled <field>__options is a CompileError at mount, ahead of the blank page it would otherwise render. declui knows the exact contract, so it writes the class for you:

from situ.declui import context_stub

print(context_stub(ISSUE, service_type=IssueService, page_size=20))
# class IssueContext(TypedDict):
#     items: list[Issue]
#     total: int

Paste it, annotate context() -> IssueContext, and the three type checkers verify the function's body as well as situ's boot check. It has to reach your source: a type checker can't use a runtime-built TypedDict as an annotation. Pass the same service_type / screens / page_size / choices you give mount_model; only those shape the contract. The stub types the row list (list[Model]), the pager total and each picker's __options, leaving anything else object; field-level types are a later phase.

Seeding client data

Two mount kwargs feed the pure-client modes:

  • rows=[...]: model instances for list / master-detail screens (values coerced exactly like defaults).
  • choices={"owner": [user1, user2]}: the option lists for reference fields; option value is the target's key, label its label_field.

Localizing a screen

Every UI string declui generates (field labels, the submit button, per-row @action labels, the empty state, the pager's Prev/Next) is emitted through the gettext marker {{ _("...") }}. situ's default _ is identity, so a plain mount reads in English with no wiring. To render a screen in another language, return a _ from your context() (a Jinja context value overrides situ's env default per request):

FR = {"Name": "Nom", "Create": "Créer", "Nothing here.": "Rien ici."}

async def context(service: ContactService, view: Mapping[str, str]) -> Mapping[str, object]:
    return {"items": service.all(), "_": lambda m: FR.get(m, m)}   # `_`: the request's translations

The same generated screen now renders in French, with no template edits and no regeneration. Because the marker rides the render context, the command region re-render localizes too. A real host returns flask-babel's gettext (keyed to the request locale) in place of a dict. Data is never translated: a model's own field values and option labels go out through _text, not _(), so a contact literally named "Name" is not turned into "Nom". A worked example is examples/declui/localized_form/ (rendered in French).

It stays server-side by design: a translatable _(...) inside a compiled client expression (:text / { } / an @event body) is a CompileError: situ resolves the template once per process, so it would freeze at the first locale. Keep user-facing strings on the server-rendered side.

Two things _ does not cover. The pager's row-count line ("Rows 1 to 10 of 42") interpolates numbers and varies in word order and pluralization across languages, so it is localized through pager_label (a full-phrase format override), not _: pass mount_model(pager_label=_("Rows %(from)s to %(to)s of %(total)s")) or your own string. A client-reactive caption, one whose text is a compiled :text that flips on a Local (declui's edit-mode "Done" / "Edit" toggle), cannot localize server-side by construction; it reads in English. Both are the boundary the fail-closed client rule marks.

Fail-closed mounting

mount_model refuses ambiguous configurations:

  • service_type= without context= → CompileError (there would be dead buttons over an empty region).
  • A server mount passed the client kwargs screens= / rows= / choices= → CompileError.
  • An unsupported screens tuple → CompileError.
  • A generated command calling a facade verb the facade lacks, or a verb whose signature can't accept the fields a form submits, is a CompileError at mount. That drift used to surface only as a 500 on the first command; now the check runs when you mount, against the facade class (or service_type for the identity default). A callable facade factory (a lambda) opts out.

template= and meta= work as on every situ mount; declui ships a default page style (declui.css) and injects the kit assets only when a kit widget is used.