UniversalFormInputHandler Reference¶
Overview¶
The UniversalFormInputHandler is a centralized, type-safe input handling system that replaces over 40 individual form handlers with a single, reusable implementation. This pattern eliminates approximately 7,000 lines of duplicate code while providing consistent input behavior across all forms in the Substation application.
Purpose¶
- Unified Input Handling: Single source of truth for form input logic
- Type Safety: Leverages Swift's type system with protocol constraints
- Code Reduction: Reduces form handler implementations from ~133 lines to ~53 lines
- Consistency: Ensures uniform keyboard navigation and interaction patterns
- Maintainability: Changes to input behavior only need to be made in one place
Benefits Over Legacy Handlers¶
- Elimination of Code Duplication: Legacy handlers repeated the same key handling logic across every form
- Protocol-Based Design: Forms only need to conform to three simple protocols
- Extensibility: Custom key handlers allow form-specific behavior without duplicating core logic
- Actor Safety: Properly handles Swift concurrency with @MainActor isolation
- Validation Integration: Built-in form validation support with error display
Architecture¶
The UniversalFormInputHandler follows a delegation pattern with protocol-based constraints:
flowchart TD
subgraph "User Interaction"
Input[Keyboard Input]
end
subgraph "Universal Handler"
Handler[UniversalFormInputHandler]
CustomHandler[Custom Key Handler]
Handler --> CustomHandler
end
subgraph "Form State Management"
FormState[FormBuilderState]
Handler --> FormState
end
subgraph "Form Protocols"
Updatable[FormStateUpdatable]
Rebuildable[FormStateRebuildable]
Validatable[FormValidatable]
end
subgraph "Actions"
Navigate[Field Navigation]
Edit[Field Editing]
Validate[Validation]
Submit[API Submission]
end
Input --> Handler
FormState --> Updatable
FormState --> Rebuildable
Handler --> Navigate
Handler --> Edit
Handler --> Validate
Validate --> Submit
style Handler fill:#4a5568
style FormState fill:#2d3748
style Submit fill:#38a169
Component Relationships¶
classDiagram
class UniversalFormInputHandler {
-tui: TUI?
+handleInput(ch, screen, formState, form, onSubmit, onCancel, customKeyHandler)
-handleSpaceKey()
-handleEnterKey()
-handleUpDownKey()
-updateFormFromState()
-rebuildFormState()
}
class FormStateUpdatable {
<<protocol>>
+updateFromFormState(state)
}
class FormStateRebuildable {
<<protocol>>
+buildFields(selectedFieldId, activeFieldId, formState)
}
class FormValidatable {
<<protocol>>
+validateForm()
}
class FormBuilderState {
+fields: [FormField]
+isCurrentFieldActive()
+nextField()
+previousField()
+activateCurrentField()
+deactivateCurrentField()
+handleCharacterInput()
+handleSpecialKey()
}
class ConcreteForm {
+keyPairName: String
+publicKey: String
+buildFields()
+updateFromFormState()
+validateForm()
}
UniversalFormInputHandler ..> FormStateUpdatable : requires
UniversalFormInputHandler ..> FormStateRebuildable : requires
UniversalFormInputHandler ..> FormValidatable : requires
UniversalFormInputHandler --> FormBuilderState : manages
ConcreteForm ..|> FormStateUpdatable : implements
ConcreteForm ..|> FormStateRebuildable : implements
ConcreteForm ..|> FormValidatable : implements
API Reference¶
UniversalFormInputHandler¶
Main Method¶
@MainActor
func handleInput<Form: FormStateUpdatable & FormStateRebuildable & FormValidatable>(
_ ch: Int32,
screen: OpaquePointer?,
formState: inout FormBuilderState,
form: inout Form,
onSubmit: @MainActor @escaping (FormBuilderState, Form) async -> Void,
onCancel: @escaping () -> Void,
customKeyHandler: (@MainActor @Sendable (Int32, inout FormBuilderState, inout Form, OpaquePointer?) async -> Bool)? = nil
) async
Parameters¶
| Parameter | Type | Description |
|---|---|---|
ch |
Int32 |
The input character code from ncurses |
screen |
OpaquePointer? |
The ncurses screen pointer |
formState |
inout FormBuilderState |
The form's state object (mutable) |
form |
inout Form |
The form instance (mutable) |
onSubmit |
async closure |
Executed when form is submitted (ENTER on inactive field) |
onCancel |
closure |
Executed when form is cancelled (ESC on inactive field) |
customKeyHandler |
optional async closure |
Custom key handler for form-specific behavior |
Required Protocols¶
FormStateUpdatable¶
Updates the form's properties from the current FormBuilderState. This synchronizes field values from the state back to the form model.
FormStateRebuildable¶
protocol FormStateRebuildable {
func buildFields(
selectedFieldId: String?,
activeFieldId: String?,
formState: FormBuilderState
) -> [FormField]
}
Rebuilds the form's field array. Used for dynamic forms where field visibility or options change based on other field values.
FormValidatable¶
Validates the form and returns an array of error messages. Empty array indicates successful validation.
Implementation Guide¶
Step 1: Define Your Form Model¶
Create a struct that holds your form's data:
struct MyResourceCreateForm {
// Form data
var resourceName: String = ""
var resourceType: ResourceType = .standard
var isEnabled: Bool = false
var selectedNetworkID: String?
// Optional: Custom validation
func validateName() -> String? {
if resourceName.isEmpty {
return "Name is required"
}
if resourceName.count < 3 {
return "Name must be at least 3 characters"
}
return nil
}
}
Step 2: Implement Required Protocols¶
buildFields Implementation¶
extension MyResourceCreateForm {
func buildFields(
selectedFieldId: String?,
activeFieldId: String? = nil,
formState: FormBuilderState
) -> [FormField] {
var fields: [FormField] = []
// Text field
fields.append(.text(FormFieldText(
id: "name",
label: "Resource Name",
value: resourceName,
placeholder: "my-resource",
isRequired: true,
isVisible: true,
isSelected: selectedFieldId == "name",
isActive: activeFieldId == "name",
cursorPosition: formState.getTextFieldCursorPosition("name"),
validationError: validateName()
)))
// Select field for enum
fields.append(.select(FormFieldSelect(
id: "type",
label: "Resource Type",
value: resourceType,
options: ResourceType.allCases,
isRequired: true,
isVisible: true,
isSelected: selectedFieldId == "type",
isActive: activeFieldId == "type"
)))
// Toggle field
fields.append(.toggle(FormFieldToggle(
id: "enabled",
label: "Enable Resource",
value: isEnabled,
isVisible: true,
isSelected: selectedFieldId == "enabled"
)))
// Selector for complex selection
if let networks = formState.availableNetworks {
fields.append(.selector(FormFieldSelector(
id: "network",
label: "Network",
items: networks,
selectedItemId: selectedNetworkID,
isRequired: false,
isVisible: true,
isSelected: selectedFieldId == "network",
isActive: activeFieldId == "network",
columns: [
SelectorColumn(header: "Name", width: 30),
SelectorColumn(header: "CIDR", width: 18)
]
)))
}
return fields
}
}
updateFromFormState Implementation¶
extension MyResourceCreateForm: FormStateUpdatable {
mutating func updateFromFormState(_ formState: FormBuilderState) {
// Update text fields
if let name = formState.getTextValue("name") {
resourceName = name
}
// Update select fields
if let typeValue = formState.getSelectValue("type") as? ResourceType {
resourceType = typeValue
}
// Update toggle fields
isEnabled = formState.getToggleValue("enabled") ?? false
// Update selector fields
if let state = formState.selectorStates["network"] {
selectedNetworkID = state.selectedItemId
}
}
}
validateForm Implementation¶
extension MyResourceCreateForm: FormValidatable {
func validateForm() -> [String] {
var errors: [String] = []
if let nameError = validateName() {
errors.append(nameError)
}
if resourceType == .advanced && selectedNetworkID == nil {
errors.append("Network is required for advanced resources")
}
return errors
}
}
Step 3: Add Protocol Conformance¶
Step 4: Create Input Handler in TUI Extension¶
@MainActor
extension TUI {
internal func handleMyResourceCreateInput(_ ch: Int32, screen: OpaquePointer?) async {
// Get local copies to avoid actor isolation issues
var localFormState = myResourceCreateFormState
var localForm = myResourceCreateForm
// Optional: Define custom key handler
let customHandler: @MainActor @Sendable (Int32, inout FormBuilderState, inout MyResourceCreateForm, OpaquePointer?) async -> Bool = { ch, formState, form, screen in
// Handle form-specific keys (e.g., F-keys, special shortcuts)
if ch == KEY_F(5) { // F5 for refresh
await self.refreshNetworkList()
return true // Handled
}
return false // Let universal handler process
}
// Call universal handler
await universalFormInputHandler.handleInput(
ch,
screen: screen,
formState: &localFormState,
form: &localForm,
onSubmit: { formState, form in
// Sync state before submission
self.myResourceCreateFormState = formState
self.myResourceCreateForm = form
// Call module's submit method
if let module = ModuleRegistry.shared.module(for: "myresource") as? MyResourceModule {
await module.submitResourceCreation(screen: screen)
}
},
onCancel: {
self.changeView(to: .myResourceList, resetSelection: false)
},
customKeyHandler: customHandler
)
// Update actor-isolated properties
myResourceCreateFormState = localFormState
myResourceCreateForm = localForm
}
}
Form Field Types¶
Text Fields¶
Standard text input with cursor navigation:
.text(FormFieldText(
id: "fieldId",
label: "Field Label",
value: currentValue,
placeholder: "hint text",
isRequired: true,
isVisible: true,
isSelected: isCurrentlySelected,
isActive: isCurrentlyActive,
cursorPosition: cursorPos,
validationError: errorMessage
))
Key Behavior: - SPACE: Activate field or add space character (when active) - Arrow keys: Move cursor within text - Backspace/Delete: Remove characters - ESC: Exit edit mode
Number Fields¶
Numeric input with validation:
.number(FormFieldNumber(
id: "port",
label: "Port Number",
value: 8080,
min: 1,
max: 65535,
isRequired: true,
isVisible: true,
isSelected: isCurrentlySelected,
isActive: isCurrentlyActive,
validationError: errorMessage
))
Key Behavior: - Only accepts numeric characters - Validates against min/max constraints
Toggle Fields¶
Boolean on/off switch:
.toggle(FormFieldToggle(
id: "enabled",
label: "Enable Feature",
value: isEnabled,
isVisible: true,
isSelected: isCurrentlySelected
))
Key Behavior: - SPACE: Toggle immediately (no activation needed) - No edit mode required
Checkbox Fields¶
Multi-value boolean selection:
.checkbox(FormFieldCheckbox(
id: "options",
label: "Select Options",
value: isChecked,
isVisible: true,
isSelected: isCurrentlySelected
))
Key Behavior: - SPACE: Toggle check state immediately - No edit mode required
Select Fields¶
Enum-like single selection:
.select(FormFieldSelect(
id: "type",
label: "Resource Type",
value: currentEnum,
options: EnumType.allCases,
isRequired: true,
isVisible: true,
isSelected: isCurrentlySelected,
isActive: isCurrentlyActive
))
Key Behavior: - SPACE: Cycle through options immediately - No edit mode required
Selector Fields¶
Complex list selection with search and columns:
.selector(FormFieldSelector(
id: "resource",
label: "Select Resource",
items: availableItems,
selectedItemId: currentSelectionId,
isRequired: false,
isVisible: true,
isSelected: isCurrentlySelected,
isActive: isCurrentlyActive,
columns: [
SelectorColumn(header: "Name", width: 30),
SelectorColumn(header: "Status", width: 10)
],
emptyMessage: "No resources available"
))
Key Behavior: - SPACE: Activate selector or toggle selection (when active) - Arrow keys: Navigate items (when active) - Character input: Search filtering (when active) - ESC: Exit selector mode
MultiSelect Fields¶
Multiple item selection:
.multiSelect(FormFieldMultiSelect(
id: "tags",
label: "Select Tags",
items: availableTags,
selectedItemIds: selectedTagIds,
isRequired: false,
isVisible: true,
isSelected: isCurrentlySelected,
isActive: isCurrentlyActive
))
Key Behavior: - SPACE: Toggle item selection (when active) - Arrow keys: Navigate items - ESC: Exit selection mode
Migration Guide¶
Identifying Legacy Handlers¶
Legacy handlers typically have this structure:
// LEGACY PATTERN - DO NOT USE
internal func handleMyFormInput(_ ch: Int32, screen: OpaquePointer?) async {
let isFieldActive = myFormState.isCurrentFieldActive()
switch ch {
case Int32(9): // TAB
if !isFieldActive {
myFormState.nextField()
myForm.updateFromFormState(myFormState)
await draw(screen: screen)
}
// ... 100+ more lines of repeated key handling ...
}
}
Migration Steps¶
-
Remove Legacy Handler Code: Delete the entire switch statement and key handling logic
-
Add Protocol Conformance:
-
Implement Required Methods: Ensure your form has:
buildFields()methodupdateFromFormState()method-
validateForm()method -
Update Handler to Use Universal Pattern:
internal func handleMyFormInput(_ ch: Int32, screen: OpaquePointer?) async { var localFormState = myFormState var localForm = myForm await universalFormInputHandler.handleInput( ch, screen: screen, formState: &localFormState, form: &localForm, onSubmit: { formState, form in self.myFormState = formState self.myForm = form await self.submitMyForm() }, onCancel: { self.changeView(to: .previousView, resetSelection: false) } ) myFormState = localFormState myForm = localForm }
Common Patterns to Update¶
Pattern 1: Custom TAB Behavior¶
If your form needs special TAB handling (e.g., mode switching):
let customHandler: @MainActor @Sendable (Int32, inout FormBuilderState, inout MyForm, OpaquePointer?) async -> Bool = { ch, formState, form, screen in
if ch == Int32(9) && formState.isCurrentFieldActive() {
if let field = formState.getCurrentField(),
case .selector(let selector) = field,
selector.id == "special-field" {
// Custom TAB behavior
form.toggleMode()
return true // Handled
}
}
return false
}
Pattern 2: Field Dependencies¶
For forms where field visibility depends on other fields:
let customHandler: @MainActor @Sendable (Int32, inout FormBuilderState, inout MyForm, OpaquePointer?) async -> Bool = { ch, formState, form, screen in
// After toggle changes, rebuild form
if ch == Int32(32) { // SPACE
if let field = formState.getCurrentField(),
case .toggle(let toggle) = field,
toggle.id == "enable-advanced" {
// Let universal handler process the toggle
// Then rebuild to show/hide dependent fields
formState = FormBuilderState(
fields: form.buildFields(
selectedFieldId: formState.getCurrentFieldId(),
activeFieldId: formState.getActiveFieldId(),
formState: formState
)
)
}
}
return false
}
Examples¶
Simple Form: KeyPair Creation¶
A basic form with text input and file loading:
@MainActor
extension TUI {
internal func handleKeyPairCreateInput(_ ch: Int32, screen: OpaquePointer?) async {
var localFormState = keyPairCreateFormState
var localForm = keyPairCreateForm
// Custom handler for file loading
let customHandler: @MainActor @Sendable (Int32, inout FormBuilderState, inout KeyPairCreateForm, OpaquePointer?) async -> Bool = { ch, formState, form, screen in
if ch == Int32(10) || ch == Int32(13) { // ENTER
if formState.isCurrentFieldActive(),
let field = formState.getCurrentField(),
case .text(let textField) = field,
textField.id == "publicKeyFilePath" {
// Load public key from file
if let error = form.loadPublicKeyFromFile() {
self.statusMessage = "Error: \(error)"
} else {
self.statusMessage = "Public key loaded"
}
return false // Continue with normal ENTER behavior
}
}
return false
}
await universalFormInputHandler.handleInput(
ch,
screen: screen,
formState: &localFormState,
form: &localForm,
onSubmit: { formState, form in
self.keyPairCreateFormState = formState
self.keyPairCreateForm = form
if let module = ModuleRegistry.shared.module(for: "keypairs") as? KeyPairsModule {
await module.submitKeyPairCreation(screen: screen)
}
},
onCancel: {
self.changeView(to: .keyPairs, resetSelection: false)
},
customKeyHandler: customHandler
)
keyPairCreateFormState = localFormState
keyPairCreateForm = localForm
}
}
Complex Form: Server Creation¶
A sophisticated form with mode switching and dynamic fields:
@MainActor
extension TUI {
internal func handleServerCreateInput(_ ch: Int32, screen: OpaquePointer?) async {
var localFormState = serverCreateFormState
var localForm = serverCreateForm
let customHandler: @MainActor @Sendable (Int32, inout FormBuilderState, inout ServerCreateForm, OpaquePointer?) async -> Bool = { ch, formState, form, screen in
// TAB switches boot source mode
if ch == Int32(9) && formState.isCurrentFieldActive() {
if let field = formState.getCurrentField(),
case .selector(let selector) = field,
selector.id == "source" {
form.toggleBootSource()
// Rebuild form to reflect mode change
formState = FormBuilderState(
fields: form.buildFields(
selectedFieldId: formState.getCurrentFieldId(),
activeFieldId: formState.getActiveFieldId(),
formState: formState
)
)
await self.draw(screen: screen)
return true
}
}
// ESC in flavor detail view goes back to category list
if ch == Int32(27) && formState.isCurrentFieldActive() {
if let field = formState.getCurrentField(),
case .selector(let selector) = field,
selector.id == "flavor",
form.flavorSelectionMode == .workloadBased,
form.selectedCategoryIndex != nil {
form.selectedCategoryIndex = nil
await self.draw(screen: screen)
return true
}
}
return false
}
await universalFormInputHandler.handleInput(
ch,
screen: screen,
formState: &localFormState,
form: &localForm,
onSubmit: { formState, form in
self.serverCreateFormState = formState
self.serverCreateForm = form
await self.createServer()
},
onCancel: {
self.viewCoordinator.currentView = .servers
self.serverCreateForm.reset()
},
customKeyHandler: customHandler
)
// Always rebuild after handler to reflect changes
localFormState = FormBuilderState(
fields: localForm.buildFields(
selectedFieldId: localFormState.getCurrentFieldId(),
activeFieldId: localFormState.getActiveFieldId(),
formState: localFormState
),
preservingStateFrom: localFormState
)
serverCreateFormState = localFormState
serverCreateForm = localForm
}
}
Form with Dynamic Selectors¶
Forms that load data and update selectors:
@MainActor
extension TUI {
internal func handleNetworkAttachInput(_ ch: Int32, screen: OpaquePointer?) async {
var localFormState = networkAttachFormState
var localForm = networkAttachForm
// Load networks if not loaded
if localForm.networks.isEmpty {
await loadAvailableNetworks()
localForm.networks = availableNetworks
localFormState = FormBuilderState(
fields: localForm.buildFields(
selectedFieldId: nil,
activeFieldId: nil,
formState: localFormState
)
)
}
await universalFormInputHandler.handleInput(
ch,
screen: screen,
formState: &localFormState,
form: &localForm,
onSubmit: { formState, form in
self.networkAttachFormState = formState
self.networkAttachForm = form
await self.attachNetwork()
},
onCancel: {
self.changeView(to: .networkList, resetSelection: false)
}
)
networkAttachFormState = localFormState
networkAttachForm = localForm
}
}
Best Practices¶
1. Actor Isolation¶
Always use local copies to avoid Swift concurrency issues:
// Good
var localFormState = myFormState
var localForm = myForm
await universalFormInputHandler.handleInput(...)
myFormState = localFormState
myForm = localForm
// Bad - will cause compilation errors
await universalFormInputHandler.handleInput(
formState: &myFormState, // Actor-isolated property
form: &myForm // Actor-isolated property
)
2. Custom Key Handlers¶
Return true only if you've fully handled the key:
let customHandler: ... = { ch, formState, form, screen in
if ch == SPECIAL_KEY {
// Handle special key
await self.doSomething()
return true // Prevent universal handler from processing
}
return false // Let universal handler process
}
3. Form Rebuilding¶
Rebuild form state when field visibility changes:
if fieldVisibilityChanged {
formState = FormBuilderState(
fields: form.buildFields(
selectedFieldId: formState.getCurrentFieldId(),
activeFieldId: formState.getActiveFieldId(),
formState: formState
),
preservingStateFrom: formState // Preserves field values
)
}
4. Validation Messages¶
Return clear, actionable error messages:
func validateForm() -> [String] {
var errors: [String] = []
// Good: Clear and specific
if name.isEmpty {
errors.append("Server name is required")
}
if ram < 512 {
errors.append("RAM must be at least 512 MB")
}
// Bad: Vague or technical
// errors.append("Invalid input")
// errors.append("Constraint violation")
return errors
}
5. Submit Handlers¶
Always sync state before API calls:
onSubmit: { formState, form in
// Sync state first
self.myFormState = formState
self.myForm = form
// Then submit
if let module = ModuleRegistry.shared.module(for: "mymodule") as? MyModule {
await module.submitForm(screen: screen)
}
}
Troubleshooting¶
Common Issues¶
Issue: Form doesn't update after key press¶
Solution: Ensure you're updating actor-isolated properties after the handler:
Issue: Custom keys not working¶
Solution: Check your custom handler returns false for keys you want the universal handler to process:
Issue: Validation errors not showing¶
Solution: Ensure your form implements FormValidatable correctly and returns non-empty array for errors:
func validateForm() -> [String] {
var errors: [String] = []
// Add validation logic
return errors // Must return array, even if empty
}
Issue: Fields not rebuilding dynamically¶
Solution: Manually rebuild FormBuilderState after changes that affect field visibility:
formState = FormBuilderState(
fields: form.buildFields(
selectedFieldId: formState.getCurrentFieldId(),
activeFieldId: formState.getActiveFieldId(),
formState: formState
),
preservingStateFrom: formState
)