mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
Feat: Add TextField<Label> generic, ViewBuilder init, and textInputAutocapitalization
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user