UP | HOME

NeoForms

Table of Contents

NeoForms

NeoForms (yalms.ui.neoforms) builds interactive forms inside Neovim buffers. Rather than using a modal dialog or a custom UI widget, forms are rendered as editable Neovim buffers with labeled fields, validation, and submit callbacks. The module uses Snacks.win for window management, so forms inherit Snacks' window styling, positioning, and keybinding system.

Prerequisites

Currently, text fields and option fields (radio buttons and checkboxes) are supported. Fields render inline in the buffer, with protected regions that prevent the user from editing labels, separators, and other non-input areas.

Creating a Form

Forms are created by calling NeoForm:new(spec, handler). The first argument is a NeoFormSpec table that defines the form's title, fields, and behavior. The second argument is a callback function that receives the form values when the user submits.

local NeoForm = require('yalms.ui.neoforms')

local function handle_submit(values)
  print(vim.inspect(values))
end

local form = NeoForm:new({
  title = "User Settings",
  fields = {
    { key = "username", kind = "text", title = "Username" },
    { key = "theme", kind = "options", title = "Theme",
      options = { {label = "Dark", value = false}, {label = "Light", value = false} } },
  }
}, handle_submit)

form:run()

After calling form:run(), the form displays in a floating window. The submit callback is invoked asynchronously when the user presses the submit key (default <leader><CR> in normal mode). The callback receives a table mapping each field's key to its current value. The form window closes automatically on submit unless the callback reopens it.

NeoForm Specification

The NeoFormSpec table accepts several options that control the form's appearance and behavior. The title field sets the window title displayed in the border. The desc field, if provided, renders a description at the top of the form. The fields array is required and defines the input fields.

Additional options include keys for custom keybindings, help for virtual lines rendered at the buffer bottom, validator for form-level validation, on_change for a callback that fires after any field changes, and transform for modifying values before they reach the submit handler. The ft option sets the buffer filetype and defaults to "markdown".

Field Types

Text Fields

Text fields accept freeform input and are created by setting kind = "text" in the field specification. The field renders as an inline text area with a title label and a separator. If the lang option is set, the field wraps its content in a code fence (triple backticks for markdown, #+begin_src /=#+endsrc= for org mode). When lang is set, the field automatically becomes multi-line, allowing the user to enter text across multiple lines.

{ key = "bio", kind = "text", title = "Biography", lang = "text", default = "Enter text..." }

Text fields support a default value, a per-field validator function, a hidden option that hides the field content behind an overlay, and multiline for explicit multi-line mode. When hidden, the field still occupies space in the buffer but its content is not visible to the user.

Option Fields

Option fields present a set of selectable items rendered inline in the buffer. They are created with kind = "options" and an options array. Each option is a table with a label (the display text) and a value (a boolean indicating the initial checked state).

Set radio = true in the field specification for single-select behavior (radio buttons), where selecting one option automatically deselects the others. Leave radio as false (the default) for multi-select (checkboxes), where any number of options can be selected.

{ key = "theme", kind = "options", title = "Theme",
  options = {
    { label = "Dark", value = false },
    { label = "Light", value = false },
  },
  radio = true,
}

In normal mode, press , (comma) with the cursor on an option to toggle its state. The field's on_change callback fires after each toggle, allowing you to react to selection changes in real time.

Custom Keys

You can register additional key bindings that have access to the form instance through the keys table in FormSpec. Each entry maps to a Snacks.win key specification, with the second element being a function that receives the form and window objects.

keys = {
  ["preview"] = { "<C-p>", function(form, win)
    print(vim.inspect(form:get_value()))
  end, { desc = "Preview values" } }
}

Fields can also register keys via their register_key callback. These keys are automatically merged into the form's key map, so you can extend field behavior without modifying the form specification.

NeoForm Methods

Once created, a form exposes several methods. The run(opts?) method displays the form in a floating window, accepting optional snacks.win.Config overrides. It returns immediately; the submit callback is invoked asynchronously when the user submits.

The get_value() method returns the current form values as a table keyed by each field's key. The get_win() method returns the snacks.win window object, or nil if the form is not currently displayed. The reset_help(help) method updates the virtual lines rendered at the bottom of the form. Setting keep_on_submit = true in the form spec prevents the form from closing automatically after submission.

Submit and Validation

When the user submits the form (default key: <leader><CR> in normal mode), the form first runs each field's validation function. If a field's validator returns an error string, that error is displayed and submission is cancelled. If all fields pass validation, the form-level validator function is called, if set. If it returns an error, submission is also cancelled.

If the transform function is provided in the form specification, it is called with the validated values and the form instance. The return value of transform is passed to the submit handler instead of the original values. Use this to coerce types, restructure the result, or perform any final processing.

API Reference

Developer Guide

Creating Custom Field Types

To create a custom field type, extend the FormField class:

local FormField = require('yalms.ui.neoforms.field')
local CustomField = FormField:extend()

function CustomField:new(spec)
  FormField.new(self, spec)
  self.render_fn = spec.render_fn
end

function CustomField:render(bufnr, row, opts)
  -- Custom render logic
end

-- Register the field type
require('yalms.ui.neoforms').register_field('custom', CustomField)

Extending Form Behavior

You can subclass NeoForm to add custom behavior:

local NeoForm = require('yalms.ui.neoforms')

local CustomForm = NeoForm:extend()

function CustomForm:new(spec, handler)
  NeoForm.new(self, spec, handler)
  self.custom_state = {}
end

function CustomForm:before_submit(values)
  -- Custom pre-submission logic
  return values
end

Hooks

Register a on_change callback in the form spec to react to field value changes:

local form = NeoForm:new({
  title = "Settings",
  fields = {...},
  on_change = function(values, form)
    print("Form changed:", vim.inspect(values))
  end,
}, handler)

The form also supports a validator function and a transform function (see Submit and Validation).