MingaEditor.State.ModalOverlay (Minga v0.1.0)

Copy Markdown View Source

Tagged-union representation of input-capturing modal overlays.

Before this work, the picker, prompt, completion menu, conflict prompt, and dashboard each lived as an independent nullable field on shell state or workspace state. The type system permitted 32 combinations even though only six were meaningful, and MingaEditor.Input.Interrupt reset eight independent axes by hand. See docs/UI-STATE-ANALYSIS.md for the full analysis.

This module replaces those fields with a single sum type:

:none
| {:picker, ModalOverlay.Picker.t()}
| {:prompt, ModalOverlay.Prompt.t()}
| {:completion, ModalOverlay.Completion.t()}
| {:conflict, ModalOverlay.Conflict.t()}
| {:dashboard, ModalOverlay.Dashboard.t()}

Migration status (#1421)

All five variants are now tracked exclusively on state.shell_state.modal. The legacy nullable fields (shell_state.dashboard, shell_state.prompt_ui, workspace.pending_conflict, workspace.completion, workspace.completion_trigger) have all been removed. #1427 finalises by collapsing Input.Interrupt and adding a Credo enforcement rule.

Do not mutate :modal directly: always call this module's open/3, transition/3, close/1, dismiss/1, update_completion/2, or update_completion_trigger/2.

Replacement policy

open/3 while a modal is already active replaces the previous one. There is no queue. The exception is :conflict: while a conflict prompt is active, other open/3 calls are logged and return state unchanged. This matches the original behaviour where ConflictPrompt sat before every other handler in the input stack.

transition/3 performs the same replacement but without the sticky rule; it is intended for FSM-style steps where the caller has already decided that a transition must happen (e.g., picker → prompt).

Per-tab semantics for completion

Completion is logically per-tab — it tracks the cursor of the buffer that triggered it. The Completion payload carries an owner tab id; dismiss_if_stale/1 runs from EditorState.switch_tab/2 after the new context is restored, dismissing completion that no longer belongs to the active tab. Other variants live on shell state, which isn't snapshotted per tab, so they don't need this hook.

Summary

Functions

Returns true when a modal is currently active (modal != :none).

Closes the active modal cleanly.

Returns the active Completion.t() when the modal is {:completion, _}, otherwise nil. Read site for chrome rendering, render-pipeline input build, and any code path that historically read workspace.completion.

Returns the active CompletionTrigger.t() from the completion payload, or a fresh trigger when no completion modal is active. Callers that just want to consult the trigger lifecycle (debounce, pending refs) without caring about completion state itself use this.

Dismisses the active modal as a cancellation.

Dismisses the active completion modal when its owner no longer matches the now-active tab. Called from EditorState.switch_tab/2 after the target tab's context is restored.

Returns true when modal matches the given variant tag.

Returns the closed modal (:none).

Opens a modal overlay, replacing any active one.

Updates the completion trigger.

Returns the variant tag of modal, or :none when no modal is active.

Transitions the active modal to a new variant unconditionally.

Updates the inner Completion.t() of the active completion modal via fun. No-op when no completion modal is active. Use for menu-navigation events (move_up/move_down) that mutate Completion without changing the gate's variant.

Types

Functions

active?(arg1)

@spec active?(t()) :: boolean()

Returns true when a modal is currently active (modal != :none).

close(state)

Closes the active modal cleanly.

Used when the modal has completed its task (e.g., picker accepted, prompt submitted). No-op when no modal is active.

completion(arg1)

@spec completion(map()) :: Minga.Editing.Completion.t() | nil

Returns the active Completion.t() when the modal is {:completion, _}, otherwise nil. Read site for chrome rendering, render-pipeline input build, and any code path that historically read workspace.completion.

Accepts any struct or map that carries shell_state.modal so the RenderPipeline.Input flavour of state works the same as EditorState.

completion_trigger(arg1)

@spec completion_trigger(map()) :: MingaEditor.CompletionTrigger.t()

Returns the active CompletionTrigger.t() from the completion payload, or a fresh trigger when no completion modal is active. Callers that just want to consult the trigger lifecycle (debounce, pending refs) without caring about completion state itself use this.

dismiss(state)

Dismisses the active modal as a cancellation.

Used when the user backs out (Esc, Ctrl-G). Identical to close/1 — the distinction exists so callers can express intent and future per-variant cleanup hooks can branch on it.

dismiss_if_stale(state)

@spec dismiss_if_stale(MingaEditor.State.t()) :: MingaEditor.State.t()

Dismisses the active completion modal when its owner no longer matches the now-active tab. Called from EditorState.switch_tab/2 after the target tab's context is restored.

Only completion has per-tab semantics; other modals live on shell state and don't snapshot per tab, so this is a no-op for them.

match(arg1, tag)

@spec match(t(), :none | variant()) :: boolean()

Returns true when modal matches the given variant tag.

match(:none, :none) is true; match({:picker, _}, :picker) is true. Any other combination is false.

none()

@spec none() :: t()

Returns the closed modal (:none).

open(state, variant, payload)

Opens a modal overlay, replacing any active one.

Conflict prompts are sticky: if the active modal is a conflict, calls to open/3 for any other variant are logged and return state unchanged. To force a transition out of a conflict modal, call close/1 or dismiss/1 first, or use transition/3.

put_completion_trigger(state, trigger)

Updates the completion trigger.

Behaviour by current modal:

  • :completion — update the payload's trigger in place.
  • :none and trigger has pending activity (debounce timer or pending request) — open a new completion modal with completion: nil and the given trigger so the bridge state has somewhere to live until the LSP response arrives.
  • :none and trigger is empty — no-op.
  • any other variant — no-op (don't displace another modal for a backend bookkeeping update).

tag(arg1)

@spec tag(t()) :: :none | variant()

Returns the variant tag of modal, or :none when no modal is active.

transition(state, variant, payload)

@spec transition(MingaEditor.State.t(), variant(), payload()) :: MingaEditor.State.t()

Transitions the active modal to a new variant unconditionally.

Equivalent to open/3 except the conflict-sticky rule is bypassed. Use this when the caller has already decided the transition must happen (e.g., dismissing a picker into a prompt).

update_completion(state, fun)

Updates the inner Completion.t() of the active completion modal via fun. No-op when no completion modal is active. Use for menu-navigation events (move_up/move_down) that mutate Completion without changing the gate's variant.