mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
3fb4944472
- 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
320 lines
10 KiB
Swift
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.d█e ] (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)
|
|
}
|
|
}
|