Skip to content

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 menu
from 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 is qgis_sdk.ui.input_for over the kept form_fields/schema_fields rule: 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>) (a FieldInput(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.