Files
TUIkit/Sources/TUIkit/Views/TextField.swift
T
phranck 3fb4944472 Refactor: Move runtime services from RenderContext to EnvironmentValues
- Add ServiceEnvironment.swift with 9 EnvironmentKeys for runtime services
  (stateStorage, lifecycle, keyEventDispatcher, renderCache, preferenceStorage,
  pulsePhase, cursorTimer, focusIndicatorColor, activeFocusSectionID)
- Remove tuiContext, pulsePhase, cursorTimer, focusIndicatorColor, and
  activeFocusSectionID as direct RenderContext properties
- Inject all services through EnvironmentValues in RenderLoop.buildEnvironment()
- Add convenience RenderContext init that accepts TUIContext and auto-injects
  services into the environment
- Simplify isolatedForBackground() to only swap environment values
- Migrate ~49 access sites in ~25 source files from context.tuiContext.X and
  context.pulsePhase/cursorTimer to context.environment.X
- Update 38 test files to use the new convenience init
2026-02-14 13:13:24 +01:00

320 lines
10 KiB
Swift

// TUIKit - Terminal UI Kit for Swift
// TextField.swift
//
// Created by LAYERED.work
// License: MIT
// MARK: - TextField
/// A control that displays an editable text interface.
///
/// You create a text field with a label and a binding to a string value.
/// The text field updates this value continuously as the user types.
///
/// ## Rendering
///
/// The text field renders as `[ text content ]` with a visible cursor when focused.
/// When empty and unfocused, it displays the prompt text in dim styling.
///
/// ```
/// Unfocused, empty: [ Enter username... ] (prompt in dim)
/// Unfocused, with text: [ john.doe ] (text in normal)
/// Focused, empty: [ ] (cursor, brackets pulse)
/// Focused, with text: [ john.de ] (cursor in text)
/// ```
///
/// ## Keyboard Controls
///
/// | Key | Action |
/// |-----|--------|
/// | Any printable | Insert character at cursor |
/// | Backspace | Delete character before cursor |
/// | Delete | Delete character at cursor |
/// | Left | Move cursor left |
/// | Right | Move cursor right |
/// | Home | Move cursor to start |
/// | End | Move cursor to end |
/// | Enter | Trigger onSubmit action |
///
/// # Basic Example
///
/// ```swift
/// @State var username = ""
///
/// TextField("Username", text: $username)
/// ```
///
/// # With Prompt
///
/// ```swift
/// TextField("Email", text: $email, prompt: Text("you@example.com"))
/// ```
///
/// # With ViewBuilder Label
///
/// ```swift
/// TextField(text: $username, prompt: Text("Required")) {
/// Text("Username").bold()
/// }
/// ```
///
/// # With Submit Action
///
/// ```swift
/// TextField("Search", text: $query)
/// .onSubmit {
/// performSearch()
/// }
/// ```
public struct TextField<Label: View>: View {
/// The label view describing the field's purpose.
let label: Label
/// The binding to the text content.
let text: Binding<String>
/// Optional prompt text shown when the field is empty.
let prompt: Text?
/// The unique focus identifier.
var focusID: String?
/// Whether the text field is disabled.
var isDisabled: Bool
/// Action to perform when the user submits (presses Enter).
var onSubmitAction: (() -> Void)?
public var body: some View {
_TextFieldCore(
label: label,
text: text,
prompt: prompt,
focusID: focusID,
isDisabled: isDisabled,
onSubmitAction: onSubmitAction
)
}
}
// MARK: - TextField Initializers (Label == Text)
extension TextField where Label == Text {
/// Creates a text field with a text label generated from a title string.
///
/// - Parameters:
/// - title: The title of the text field, describing its purpose.
/// - text: The text to display and edit.
public init(_ title: String, text: Binding<String>) {
self.label = Text(title)
self.text = text
self.prompt = nil
// Auto-generated focusID from view identity (collision-free)
self.focusID = nil
self.isDisabled = false
self.onSubmitAction = nil
}
/// Creates a text field with a prompt.
///
/// - Parameters:
/// - title: The title of the text field, describing its purpose.
/// - text: The text to display and edit.
/// - prompt: A Text representing the prompt which provides users with
/// guidance on what to type into the text field.
public init(_ title: String, text: Binding<String>, prompt: Text?) {
self.label = Text(title)
self.text = text
self.prompt = prompt
// Auto-generated focusID from view identity (collision-free)
self.focusID = nil
self.isDisabled = false
self.onSubmitAction = nil
}
}
// MARK: - TextField Initializers (Generic Label)
extension TextField {
/// Creates a text field with a prompt generated from a `Text` and a custom label.
///
/// Use this initializer when you need a custom label view instead of a simple string.
///
/// # Example
///
/// ```swift
/// TextField(text: $username, prompt: Text("Required")) {
/// HStack {
/// Text("Username").bold()
/// Text("*").foregroundStyle(.red)
/// }
/// }
/// ```
///
/// - Parameters:
/// - text: The text to display and edit.
/// - prompt: A Text representing the prompt which provides users with
/// guidance on what to type into the text field.
/// - label: A view that describes the purpose of the text field.
public init(
text: Binding<String>,
prompt: Text? = nil,
@ViewBuilder label: () -> Label
) {
self.label = label()
self.text = text
self.prompt = prompt
self.focusID = nil
self.isDisabled = false
self.onSubmitAction = nil
}
}
// MARK: - TextField Modifiers
extension TextField {
/// Creates a disabled version of this text field.
///
/// - Parameter disabled: Whether the text field is disabled.
/// - Returns: A new text field with the disabled state.
public func disabled(_ disabled: Bool = true) -> TextField {
var copy = self
copy.isDisabled = disabled
return copy
}
/// Adds an action to perform when the user submits (presses Enter).
///
/// Use this modifier to invoke an action when the user presses Enter
/// while the text field has focus.
///
/// # Example
///
/// ```swift
/// TextField("Search", text: $query)
/// .onSubmit {
/// performSearch()
/// }
/// ```
///
/// - Parameter action: The action to perform on submit.
/// - Returns: A text field that performs the action on submit.
public func onSubmit(_ action: @escaping () -> Void) -> TextField {
var copy = self
copy.onSubmitAction = action
return copy
}
/// Sets a custom focus identifier for this text field.
///
/// - Parameter id: The unique focus identifier.
/// - Returns: A text field with the specified focus identifier.
public func focusID(_ id: String) -> TextField {
var copy = self
copy.focusID = id
return copy
}
}
// MARK: - Internal Core View
/// Internal view that handles the actual rendering of TextField.
private struct _TextFieldCore<Label: View>: View, Renderable, Layoutable {
let label: Label
let text: Binding<String>
let prompt: Text?
let focusID: String?
let isDisabled: Bool
let onSubmitAction: (() -> Void)?
/// Minimum width for the text field content area.
private let minContentWidth = 10
/// Default visible width for the text field content area when no proposal is given.
private let defaultContentWidth = 20
var body: Never {
fatalError("_TextFieldCore renders via Renderable")
}
/// Returns the size this text field needs.
///
/// TextField is width-flexible: it has a minimum width but expands
/// to fill available horizontal space in HStack.
func sizeThatFits(proposal: ProposedSize, context: RenderContext) -> ViewSize {
let width = proposal.width ?? defaultContentWidth
return ViewSize(
width: max(minContentWidth, width),
height: 1,
isWidthFlexible: true,
isHeightFlexible: false
)
}
func renderToBuffer(context: RenderContext) -> FrameBuffer {
let stateStorage = context.environment.stateStorage!
let palette = context.environment.palette
let cursorStyle = context.environment.textCursorStyle
// TextField expands to fill available width (reserve 2 chars for caps)
let contentWidth = max(minContentWidth, context.availableWidth - 2)
let persistedFocusID = FocusRegistration.persistFocusID(
context: context,
explicitFocusID: focusID,
defaultPrefix: "textfield",
propertyIndex: 1 // focusID
)
// Get or create persistent handler from state storage.
// The handler maintains cursor position across renders.
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) // handler
let handlerBox: StateBox<TextFieldHandler> = stateStorage.storage(
for: handlerKey,
default: TextFieldHandler(
focusID: persistedFocusID,
text: text,
canBeFocused: !isDisabled
)
)
let handler = handlerBox.value
// Keep handler in sync with current values
handler.text = text
handler.canBeFocused = !isDisabled
handler.onSubmit = onSubmitAction
handler.textContentType = context.environment.textContentType
handler.clampCursorPosition()
FocusRegistration.register(context: context, handler: handler)
let isFocused = FocusRegistration.isFocused(context: context, focusID: persistedFocusID)
// Build the text field content using shared renderer
let renderer = TextFieldContentRenderer(
prompt: prompt,
isDisabled: isDisabled,
displayCharacter: { index, text in
text[text.index(text.startIndex, offsetBy: index)]
}
)
let fieldContent = renderer.buildContent(
text: text.wrappedValue,
cursorPosition: handler.cursorPosition,
selectionRange: handler.selectionRange,
isFocused: isFocused,
palette: palette,
cursorStyle: cursorStyle,
cursorTimer: context.environment.cursorTimer,
contentWidth: contentWidth
)
// Wrap with half-block caps
let capColor = palette.accent.opacity(ViewConstants.focusBorderDim)
let openCap = ANSIRenderer.colorize(String(TerminalSymbols.openCap), foreground: capColor)
let closeCap = ANSIRenderer.colorize(String(TerminalSymbols.closeCap), foreground: capColor)
return FrameBuffer(text: openCap + fieldContent + closeCap)
}
}