Components¶
Reference for the NiceView UI components. See also Data Adapters, Field Types & Customization, Field Metadata Comparison and Dialogs.
ModelForm¶
ModelForm renders a Pydantic model as an editable form (fields only — no chrome).
Use EditFormWrapper to add a title, description, and action buttons.
from niceview import ModelForm
# In-memory item (explicit type optional)
form = ModelForm.from_item(user)
form = ModelForm.from_item(User, user)
# JSON file — persists on save, supports refresh; optional locking / timestamps
form = ModelForm.from_json(User, Path('user.json'))
form = ModelForm.from_json(User, Path('user.json'), lock_field='updated_at', created_field='created_at')
# CollectionAdapter + key — any storage backend
form = ModelForm.from_adapter(User, adapter, str(key))
# ItemAdapter directly (e.g. JsonAdapter) — no key needed
form = ModelForm.from_adapter(User, json_adapter)
form.render()
Options (apply to all ModelForm and ModelGrid factory methods):
ModelForm.from_item(user,
include=['name', 'age'], # or exclude=['active']
field_infos={'age': niceview.Field(label='Age')}, # per-field overrides
autosave=True, # save after every validated change
local_tz='Europe/Berlin', # timezone for datetime display
on_change=my_callback, # called after every validated change
)
Runtime item switching (master-detail navigation):
# load() switches the displayed item and binds a new adapter
form.load(adapter, str(row_key)) # convenience: wraps in BoundItem
form.load(BoundItem(adapter, key)) # explicit BoundItem
# item setter — only for unbound forms (from_item); raises on adapter-bound forms
form.item = updated_user
Adapter state and validation:
form.adapter_bound # True if save()/refresh() are available
form.has_validation_errors # True if any field or model errors present
form.validation_errors # dict[str, str] — field-level errors
form.nonfield_validation_errors # list[str] — model-level errors
form.save() # persist to the adapter; shows a ui.notify popup
form.save(notify=False) # same, but without any popup (programmatic saves)
form.refresh(notify=False) # reload from the adapter, also without popup
Accessing widgets after render(): every rendered field's widget is available via
form.widgets[field_name] (a plain dict) or form.w(field_name). w() also offers typed
narrowing — pass the expected widget class and get it back correctly typed, with a
TypeError if the field rendered as something else. This is the canonical way to style
or tweak individual widgets after render():
form.w('name') # ui.element subclass (or CheckboxGroup/ModelGrid/... for composite fields)
form.w('name', ui.input).props('outlined') # typed: IDE knows it's a ui.input
form.w('perms', CheckboxGroup).checkboxes['admin'].classes('text-negative')
form.widgets['age'].classes('w-32') # dict access, untyped
w() raises KeyError for unrendered/excluded fields and TypeError on a widget-class mismatch.
Change events carry field name and old/new value:
Events fire when a value is committed, not when it is typed — see Validation below. One interaction can therefore emit several events (every field that changed), and an edit that was blocked by a validation error is reported when the error clears.Layout¶
Imperative form layout¶
Custom field layout — render fields individually to control placement in rows/columns:
form = ModelForm.from_item(user)
with ui.row():
with ui.column():
form.render_field('name').classes('w-full') # returns the widget for direct styling
form.render_field('age').classes('w-full')
with ui.column():
form.render_field('active')
form.render_nonfield_errors().classes('q-mt-sm') # returns the ui.label
render_field() accepts optional niceview.Field kwargs to override field metadata for this render only:
form.render_field('name', label='Short name') # custom label
form.render_field('is_active', label='') # suppress label
form.render_field('budget', suffix='k€') # add suffix
render_field() returns the created widget (ui.element subclass) and raises ValueError for unknown or hidden fields.
render_nonfield_errors() returns the ui.label; omit the call to suppress model-level error display.
render() is equivalent to calling render_field() for all non-hidden fields followed by render_nonfield_errors().
Declarative from layout¶
By default a form stacks its fields. A layout arranges them: a nested list of field names,
either in Meta.profiles (when the arrangement belongs to the model) or as layout= for a
single form.
class Contact(pydantic.BaseModel):
...
class Meta:
profiles = {
'card': [
['# Name', ['first_name', 'last_name']],
['# Address', 'street', ['zip_code:sm:w-1/3', 'city']],
],
}
ModelForm.from_item(contact, profile='card').render()
ModelForm.from_item(contact, layout=['name', ['zip_code', 'city']]).render()
| Notation | Meaning |
|---|---|
'city' |
the field |
'zip_code:sm:w-1/3' |
the field, with CSS classes — only the first colon separates, so Tailwind prefixes (sm:, hover:) survive |
['zip_code', 'city'] |
a nested list opens a container; rows and columns alternate with each level |
'# Address' |
as the first element of a group: it becomes a ui.card (flat, bordered) with that title, and always stacks |
'## Address' |
the same section heading, but no card around it — a plain ui.column that stacks just as well |
':gap-8 items-end' |
as the first element of a group: replaces the container's default classes |
'@test' |
an action button from the form's actions table — see Actions below |
A section is a group with a title. The number of # picks its shape, not its heading:
'# Address' draws the flat bordered card, '## Address' leaves the frame away and renders
only the heading above the fields. Both use section_title_classes, so the two look alike
wherever they sit; both always stack, and both take a ':classes' of their own for their
container ('w-full' for the card, 'w-full gap-4' for the plain column). Three or more #
raise ValueError — there is no third shape.
A layout is a profile with an arrangement: it defines which fields are rendered, in which
order — Meta.field_order does not apply on top of it. Grids and lists read the same entry and
ignore the nesting, so one profile can serve a form and a table. Unknown, duplicated or
excluded field names raise ValueError naming the position (layout[1][0]).
Field selection is a view concern: include/exclude/field_order pick the set (form,
grid and list), layout adds the arrangement (form only). None of them narrow validation, which
always covers the whole model (see Validation).
Two rules exist to avoid fighting Tailwind, and both are worth knowing:
- Fields in a row share the width evenly (
flex-1 min-w-0). A field that brings classes of its own gets those instead —flex-1setsflex-basis: 0and would silently override any width that was asked for. (min-w-0is always added: it is layout mechanics, not styling.) - Container classes replace the defaults rather than adding to them:
gap-4 gap-8is resolved by stylesheet order, not by the order in the class list. The defaults themselves are chrome knobs —form_row_classes,form_column_classes,form_card_classes/form_card_propsonChromeStyle— so a':classes'first element is only needed to override one container.
Without a layout no container is created at all, so the fields become direct children of
whatever the caller opened — which is what makes the ui.grid() two-column trick work:
Uniform styling is a separate knob. base_props and default_classes apply to every widget
of the form, whatever its type — ui.select, ui.input_chips and ui.color_input included,
which ui.input.default_props() would miss (ui.textarea inherits from ui.input, the others
do not):
How props and classes travel down the cascade¶
Props and classes are not handled the same way, and the difference is not arbitrary: a Quasar prop has a key, a CSS class does not.
props — additive |
classes — replacing |
|
|---|---|---|
| How sources combine | merged per key, later source wins | the most specific source replaces the others |
| Why | props('outlined dense') is a dict; dense=false from a later source simply overwrites the earlier dense |
'w-full w-1/2' is decided by stylesheet order, not by the order in the class list — merging cannot mean anything |
| Form-wide knob | base_props — a base every field builds on |
default_classes — a fallback for fields that bring none |
The chain, lowest priority first:
| # | Source | props |
classes |
|---|---|---|---|
| 1 | ModelForm(base_props=…) / (default_classes=…), or the same names in Meta |
base | used only if 2–5 set nothing |
| 2 | Annotated[str, niceview.Field(props=…, classes=…)] on the model field |
merged over 1 | replaces 1 |
| 3 | Meta.field_infos |
merged over 2 | replaces 2 |
| 4 | ModelForm(field_infos=…) |
merged over 3 | replaces 3 |
| 5 | the layout's 'zip_code:sm:w-1/3' |
— | replaces 4 |
| 6 | form.render_field('zip_code', classes=…) |
merged over 4 | replaces 4 |
Steps 2–4 are a FieldInfo merge, so a later source only overrides what it sets explicitly.
Steps 5 and 6 never meet: the layout applies to render(), the kwargs to render_field().
class Contact(pydantic.BaseModel):
name: str
zip_code: Annotated[str, niceview.Field(classes='text-right')]
ModelForm.from_item(contact, base_props='outlined dense', default_classes='w-full',
layout=['name', ['zip_code:sm:w-1/3', 'city']]).render()
# name -> classes 'w-full' (the default; nothing more specific applies)
# zip_code -> classes 'sm:w-1/3 min-w-0' (layout wins — 'text-right' is gone)
# city -> classes 'w-full flex-1 min-w-0' (default + its even share of the row)
# all three -> props 'outlined dense'
One consequence is worth stating plainly: because classes replace, 'zip_code:sm:w-1/3' drops
the field's own text-right. Put both in the layout ('zip_code:sm:w-1/3 text-right') when you
want them together.
The other, from props being additive: a boolean prop from base_props cannot be taken back
declaratively for a single field — props='dense=false' parses to the string 'false', which
Quasar reads as truthy. Do it imperatively on the returned element instead:
form.render_field('notes').props(remove='dense')
form.w('notes').classes(replace='w-1/3') # the only way to remove, for classes too
What the layout notation cannot express, render_field() still can: it renders one field
wherever you call it, and honours the same form-level defaults.
Actions¶
A button that is not a field — Test connection next to the host, Generate next to the
password. It has no value, no validation and no place in the model, so it is layout rather than a
field: Fields stays a mapping of model fields, and everything that walks it (pushing values,
converting them, validating) needs no exception for it.
Two parts, because a callback cannot live in a layout string: the actions table says what the
button does, '@name' says where it sits.
from niceview import FormAction, ModelForm
ModelForm.from_item(cfg,
layout=[['# Server', ['host', 'port', '@test']],
['# Access', ['password', '@generate']]],
actions={'test': FormAction('Test', icon='bolt', on_click=test_connection),
'generate': FormAction('', icon='casino', on_click=make_password)},
).render()
FormAction |
Meaning |
|---|---|
label |
the button's text; '' makes it icon-only. A callable is resolved when it renders, like every ChromeText slot |
on_click |
handler for the event arguments of the place the button sits in (see below) — sync or async |
icon |
Material icon name |
tooltip |
shown on hover, unless ChromeStyle(tooltips=False) |
props |
Quasar props, merged on top of the chrome's place and shape layers |
classes |
CSS classes; in a row they replace the alignment niceview picks (see below) |
requires_valid |
disable the button while the form has validation errors — only where there is a form (see below) |
The event carries the form, which is the point of it — e.form.item is the last fully valid
state, e.form.draft what the widgets hold right now:
async def test_connection(e):
await ping(e.form.draft.host) # what the user sees, valid or not
def make_password(e):
e.form.w('password').set_value(secrets.token_urlsafe(12)) # the form commits it as usual
requires_valid is the one bit of state niceview takes over, because it is the one it already
knows (has_validation_errors). Everything else the application does on the button itself:
form.w('@test') # the button — addressed the way the layout writes it
form.action_buttons['test'] # the same, by plain name
In a row, an action lands on the same line as the box of the fields beside it, not on the middle of their total height: Quasar keeps a strip of 20px free below a field that can show a message, so that the layout does not jump when one appears. niceview knows which fields do that (any validated widget, and any widget showing a hint) and picks the classes per row:
| The row holds | Classes | Why |
|---|---|---|
| a text field, a select, anything with a hint | self-center mb-5 |
the margin makes the centred margin box as much taller as the field keeps free, lifting the button back onto the box |
| only switches, sliders, checkbox groups | self-center |
those are exactly as tall as they look — there is nothing to compensate |
| only actions | self-center |
same |
This does not depend on outlined, filled or dense: those change how tall the box is, and
the compensation is about what sits below it. Classes of your own — in the layout
('@test:self-end') or on the FormAction — replace the pair wholesale.
An action carries no role — add, delete, save … are niceview's closed vocabulary for
what it means by a button. Place and shape still apply (an action among the fields is form,
one in a title row is toolbar), so it sits among the others and styles itself with props.
For a layout built by hand, render_action('test') places one wherever it is called, exactly as
render_field() does for a field.
Every wrapper's title row takes the same FormAction under chrome_actions=, so the
naturally-placed button — Test all next to Save — is not the one left out:
EditFormWrapper.from_json(Connection, path, title='Connection',
chrome_actions={'check': FormAction('Test all', icon='bolt', requires_valid=True,
on_click=check_everything)},
).render()
wrapper.action_buttons['check'] # ui.button
The application's actions come first in that row, so niceview's own buttons keep the right edge
they have everywhere. chrome_actions and actions are separate on purpose: the first belongs
to the wrapper's row, the second to the layout, and a factory call sets both.
A title row without a form has no item to hand over, so each place sends the event arguments of
what it is about — plus e.name, e.action, and e.wrapper outside a form:
| Title row of | Event arguments (exported from niceview) |
The event names |
|---|---|---|
ModelForm, EditFormWrapper |
FormActionEventArguments |
e.form, as above |
EditGridWrapper |
GridActionEventArguments |
e.row_key, e.item — the selected row, both None when nothing is selected |
DrillDownWrapper's detail_actions |
DrillDownActionEventArguments |
e.key, e.item — the item on screen; these buttons sit left of Delete and show in the detail view only |
DrillDownWrapper's list_actions |
DrillDownListActionEventArguments |
no key/item — there is no single item in the list view; these buttons sit left of Add and show in the list view only |
DrillDownWrapper is the one wrapper with two views, so its title row takes two action tables
instead of one: list_actions= (left of Add, list view only) and detail_actions= (left of
Delete, detail view only — chrome_actions= is accepted as its alias). The other wrappers have a
single view, so chrome_actions= alone says all there is to say.
EditGridWrapper.from_adapter(User, adapter, title='Users',
chrome_actions={'mail': FormAction('', icon='mail', tooltip='Write to the selected user',
on_click=lambda e: write_to(e.item) if e.item else
ui.notify('Select a row first'))},
).render()
An empty selection is a case a grid's action has to answer, exactly as Edit and Delete do. A drill-down's never sees one — there is no item to act on in the list view, and that is where its buttons are hidden.
requires_valid needs a form to ask, so the two places that have one accept it: a form, and the
detail view a DrillDownWrapper builds itself (where the flag follows whichever item is open).
An EditGridWrapper, a DrillDownWrapper's list_actions (the list view is about no single
item), or a DrillDownWrapper with a render_detail of its own, raises instead of leaving the
button enabled without a word.
Validation¶
Three layers run in a fixed order. The first two are the widget's own and behave exactly the
same in render_field() without any model; the third is what ModelForm adds.
| Layer | Runs on | Message shown | |
|---|---|---|---|
| 1a | required — rejects an empty value (None, '', []; never False/0) |
the raw widget value | below the widget |
| 1b | niceview.Field(validation=...) — a callable or a {message: predicate} dict, NiceGUI's own contract |
the raw widget value | below the widget |
| 2 | value conversion to the field's Python type | the raw widget value | 'Error interpreting widget value' |
| 3 | ModelForm only: model_validate() over the whole item after every change |
the converted draft | field errors below the widget, model-level errors in render_nonfield_errors() |
A value rejected by layer 1 is never converted and never reaches the model, so Pydantic never sees a value the user was already told is wrong. A field's own message wins over the model's message for the same field.
Layer 3 validates the whole Pydantic model, not only the rendered fields: include,
exclude, layout and field_order choose what is shown, never what is checked. So a
constraint on a field with no widget — one excluded or hidden, or a cross-field
@model_validator — still runs, and its message, having no widget to sit under, surfaces as a
model-level (nonfield) error.
class User(pydantic.BaseModel):
# required (no default) + a widget-level rule + a model constraint, in that order
name: Annotated[str, pydantic.Field(max_length=20),
niceview.Field(validation=lambda v: 'no digits' if any(c.isdigit() for c in v) else None)]
form = ModelForm.from_item(user, required_marker=' *', required_message='Required').render()
The item is written only when it validates as a whole. form.item is the last state that
passed every layer — the state save() would persist — and it is written in place, so
bind_text_from(form.item, 'name') keeps working across edits. The values currently in the
widgets, valid or not, are form.draft (a copy):
form.item # last fully valid state; identity stable; what save() writes
form.draft # what the widgets currently hold, including invalid values
form.has_validation_errors, form.validation_errors, form.nonfield_validation_errors
These model-level errors have no field to attach to. render() places the label
for them automatically; with a hand-built layout of render_field() calls, call
form.render_nonfield_errors() yourself — otherwise a cross-field error blocks every commit
without showing why (the form logs a warning when that happens).
Two limits worth knowing: an async validation function is displayed by the widget but
cannot block a commit (the commit path is synchronous), and required is skipped for disabled
fields so a non-editable empty field cannot block the form forever.
ModelGrid / ModelGridInlineEdit¶
ModelGrid renders a list as a read-only AgGrid table.
ModelGridInlineEdit adds per-cell editing with immediate validation and persistence.
from niceview import ModelGrid, ModelGridInlineEdit
# In-memory list
grid = ModelGrid.from_list(User, user_list, include=['name', 'age'])
grid = ModelGridInlineEdit.from_list(User, user_list)
# JSON file: created with [] if missing; Refresh button reloads from disk
grid = ModelGrid.from_json(User, Path('users.json'))
grid = ModelGridInlineEdit.from_json(User, Path('users.json'))
# Any adapter (two equivalent forms)
grid = ModelGrid(User, adapter) # constructor
grid = ModelGrid.from_adapter(User, adapter) # for API symmetry with ModelForm.from_adapter()
grid.render()
grid.on_change(lambda e: print(e.row_key, e.field_name, e.new_value))
grid.adapter # read-only property — returns the backing CollectionAdapter
# Row selection (rowSelection='single'): e.row_key/e.item mirror ModelList.on_select;
# both are None when the selection is cleared
grid = ModelGrid.from_list(User, users, rowSelection='single')
grid.on_select(lambda e: print(e.row_key, e.item))
# Styling: the canonical way is the exposed .widget after render()
grid.render()
grid.widget.classes('w-full')
Choice columns (select / toggle / radio, Literal, modelselect). A field with options
(or a bare Literal) shows its label in the cell — not the stored id — and, in a
ModelGridInlineEdit, edits through a dropdown of those labels (via aggrid refData +
agSelectCellEditor). This needs no extra config; it mirrors the form's select.
kind: Annotated[RoomType, niceview.Field(widget_type='ui.select', options=ROOM_LABELS)] = 'meeting'
# grid cell shows 'Meeting room'; inline edit offers the labels; the stored value stays 'meeting'.
For modelselect fields (options come from a related model), register the repositories so the
grid can resolve the labels — on the grid directly or via EditGridWrapper. It may be called
after render(); the columns and rows refresh in place:
grid = ModelGrid.from_adapter(Book, books).render()
grid.with_repositories({Author: authors_adapter}) # author column now shows the author's name
A modelselect bound to a relationship object (e.g. author: Author) is label-display only in
the grid — its write-back needs the form's FK sync, so edit it through the Add/Edit dialog. A
scalar FK (author_id) is edited inline like any other choice.
with_repositories() is uniform across ModelForm, ModelGrid, ModelList and the wrappers
(EditFormWrapper, EditGridWrapper, DrillDownWrapper) — so a ModelList or drilldown list
shows a key field's label too. Keys are a field name (preferred) or the related model type, and
registrations are additive: repeated calls merge, and a wrapper merges its own into the
component it embeds rather than replacing them (a later entry wins on key collision).
EditGridWrapper / EditFormWrapper¶
Both wrappers add a title, optional description, and action buttons as chrome above their inner component.
title and description default from the model's Meta and are overridden by the kwarg — so a model can
carry its own heading without repeating it at every call site. The cardinality is kept apart: the form wrapper
reads the singular Meta.title, the grid (like DrillDownWrapper's list) reads the plural Meta.title_plural,
and Meta.description is shared. A model with only Meta.title set never bleeds that singular into a grid —
the grid stays on its auto '{Type} List' title until Meta.title_plural is given.
from niceview import EditGridWrapper, EditFormWrapper
# Grid with CRUD buttons
EditGridWrapper.from_list(User, user_list, title='Users').render()
EditGridWrapper.from_json(User, Path('users.json'), title='Users').render()
EditGridWrapper.from_adapter(User, adapter, title='Users').render()
# inline_edit=True uses ModelGridInlineEdit instead of ModelGrid
EditGridWrapper.from_list(User, user_list, title='Users', inline_edit=True).render()
# Form with chrome — factory methods mirror ModelForm's, accept all ModelForm options plus wrapper options
EditFormWrapper.from_item(user, title='Edit User').render()
EditFormWrapper.from_json(User, Path('user.json'), title='Config', autosave=True).render()
EditFormWrapper.from_adapter(User, adapter, key, title='Edit User').render()
# repositories= wires up modelselect fields (for FK relationships in EditFormWrapper)
EditFormWrapper.from_adapter(Book, books_adapter, book_id, title='Edit Book',
repositories={Author: authors_adapter}).render()
EditGridWrapper button defaults — all buttons shown by default (icon only):
| Option | Default | Description |
|---|---|---|
add_button |
'' (icon) |
Opens create dialog |
edit_button |
'' (icon) |
Opens edit dialog; None for ModelGridInlineEdit |
delete_button |
'' (icon) |
Deletes selected row after confirmation |
refresh_button |
'' (icon) |
Reloads from adapter |
EditFormWrapper button defaults — depend on whether an adapter is bound:
| Factory | save_button |
refresh_button |
|---|---|---|
from_item() |
None (hidden) |
None (hidden) |
from_json() |
'' (icon) |
'' (icon) |
from_adapter() |
'' (icon) |
'' (icon) |
Autosave always suppresses save_button. Pass None to hide any button; pass a string to set its label ('' = icon only).
Exposed NiceGUI elements — chrome elements are accessible for styling after the factory call:
# EditGridWrapper
wrapper = EditGridWrapper.from_list(User, user_list, title='Users').render()
wrapper.title # ui.label | None
wrapper.description # ui.markdown | None
wrapper.title_row # ui.row | None
wrapper.add_button.props('color=primary') # ui.button | None
wrapper.edit_button # ui.button | None
wrapper.delete_button # ui.button | None
wrapper.refresh_button # ui.button | None
wrapper.action_buttons # dict[str, ui.button] — from chrome_actions=
# EditFormWrapper
wrapper = EditFormWrapper.from_adapter(User, adapter, key, title='Edit User').render()
wrapper.title.classes('text-primary') # ui.label | None
wrapper.description # ui.markdown | None
wrapper.title_row # ui.row | None
wrapper.save_button.props('color=green') # ui.button | None
wrapper.refresh_button # ui.button | None
wrapper.action_buttons # dict[str, ui.button] — from chrome_actions=
Every wrapper also takes chrome_actions={'name': FormAction(...)} for the application's own
buttons in that row — see Actions.
Chrome styling¶
Styling elements one by one is the fine adjustment. The shared look of the chrome — the title
row of every wrapper, its buttons, the dialogs, the notifications, the title of a section inside
a form — is a ChromeStyle, set once for the whole application:
from niceview import ChromeStyle, set_chrome_style
set_chrome_style(toolbar_button_props='dense outline', tooltips=False) # change single attributes
set_chrome_style(ChromeStyle(title_classes='text-h5 grow')) # or replace it wholesale
Call it at startup, before the first page is built: wrappers read the style when they render.
A single widget can opt out with chrome_style= — which replaces the default rather than
adding to it, so derive it from the current one with ChromeStyle.derived():
EditGridWrapper.from_list(User, users,
chrome_style=ChromeStyle.derived(button_group=False),
).render()
Attribute reference¶
The cascade itself — place, role, shape, and why there is no base layer below them — is explained once in Concepts. This is the reference:
| Attribute | Default | Applies to |
|---|---|---|
title_row_classes |
'w-full items-center flex-nowrap' |
the title row of every wrapper |
title_classes |
'text-h6 grow' |
the title label (grow pushes the buttons right) |
card_title_classes |
'text-subtitle2' |
a layout section with a card ('# …') |
section_title_classes |
'text-subtitle2' |
a layout section without one ('## …'), an embedded grid's label |
toolbar_ / form_ / dialog_button_props |
'' |
the buttons of that place |
icon_button_props / labelled_button_props |
'' / '' |
a button without / with a label |
toolbar_ / form_ / dialog_icon_button_props |
None |
the icon shape in that place: None inherits, '' suppresses, a value replaces |
shape_in_group |
False |
whether the shape also applies inside a button group |
add_button_props … cancel_button_props |
'', delete: 'color=negative' |
per role, merged last |
button_group |
True |
whether buttons that show together are joined in a ui.button_group |
button_group_style |
'width: fit-content; flex: none' |
inline style of that group |
button_row_classes |
'flex items-center gap-1 w-fit flex-none' |
the container used instead of the group |
tooltips |
True |
whether the buttons carry their tooltips (the texts are ChromeText) |
dialog_props / dialog_style |
maximized below md, 'width: 400px' |
every dialog niceview opens |
dialog_card_classes / dialog_title_classes / dialog_button_row_classes |
'w-full' / 'text-h6' / 'w-full place-content-end' |
the parts of a dialog |
notify_position / notify_timeout / notify_close_button |
'bottom' / 5.0 / False |
niceview's notifications |
notify |
None |
hook (message, kind) -> None for an application with its own messaging |
list_props |
'dense separator' |
a ModelList's ui.list |
list_item_classes |
'cursor-pointer' |
one row (ui.item) |
list_title_props / list_subtitle_props |
'' / 'caption' |
the two ui.item_labels of a row |
list_chevron_icon |
'chevron_right' |
drill-down hint at the right edge; None renders none |
list_chevron_classes |
'text-grey' |
that icon |
Merge semantics in practice¶
The merge rule per type (str additive, str | None replacing, *_classes wholesale) is
explained once in Concepts. Two things worth knowing on top of it:
A button group is only used when there is something to join: Quasar styles it as one control —
squared-off inner edges, a shared border — so a group of one would be a button wearing a group's
clothes. What counts is how many buttons are on screen together, not how many are configured:
an EditFormWrapper with autosave=True shows only Refresh, and a DrillDownWrapper shows Add
in the list view and Delete in the detail view, never both. Those go into button_row_classes
instead.
The list_* attributes are what a ModelList is made of — it has no title row, and styling
.widget afterwards would not reach the rows, which update_rows() rebuilds. A style set on a
DrillDownWrapper reaches the list it renders, so one chrome_style= covers both:
ModelList.from_list(User, users, chrome_style=ChromeStyle.derived(list_chevron_icon=None))
DrillDownWrapper.from_list(User, users, chrome_style=ChromeStyle.derived(list_props='separator'))
ChromeStyle is frozen — replace() returns a copy, so a style handed to one wrapper cannot be
mutated by another.
Field styling¶
The second cascade, keyed by widget category rather than by place or role — see Concepts for why. In practice:
from niceview import set_field_style
set_field_style(input_props='outlined dense', control_props='dense', default_classes='w-full')
| Attribute | Applies to |
|---|---|
input_props |
the QInput/QSelect based widgets (widgets.INPUT_BASED_WIDGETS) |
control_props |
checkbox, switch, radio, toggle, checkbox_group, slider, rating (widgets.CONTROL_WIDGETS) |
default_classes |
every field that brings none of its own and whose form sets none either |
Three layers, narrowest last: the application's category props, then ModelForm(base_props=…)
for one form, then FieldInfo(props=…) for one field. Props are additive per key, classes
replace.
Texts¶
Every string niceview shows is a ChromeText slot — tooltips, dialog labels, notifications, the
required marker and message. Replacing them is all a single-language application in a language
other than English needs:
from niceview import set_chrome_text
set_chrome_text(add_tooltip='Neuen Eintrag anlegen', ok_label='Ok',
delete_selected_message='Den Eintrag *{key}* wirklich löschen?')
Placeholders are named ({key}, {error}), never positional, so a translation can reorder the
sentence. Every slot also accepts a callable, resolved when the text is rendered rather than when
it is configured — that is the hook for a multilingual application, where the language belongs to
the client rather than to the process:
Model texts are deliberately not here: a field's label comes from FieldInfo (or pydantic's
title), so it already belongs to the application. See DESIGN.md for why niceview
ships no gettext of its own.
EditGridWrapper options:
wrapper = EditGridWrapper.from_list(User, users,
title='Users', # shown as text-h6; omitted/None = Meta.title_plural or auto '{Type} List'; '' = no title row
description='...', # markdown below the title row
on_add=my_handler, # replaces the default Add (create + open dialog); sync or async
add_button='Add', # label or '' for icon-only; None = hidden
edit_button='', # same
delete_button='', # same
refresh_button=None, # same
chrome_actions={}, # the application's own buttons, left of niceview's (see Actions)
chrome_style=None, # look of the title row (see Chrome styling)
)
wrapper.with_repositories({Author: authors_adapter}) # type → adapter; for modelselect fields in dialogs
wrapper.render()
EditFormWrapper options (all ModelForm options also accepted):
EditFormWrapper.from_item(user,
title='Edit User', # shown as text-h6; omitted/None = Meta.title (singular); '' = no title; no auto title
description='...', # markdown below the title row
save_button='Save', # label or '' for icon-only; None = hidden
refresh_button='', # same
chrome_actions={}, # the application's own buttons, left of niceview's (see Actions)
chrome_style=None, # look of the title row (see Chrome styling)
repositories={Author: authors_adapter}, # modelselect FK fields
# ModelForm options:
include=['name', 'age'],
autosave=True,
local_tz='Europe/Berlin',
on_change=my_callback,
).render()
Card-Based List Editing¶
ModelGrid/EditGridWrapper render a list as a table with Add/Edit/Delete dialogs. For a
mobile-friendly, inline-editable alternative — one card per item, each with its own layout and
autosaving fields — compose ModelForm.from_adapter() with a CollectionAdapter and
ui.refreshable yourself; there is no dedicated wrapper class for this because the layout is
inherently application-specific:
from nicegui import ui
from niceview import JsonListAdapter, ModelForm
adapter = JsonListAdapter(Forwarding, Path('forwardings.json'))
@ui.refreshable
def render_cards() -> None:
for key, item in adapter.items():
form = ModelForm.from_adapter(Forwarding, adapter, key, autosave=True)
with ui.card().classes('w-full'):
with ui.row().classes('w-full items-center'):
form.render_field('name').classes('grow')
ui.button(icon='delete').on_click(lambda _, it=item: delete_row(it))
with ui.row().classes('w-full'):
form.render_field('method').classes('w-1/4')
form.render_field('url').classes('grow')
form.render_nonfield_errors()
def add_row() -> None:
adapter.create(Forwarding())
render_cards.refresh()
def delete_row(item: Forwarding) -> None:
adapter.delete(adapter.key_from_item(item))
render_cards.refresh()
render_cards()
ui.button('Add', icon='add', on_click=add_row)
Each card is its own ModelForm bound to one item via from_adapter(Type, adapter, key), so
autosave=True persists every validated field change independently — no shared save button, no
row selection. render_field() (see Custom field layout) places fields freely
within the card instead of the table-column layout ModelGrid would use. Add/Delete mutate the
adapter directly and call render_cards.refresh() (NiceGUI's @ui.refreshable) to re-render the
card list; use a ReactiveAdapter/ObservableList-backed adapter instead if you want the list to
update automatically on mutation (see "Reactive updates" in Data Adapters).
See examples/12_card_list.py for a runnable version.
ModelList / DrillDownWrapper¶
ModelList renders a collection as a Quasar list — tappable rows with a title and subtitle,
suited for mobile-first single-column navigation. DrillDownWrapper is an embeddable list
<-> detail navigation widget built on top of it: a title row (Add in list view; Back + item
title + Delete in detail view) plus a body that swaps between the list and a per-item detail
view, with a slide animation on every swap. The title row is built once and only updated in
place on navigation (not the body — see "Styling after render()" below); it owns no NiceGUI
page/route of its own — render() draws into whatever context it's called in, same as any
other niceview widget, so it can sit inside a ui.card(), a tab panel, or a bigger page layout
without taking it over.
from niceview import ModelList, DrillDownWrapper
# Standalone list — fire on_select callback when an item is tapped
list_view = ModelList.from_list(User, users,
title_field='name', # first visible field by default
subtitle_fields=['email'], # next two visible fields by default
chrome_style=None, # look of the list and its rows (see Chrome styling)
)
list_view.on_select(lambda e: print(e.row_key, e.item))
list_view.render()
# Drill-down: embed inside your own page/card, then render()
with ui.card().classes('w-full'):
DrillDownWrapper.from_list(User, users,
title='Users',
item_title_field='name',
item_subtitle_fields=['email', 'active'],
).render()
# Works with any adapter
DrillDownWrapper.from_json(User, Path('users.json'), title='Users').render()
DrillDownWrapper.from_adapter(User, adapter, title='Users').render()
DrillDownWrapper options:
DrillDownWrapper.from_list(User, users,
title='Users', # list title; omitted/None = Meta.title_plural or auto '{Type} List'; '' = none (detail shows the item title)
description='...', # markdown below the title row, in both views
item_title_field='name', # field shown as detail title (auto-detected if omitted); alias: title_field
item_subtitle_fields=['email'], # fields shown as subtitle (next two visible if omitted); alias: subtitle_fields
add_button='', # '' = icon only; None = hidden
delete_button='', # same
back_button='', # same — None leaves the detail view without a way back
search=False, # search box in the list view's title row, left of list_actions/Add (client-side, all visible fields)
list_actions={}, # the application's own buttons, in the list view left of Add (see Actions)
detail_actions={}, # the application's own buttons, in the detail view left of Delete (see Actions); chrome_actions= is an alias
chrome_style=None, # look of the title row (see Chrome styling)
on_add=None, # override the Add click handler entirely, sync or async (see below)
on_back=None, # if set, shows a Back button in the list view too (for nesting), sync or async
render_list_item=None, # override list row rendering (see below)
render_list_container=None, # wrap the rendered rows, e.g. for make_sortable (see below)
render_detail=None, # override detail rendering (see below)
# ModelList options forwarded when render_list_item is not set:
include=['name', 'email'],
exclude=['secret'],
)
item_type() and navigates straight to its detail view for editing —
no upfront dialog, matching the autosave-first pattern used throughout niceview. wrapper.open(key)
navigates to a detail view programmatically, e.g. from a custom on_add.
on_add and on_back may be written as def or as async def; an async handler is awaited
before the click is done. That's what you want when the item needs an answer before it can
exist — the dialogs in Dialogs are all async:
async def handle_add() -> None:
name = await input_dialog('New project', label='Name')
if name is None:
return # cancelled — nothing created
item = adapter.create(Project(name=name))
wrapper.open(adapter.key_from_item(item))
wrapper = DrillDownWrapper.from_adapter(Project, adapter, on_add=handle_add)
The same holds for the event callbacks elsewhere in niceview (ModelForm.on_change,
ModelGrid.on_select, ModelList.on_select, EditGridWrapper.on_change), for a field's
options= callable, and for a field's validation= function. The renderer callbacks
(render_detail, render_list_item, render_list_container) are the exception: they run
inside a refreshable body and must be synchronous. Load slow data before calling render(),
or render a placeholder and fill it from a task.
Styling after render(): like EditGridWrapper/EditFormWrapper, DrillDownWrapper exposes
its title row elements — wrapper.title_row, wrapper.title, wrapper.description,
wrapper.back_button, wrapper.search_input, wrapper.add_button, wrapper.delete_button (all
| None; the buttons are None only if disabled entirely via
add_button=None/delete_button=None/back_button=None, search_input unless search=True,
never just because they're hidden in the current view), plus wrapper.list_action_buttons from
list_actions= and wrapper.action_buttons from detail_actions=/chrome_actions=. Its title row is built by the same
chrome style as the other two wrappers. Unlike a naive refreshable, the title row is built exactly once in
render() and only updated (text, visibility) on every list<->detail navigation, so styling
applied once (wrapper.title.classes(...)) survives navigation instead of being wiped on the
next swap. The body (list/detail content) is deliberately not exposed: it's genuinely torn
down and rebuilt on every navigation — that's also where the slide animation lives — so any
styling applied to it would be silently lost on the next swap; offering it would be misleading.
ModelList exposes only .widget (the ui.list) — it has no title row of its own, so there's
nothing else to expose. Its rows are styled through the chrome style, not
through .widget: update_rows() rebuilds them.
Custom list rows and detail layout. Both are escape hatches for the two cases the generic defaults can't handle: hand-placed field layout, and heterogeneous item types.
def render_list_item(key: str, item: Widget, select: Callable[[], None]) -> None:
with ui.row().classes('items-center gap-2').on('click', lambda: select()):
ui.icon(WIDGET_ICONS[item.widget_type])
ui.label(str(item))
def render_detail(adapter: CollectionAdapter, key: str, set_key: Callable[[str], None]) -> None:
item = adapter.read(key)
model_cls = WIDGET_MODELS[item.widget_type] # resolve the concrete type per item
form = ModelForm.from_adapter(model_cls, adapter, key, autosave=True)
form.render_field('position_x').classes('w-1/2')
form.render_field('position_y').classes('w-1/2')
# ... hand-placed fields per widget type ...
DrillDownWrapper.from_adapter(WidgetModel, widgets_adapter,
render_list_item=render_list_item, render_detail=render_detail, add_button=None,
).render()
Wrapping the rows (e.g. drag-to-reorder via make_sortable) needs the container, not each row, and needs to be re-applied every time the list re-renders — that's what render_list_container is for (only used together with render_list_item):
def render_list_container(render_rows: Callable[[], None]) -> None:
with ui.column().classes('w-full gap-1') as container:
render_rows()
container.make_sortable(handle='.drag-handle', on_end=handle_reorder)
DrillDownWrapper.from_adapter(WidgetModel, widgets_adapter,
render_list_item=render_list_item, render_list_container=render_list_container,
render_detail=render_detail, add_button=None,
).render()
render_detail's set_key callback is for renaming: call it whenever the item's key changes
(e.g. a "Name" input's blur handler that calls adapter.rename(...)) to keep the wrapper's
navigation state in sync — it can be called any time, not just synchronously while
render_detail runs. There's no dedicated rename feature on the wrapper; a "Name" widget in
render_detail wired to DirectoryAdapter.rename() (see Data Adapters) is all it takes.
Two first-class backends, both driven entirely through the hooks above:
- A JSON list in one file (JsonListAdapter, or ListAdapter over a nested list field) —
homogeneous or heterogeneous items, no rename (items aren't named). See examples/09_drilldown.py.
- One file per item in a directory (DirectoryAdapter, see Data Adapters) — items are just filename
metadata; rename is a "Name" field in render_detail, wired to DirectoryAdapter.rename().
See examples/13_directory_drilldown.py.
render_field — a single widget without a model¶
niceview.render_field() renders one widget from one FieldInfo, with no Pydantic model
involved, and niceview.field_value() reads it back with the same value conversions
ModelForm applies. Use them when your code — not a model class — decides what a field is:
an interpreter for a schema you must not turn into a class, a hand-built form, a widget in a
dialog. ModelForm is built on the same functions, so both paths produce identical widgets.
from niceview import Field, render_field, field_value
fi = Field(label='Name', widget_type='ui.input', props='outlined dense', classes='w-full')
widget = render_field(fi, 'Alice') # renders in the current NiceGUI context
...
name = field_value(widget, fi) # -> 'Alice'
A small form is a loop plus a dict:
FIELDS = {
'name': Field(label='Name', widget_type='ui.input', props='outlined dense', classes='w-full'),
'age': Field(label='Age', widget_type='ui.number', field_type=int, min=0, max=120),
'start': Field(label='Start', widget_type='date'),
'color': Field(label='Color', widget_type='ui.select', options=['red', 'green']),
}
with ui.column().classes('w-full gap-3'):
widgets = {key: render_field(fi, values.get(key)) for key, fi in FIELDS.items()}
def collect() -> dict:
return {key: field_value(w, FIELDS[key]) for key, w in widgets.items()}
What render_field() does — everything ModelForm does to build a widget, and nothing that
needs a model: it creates the widget for field_info.widget_type, applies label,
placeholder, hint, options (list, dict, or a sync/async callable),
min/max/step/multiple and the other widget-specific attributes, then props, classes,
style, tooltip and editable=False (disabled). A description — help text that came from a
schema rather than from whoever laid out the form — is placed by description_as, exactly as in
a ModelForm: render_field(fi, value, description_as='hint'). Validation layer 1 is wired up as well:
required appends the marker to the label and rejects an empty value, then
field_info.validation runs. It sets the initial value — and stops there. There is no change
event, no autosave, no validation against a model, no widgets registry: the caller owns the
widget.
Differences to a ModelForm field:
ModelForm |
render_field() |
|
|---|---|---|
widget_type |
inferred from the annotation | required — there is nothing to infer from |
field_type |
from the annotation | set it explicitly (default str), see below |
required |
from is_required() (no default) |
set it explicitly |
| Value | read from / written to the item | passed in, read back via field_value() |
| Validation | layers 1–3 (model validation on top) | layers 1–2 (identical behaviour, no model) |
'editgrid', 'modelselect' |
supported | ValueError — both need a model type and a repository |
field_type drives the conversions in field_value() that depend on the target type:
Field(widget_type='ui.number', field_type=int) reads back int instead of float,
field_type=list[str] | None maps an empty multi-selection back to None, and
field_type=list[int], item_type=int splits a comma-separated ui.input. Everything else
works with the default.
For 'date', 'time', 'datetime' and 'timedelta' the value may be passed either as the
Python object or as the ISO string a JSON document already contains — both render; field_value()
always returns the Python object. local_tz= on both functions controls the display timezone
of 'datetime', exactly as ModelForm's option of the same name.
required needs no model here: niceview.Field(required=True) appends ' *' to the label
(render_field(fi, value, required_marker=None) switches that off) and rejects an empty value,
which is what makes a JSON Schema required list directly usable.
See examples/14_render_field.py for a runnable version that builds its fields from a
schema-like dict instead of a model.