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 missingbinds=→CompileError; - a
component=that isn't a PascalCase tag, or a screen that places a tag with nocomponents=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-objectobject_editablerule 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.