form

package
v0.12.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 20, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package form is the modal text-input controller for overlay forms: an ordered set of single- and multi-line fields with one focus ring, a dirty-discard guard (esc on edited content asks yes/no instead of dropping it), a submit/cancel/editor event contract with a consumed-key signal, and a pluggable autocomplete seam (a trigger rune such as @ starts a query; suggestions are fetched asynchronously as commands). The package is domain-free — it knows nothing about issues or Jira — so any overlay that collects text can ride it, and it stays liftable into a standalone module. Following primer's idiom it is state structs plus render helpers, not a tea.Model: the owner routes messages in and places the rendered content. It is deliberately not primer's input.Editor: Editor is a full tea.Model over a fixed title+body pair, where this package mixes one-line and multiline fields, gates required ones, and exposes the completion seam.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Autocomplete

type Autocomplete struct {
	// Trigger starts a completion token at a word boundary, e.g. '@'. Zero
	// means bare mode: every trailing token completes and an acceptance
	// replaces the token as-is — for value lists rather than mentions.
	Trigger rune
	// MinQuery is how many runes must follow the trigger before fetching;
	// zero means 1, so a bare trigger never fires a fetch.
	MinQuery int
	// IsBoundary marks the runes that end a token; nil means whitespace.
	// A comma-separated field adds ',' so each list entry completes alone.
	IsBoundary func(r rune) bool
	// Fetch resolves a query to suggestions. It runs inside a tea.Cmd (its
	// own goroutine), so it may block on I/O; the result is dropped if the
	// query has moved on by the time it lands.
	Fetch func(query string) []Suggestion
}

Autocomplete plugs completion into one field. The form watches the word being typed at the cursor; once it starts with Trigger and the query is long enough, Fetch runs in a command and its results show as a list.

type Config

type Config struct {
	// Title renders above the fields (e.g. "comment on PROJ-1").
	Title  string
	Fields []FieldSpec
	// EditorHatch offers ctrl+e on a focused multiline field: the form emits
	// EventEditor and the owner takes the draft to an external editor. The
	// in-TUI field is always the default; the editor is the escape hatch.
	EditorHatch bool
	// Width bounds every field's rendered width.
	Width  int
	Styles Styles
}

Config declares a whole form.

type EventKind

type EventKind int

EventKind is what a completed Update asks the owner to do.

const (
	// EventNone means the form consumed (or ignored) the message and stays open.
	EventNone EventKind = iota
	// EventSubmit means every required field is filled; read Values.
	EventSubmit
	// EventCancel means the user backed out (esc on a pristine form, or a
	// confirmed discard). The values are abandoned.
	EventCancel
	// EventEditor asks the owner to continue the focused multiline field in
	// the external editor, seeded with Values. The form stays open until the
	// owner closes it, so a failed editor launch loses nothing.
	EventEditor
	// EventChanged means a Notify cycle field just stepped to a new value; the
	// owner reads it (via Value) and may push dependent Options back through
	// SetOptions. The form stays open.
	EventChanged
)

type FieldSpec

type FieldSpec struct {
	// Label renders above the field; empty omits the row.
	Label string
	// Placeholder shows in the empty field.
	Placeholder string
	// Initial pre-fills the field; the dirty guard compares against it, so a
	// prefilled-then-untouched form still cancels without a confirmation.
	Initial string
	// Multiline makes the field a textarea (enter inserts a newline and
	// ctrl+s submits) instead of a one-line input.
	Multiline bool
	// Rows is the textarea height; zero means 5.
	Rows int
	// Options, when non-empty, makes the field a cycle selector rather than a
	// text input: it holds one of the listed values and ←/→ (or h/l) step
	// through them. Its value is always one of Options, so a required cycle
	// field can never be blank. Initial selects the starting option (the first
	// when Initial matches none). Multiline/Autocomplete are ignored on a
	// cycle field.
	Options []string
	// Notify marks a cycle field whose value changes the owner wants to react
	// to — a project selector that drives a dependent type list, say. When such
	// a field steps to a new value the form returns EventChanged, so the owner
	// can refetch and push new Options back through SetOptions.
	Notify bool
	// Optional lets the field submit blank. A required field left blank
	// blocks the submit and takes focus instead. An Optional field renders an
	// "(optional)" marker on its label so the blank-is-fine contract is visible.
	Optional bool
	// Validate, when set, runs on trySubmit against the field's value. A
	// non-nil error blocks the submit, focuses the field, and renders the
	// message inline beneath it — the same gate the required check uses, but
	// for content the field itself can't express (a duration that won't parse).
	Validate func(string) error
	// Autocomplete, when set, watches this field for its trigger token and
	// offers fetched suggestions. Nil disables the seam.
	Autocomplete *Autocomplete
}

FieldSpec declares one field of a form.

type Model

type Model struct {
	// contains filtered or unexported fields
}

Model is one open form. The zero value is inert; construct with New.

func New

func New(cfg Config) Model

New builds a focused form: the first field owns the keyboard.

func (*Model) AcceptedDetail added in v0.12.1

