diff --git a/Sources/TUIkit/Focus/TextFieldHandler.swift b/Sources/TUIkit/Focus/TextFieldHandler.swift index f4186ae..dbcf132 100644 --- a/Sources/TUIkit/Focus/TextFieldHandler.swift +++ b/Sources/TUIkit/Focus/TextFieldHandler.swift @@ -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 } diff --git a/Sources/TUIkit/Views/TextField.swift b/Sources/TUIkit/Views/TextField.swift index 8f92086..3684bb7 100644 --- a/Sources/TUIkit/Views/TextField.swift +++ b/Sources/TUIkit/Views/TextField.swift @@ -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: View { + /// The label view describing the field's purpose. + let label: Label /// The binding to the text content. let text: Binding @@ -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) { - 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, 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, - 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: View, Renderable { + let label: Label let text: Binding 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) } diff --git a/Tests/TUIkitTests/TextFieldHandlerTests.swift b/Tests/TUIkitTests/TextFieldHandlerTests.swift index caf1835..565e53c 100644 --- a/Tests/TUIkitTests/TextFieldHandlerTests.swift +++ b/Tests/TUIkitTests/TextFieldHandlerTests.swift @@ -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") + } } diff --git a/Tests/TUIkitTests/TextFieldTests.swift b/Tests/TUIkitTests/TextFieldTests.swift index 6130a9a..5505f7a 100644 --- a/Tests/TUIkitTests/TextFieldTests.swift +++ b/Tests/TUIkitTests/TextFieldTests.swift @@ -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) + } }