|
| 1 | +# builder_page.py |
| 2 | +# |
| 3 | +# Copyright 2025 Aryan Kaushik |
| 4 | +# |
| 5 | +# This program is free software: you can redistribute it and/or modify |
| 6 | +# it under the terms of the GNU General Public License as published by |
| 7 | +# the Free Software Foundation, either version 3 of the License, or |
| 8 | +# (at your option) any later version. |
| 9 | +# |
| 10 | +# SPDX-License-Identifier: GPL-3.0-or-later |
| 11 | + |
| 12 | +import os |
| 13 | + |
| 14 | +from gi.repository import Adw, Gtk, Gio |
| 15 | + |
| 16 | +from .form_model import FormField, FormModel |
| 17 | +from .field_editor_row import FieldEditorRow |
| 18 | +from .field_type_dialog import FieldTypeDialog |
| 19 | + |
| 20 | + |
| 21 | +@Gtk.Template(resource_path="/in/aryank/openforms/builder_page.ui") |
| 22 | +class BuilderPage(Gtk.Box): |
| 23 | + """ |
| 24 | + The GUI Form Builder page. |
| 25 | + Users can create or edit a form visually; the result is serialised |
| 26 | + to JSON (same schema as hand-written configs) when they hit Save. |
| 27 | +
|
| 28 | + The page is opened as a new tab from FormConfig, just like FormPage. |
| 29 | + After saving, the produced JSON path is passed back so the tab title |
| 30 | + updates and the file can be immediately used. |
| 31 | + """ |
| 32 | + |
| 33 | + __gtype_name__ = "BuilderPage" |
| 34 | + |
| 35 | + # Template children (defined in builder_page.ui) |
| 36 | + builder_toast_overlay: Adw.ToastOverlay = Gtk.Template.Child() |
| 37 | + form_name_row: Adw.EntryRow = Gtk.Template.Child() |
| 38 | + fields_group: Adw.PreferencesGroup = Gtk.Template.Child() |
| 39 | + |
| 40 | + def __init__(self, **kwargs): |
| 41 | + super().__init__(**kwargs) |
| 42 | + self.set_hexpand(True) |
| 43 | + self.set_vexpand(True) |
| 44 | + |
| 45 | + self.model = FormModel() |
| 46 | + self._rows: list[FieldEditorRow] = [] |
| 47 | + |
| 48 | + # Wire the form-name entry (declared in UI template) |
| 49 | + self.form_name_row.connect("changed", self._on_form_name_changed) |
| 50 | + |
| 51 | + def set_page(self, page): |
| 52 | + """Called by FormConfig after the tab is created, same pattern as FormPage.""" |
| 53 | + self.page = page |
| 54 | + |
| 55 | + def load_from_model(self, model: FormModel): |
| 56 | + """Populate the builder from an existing FormModel (for editing a saved form).""" |
| 57 | + self.model = model |
| 58 | + self.form_name_row.set_text(model.form_name) |
| 59 | + for ff in model.fields: |
| 60 | + self._append_row(ff) |
| 61 | + |
| 62 | + @Gtk.Template.Callback() |
| 63 | + def on_add_field_clicked(self, *_): |
| 64 | + dialog = FieldTypeDialog(self._add_field_of_type) |
| 65 | + dialog.present(self.get_root()) |
| 66 | + |
| 67 | + @Gtk.Template.Callback() |
| 68 | + def on_save_clicked(self, *_): |
| 69 | + file_dialog = Gtk.FileDialog() |
| 70 | + file_dialog.set_title("Save Form Config") |
| 71 | + safe_name = (self.model.form_name or "form").lower().replace(" ", "_") |
| 72 | + file_dialog.set_initial_name(safe_name + ".json") |
| 73 | + |
| 74 | + json_filter = Gtk.FileFilter() |
| 75 | + json_filter.set_name("JSON files") |
| 76 | + json_filter.add_mime_type("application/json") |
| 77 | + json_filter.add_pattern("*.json") |
| 78 | + filters = Gio.ListStore.new(Gtk.FileFilter) |
| 79 | + filters.append(json_filter) |
| 80 | + file_dialog.set_filters(filters) |
| 81 | + |
| 82 | + file_dialog.save(self.get_root(), None, self._on_save_finish) |
| 83 | + |
| 84 | + def _on_form_name_changed(self, row): |
| 85 | + self.model.form_name = row.get_text() |
| 86 | + # Keep the tab title in sync if we have a page reference |
| 87 | + if hasattr(self, "page") and self.page and hasattr(self.page, "tab_page"): |
| 88 | + title = row.get_text() or "New Form" |
| 89 | + self.page.tab_page.set_title(f"✏ {title}") |
| 90 | + |
| 91 | + def _add_field_of_type(self, ftype: str): |
| 92 | + ff = FormField(type=ftype, label="") |
| 93 | + self.model.fields.append(ff) |
| 94 | + self._append_row(ff, expanded=True) |
| 95 | + |
| 96 | + def _append_row(self, ff: FormField, expanded: bool = False): |
| 97 | + row = FieldEditorRow( |
| 98 | + ff, |
| 99 | + on_delete=self._delete_row, |
| 100 | + on_move_up=self._move_up, |
| 101 | + on_move_down=self._move_down, |
| 102 | + ) |
| 103 | + self._rows.append(row) |
| 104 | + self.fields_group.add(row) |
| 105 | + if expanded: |
| 106 | + row.set_expanded(True) |
| 107 | + |
| 108 | + def _delete_row(self, row: FieldEditorRow): |
| 109 | + self.model.fields.remove(row.form_field) |
| 110 | + self._rows.remove(row) |
| 111 | + self.fields_group.remove(row) |
| 112 | + |
| 113 | + def _move_up(self, row: FieldEditorRow): |
| 114 | + idx = self._rows.index(row) |
| 115 | + if idx > 0: |
| 116 | + self._swap(idx, idx - 1) |
| 117 | + |
| 118 | + def _move_down(self, row: FieldEditorRow): |
| 119 | + idx = self._rows.index(row) |
| 120 | + if idx < len(self._rows) - 1: |
| 121 | + self._swap(idx, idx + 1) |
| 122 | + |
| 123 | + def _swap(self, i: int, j: int): |
| 124 | + # Swap in model |
| 125 | + self.model.fields[i], self.model.fields[j] = ( |
| 126 | + self.model.fields[j], |
| 127 | + self.model.fields[i], |
| 128 | + ) |
| 129 | + # Swap in row list, then rebuild visual order |
| 130 | + self._rows[i], self._rows[j] = self._rows[j], self._rows[i] |
| 131 | + for r in self._rows: |
| 132 | + self.fields_group.remove(r) |
| 133 | + for r in self._rows: |
| 134 | + self.fields_group.add(r) |
| 135 | + |
| 136 | + def _on_save_finish(self, dialog, result): |
| 137 | + try: |
| 138 | + file = dialog.save_finish(result) |
| 139 | + except Exception: |
| 140 | + # User cancelled |
| 141 | + return |
| 142 | + |
| 143 | + path = file.get_path() |
| 144 | + if not path: |
| 145 | + self._show_toast("Could not determine save path") |
| 146 | + return |
| 147 | + |
| 148 | + try: |
| 149 | + with open(path, "w", encoding="utf-8") as f: |
| 150 | + f.write(self.model.to_json()) |
| 151 | + except OSError as e: |
| 152 | + self._show_toast(f"Save failed: {e}") |
| 153 | + return |
| 154 | + |
| 155 | + self._show_toast(f"Saved to {path}") |
| 156 | + |
| 157 | + # Update tab title to reflect saved file name |
| 158 | + if hasattr(self, "page") and self.page and hasattr(self.page, "tab_page"): |
| 159 | + self.page.tab_page.set_title(os.path.basename(path)) |
| 160 | + |
| 161 | + def _show_toast(self, message: str, timeout: int = 3): |
| 162 | + toast = Adw.Toast.new(message) |
| 163 | + toast.set_timeout(timeout) |
| 164 | + self.builder_toast_overlay.add_toast(toast) |
0 commit comments