diff --git a/Sources/TUIkit/App/App.swift b/Sources/TUIkit/App/App.swift index 06e5e4e3..28decfc5 100644 --- a/Sources/TUIkit/App/App.swift +++ b/Sources/TUIkit/App/App.swift @@ -121,6 +121,7 @@ extension AppRunner { tuiContext: tuiContext ) let pulseTimer = PulseTimer(renderNotifier: appState) + let cursorTimer = CursorTimer(renderNotifier: appState) // Setup signals.install() @@ -145,11 +146,12 @@ extension AppRunner { isRunning = true - // Start the breathing focus indicator animation + // Start animation timers pulseTimer.start() + cursorTimer.start() // Initial render - renderer.render(pulsePhase: pulseTimer.phase) + renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) // Main loop while isRunning { @@ -168,7 +170,7 @@ extension AppRunner { // Check if terminal was resized or state changed if signals.consumeRerenderFlag() || appState.needsRender { appState.didRender() - renderer.render(pulsePhase: pulseTimer.phase) + renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) } // Read key events (non-blocking with VTIME=0) @@ -182,10 +184,10 @@ extension AppRunner { eventsProcessed += 1 } - // Sleep 33ms to yield CPU. - // This sets the maximum frame rate to ~30 FPS. - // The sleep ensures low CPU usage even during continuous input. - usleep(33_000) + // Sleep 28ms to yield CPU. + // This sets the maximum frame rate to ~35 FPS. + // + usleep(28_000) } // Stop pulse timer before cleanup diff --git a/Sources/TUIkit/App/CursorTimer.swift b/Sources/TUIkit/App/CursorTimer.swift new file mode 100644 index 00000000..d72a60eb --- /dev/null +++ b/Sources/TUIkit/App/CursorTimer.swift @@ -0,0 +1,155 @@ +// TUIKit - Terminal UI Kit for Swift +// CursorTimer.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation + +/// Drives the cursor animation for TextField and SecureField. +/// +/// `CursorTimer` maintains two phase values for different animation styles: +/// - `blinkVisible`: Boolean for sharp on/off blinking +/// - `pulsePhase`: Smooth 0-1 sine wave for pulsing +/// +/// The timer runs independently from the ``PulseTimer`` (which handles focus indicators) +/// to allow different animation speeds and precise control over cursor timing. +/// +/// ## Animation Speeds +/// +/// The speed is controlled by ``TextCursorStyle/Speed``: +/// - `.slow`: 800ms cycle (visible 400ms, hidden 400ms) +/// - `.regular`: 530ms cycle (visible 265ms, hidden 265ms) +/// - `.fast`: 300ms cycle (visible 150ms, hidden 150ms) +/// +/// ## Usage +/// +/// ```swift +/// let cursor = CursorTimer(renderNotifier: appState) +/// cursor.start() +/// // In render code: +/// if cursor.blinkVisible(for: .regular) { +/// // show cursor +/// } +/// let phase = cursor.pulsePhase(for: .regular) +/// ``` +final class CursorTimer { + /// Base tick interval in milliseconds. + /// We use a fast tick (50ms) and derive phases from elapsed time. + private let tickIntervalMs = 50 + + /// Elapsed ticks since timer started. + private var elapsedTicks = 0 + + /// The GCD timer source. + private var timer: DispatchSourceTimer? + + /// The render notifier to trigger re-renders. + private weak var renderNotifier: AppState? + + /// Creates a new cursor timer. + /// + /// - Parameter renderNotifier: The app state to notify when a re-render + /// is needed. Held weakly to avoid retain cycles. + init(renderNotifier: AppState) { + self.renderNotifier = renderNotifier + } + + deinit { + stop() + } +} + +// MARK: - Phase Computation + +extension CursorTimer { + /// Returns whether the cursor should be visible for blink animation. + /// + /// - Parameter speed: The cursor speed setting. + /// - Returns: `true` if cursor should be visible, `false` if hidden. + func blinkVisible(for speed: TextCursorStyle.Speed) -> Bool { + let cycleMs = speed.blinkCycleMs + let elapsedMs = elapsedTicks * tickIntervalMs + let positionInCycle = elapsedMs % cycleMs + // Visible for first half of cycle + return positionInCycle < (cycleMs / 2) + } + + /// Returns the pulse phase (0-1) for smooth cursor animation. + /// + /// The phase follows a sine curve for smooth breathing: + /// - 0.0: Dimmest + /// - 1.0: Brightest + /// + /// - Parameter speed: The cursor speed setting. + /// - Returns: Phase value between 0 and 1. + func pulsePhase(for speed: TextCursorStyle.Speed) -> Double { + let cycleMs = speed.pulseCycleMs + let elapsedMs = elapsedTicks * tickIntervalMs + let positionInCycle = elapsedMs % cycleMs + let normalized = Double(positionInCycle) / Double(cycleMs) + // Sine wave: 0 → 1 → 0 over the cycle + return sin(normalized * .pi) + } +} + +// MARK: - Timer Control + +extension CursorTimer { + /// Starts the cursor animation timer. + /// + /// If the timer is already running, this is a no-op. + func start() { + guard timer == nil else { return } + + let source = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + let interval = DispatchTimeInterval.milliseconds(tickIntervalMs) + source.schedule(deadline: .now() + interval, repeating: interval) + + source.setEventHandler { [weak self] in + guard let self else { return } + self.elapsedTicks += 1 + self.renderNotifier?.setNeedsRender() + } + + source.resume() + timer = source + } + + /// Stops the cursor animation timer. + func stop() { + timer?.cancel() + timer = nil + elapsedTicks = 0 + } + + /// Resets the cursor animation to the visible/bright state. + /// + /// Call this when a text field gains focus to ensure the cursor + /// starts in a visible state. + func reset() { + elapsedTicks = 0 + } +} + +// MARK: - Speed Cycle Durations + +extension TextCursorStyle.Speed { + /// The blink cycle duration in milliseconds (on + off). + var blinkCycleMs: Int { + switch self { + case .slow: 1000 // 500ms on, 500ms off + case .regular: 660 // 330ms on, 330ms off + case .fast: 400 // 200ms on, 200ms off + } + } + + /// The pulse cycle duration in milliseconds (dim → bright → dim). + var pulseCycleMs: Int { + switch self { + case .slow: 1200 // 1.2 second breathing cycle + case .regular: 800 // 0.8 second breathing cycle + case .fast: 500 // 0.5 second breathing cycle + } + } +} diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index ce6d535c..cf9ea614 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -161,9 +161,11 @@ extension RenderLoop { /// /// See the class-level documentation for the complete pipeline steps. /// - /// - Parameter pulsePhase: The current breathing indicator phase (0–1). - /// Passed from ``PulseTimer`` via ``AppRunner``. - func render(pulsePhase: Double = 0) { + /// - Parameters: + /// - pulsePhase: The current breathing indicator phase (0–1). + /// Passed from ``PulseTimer`` via ``AppRunner``. + /// - cursorTimer: The cursor timer for TextField/SecureField animations. + func render(pulsePhase: Double = 0, cursorTimer: CursorTimer? = nil) { // Clear per-frame state before re-rendering tuiContext.keyEventDispatcher.clearHandlers() tuiContext.preferences.beginRenderPass() @@ -235,6 +237,7 @@ extension RenderLoop { tuiContext: tuiContext ) context.pulsePhase = pulsePhase + context.cursorTimer = cursorTimer // Render main content into a FrameBuffer. // app.body is evaluated fresh each frame. @State values survive @@ -260,6 +263,7 @@ extension RenderLoop { tuiContext: tuiContext ) correctedContext.pulsePhase = pulsePhase + correctedContext.cursorTimer = cursorTimer buffer = renderScene(scene, context: correctedContext.withChildIdentity(type: type(of: scene))) } diff --git a/Sources/TUIkit/Notification/NotificationHostModifier.swift b/Sources/TUIkit/Notification/NotificationHostModifier.swift index 818b99ac..ce12ee33 100644 --- a/Sources/TUIkit/Notification/NotificationHostModifier.swift +++ b/Sources/TUIkit/Notification/NotificationHostModifier.swift @@ -162,7 +162,7 @@ private extension NotificationHostModifier { .max() ?? 0 lifecycle.startTask(token: token, priority: .medium) { [lifecycle] in - let triggerNanos: UInt64 = 33_000_000 // 33ms (~30 FPS) + let triggerNanos: UInt64 = 28_000_000 // 28ms (~35 FPS) while !Task.isCancelled { let now = Date().timeIntervalSinceReferenceDate diff --git a/Sources/TUIkit/Rendering/Renderable.swift b/Sources/TUIkit/Rendering/Renderable.swift index 1e13bc78..1f1e4021 100644 --- a/Sources/TUIkit/Rendering/Renderable.swift +++ b/Sources/TUIkit/Rendering/Renderable.swift @@ -110,6 +110,12 @@ public struct RenderContext { /// A value of 0 means dimmest, 1 means brightest. var pulsePhase: Double = 0 + /// The cursor timer for TextField/SecureField animations. + /// + /// Set by ``RenderLoop`` at the start of each frame. + /// Read by text fields to compute blink and pulse phases. + var cursorTimer: CursorTimer? + /// The focus indicator color for the first border encountered in this subtree. /// /// Set by ``FocusSectionModifier`` when the section is active. diff --git a/Sources/TUIkit/Styling/Palettes/PalettePreset.swift b/Sources/TUIkit/Styling/Palettes/PalettePreset.swift index 53250606..2f6757b3 100644 --- a/Sources/TUIkit/Styling/Palettes/PalettePreset.swift +++ b/Sources/TUIkit/Styling/Palettes/PalettePreset.swift @@ -66,6 +66,7 @@ public struct SystemPalette: Palette { // UI elements public let border: Color public let focusBackground: Color + public let cursorColor: Color // Additional backgrounds public let statusBarBackground: Color @@ -105,6 +106,7 @@ public struct SystemPalette: Palette { // UI elements self.border = Color.hsl(hue, tuning.borderSaturation, tuning.borderLightness) self.focusBackground = Color.hsl(tuning.fgHue, tuning.fgTerSaturation, tuning.focusBgLightness) + self.cursorColor = Color.hsl(tuning.cursorHue, tuning.cursorSaturation, tuning.cursorLightness) } } @@ -152,6 +154,11 @@ private extension SystemPalette { // Focus background let focusBgLightness: Double + + // Cursor + let cursorHue: Double + let cursorSaturation: Double + let cursorLightness: Double } } @@ -173,7 +180,8 @@ private extension SystemPalette.Tuning { errorHue: wrapHue(120 - 105), errorSaturation: 100, errorLightness: 60, infoHue: wrapHue(120 + 45), infoSaturation: 100, infoLightness: 60, borderSaturation: 33, borderLightness: 26, - focusBgLightness: 15 + focusBgLightness: 15, + cursorHue: 120, cursorSaturation: 100, cursorLightness: 70 ) case .amber: @@ -188,7 +196,8 @@ private extension SystemPalette.Tuning { errorHue: wrapHue(40 - 25), errorSaturation: 100, errorLightness: 60, infoHue: wrapHue(40 + 10), infoSaturation: 100, infoLightness: 70, borderSaturation: 33, borderLightness: 26, - focusBgLightness: 12 + focusBgLightness: 12, + cursorHue: 45, cursorSaturation: 100, cursorLightness: 60 ) case .red: @@ -203,7 +212,8 @@ private extension SystemPalette.Tuning { errorHue: 0, errorSaturation: 0, errorLightness: 100, infoHue: 0, infoSaturation: 100, infoLightness: 80, borderSaturation: 33, borderLightness: 26, - focusBgLightness: 15 + focusBgLightness: 15, + cursorHue: 0, cursorSaturation: 100, cursorLightness: 70 ) case .violet: @@ -218,7 +228,8 @@ private extension SystemPalette.Tuning { errorHue: wrapHue(270 + 180), errorSaturation: 85, errorLightness: 65, infoHue: wrapHue(270 - 60), infoSaturation: 70, infoLightness: 70, borderSaturation: 40, borderLightness: 25, - focusBgLightness: 18 + focusBgLightness: 18, + cursorHue: 270, cursorSaturation: 85, cursorLightness: 78 ) case .blue: @@ -233,7 +244,8 @@ private extension SystemPalette.Tuning { errorHue: wrapHue(200 - 185), errorSaturation: 100, errorLightness: 60, infoHue: wrapHue(200 + 5), infoSaturation: 100, infoLightness: 75, borderSaturation: 33, borderLightness: 26, - focusBgLightness: 13 + focusBgLightness: 13, + cursorHue: 200, cursorSaturation: 100, cursorLightness: 60 ) case .white: @@ -248,7 +260,8 @@ private extension SystemPalette.Tuning { errorHue: 0, errorSaturation: 60, errorLightness: 75, infoHue: 210, infoSaturation: 60, infoLightness: 75, borderSaturation: 0, borderLightness: 28, - focusBgLightness: 20 + focusBgLightness: 20, + cursorHue: 0, cursorSaturation: 0, cursorLightness: 100 ) } } diff --git a/Sources/TUIkit/Styling/TextCursorStyle.swift b/Sources/TUIkit/Styling/TextCursorStyle.swift new file mode 100644 index 00000000..cf0403d6 --- /dev/null +++ b/Sources/TUIkit/Styling/TextCursorStyle.swift @@ -0,0 +1,239 @@ +// TUIKit - Terminal UI Kit for Swift +// TextCursorStyle.swift +// +// Created by LAYERED.work +// License: MIT + +// MARK: - TextCursorStyle + +/// Defines the visual appearance and animation of the text cursor in text fields. +/// +/// Use this type with the `.textCursor(_:)` modifier to customize how the cursor +/// appears in ``TextField`` and ``SecureField`` components. +/// +/// ## Cursor Shapes +/// +/// TUIkit provides three cursor shapes optimized for terminal display: +/// +/// | Shape | Character | Description | +/// |-------|-----------|-------------| +/// | `block` | `█` | Full block cursor (default) | +/// | `bar` | `│` | Centered vertical line | +/// | `underscore` | `▁` | Lower one eighth block | +/// +/// ## Animation Styles +/// +/// | Animation | Description | +/// |-----------|-------------| +/// | `none` | Static cursor, no animation | +/// | `blink` | Classic on/off blinking | +/// | `pulse` | Smooth color pulsing between dim and bright | +/// +/// ## Animation Speed +/// +/// | Speed | Multiplier | Cycle Duration | +/// |-------|------------|----------------| +/// | `slow` | 1.5x | ~1.3 seconds | +/// | `regular` | 3x | ~0.67 seconds | +/// | `fast` | 6x | ~0.33 seconds | +/// +/// ## Usage +/// +/// ```swift +/// // Block cursor with pulse animation (default) +/// TextField("Name", text: $name) +/// +/// // Bar cursor with blink animation +/// TextField("Email", text: $email) +/// .textCursor(.bar, animation: .blink) +/// +/// // Fast blinking underscore cursor +/// TextField("Code", text: $code) +/// .textCursor(.underscore, animation: .blink, speed: .fast) +/// +/// // Apply to all text fields in a container +/// VStack { +/// TextField("First", text: $first) +/// TextField("Last", text: $last) +/// } +/// .textCursor(.bar) +/// ``` +public struct TextCursorStyle: Equatable, Sendable { + /// The visual shape of the cursor. + public let shape: Shape + + /// The animation style of the cursor. + public let animation: Animation + + /// The speed of the cursor animation. + public let speed: Speed + + /// Creates a text cursor style with the specified shape, animation, and speed. + /// + /// - Parameters: + /// - shape: The cursor shape. Defaults to `.block`. + /// - animation: The cursor animation. Defaults to `.blink`. + /// - speed: The animation speed. Defaults to `.regular`. + public init(shape: Shape = .block, animation: Animation = .blink, speed: Speed = .regular) { + self.shape = shape + self.animation = animation + self.speed = speed + } +} + +// MARK: - Shape + +extension TextCursorStyle { + /// The visual shape of the text cursor. + public enum Shape: String, CaseIterable, Sendable { + /// Full block cursor (`█`, U+2588). + /// + /// The default cursor shape, providing maximum visibility. + case block + + /// Centered vertical bar cursor (`│`, U+2502). + /// + /// A thin vertical line similar to modern GUI text editors. + case bar + + /// Lower underscore cursor (`▁`, U+2581). + /// + /// A horizontal line at the bottom of the character cell. + case underscore + + /// The Unicode character representing this cursor shape. + public var character: Character { + switch self { + case .block: "█" + case .bar: "│" + case .underscore: "▁" + } + } + } +} + +// MARK: - Animation + +extension TextCursorStyle { + /// The animation style for the text cursor. + public enum Animation: String, CaseIterable, Sendable { + /// No animation. The cursor remains static. + case none + + /// Classic blinking animation. + /// + /// The cursor alternates between visible and invisible at a fixed interval. + case blink + + /// Smooth pulsing animation. + /// + /// The cursor color smoothly transitions between dim and bright, + /// creating a gentle breathing effect. This is the default animation. + case pulse + } +} + +// MARK: - Speed + +extension TextCursorStyle { + /// The speed of the cursor animation. + /// + /// Each speed defines specific cycle durations for blink and pulse animations, + /// controlled by the ``CursorTimer``. + public enum Speed: String, CaseIterable, Sendable { + /// Slow animation. + /// + /// - Blink: 1000ms cycle (500ms on, 500ms off) + /// - Pulse: 1200ms cycle (1.2 second breathing) + case slow + + /// Regular animation (default). + /// + /// - Blink: 660ms cycle (330ms on, 330ms off) + /// - Pulse: 800ms cycle (0.8 second breathing) + case regular + + /// Fast animation. + /// + /// - Blink: 400ms cycle (200ms on, 200ms off) + /// - Pulse: 500ms cycle (0.5 second breathing) + case fast + } +} + +// MARK: - Convenience Initializers + +extension TextCursorStyle { + /// A block cursor with blink animation at regular speed (the default style). + public static let block = TextCursorStyle(shape: .block, animation: .blink, speed: .regular) + + /// A bar cursor with blink animation at regular speed. + public static let bar = TextCursorStyle(shape: .bar, animation: .blink, speed: .regular) + + /// An underscore cursor with blink animation at regular speed. + public static let underscore = TextCursorStyle(shape: .underscore, animation: .blink, speed: .regular) +} + +// MARK: - Environment Key + +/// Environment key for the text cursor style. +private struct TextCursorStyleKey: EnvironmentKey { + static let defaultValue = TextCursorStyle() +} + +extension EnvironmentValues { + /// The text cursor style for text fields. + /// + /// Set this value using the `.textCursor(_:)` modifier: + /// + /// ```swift + /// TextField("Name", text: $name) + /// .textCursor(.bar, animation: .blink) + /// ``` + public var textCursorStyle: TextCursorStyle { + get { self[TextCursorStyleKey.self] } + set { self[TextCursorStyleKey.self] = newValue } + } +} + +// MARK: - View Extension + +extension View { + /// Sets the text cursor style for text fields within this view. + /// + /// Use this modifier to customize the cursor appearance in ``TextField`` + /// and ``SecureField`` components. + /// + /// ```swift + /// TextField("Name", text: $name) + /// .textCursor(.bar) + /// ``` + /// + /// - Parameter style: The cursor style to use. + /// - Returns: A view with the cursor style applied. + public func textCursor(_ style: TextCursorStyle) -> some View { + environment(\.textCursorStyle, style) + } + + /// Sets the text cursor style with separate shape, animation, and speed parameters. + /// + /// Use this modifier when you want to specify shape, animation, and speed: + /// + /// ```swift + /// TextField("Code", text: $code) + /// .textCursor(.underscore, animation: .blink, speed: .fast) + /// ``` + /// + /// - Parameters: + /// - shape: The cursor shape. + /// - animation: The cursor animation. Defaults to `.pulse`. + /// - speed: The animation speed. Defaults to `.regular`. + /// - Returns: A view with the cursor style applied. + public func textCursor( + _ shape: TextCursorStyle.Shape, + animation: TextCursorStyle.Animation = .pulse, + speed: TextCursorStyle.Speed = .regular + ) -> some View { + environment(\.textCursorStyle, TextCursorStyle(shape: shape, animation: animation, speed: speed)) + } +} diff --git a/Sources/TUIkit/Styling/Theme.swift b/Sources/TUIkit/Styling/Theme.swift index 3463d56b..13f07472 100644 --- a/Sources/TUIkit/Styling/Theme.swift +++ b/Sources/TUIkit/Styling/Theme.swift @@ -77,6 +77,12 @@ public protocol Palette: Cyclable { /// Background color for focused list/table rows. var focusBackground: Color { get } + + /// Text cursor color for TextField and SecureField. + /// + /// Defaults to `accent` if not explicitly set. Custom palettes can override + /// this to provide a distinct cursor color independent of the accent. + var cursorColor: Color { get } } // MARK: - Default Palette Implementation @@ -96,6 +102,8 @@ extension Palette { // MARK: - UI Element Defaults public var focusBackground: Color { foregroundTertiary.opacity(0.3) } + + public var cursorColor: Color { accent } } // MARK: - Palette Environment Key diff --git a/Sources/TUIkit/Views/SecureField.swift b/Sources/TUIkit/Views/SecureField.swift index 49175415..9f4343c8 100644 --- a/Sources/TUIkit/Views/SecureField.swift +++ b/Sources/TUIkit/Views/SecureField.swift @@ -181,9 +181,6 @@ private struct _SecureFieldCore: View, Renderable { let isDisabled: Bool let onSubmitAction: (() -> Void)? - /// The cursor character shown when focused. - private let cursorChar: Character = "█" - /// The masking character for password display (U+25CF Black Circle). private let maskChar: Character = "●" @@ -198,6 +195,7 @@ private struct _SecureFieldCore: View, Renderable { let focusManager = context.environment.focusManager let stateStorage = context.tuiContext.stateStorage let palette = context.environment.palette + let cursorStyle = context.environment.textCursorStyle // Determine content width: use available width if explicit frame set, otherwise default // Account for focus indicators (2 chars for ❙ on each side) @@ -249,7 +247,8 @@ private struct _SecureFieldCore: View, Renderable { handler: handler, isFocused: isFocused, palette: palette, - pulsePhase: context.pulsePhase, + cursorStyle: cursorStyle, + cursorTimer: context.cursorTimer, contentWidth: contentWidth ) @@ -261,7 +260,8 @@ private struct _SecureFieldCore: View, Renderable { handler: TextFieldHandler, isFocused: Bool, palette: any Palette, - pulsePhase: Double, + cursorStyle: TextCursorStyle, + cursorTimer: CursorTimer?, contentWidth: Int ) -> String { let textValue = text.wrappedValue @@ -280,6 +280,8 @@ private struct _SecureFieldCore: View, Renderable { cursorPosition: handler.cursorPosition, selectionRange: handler.selectionRange, palette: palette, + cursorStyle: cursorStyle, + cursorTimer: cursorTimer, background: backgroundColor, width: contentWidth ) @@ -293,18 +295,8 @@ private struct _SecureFieldCore: View, Renderable { ) } - // Add medium vertical bars when focused (outside the content area) - if isFocused && !isDisabled { - // Pulse between 35% and 100% accent - let dimAccent = palette.accent.opacity(0.35) - let accentColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase) - - let bar = ANSIRenderer.colorize("❙", foreground: accentColor) - return "\(bar)\(innerContent)\(bar)" - } - - // Unfocused: add space placeholders to maintain alignment - return " \(innerContent) " + // No focus markers needed - the cursor itself indicates focus + return innerContent } /// Builds the prompt content (shown when empty and unfocused). @@ -340,6 +332,8 @@ private struct _SecureFieldCore: View, Renderable { cursorPosition: Int, selectionRange: Range?, palette: any Palette, + cursorStyle: TextCursorStyle, + cursorTimer: CursorTimer?, background: Color, width: Int ) -> String { @@ -360,6 +354,14 @@ private struct _SecureFieldCore: View, Renderable { // The visible window in the text let visibleStart = scrollOffset + // Compute cursor visibility and color based on animation style + let (cursorVisible, cursorColor) = computeCursorState( + baseColor: palette.cursorColor, + animation: cursorStyle.animation, + speed: cursorStyle.speed, + cursorTimer: cursorTimer + ) + // Build output character by character var result = "" var outputWidth = 0 @@ -368,8 +370,18 @@ private struct _SecureFieldCore: View, Renderable { let textIndex = visibleStart + visibleIndex if textIndex == clampedPosition { - // Render cursor - result += ANSIRenderer.colorize(String(cursorChar), foreground: palette.accent, background: background) + if cursorVisible { + // Cursor visible: show cursor character + let cursorChar = cursorStyle.shape.character + result += ANSIRenderer.colorize(String(cursorChar), foreground: cursorColor, background: background) + } else { + // Cursor hidden (blink off): show underlying bullet or space + if textIndex < textLength { + result += ANSIRenderer.colorize(String(maskChar), foreground: palette.foreground, background: background) + } else { + result += ANSIRenderer.colorize(" ", foreground: palette.foreground, background: background) + } + } outputWidth += 1 } else if textIndex < textLength && visibleIndex < width - (textIndex >= clampedPosition ? 0 : 1) { // Render bullet @@ -378,11 +390,11 @@ private struct _SecureFieldCore: View, Renderable { let isSelected = selectionRange.map { textIndex >= $0.lowerBound && textIndex < $0.upperBound } ?? false if isSelected { - // Selection highlight: accent background, foreground contrasts + // Selection highlight: dimmed accent background, foreground contrasts result += ANSIRenderer.colorize( String(maskChar), foreground: palette.background, - background: palette.accent + background: palette.accent.opacity(0.6) ) } else { result += ANSIRenderer.colorize(String(maskChar), foreground: palette.foreground, background: background) @@ -401,4 +413,32 @@ private struct _SecureFieldCore: View, Renderable { return result } + + /// Computes the cursor visibility and color based on the animation style and cursor timer. + /// + /// - Returns: A tuple of (visible, color) where visible indicates if the cursor should be shown. + private func computeCursorState( + baseColor: Color, + animation: TextCursorStyle.Animation, + speed: TextCursorStyle.Speed, + cursorTimer: CursorTimer? + ) -> (visible: Bool, color: Color) { + switch animation { + case .none: + // Static cursor, always visible at full brightness + return (true, baseColor) + + case .blink: + // Classic blink: on/off based on cursor timer + let visible = cursorTimer?.blinkVisible(for: speed) ?? true + return (visible, baseColor) + + case .pulse: + // Smooth pulse: always visible, color varies + let phase = cursorTimer?.pulsePhase(for: speed) ?? 1.0 + let dimColor = baseColor.opacity(0.35) + let color = Color.lerp(dimColor, baseColor, phase: phase) + return (true, color) + } + } } diff --git a/Sources/TUIkit/Views/Spinner.swift b/Sources/TUIkit/Views/Spinner.swift index 8bc0e28a..cc1998af 100644 --- a/Sources/TUIkit/Views/Spinner.swift +++ b/Sources/TUIkit/Views/Spinner.swift @@ -273,7 +273,7 @@ private struct _SpinnerCore: View, Renderable { if !lifecycle.hasAppeared(token: token) { _ = lifecycle.recordAppear(token: token) {} - let triggerNanos: UInt64 = 33_000_000 // 33ms — matches run loop poll rate (~30 FPS) + let triggerNanos: UInt64 = 28_000_000 // 28ms — matches run loop poll rate (~35 FPS) lifecycle.startTask(token: token, priority: .medium) { while !Task.isCancelled { try? await Task.sleep(nanoseconds: triggerNanos) diff --git a/Sources/TUIkit/Views/TextField.swift b/Sources/TUIkit/Views/TextField.swift index dfe793dc..70b13acd 100644 --- a/Sources/TUIkit/Views/TextField.swift +++ b/Sources/TUIkit/Views/TextField.swift @@ -227,9 +227,6 @@ private struct _TextFieldCore: View, Renderable { let isDisabled: Bool let onSubmitAction: (() -> Void)? - /// The cursor character shown when focused. - private let cursorChar: Character = "█" - /// Default visible width for the text field content area. private let defaultContentWidth = 20 @@ -241,6 +238,7 @@ private struct _TextFieldCore: View, Renderable { let focusManager = context.environment.focusManager let stateStorage = context.tuiContext.stateStorage let palette = context.environment.palette + let cursorStyle = context.environment.textCursorStyle // Determine content width: use available width if explicit frame set, otherwise default // Account for focus indicators (2 chars for ❙ on each side) @@ -291,7 +289,8 @@ private struct _TextFieldCore: View, Renderable { handler: handler, isFocused: isFocused, palette: palette, - pulsePhase: context.pulsePhase, + cursorStyle: cursorStyle, + cursorTimer: context.cursorTimer, contentWidth: contentWidth ) @@ -303,7 +302,8 @@ private struct _TextFieldCore: View, Renderable { handler: TextFieldHandler, isFocused: Bool, palette: any Palette, - pulsePhase: Double, + cursorStyle: TextCursorStyle, + cursorTimer: CursorTimer?, contentWidth: Int ) -> String { let textValue = text.wrappedValue @@ -322,6 +322,8 @@ private struct _TextFieldCore: View, Renderable { cursorPosition: handler.cursorPosition, selectionRange: handler.selectionRange, palette: palette, + cursorStyle: cursorStyle, + cursorTimer: cursorTimer, background: backgroundColor, width: contentWidth ) @@ -335,18 +337,8 @@ private struct _TextFieldCore: View, Renderable { ) } - // Add medium vertical bars when focused (outside the content area) - if isFocused && !isDisabled { - // Pulse between 35% and 100% accent - let dimAccent = palette.accent.opacity(0.35) - let accentColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase) - - let bar = ANSIRenderer.colorize("❙", foreground: accentColor) - return "\(bar)\(innerContent)\(bar)" - } - - // Unfocused: add space placeholders to maintain alignment - return " \(innerContent) " + // No focus markers needed - the cursor itself indicates focus + return innerContent } /// Builds the prompt content (shown when empty and unfocused). @@ -381,6 +373,8 @@ private struct _TextFieldCore: View, Renderable { cursorPosition: Int, selectionRange: Range?, palette: any Palette, + cursorStyle: TextCursorStyle, + cursorTimer: CursorTimer?, background: Color, width: Int ) -> String { @@ -401,6 +395,14 @@ private struct _TextFieldCore: View, Renderable { // The visible window in the text let visibleStart = scrollOffset + // Compute cursor visibility and color based on animation style + let (cursorVisible, cursorColor) = computeCursorState( + baseColor: palette.cursorColor, + animation: cursorStyle.animation, + speed: cursorStyle.speed, + cursorTimer: cursorTimer + ) + // Build output character by character var result = "" var outputWidth = 0 @@ -409,8 +411,20 @@ private struct _TextFieldCore: View, Renderable { let textIndex = visibleStart + visibleIndex if textIndex == clampedPosition { - // Render cursor - result += ANSIRenderer.colorize(String(cursorChar), foreground: palette.accent, background: background) + if cursorVisible { + // Cursor visible: show cursor character + let cursorChar = cursorStyle.shape.character + result += ANSIRenderer.colorize(String(cursorChar), foreground: cursorColor, background: background) + } else { + // Cursor hidden (blink off): show underlying character or space + if textIndex < text.count { + let charIndex = text.index(text.startIndex, offsetBy: textIndex) + let char = text[charIndex] + result += ANSIRenderer.colorize(String(char), foreground: palette.foreground, background: background) + } else { + result += ANSIRenderer.colorize(" ", foreground: palette.foreground, background: background) + } + } outputWidth += 1 } else if textIndex < text.count && visibleIndex < width - (textIndex >= clampedPosition ? 0 : 1) { // Render character @@ -421,11 +435,11 @@ private struct _TextFieldCore: View, Renderable { let isSelected = selectionRange.map { textIndex >= $0.lowerBound && textIndex < $0.upperBound } ?? false if isSelected { - // Selection highlight: accent background, foreground contrasts + // Selection highlight: dimmed accent background, foreground contrasts result += ANSIRenderer.colorize( String(char), foreground: palette.background, - background: palette.accent + background: palette.accent.opacity(0.6) ) } else { result += ANSIRenderer.colorize(String(char), foreground: palette.foreground, background: background) @@ -444,4 +458,32 @@ private struct _TextFieldCore: View, Renderable { return result } + + /// Computes the cursor visibility and color based on the animation style and cursor timer. + /// + /// - Returns: A tuple of (visible, color) where visible indicates if the cursor should be shown. + private func computeCursorState( + baseColor: Color, + animation: TextCursorStyle.Animation, + speed: TextCursorStyle.Speed, + cursorTimer: CursorTimer? + ) -> (visible: Bool, color: Color) { + switch animation { + case .none: + // Static cursor, always visible at full brightness + return (true, baseColor) + + case .blink: + // Classic blink: on/off based on cursor timer + let visible = cursorTimer?.blinkVisible(for: speed) ?? true + return (visible, baseColor) + + case .pulse: + // Smooth pulse: always visible, color varies + let phase = cursorTimer?.pulsePhase(for: speed) ?? 1.0 + let dimColor = baseColor.opacity(0.35) + let color = Color.lerp(dimColor, baseColor, phase: phase) + return (true, color) + } + } } diff --git a/Sources/TUIkitExample/Pages/TextFieldPage.swift b/Sources/TUIkitExample/Pages/TextFieldPage.swift index 1310f019..76063906 100644 --- a/Sources/TUIkitExample/Pages/TextFieldPage.swift +++ b/Sources/TUIkitExample/Pages/TextFieldPage.swift @@ -1,4 +1,4 @@ -// 🖥️ TUIKit — Terminal UI Kit for Swift +// TUIKit - Terminal UI Kit for Swift // TextFieldPage.swift // // Created by LAYERED.work @@ -10,42 +10,76 @@ import TUIkit /// /// Shows interactive text field features including: /// - Basic text input with cursor +/// - Cursor styles (block, bar, underscore) +/// - Cursor animations (none, blink, pulse) +/// - Cursor speeds (slow, regular, fast) /// - Cursor navigation (left/right/home/end) /// - Text editing (insert, backspace, delete) /// - onSubmit action /// - Disabled state -/// - Live state display struct TextFieldPage: View { - @State var username: String = "" - @State var email: String = "" + @State var demoText: String = "" @State var searchQuery: String = "" @State var disabledText: String = "Cannot edit" @State var submittedValue: String = "" + @State var cursorShapeIndex: Int = 0 + @State var cursorAnimationIndex: Int = 0 + @State var cursorSpeedIndex: Int = 1 // Start at regular + + private let shapes: [TextCursorStyle.Shape] = [.block, .bar, .underscore] + private let animations: [TextCursorStyle.Animation] = [.none, .blink, .pulse] + private let speeds: [TextCursorStyle.Speed] = [.slow, .regular, .fast] + + private var currentShape: TextCursorStyle.Shape { + shapes[cursorShapeIndex] + } + + private var currentAnimation: TextCursorStyle.Animation { + animations[cursorAnimationIndex] + } + + private var currentSpeed: TextCursorStyle.Speed { + speeds[cursorSpeedIndex] + } + + private var shapeLabel: String { + switch currentShape { + case .block: "█ Block" + case .bar: "│ Bar" + case .underscore: "▁ Underscore" + } + } + + private var animationLabel: String { + switch currentAnimation { + case .none: "Static" + case .blink: "Blink" + case .pulse: "Pulse" + } + } + + private var speedLabel: String { + switch currentSpeed { + case .slow: "Slow" + case .regular: "Regular" + case .fast: "Fast" + } + } + var body: some View { VStack(alignment: .leading, spacing: 1) { - DemoSection("Basic Text Fields") { + DemoSection("Cursor Demo") { VStack(alignment: .leading, spacing: 1) { HStack(spacing: 1) { - Text("Username:").foregroundStyle(.palette.foregroundSecondary) - TextField("Username", text: $username) + Text("Input:").foregroundStyle(.palette.foregroundSecondary) + TextField("Type here...", text: $demoText) } - HStack(spacing: 1) { - Text("Email:").foregroundStyle(.palette.foregroundSecondary) - TextField("Email", text: $email, prompt: Text("you@example.com")) - } - } - } - - DemoSection("With onSubmit") { - VStack(alignment: .leading, spacing: 1) { HStack(spacing: 1) { Text("Search:").foregroundStyle(.palette.foregroundSecondary) TextField("Search", text: $searchQuery) - .onSubmit { - submittedValue = searchQuery - } + .onSubmit { submittedValue = searchQuery } } if !submittedValue.isEmpty { HStack(spacing: 1) { @@ -53,6 +87,7 @@ struct TextFieldPage: View { Text(submittedValue).foregroundStyle(.palette.success) } } + Text("Cursor style set on container, inherited by all fields").dim() } } @@ -65,37 +100,31 @@ struct TextFieldPage: View { } } - DemoSection("Current Values") { - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: 1) { - Text("Username:").foregroundStyle(.palette.foregroundSecondary) - Text(username.isEmpty ? "(empty)" : "\"\(username)\"").foregroundStyle(.palette.accent) - } - HStack(spacing: 1) { - Text("Email:").foregroundStyle(.palette.foregroundSecondary) - Text(email.isEmpty ? "(empty)" : "\"\(email)\"").foregroundStyle(.palette.accent) - } - HStack(spacing: 1) { - Text("Search:").foregroundStyle(.palette.foregroundSecondary) - Text(searchQuery.isEmpty ? "(empty)" : "\"\(searchQuery)\"").foregroundStyle(.palette.accent) + HStack(alignment: .top, spacing: 3) { + DemoSection("Keyboard Controls") { + VStack(alignment: .leading) { + Text("[←] [→] Move cursor left/right").dim() + Text("[Home] [End] Jump to start/end").dim() + Text("[Backspace] Delete before cursor").dim() + Text("[Delete] Delete at cursor").dim() + Text("[Enter] Submit (triggers onSubmit)").dim() + Text("[Tab] Move to next field").dim() } } - } - DemoSection("Keyboard Controls") { - VStack(alignment: .leading) { - Text("Type any character to insert at cursor").dim() - Text("[←] [→] Move cursor left/right").dim() - Text("[Home] [End] Jump to start/end").dim() - Text("[Backspace] Delete before cursor").dim() - Text("[Delete] Delete at cursor").dim() - Text("[Enter] Submit (triggers onSubmit)").dim() - Text("[Tab] Move to next field").dim() + DemoSection("Cursor Settings") { + VStack(alignment: .leading) { + Text("[F1] Shape: Block, Bar, Underscore").dim() + Text("[F2] Animation: Static, Blink, Pulse").dim() + Text("[F3] Speed: Slow, Regular, Fast").dim() + } } } Spacer() } + .textCursor(currentShape, animation: currentAnimation, speed: currentSpeed) + .statusBarItems(cursorStatusBarItems) .appHeader { HStack { Text("TextField Demo").bold().foregroundStyle(.palette.accent) @@ -104,4 +133,18 @@ struct TextFieldPage: View { } } } + + private var cursorStatusBarItems: [any StatusBarItemProtocol] { + [ + StatusBarItem(shortcut: Shortcut.f1, label: shapeLabel) { + cursorShapeIndex = (cursorShapeIndex + 1) % shapes.count + }, + StatusBarItem(shortcut: Shortcut.f2, label: animationLabel) { + cursorAnimationIndex = (cursorAnimationIndex + 1) % animations.count + }, + StatusBarItem(shortcut: Shortcut.f3, label: speedLabel) { + cursorSpeedIndex = (cursorSpeedIndex + 1) % speeds.count + }, + ] + } } diff --git a/Tests/TUIkitTests/TextCursorStyleTests.swift b/Tests/TUIkitTests/TextCursorStyleTests.swift new file mode 100644 index 00000000..f4e52dd3 --- /dev/null +++ b/Tests/TUIkitTests/TextCursorStyleTests.swift @@ -0,0 +1,194 @@ +// TUIKit - Terminal UI Kit for Swift +// TextCursorStyleTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Testing +@testable import TUIkit + +@Suite("TextCursorStyle") +struct TextCursorStyleTests { + // MARK: - Shape Character Tests + + @Test("Block shape returns full block character") + func blockShapeCharacter() { + #expect(TextCursorStyle.Shape.block.character == "█") + } + + @Test("Bar shape returns centered vertical line") + func barShapeCharacter() { + #expect(TextCursorStyle.Shape.bar.character == "│") + } + + @Test("Underscore shape returns lower block") + func underscoreShapeCharacter() { + #expect(TextCursorStyle.Shape.underscore.character == "▁") + } + + // MARK: - Default Values + + @Test("Default style uses block shape with blink animation at regular speed") + func defaultStyle() { + let style = TextCursorStyle() + #expect(style.shape == .block) + #expect(style.animation == .blink) + #expect(style.speed == .regular) + } + + @Test("Static block convenience uses block shape with blink at regular speed") + func staticBlockConvenience() { + let style = TextCursorStyle.block + #expect(style.shape == .block) + #expect(style.animation == .blink) + #expect(style.speed == .regular) + } + + @Test("Static bar convenience uses bar shape with blink at regular speed") + func staticBarConvenience() { + let style = TextCursorStyle.bar + #expect(style.shape == .bar) + #expect(style.animation == .blink) + #expect(style.speed == .regular) + } + + @Test("Static underscore convenience uses underscore shape with blink at regular speed") + func staticUnderscoreConvenience() { + let style = TextCursorStyle.underscore + #expect(style.shape == .underscore) + #expect(style.animation == .blink) + #expect(style.speed == .regular) + } + + // MARK: - Custom Initialization + + @Test("Custom style with bar and blink") + func customStyleBarBlink() { + let style = TextCursorStyle(shape: .bar, animation: .blink) + #expect(style.shape == .bar) + #expect(style.animation == .blink) + } + + @Test("Custom style with underscore and no animation") + func customStyleUnderscoreNone() { + let style = TextCursorStyle(shape: .underscore, animation: .none) + #expect(style.shape == .underscore) + #expect(style.animation == .none) + } + + // MARK: - Equatable + + @Test("Styles with same values are equal") + func equalityWithSameValues() { + let style1 = TextCursorStyle(shape: .bar, animation: .blink) + let style2 = TextCursorStyle(shape: .bar, animation: .blink) + #expect(style1 == style2) + } + + @Test("Styles with different shapes are not equal") + func inequalityWithDifferentShapes() { + let style1 = TextCursorStyle(shape: .block, animation: .pulse) + let style2 = TextCursorStyle(shape: .bar, animation: .pulse) + #expect(style1 != style2) + } + + @Test("Styles with different animations are not equal") + func inequalityWithDifferentAnimations() { + let style1 = TextCursorStyle(shape: .block, animation: .pulse) + let style2 = TextCursorStyle(shape: .block, animation: .blink) + #expect(style1 != style2) + } + + // MARK: - Shape CaseIterable + + @Test("Shape has exactly three cases") + func shapeHasThreeCases() { + #expect(TextCursorStyle.Shape.allCases.count == 3) + } + + @Test("Shape cases are block, bar, underscore") + func shapeCasesCorrect() { + let cases = TextCursorStyle.Shape.allCases + #expect(cases.contains(.block)) + #expect(cases.contains(.bar)) + #expect(cases.contains(.underscore)) + } + + // MARK: - Animation CaseIterable + + @Test("Animation has exactly three cases") + func animationHasThreeCases() { + #expect(TextCursorStyle.Animation.allCases.count == 3) + } + + @Test("Animation cases are none, blink, pulse") + func animationCasesCorrect() { + let cases = TextCursorStyle.Animation.allCases + #expect(cases.contains(.none)) + #expect(cases.contains(.blink)) + #expect(cases.contains(.pulse)) + } + + // MARK: - Speed + + @Test("Speed has exactly three cases") + func speedHasThreeCases() { + #expect(TextCursorStyle.Speed.allCases.count == 3) + } + + @Test("Speed cases are slow, regular, fast") + func speedCasesCorrect() { + let cases = TextCursorStyle.Speed.allCases + #expect(cases.contains(.slow)) + #expect(cases.contains(.regular)) + #expect(cases.contains(.fast)) + } + + @Test("Slow speed has correct blink cycle") + func slowSpeedBlinkCycle() { + #expect(TextCursorStyle.Speed.slow.blinkCycleMs == 1000) + } + + @Test("Regular speed has correct blink cycle") + func regularSpeedBlinkCycle() { + #expect(TextCursorStyle.Speed.regular.blinkCycleMs == 660) + } + + @Test("Fast speed has correct blink cycle") + func fastSpeedBlinkCycle() { + #expect(TextCursorStyle.Speed.fast.blinkCycleMs == 400) + } + + @Test("Slow speed has correct pulse cycle") + func slowSpeedPulseCycle() { + #expect(TextCursorStyle.Speed.slow.pulseCycleMs == 1200) + } + + @Test("Regular speed has correct pulse cycle") + func regularSpeedPulseCycle() { + #expect(TextCursorStyle.Speed.regular.pulseCycleMs == 800) + } + + @Test("Fast speed has correct pulse cycle") + func fastSpeedPulseCycle() { + #expect(TextCursorStyle.Speed.fast.pulseCycleMs == 500) + } + + @Test("Styles with different speeds are not equal") + func inequalityWithDifferentSpeeds() { + let style1 = TextCursorStyle(shape: .block, animation: .pulse, speed: .slow) + let style2 = TextCursorStyle(shape: .block, animation: .pulse, speed: .fast) + #expect(style1 != style2) + } + + // MARK: - Environment Default + + @Test("Environment default is block with blink at regular speed") + func environmentDefaultValue() { + let env = EnvironmentValues() + let style = env.textCursorStyle + #expect(style.shape == .block) + #expect(style.animation == .blink) + #expect(style.speed == .regular) + } +}