Forms: an action's inputs as a window
A declared action already describes its inputs as JSON Schema and its handler
as a binding name, never a callable, so nothing in the description can carry
Python code. qgis_sdk.ui.Form turns that description into the catalogue’s
input components and submits them through the view’s ctx.actions; the
plugin never imports Qt for it:
from qgis_sdk import menufrom qgis_sdk.ui import Button, Column, Dialog, Form
@menu("Plugins/Demo")class BufferDialog(Dialog): def build(self): self.form = Form.for_action(self.ctx, "buffer", initial=self.initial) run = Button("run", "Buffer") run.clicked(self.run) return Column("buffer_dialog", [self.form, run])
def run(self) -> None: output = self.form.submit() # coerced, through ctx.actions self.ctx.notifications.success(f"Buffered into {output.name}") self.ctx.view.request_close()Three constructors build the same tree. Form.for_action(ctx, action_id, initial=) reads the action’s description from ctx.actions.describe(...),
builds one input per field, attaches the form to the context and applies
initial (an unknown name is refused). Form.from_schema(schema | ActionDescription, action_id=, initial=) does the same from a request schema
- a page’s generated one, say - without a context; attach it yourself.
Form(fields=[Text("name", required=True), ...], action_id=)takes the inputs an author placed by hand. The field-to-input rule isqgis_sdk.ui.input_forover the keptform_fields/schema_fieldsrule: text ->Text, integer ->Number(integer=True), number ->Number, metres ->Distance, boolean ->Toggle, layer ->LayerPicker, crs ->CrsPicker, choice ->Choice, field ->FieldPicker(of=<the sibling layer input>)(aFieldInput(of=)whose layer is not in the same schema is refused), range ->Number(range=), colour ->Colour, file ->FileInput(filters=), directory ->DirectoryInput. Required fields (spec.required) are marked on the input and*on the row label.
A form is a Component: fields (display order), field(name), values()
(every field’s model value), set_values(mapping) (an unknown name refused
before any change), changed(listener) called with (name, value) after any
field changes, snapshot() with its fields, attach(ctx)/mount()/
dispose(). submit() refuses an empty required field by name first
(QgisSdkFormError), then calls ctx.actions.call(action_id, **values) -
the same coercion a page or a button goes through - leaving out fields whose
value is None so the handler’s defaults apply; a form built without an
action, or not attached, refuses with QgisSdkUiError. Mounted, a form is a
QWidget with a QFormLayout of labelled rows and no buttons: place a
Button (buttons/<name>, clicked(listener), click()) next to it inside
a Column(name, [...]), the one container of this slice; Column.find(name)
walks the tree. The widget’s objectName is the form’s name (form by
default), its fields’ form/<field>.
Under the fakes a form is state: build the surface, assign ctx and
initial, call build(), set_values, click() the button and assert on
RecordingNotifications.records and FakeViewControl.close_requests - the
example’s test_the_buffer_dialog_is_the_buffer_action_as_a_form does
exactly that. coerce_values stays strict in the other direction: "3"
becomes 3 for an integer field, "three" is refused with the field name and
the expected type, and the submitted value is never echoed because a field can
hold a path or a credential.
The escape hatch is not a second API: PluginRuntime.actions is keyed by
action id and holds ordinary bound methods, so conventional PyQt code can
build its own dialog and call self.ctx.actions.<id>(...) with the same
validation - name every interactive widget (UI001), because the snapshots
agents read and the tests that drive a window find widgets by objectName.