Skip to content

Fields & models

Declaring intent: Field(...)

Field is a transparent Annotated carrier — the same discipline as situ's site markers, so title: Annotated[str, Field(label="Title")] is still str to every type checker.

from situ.declui import Field

@dataclass
class Post:
    title:   Annotated[str,  Field(label="Title", label_field=True)]
    user:    Annotated[str,  Field(editable="editing and value != 'admin'")]
    comment: Annotated[str,  Field(widget="RichText", visible="editing or not is_private")]
    token:   Annotated[str,  Field(hidden=True)]
Option Effect
label= the caption (default: the field name, capitalized)
label_field=True this field is the object's display label — used by references, lists, one-to-many rows
hidden=True never rendered (primary keys, internal flags)
secret=True a password input
widget= override the type's default widget — "RichText", "Popup", "Combobox"
search=True on a server tracker, generate a client search box filtering rows on this str field — zero network
required=True on a write form, disable the submit until this field is filled
link= on a server tracker, make this cell a link — names a row field carrying the URL
cell_format= on a table, a display formatter — "money" / "date" / "datetime" / "num" / "percent" / "bool" (a Literal; a bad key is a CompileError)
filter=True on a table, a per-column filter input
align= on a table, cell alignment — "left" / "right" / "center" (numeric columns default right)
sort=False / sort_type= on a table, turn a column's sort off / override the type-derived comparator ("text" / "numeric" / "date")
visible= / editable= / valid= predicate strings