func (m *Model) AcceptedDetail(i int) string

AcceptedDetail returns the Detail of the suggestion last accepted into field i (empty when none was accepted, or a later edit invalidated it). The owner uses it to recover the opaque value behind a display label — an assignee's accountId, say — without re-resolving the field text.

func (*Model) Active

func (m *Model) Active() bool

Active reports whether the form is open — a zero Model is inert.

func (*Model) Body added in v0.12.1

func (m *Model) Body() string

Body is the scrollable part of the form: the title and the labeled fields (with any suggestion list under the focused one). FocusRegion is measured against exactly this, so an owner can scroll Body to follow focus and pin Foot beneath the viewport.

func (*Model) FocusRegion added in v0.12.1

func (m *Model) FocusRegion() (top, height int, ok bool)

FocusRegion reports the focused field's block position within View(): top is its first line (0-based), height its line span. A framing Shell scrolls to keep that range visible as focus moves, so a form taller than the height cap follows the cursor instead of stranding it off-screen. ok is false for an inert form. Lines are counted by newline exactly as View joins the blocks, so the region lands on the same rows the Shell renders.

func (*Model) Foot added in v0.12.1

func (m *Model) Foot() string

Foot is the form's pinned chrome: any form-level error above the hint (or submitting, or discard-confirm) row. It must stay visible however the body scrolls — it carries the submit/cancel/confirm affordances — so an owner renders it outside the scroll viewport.

func (*Model) SetError added in v0.12.1

func (m *Model) SetError(msg string)

SetError clears the submitting state and shows msg as a form-level error line above the hint row — the seam an owner uses to surface a failed submit while keeping the draft intact.

func (*Model) SetOptions added in v0.12.1

func (m *Model) SetOptions(i int, options []string)

SetOptions replaces cycle field i's options — the seam an owner uses to swap a dependent list live (the type list that changes with the selected project). The selection stays on its current value when that value survives the swap, otherwise it snaps to the first option so the field never points past the end. A no-op on a non-cycle field or an empty list (which would degrade the field to a blank). initialSel tracks the new selection, so an owner-driven swap does not by itself read as a user edit — the field that drove the change is what marks the form dirty.

func (*Model) SetSubmitting added in v0.12.1

func (m *Model) SetSubmitting(frame string)

SetSubmitting marks the form as awaiting an async submit, rendering frame (a spinner glyph) plus "submitting…" in place of the hint row. It clears any prior form-level error so the two never show at once.

func (*Model) SetValue

func (m *Model) SetValue(i int, s string)

SetValue replaces field i's content without touching its Initial, so the dirty guard keeps comparing against the true baseline — the seam a caller rebuilding a form mid-edit (a resize) needs to carry a draft over.

func (*Model) Update

func (m *Model) Update(msg tea.Msg) (tea.Cmd, EventKind, bool)

Update routes a message into the form. The event tells the owner what completed; consumed reports whether the form owned the message, so an owner layering global keys knows when to fall through.

func (*Model) Value

func (m *Model) Value(i int) string

Value returns field i's current content ("" out of range).

func (*Model) Values

func (m *Model) Values() []string

Values returns every field's current content in declaration order.

func (*Model) View

func (m *Model) View() string

View renders the whole form — the scrollable body then the pinned foot — as one string. It is the plain path (tests, any owner that frames the form itself); an owner that scrolls a tall form draws Body and Foot separately so the foot row never scrolls off (see Model.Body, Model.Foot).

type Styles

type Styles struct {
	Title              lipgloss.Style
	Label              lipgloss.Style
	LabelFocused       lipgloss.Style
	Border             lipgloss.Style // an unfocused field's box border and cycle chevrons
	BorderFocused      lipgloss.Style // the focused field's box border and cycle chevrons
	HintKey            lipgloss.Style
	HintText           lipgloss.Style
	Question           lipgloss.Style // the dirty-discard confirmation
	Suggestion         lipgloss.Style
	SuggestionSelected lipgloss.Style
	Error              lipgloss.Style // field and form-level validation/submit errors
}

Styles are the form's render styles, injected by the owner so the form stays theme-agnostic.

type Suggestion added in v0.12.1

type Suggestion struct {
	Value string
	Label string
	// Detail is an opaque payload carried with the suggestion but never
	// inserted into the field — the owner reads it via AcceptedDetail after an
	// acceptance. It lets a readable label (an assignee's display name) stand
	// in the field while the owner keeps the value it maps to (an accountId),
	// so no re-resolve is needed at submit.
	Detail string
}

Suggestion is one completion candidate: Value is inserted into the field on acceptance (with the trigger rune re-prefixed when there is one), while Label is what the list renders. The two are equal for plain value lists; they diverge when a human-readable label stands in for an opaque value.

type SuggestionsMsg

type SuggestionsMsg struct {
	Field int
	Query string
	Items []Suggestion
}

SuggestionsMsg carries fetched suggestions back into the form. Field and Query pin the result to the fetch that asked, so a slow response can never attach to a newer token (the generation-guard idea, keyed by content).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL