Feat: Add TextField<Label> generic, ViewBuilder init, and textInputAutocapitalization

This commit is contained in:
phranck
2026-02-09 19:15:56 +01:00
parent 7bcd4038a7
commit d607fdc831
4 changed files with 301 additions and 39 deletions
+65 -1
View File
@@ -54,6 +54,9 @@ final class TextFieldHandler: Focusable {
/// Callback triggered when the user presses Enter.
var onSubmit: (() -> Void)?
/// The autocapitalization behavior for text input.
var autocapitalization: TextInputAutocapitalization = .never
/// Creates a text field handler.
///
/// - Parameters:
@@ -127,15 +130,76 @@ extension TextFieldHandler {
extension TextFieldHandler {
/// Inserts a character at the current cursor position.
///
/// Applies autocapitalization rules based on the current setting.
///
/// - Parameter char: The character to insert.
func insertCharacter(_ char: Character) {
var current = text.wrappedValue
let index = current.index(current.startIndex, offsetBy: min(cursorPosition, current.count))
current.insert(char, at: index)
// Apply autocapitalization
let transformedChar = applyAutocapitalization(char, in: current, at: cursorPosition)
current.insert(transformedChar, at: index)
text.wrappedValue = current
cursorPosition += 1
}
/// Applies autocapitalization rules to a character.
///
/// - Parameters:
/// - char: The character to potentially transform.
/// - text: The current text content.
/// - position: The cursor position where the character will be inserted.
/// - Returns: The transformed character.
private func applyAutocapitalization(_ char: Character, in text: String, at position: Int) -> Character {
guard char.isLetter else { return char }
switch autocapitalization {
case .never:
return char
case .characters:
return char.uppercased().first ?? char
case .words:
// Capitalize if at start or after whitespace
if position == 0 {
return char.uppercased().first ?? char
}
let previousIndex = text.index(text.startIndex, offsetBy: position - 1)
if text[previousIndex].isWhitespace {
return char.uppercased().first ?? char
}
return char
case .sentences:
// Capitalize if at start or after sentence-ending punctuation followed by space
if position == 0 {
return char.uppercased().first ?? char
}
// Look for pattern: sentence end (. ! ?) followed by optional spaces
var foundSentenceEnd = false
var idx = position - 1
while idx >= 0 {
let charIndex = text.index(text.startIndex, offsetBy: idx)
let char = text[charIndex]
if char.isWhitespace {
idx -= 1
continue
}
if char == "." || char == "!" || char == "?" {
foundSentenceEnd = true
}
break
}
if foundSentenceEnd {
return char.uppercased().first ?? char
}
return char
}
}
/// Deletes the character before the cursor (backspace).
func deleteBackward() {
guard cursorPosition > 0 else { return }
+132 -35
View File
@@ -6,6 +6,26 @@
import Foundation
// MARK: - Text Input Autocapitalization
/// The autocapitalization behavior for text input.
///
/// In a terminal environment, autocapitalization affects how typed text
/// is transformed before being stored in the text binding.
public enum TextInputAutocapitalization: Sendable {
/// No autocapitalization is applied.
case never
/// The first letter of each word is capitalized.
case words
/// The first letter of each sentence is capitalized.
case sentences
/// All letters are capitalized.
case characters
}
// MARK: - TextField
/// A control that displays an editable text interface.
@@ -52,6 +72,14 @@ import Foundation
/// 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
@@ -60,9 +88,9 @@ import Foundation
/// performSearch()
/// }
/// ```
public struct TextField: View {
/// The label/title text describing the field's purpose.
let title: String
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>
@@ -79,18 +107,38 @@ public struct TextField: View {
/// Action to perform when the user submits (presses Enter).
let onSubmitAction: (() -> Void)?
/// The autocapitalization behavior.
let autocapitalization: TextInputAutocapitalization
public var body: some View {
_TextFieldCore(
label: label,
text: text,
prompt: prompt,
focusID: focusID,
isDisabled: isDisabled,
onSubmitAction: onSubmitAction,
autocapitalization: autocapitalization
)
}
}
// 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.title = title
self.label = Text(title)
self.text = text
self.prompt = nil
self.focusID = "textfield-\(title)"
self.isDisabled = false
self.onSubmitAction = nil
self.autocapitalization = .never
}
/// Creates a text field with a prompt.
@@ -101,40 +149,51 @@ public struct TextField: View {
/// - 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.title = title
self.label = Text(title)
self.text = text
self.prompt = prompt
self.focusID = "textfield-\(title)"
self.isDisabled = false
self.onSubmitAction = nil
self.autocapitalization = .never
}
}
/// Internal initializer with all parameters.
init(
title: String,
// 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?,
focusID: String,
isDisabled: Bool,
onSubmitAction: (() -> Void)?
prompt: Text? = nil,
@ViewBuilder label: () -> Label
) {
self.title = title
self.label = label()
self.text = text
self.prompt = prompt
self.focusID = focusID
self.isDisabled = isDisabled
self.onSubmitAction = onSubmitAction
}
public var body: some View {
_TextFieldCore(
title: title,
text: text,
prompt: prompt,
focusID: focusID,
isDisabled: isDisabled,
onSubmitAction: onSubmitAction
)
self.focusID = "textfield-\(UUID().uuidString)"
self.isDisabled = false
self.onSubmitAction = nil
self.autocapitalization = .never
}
}
@@ -147,12 +206,13 @@ extension TextField {
/// - Returns: A new text field with the disabled state.
public func disabled(_ disabled: Bool = true) -> TextField {
TextField(
title: title,
label: label,
text: text,
prompt: prompt,
focusID: focusID,
isDisabled: disabled,
onSubmitAction: onSubmitAction
onSubmitAction: onSubmitAction,
autocapitalization: autocapitalization
)
}
@@ -174,12 +234,39 @@ extension TextField {
/// - Returns: A text field that performs the action on submit.
public func onSubmit(_ action: @escaping () -> Void) -> TextField {
TextField(
title: title,
label: label,
text: text,
prompt: prompt,
focusID: focusID,
isDisabled: isDisabled,
onSubmitAction: action
onSubmitAction: action,
autocapitalization: autocapitalization
)
}
/// Sets the autocapitalization behavior for this text field.
///
/// Use this modifier to control how text is automatically capitalized
/// as the user types.
///
/// # Example
///
/// ```swift
/// TextField("Name", text: $name)
/// .textInputAutocapitalization(.words)
/// ```
///
/// - Parameter autocapitalization: The autocapitalization behavior.
/// - Returns: A text field with the specified autocapitalization.
public func textInputAutocapitalization(_ autocapitalization: TextInputAutocapitalization) -> TextField {
TextField(
label: label,
text: text,
prompt: prompt,
focusID: focusID,
isDisabled: isDisabled,
onSubmitAction: onSubmitAction,
autocapitalization: autocapitalization
)
}
}
@@ -187,13 +274,14 @@ extension TextField {
// MARK: - Internal Core View
/// Internal view that handles the actual rendering of TextField.
private struct _TextFieldCore: View, Renderable {
let title: String
private struct _TextFieldCore<Label: View>: View, Renderable {
let label: Label
let text: Binding<String>
let prompt: Text?
let focusID: String
let isDisabled: Bool
let onSubmitAction: (() -> Void)?
let autocapitalization: TextInputAutocapitalization
/// The cursor character shown when focused.
private let cursorChar: Character = "█"
@@ -236,6 +324,7 @@ private struct _TextFieldCore: View, Renderable {
handler.text = text
handler.canBeFocused = !isDisabled
handler.onSubmit = onSubmitAction
handler.autocapitalization = autocapitalization
handler.clampCursorPosition()
// Register with focus manager
@@ -304,8 +393,16 @@ private struct _TextFieldCore: View, Renderable {
/// Builds the prompt content (shown when empty and unfocused).
private func buildPromptContent(palette: any Palette) -> String {
// Extract prompt text - for now just use title as fallback
let promptText = title
// Use the prompt text if available, rendered via its own render path
// For now, use a simple placeholder approach
let promptText: String
if let prompt {
// Render the prompt Text view to extract its string content
let buffer = TUIkit.renderToBuffer(prompt, context: RenderContext(availableWidth: 100, availableHeight: 1))
promptText = buffer.lines.first?.stripped ?? ""
} else {
promptText = ""
}
let paddedPrompt = promptText.padding(toLength: minContentWidth, withPad: " ", startingAt: 0)
return ANSIRenderer.colorize(paddedPrompt, foreground: palette.foregroundTertiary)
}
@@ -347,4 +347,75 @@ struct TextFieldHandlerTests {
#expect(handler.cursorPosition == 2) // Clamped to "Hi".count
}
// MARK: - Autocapitalization
@Test("Autocapitalization never does not transform")
func autocapitalizationNever() {
var text = ""
let binding = Binding(get: { text }, set: { text = $0 })
let handler = TextFieldHandler(focusID: "test", text: binding)
handler.autocapitalization = .never
handler.insertCharacter("a")
handler.insertCharacter("b")
#expect(text == "ab")
}
@Test("Autocapitalization characters uppercases all")
func autocapitalizationCharacters() {
var text = ""
let binding = Binding(get: { text }, set: { text = $0 })
let handler = TextFieldHandler(focusID: "test", text: binding)
handler.autocapitalization = .characters
handler.insertCharacter("a")
handler.insertCharacter("b")
#expect(text == "AB")
}
@Test("Autocapitalization words capitalizes first letter of each word")
func autocapitalizationWords() {
var text = ""
let binding = Binding(get: { text }, set: { text = $0 })
let handler = TextFieldHandler(focusID: "test", text: binding)
handler.autocapitalization = .words
handler.insertCharacter("h")
handler.insertCharacter("e")
handler.insertCharacter("l")
handler.insertCharacter("l")
handler.insertCharacter("o")
handler.insertCharacter(" ")
handler.insertCharacter("w")
handler.insertCharacter("o")
handler.insertCharacter("r")
handler.insertCharacter("l")
handler.insertCharacter("d")
#expect(text == "Hello World")
}
@Test("Autocapitalization sentences capitalizes after sentence end")
func autocapitalizationSentences() {
var text = ""
let binding = Binding(get: { text }, set: { text = $0 })
let handler = TextFieldHandler(focusID: "test", text: binding)
handler.autocapitalization = .sentences
// "hi. hello"
handler.insertCharacter("h")
handler.insertCharacter("i")
handler.insertCharacter(".")
handler.insertCharacter(" ")
handler.insertCharacter("h")
handler.insertCharacter("e")
handler.insertCharacter("l")
handler.insertCharacter("l")
handler.insertCharacter("o")
#expect(text == "Hi. Hello")
}
}
+33 -3
View File
@@ -27,8 +27,10 @@ struct TextFieldTests {
let textField = TextField("Username", text: binding)
// Should compile and create without error
#expect(textField.title == "Username")
// Should compile and render without error
let context = testContext()
let buffer = renderToBuffer(textField, context: context)
#expect(buffer.height == 1)
}
@Test("TextField initializes with prompt")
@@ -38,7 +40,6 @@ struct TextFieldTests {
let textField = TextField("Email", text: binding, prompt: Text("you@example.com"))
#expect(textField.title == "Email")
#expect(textField.prompt != nil)
}
@@ -151,4 +152,33 @@ struct TextFieldTests {
#expect(buffer.height == 2)
}
// MARK: - ViewBuilder Label
@Test("TextField with ViewBuilder label")
func viewBuilderLabel() {
var text = ""
let binding = Binding(get: { text }, set: { text = $0 })
let textField = TextField(text: binding, prompt: Text("Enter text")) {
Text("Custom Label")
}
let context = testContext()
let buffer = renderToBuffer(textField, context: context)
#expect(buffer.height == 1)
}
// MARK: - Autocapitalization
@Test("TextField with autocapitalization modifier")
func autocapitalizationModifier() {
var text = ""
let binding = Binding(get: { text }, set: { text = $0 })
let textField = TextField("Name", text: binding)
.textInputAutocapitalization(.words)
#expect(textField.autocapitalization == .words)
}
}