labels= / badge= / format= / source= shape a server-tracker cell — see tracker chrome. (format= is the tracker's server-side strftime; a client table uses cell_format= above.)

Reserved options

in_= is accepted so models type-check and currently has no effect — reserved for a planned slice. (filter= is now consumed on a table screen.)

Type → widget

The DRY floor: a bare typed field gets a working control. Anything unmapped is a CompileError naming the type — silent degradation to a text box would hide the problem.

Model type Generated signal Control
str Local[str] <input type="text"> (type="password" if secret)
int Local[int] <input type="number"> — truly numeric in predicates
float Local[float] <input type="number" step="any">
Decimal Local[str] <input inputmode="decimal">stays a string: a JS float would corrupt money
bool Local[bool] a checkbox
date (or date \| None) Local[str] <input type="date"> (ISO string — compares correctly, including against today())
Enum Local[str] <select> over the members, value = each member's .value
another model class Local[str] a reference: <select> over choices, option value = the target's key, label = its label_field
list[SubModel] Local[list] a one-to-many: a read-only nested list of the children's labels

Nullable references get a — none — option; required ones default to the first choice.

Widget overrides

  • widget="RichText" → a <textarea>.
  • widget="Popup" → the native <select> (an alias, for Enum or reference fields — MetaUI's vocabulary).
  • widget="Combobox" → the kit's searchable single-select over the same signal. declui auto-injects the kit runtime (widgets.js + ui.css) into the page head only when a screen uses a kit widget.

widget="custom" — the composite escape hatch

The type→widget map is one field → one control. When a single logical control spans several model attributes with author-supplied markup (a country + city pair; a latitude + longitude pair typed into one row), declare a custom widget on a write form:

place: Annotated[str, Field(
    label="Location",
    widget="custom",
    control='<div class="country-city"><select :bind="country"></select>'
            '<input :bind="city"></div>',   # situ-dialect markup, its own binders
    binds=("country", "city"),              # the model attributes it reads/writes
)]

declui owns the slot and the value channel, nothing more: it splices control into the field's row and declares one Local[str] per name in binds. Each bind rides the form's POST with channel into facade.create / update by name — so a composite writes N attributes from one control. The field's own name never becomes a signal; binds do.

control= is declaratively-bound markup only: :bind, :show, :each, :text work, but there is no handler channel — declui generates the component .py, so an event binder like @input="pick" would reference a function that doesn't exist. Use it for composites whose attributes are entered independently. For a linked composite — one control that drives another — reach for a component instead.

component= — a linked composite (a real situ component)

When the composite needs behaviour (choosing a country prefills or filters the city; a control that fetches), point the slot at a real situ component and resolve it with mount_model(components=...):

# the model
location: Annotated[str, Field(
    widget="custom",
    component="CountryCity",      # a PascalCase tag, resolved via components=
    binds=("country", "city"),   # the attributes it reads/writes
)]

# the mount
mount_model(path="/new", screen=Screen(model=Place), screens=("create",),
            service_type=PlaceService, facade=Places,
            components=Context.from_dir(HERE / "components"))  # resolves <CountryCity/>

declui declares each bind Provide[str] and places a bare <CountryCity/>; at mount it splices the real component (components/country_city.{html,py}) into the one island. The child Injects the binds by name, so it reads and writes the form's cells — and it is an ordinary component, so it has its own state and handlers:

# country_city.py — the child control
from situ.compiler.markers import Inject
country: Inject[str]              # the form provides these; the child aliases them
city: Inject[str]
def pick() -> None:              # a handler — the thing the inline control can't have
    global city                  # a bare-signal write, declared global
    if country == "FR": city = "Paris"
    # ... (no docstring: a handler body is statements, not a bare string)
<!-- country_city.html — @input, not @change (situ's events: click/dblclick/submit/input) -->
<select :bind="country" @input="pick"></select>
<input :bind="city">

The child can also carry a server command — with one constraint: declui's mount injects the same facade into every server-sited signal, so a child's fetch must call a verb on the form's own facade (not a separate service). A per-country server fetch is reachable but shares the form facade; a client-seeded linkage (above) has no such caveat.

It fails closed, by design — the control is opaque to declui, so declui refuses anything it can't honour:

  • neither, or both, of control= / component=; or missing binds=CompileError;
  • a component= that isn't a PascalCase tag, or a screen that places a tag with no components= Context to resolve it → CompileError;
  • a bind that isn't a Python identifier, or collides with a reserved name (value, editing) → CompileError;
  • editable= / valid= / a whole-object object_editable rule targeting it → CompileError (there is no single native control to disable or gate; gate inside the control, or on the facade).

visible= is supported — it compiles to a :show on the row, same as any field. Reach for a custom widget only for genuine composites; Combobox already covers searchable single-selects, and a plain typed field already covers everything atomic.

Model sources

read_model(cls) reads five model families through one interface — the generated component is byte-identical whichever you use:

@dataclass
class Product:
    name: Annotated[str, Field(label_field=True)]
    price: Annotated[Decimal, Field()] = Decimal("0")

Plain stdlib — no extra dependency.

class Product(msgspec.Struct):          # or @attrs.define, or pydantic.BaseModel
    name: Annotated[str, Field(label_field=True)]
    price: Annotated[Decimal, Field()] = Decimal("0")

Same Annotated[T, Field(...)] convention; install situ[model-adapters]. Each library is imported only if your model actually uses it.

class Product(Base):
    __tablename__ = "product"
    id: Mapped[int] = mapped_column(primary_key=True, info={"declui": Field(hidden=True)})
    name: Mapped[str] = mapped_column(info={"declui": Field(label_field=True)})
    price: Mapped[Decimal]

A structural adapter: mapped columns are read from the mapper (col.type.python_type → the Python type), and declui intent rides mapped_column(info={"declui": Field(...)}). Generating a form performs no query. relationship() attributes are not columns and are out of scope for this adapter; a MappedAsDataclass model is detected as SQLAlchemy first. Known edge: a native-string sa.Enum("a", "b") (no Python Enum class) renders as a text input.

Defaults and seeding

Field defaults become the generated signals' initial values, coerced to the wire: an Enum default → its .value, Decimal → its string form, date | None = None"", a default_factory is resolved where it yields a static literal (an attrs Factory(takes_self=True) degrades gracefully to no default). The same coercions apply to seeded rows.