Feat: Add Slider and Stepper components with TrackStyle refactor

- Slider: Interactive track control with keyboard navigation (arrow keys, +/-, Home/End)
- Stepper: Increment/decrement control with value or custom callbacks
- TrackStyle: Renamed from ProgressBarStyle, shared between ProgressView and Slider
- TrackRenderer: Extracted utility for track rendering
- 59 new tests (892 total), Example app demo pages for both components
This commit is contained in:
phranck
2026-02-09 21:52:37 +01:00
parent e528e962de
commit bc9841cdc9
16 changed files with 2692 additions and 217 deletions
+161
View File
@@ -0,0 +1,161 @@
// TUIKit - Terminal UI Kit for Swift
// SliderHandler.swift
//
// Created by LAYERED.work
// License: MIT
/// A focus handler for slider components.
///
/// `SliderHandler` manages value changes and keyboard input for `Slider`.
/// It handles:
/// - Increment/decrement via arrow keys or +/-
/// - Jump to min/max via Home/End
/// - Clamping values to the defined range
///
/// ## Keyboard Controls
///
/// | Key | Action |
/// |-----|--------|
/// | `` or `+` | Increment by step |
/// | `` or `-` | Decrement by step |
/// | `Home` | Jump to minimum |
/// | `End` | Jump to maximum |
final class SliderHandler<V: BinaryFloatingPoint>: Focusable where V.Stride: BinaryFloatingPoint {
/// The unique identifier for this focusable element.
let focusID: String
/// The binding to the current value.
var value: Binding<V>
/// The range of valid values.
let bounds: ClosedRange<V>
/// The step size for increment/decrement.
let step: V.Stride
/// Whether this element can currently receive focus.
var canBeFocused: Bool
/// Callback triggered when editing begins or ends.
var onEditingChanged: ((Bool) -> Void)?
/// Whether the slider is currently being edited.
private var isEditing = false
/// Creates a slider handler.
///
/// - Parameters:
/// - focusID: The unique focus identifier.
/// - value: The binding to the current value.
/// - bounds: The range of valid values.
/// - step: The step size for changes.
/// - canBeFocused: Whether this element can receive focus. Defaults to `true`.
init(
focusID: String,
value: Binding<V>,
bounds: ClosedRange<V>,
step: V.Stride,
canBeFocused: Bool = true
) {
self.focusID = focusID
self.value = value
self.bounds = bounds
self.step = step
self.canBeFocused = canBeFocused
}
}
// MARK: - Key Event Handling
extension SliderHandler {
func handleKeyEvent(_ event: KeyEvent) -> Bool {
switch event.key {
case .right, .character("+"), .character("="):
beginEditingIfNeeded()
increment()
return true
case .left, .character("-"), .character("_"):
beginEditingIfNeeded()
decrement()
return true
case .home:
beginEditingIfNeeded()
jumpToMinimum()
return true
case .end:
beginEditingIfNeeded()
jumpToMaximum()
return true
default:
return false
}
}
}
// MARK: - Value Manipulation
extension SliderHandler {
/// Increments the value by the step size.
func increment() {
let newValue = value.wrappedValue + V(step)
value.wrappedValue = min(bounds.upperBound, newValue)
}
/// Decrements the value by the step size.
func decrement() {
let newValue = value.wrappedValue - V(step)
value.wrappedValue = max(bounds.lowerBound, newValue)
}
/// Jumps to the minimum value.
func jumpToMinimum() {
value.wrappedValue = bounds.lowerBound
}
/// Jumps to the maximum value.
func jumpToMaximum() {
value.wrappedValue = bounds.upperBound
}
/// Clamps the current value to the bounds.
func clampValue() {
let clamped = max(bounds.lowerBound, min(bounds.upperBound, value.wrappedValue))
if clamped != value.wrappedValue {
value.wrappedValue = clamped
}
}
}
// MARK: - Editing State
extension SliderHandler {
/// Begins editing if not already editing.
private func beginEditingIfNeeded() {
guard !isEditing else { return }
isEditing = true
onEditingChanged?(true)
}
/// Ends editing if currently editing.
private func endEditingIfNeeded() {
guard isEditing else { return }
isEditing = false
onEditingChanged?(false)
}
}
// MARK: - Focus Lifecycle
extension SliderHandler {
func onFocusReceived() {
clampValue()
}
func onFocusLost() {
endEditingIfNeeded()
}
}
+222
View File
@@ -0,0 +1,222 @@
// TUIKit - Terminal UI Kit for Swift
// StepperHandler.swift
//
// Created by LAYERED.work
// License: MIT
/// A focus handler for stepper components.
///
/// `StepperHandler` manages value changes and keyboard input for `Stepper`.
/// It handles:
/// - Increment/decrement via arrow keys or +/-
/// - Jump to min/max via Home/End (when bounds are defined)
/// - Clamping values to the defined range
///
/// ## Keyboard Controls
///
/// | Key | Action |
/// |-----|--------|
/// | `->` or `+` | Increment by step |
/// | `<-` or `-` | Decrement by step |
/// | `Home` | Jump to minimum (if bounds defined) |
/// | `End` | Jump to maximum (if bounds defined) |
final class StepperHandler<V: Strideable>: Focusable where V.Stride: SignedNumeric {
/// The unique identifier for this focusable element.
let focusID: String
/// The binding to the current value.
var value: Binding<V>
/// The optional range of valid values.
let bounds: ClosedRange<V>?
/// The step size for increment/decrement.
let step: V.Stride
/// Whether this element can currently receive focus.
var canBeFocused: Bool
/// Callback triggered when increment is requested.
var onIncrement: (() -> Void)?
/// Callback triggered when decrement is requested.
var onDecrement: (() -> Void)?
/// Callback triggered when editing begins or ends.
var onEditingChanged: ((Bool) -> Void)?
/// Whether the stepper is currently being edited.
private var isEditing = false
/// Creates a stepper handler with a value binding.
///
/// - Parameters:
/// - focusID: The unique focus identifier.
/// - value: The binding to the current value.
/// - bounds: The optional range of valid values.
/// - step: The step size for changes.
/// - canBeFocused: Whether this element can receive focus. Defaults to `true`.
init(
focusID: String,
value: Binding<V>,
bounds: ClosedRange<V>? = nil,
step: V.Stride,
canBeFocused: Bool = true
) {
self.focusID = focusID
self.value = value
self.bounds = bounds
self.step = step
self.canBeFocused = canBeFocused
}
/// Creates a stepper handler with custom increment/decrement callbacks.
///
/// - Parameters:
/// - focusID: The unique focus identifier.
/// - onIncrement: Callback when increment is requested.
/// - onDecrement: Callback when decrement is requested.
/// - canBeFocused: Whether this element can receive focus. Defaults to `true`.
init(
focusID: String,
onIncrement: (() -> Void)?,
onDecrement: (() -> Void)?,
canBeFocused: Bool = true
) {
self.focusID = focusID
// Create a dummy binding that does nothing
var dummy: V? = nil
self.value = Binding(
get: { dummy ?? (0 as! V) },
set: { dummy = $0 }
)
self.bounds = nil
self.step = 1 as! V.Stride
self.canBeFocused = canBeFocused
self.onIncrement = onIncrement
self.onDecrement = onDecrement
}
}
// MARK: - Key Event Handling
extension StepperHandler {
func handleKeyEvent(_ event: KeyEvent) -> Bool {
switch event.key {
case .right, .character("+"), .character("="):
beginEditingIfNeeded()
increment()
return true
case .left, .character("-"), .character("_"):
beginEditingIfNeeded()
decrement()
return true
case .home:
if bounds != nil {
beginEditingIfNeeded()
jumpToMinimum()
return true
}
return false
case .end:
if bounds != nil {
beginEditingIfNeeded()
jumpToMaximum()
return true
}
return false
default:
return false
}
}
}
// MARK: - Value Manipulation
extension StepperHandler {
/// Increments the value by the step size.
func increment() {
if let onIncrement {
onIncrement()
} else {
let newValue = value.wrappedValue.advanced(by: step)
if let bounds {
value.wrappedValue = min(bounds.upperBound, newValue)
} else {
value.wrappedValue = newValue
}
}
}
/// Decrements the value by the step size.
func decrement() {
if let onDecrement {
onDecrement()
} else {
// For Strideable, we need to negate the step
let negativeStep = (0 as! V.Stride) - step
let newValue = value.wrappedValue.advanced(by: negativeStep)
if let bounds {
value.wrappedValue = max(bounds.lowerBound, newValue)
} else {
value.wrappedValue = newValue
}
}
}
/// Jumps to the minimum value.
func jumpToMinimum() {
guard let bounds else { return }
value.wrappedValue = bounds.lowerBound
}
/// Jumps to the maximum value.
func jumpToMaximum() {
guard let bounds else { return }
value.wrappedValue = bounds.upperBound
}
/// Clamps the current value to the bounds.
func clampValue() {
guard let bounds else { return }
if value.wrappedValue < bounds.lowerBound {
value.wrappedValue = bounds.lowerBound
} else if value.wrappedValue > bounds.upperBound {
value.wrappedValue = bounds.upperBound
}
}
}
// MARK: - Editing State
extension StepperHandler {
/// Begins editing if not already editing.
private func beginEditingIfNeeded() {
guard !isEditing else { return }
isEditing = true
onEditingChanged?(true)
}
/// Ends editing if currently editing.
private func endEditingIfNeeded() {
guard isEditing else { return }
isEditing = false
onEditingChanged?(false)
}
}
// MARK: - Focus Lifecycle
extension StepperHandler {
func onFocusReceived() {
clampValue()
}
func onFocusLost() {
endEditingIfNeeded()
}
}
@@ -0,0 +1,188 @@
// TUIKit - Terminal UI Kit for Swift
// TrackRenderer.swift
//
// Created by LAYERED.work
// License: MIT
// MARK: - Track Renderer
/// Utility for rendering track-based visual indicators.
///
/// `TrackRenderer` provides shared rendering logic for controls that display
/// a visual track, such as `ProgressView` and `Slider`. It supports multiple
/// styles via ``TrackStyle``.
///
/// ## Usage
///
/// ```swift
/// let track = TrackRenderer.render(
/// fraction: 0.5,
/// width: 20,
/// style: .block,
/// filledColor: palette.foregroundSecondary,
/// emptyColor: palette.foregroundTertiary,
/// accentColor: palette.accent
/// )
/// ```
enum TrackRenderer {
/// Renders a track with the specified style and colors.
///
/// - Parameters:
/// - fraction: The completed fraction (0.0 to 1.0).
/// - width: The width in characters.
/// - style: The visual style to use.
/// - filledColor: The color for filled portions.
/// - emptyColor: The color for empty portions.
/// - accentColor: The color for accent elements (e.g., dot head).
/// - Returns: An ANSI-styled string representing the track.
static func render(
fraction: Double,
width: Int,
style: TrackStyle,
filledColor: Color,
emptyColor: Color,
accentColor: Color
) -> String {
guard width > 0 else { return "" }
switch style {
case .block:
return renderSimpleStyle(
fraction: fraction, width: width,
filledChar: "", emptyChar: "",
filledColor: filledColor, emptyColor: emptyColor
)
case .blockFine:
return renderBlockFineStyle(
fraction: fraction, width: width,
filledColor: filledColor, emptyColor: emptyColor
)
case .shade:
return renderSimpleStyle(
fraction: fraction, width: width,
filledChar: "", emptyChar: "",
filledColor: filledColor, emptyColor: emptyColor
)
case .bar:
return renderSimpleStyle(
fraction: fraction, width: width,
filledChar: "", emptyChar: "",
filledColor: filledColor, emptyColor: emptyColor
)
case .dot:
return renderHeadStyle(
fraction: fraction, width: width,
filledChar: "", headChar: "", emptyChar: "",
filledColor: filledColor, headColor: accentColor, emptyColor: emptyColor
)
}
}
}
// MARK: - Private Rendering Methods
extension TrackRenderer {
/// Renders a simple two-character style (filled + empty, no head indicator).
private static func renderSimpleStyle(
fraction: Double,
width: Int,
filledChar: Character,
emptyChar: Character,
filledColor: Color,
emptyColor: Color
) -> String {
let filledCount = Int((fraction * Double(width)).rounded())
let emptyCount = width - filledCount
var result = ""
if filledCount > 0 {
result += ANSIRenderer.colorize(
String(repeating: filledChar, count: filledCount),
foreground: filledColor
)
}
if emptyCount > 0 {
result += ANSIRenderer.colorize(
String(repeating: emptyChar, count: emptyCount),
foreground: emptyColor
)
}
return result
}
/// Renders the `.blockFine` style with sub-character fractional precision.
private static func renderBlockFineStyle(
fraction: Double,
width: Int,
filledColor: Color,
emptyColor: Color
) -> String {
let totalEighths = fraction * Double(width) * 8.0
let fullCells = Int(totalEighths) / 8
let remainderEighths = Int(totalEighths) % 8
let fractionalBlocks: [Character] = ["", "", "", "", "", "", ""]
var result = ""
if fullCells > 0 {
let filledBar = String(repeating: "", count: fullCells)
result += ANSIRenderer.colorize(filledBar, foreground: filledColor)
}
let cellsUsed: Int
if remainderEighths > 0 && fullCells < width {
let partialChar = fractionalBlocks[remainderEighths - 1]
result += ANSIRenderer.colorize(String(partialChar), foreground: filledColor)
cellsUsed = fullCells + 1
} else {
cellsUsed = fullCells
}
let emptyCount = width - cellsUsed
if emptyCount > 0 {
let emptyBar = String(repeating: "", count: emptyCount)
result += ANSIRenderer.colorize(emptyBar, foreground: emptyColor)
}
return result
}
/// Renders a head-indicator style (filled track + head + empty track).
private static func renderHeadStyle(
fraction: Double,
width: Int,
filledChar: Character,
headChar: Character,
emptyChar: Character,
filledColor: Color,
headColor: Color,
emptyColor: Color
) -> String {
let filledCount = Int((fraction * Double(width)).rounded())
var result = ""
let trackCount = max(0, filledCount - 1)
if trackCount > 0 {
result += ANSIRenderer.colorize(
String(repeating: filledChar, count: trackCount),
foreground: filledColor
)
}
if filledCount > 0 && filledCount <= width {
result += ANSIRenderer.colorize(String(headChar), foreground: headColor)
}
let emptyCount = width - max(filledCount, 0)
if emptyCount > 0 {
result += ANSIRenderer.colorize(
String(repeating: emptyChar, count: emptyCount),
foreground: emptyColor
)
}
return result
}
}
+57
View File
@@ -0,0 +1,57 @@
// TUIKit - Terminal UI Kit for Swift
// TrackStyle.swift
//
// Created by LAYERED.work
// License: MIT
// MARK: - Track Style
/// The visual style of a track-based control like ProgressView or Slider.
///
/// TUIKit provides five built-in styles using different Unicode characters:
///
/// ```
/// block:
/// blockFine: (sub-character precision)
/// shade:
/// bar:
/// dot:
/// ```
public enum TrackStyle: Sendable, Equatable {
/// Full block characters (default).
///
/// Uses `` for filled cells and `` for empty cells.
case block
/// Full block characters with sub-character fractional precision.
///
/// Uses `` for filled cells, fractional blocks (``) for the
/// partial cell at the boundary, and `` for empty cells. This gives
/// 8x finer visual resolution than ``block``.
case blockFine
/// Shade characters for a softer, textured look.
///
/// Uses `` (dark shade) for filled and `` (light shade) for empty.
case shade
/// Vertical bar characters with a horizontal line track.
///
/// Uses `` for filled and `` for empty.
case bar
/// Rectangle track with a dot indicator at the progress position.
///
/// Uses `` for filled, `` as the progress head, and `` for empty.
/// The dot head renders in the accent color.
case dot
}
// MARK: - Backwards Compatibility
/// Backwards-compatible type alias for `TrackStyle`.
///
/// Use `TrackStyle` in new code. This alias exists to maintain
/// compatibility with existing code using `ProgressBarStyle`.
@available(*, deprecated, renamed: "TrackStyle")
public typealias ProgressBarStyle = TrackStyle
+27 -190
View File
@@ -1,52 +1,9 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// TUIKit - Terminal UI Kit for Swift
// ProgressView.swift
//
// Created by LAYERED.work
// License: MIT
// MARK: - ProgressBar Style
/// The visual style of a progress bar.
///
/// TUIKit provides five built-in styles using different Unicode characters:
///
/// ```
/// block:
/// blockFine: (sub-character precision)
/// shade:
/// bar:
/// dot:
/// ```
public enum ProgressBarStyle: Sendable, Equatable {
/// Full block characters (default).
///
/// Uses `` for filled cells and `` for empty cells.
case block
/// Full block characters with sub-character fractional precision.
///
/// Uses `` for filled cells, fractional blocks (``) for the
/// partial cell at the boundary, and `` for empty cells. This gives
/// 8× finer visual resolution than ``block``.
case blockFine
/// Shade characters for a softer, textured look.
///
/// Uses `` (dark shade) for filled and `` (light shade) for empty.
case shade
/// Vertical bar characters with a horizontal line track.
///
/// Uses `` for filled and `` for empty.
case bar
/// Rectangle track with a dot indicator at the progress position.
///
/// Uses `` for filled, `` as the progress head, and `` for empty.
/// The dot head renders in the accent color.
case dot
}
// MARK: - ProgressView
/// A view that shows the progress toward completion of a task.
@@ -67,14 +24,14 @@ public enum ProgressBarStyle: Sendable, Equatable {
///
/// ## Styles
///
/// Set the style via the `progressBarStyle(_:)` modifier or pass it directly:
/// Set the style via the `trackStyle(_:)` modifier or pass it directly:
///
/// ```swift
/// ProgressView(value: 0.5)
/// .progressBarStyle(.shade)
/// .trackStyle(.shade)
/// ```
///
/// See ``ProgressBarStyle`` for all available styles.
/// See ``TrackStyle`` for all available styles.
///
/// ## Examples
///
@@ -115,7 +72,7 @@ public struct ProgressView<Label: View, CurrentValueLabel: View>: View {
let fractionCompleted: Double?
/// The visual style of the progress bar.
var style: ProgressBarStyle
var style: TrackStyle
/// The label view displayed above the bar (left-aligned).
let label: Label?
@@ -213,16 +170,25 @@ extension ProgressView {
///
/// ```swift
/// ProgressView(value: 0.5)
/// .progressBarStyle(.shade)
/// .trackStyle(.shade)
/// ```
///
/// - Parameter style: The progress bar style.
/// - Parameter style: The track style.
/// - Returns: A progress view with the specified style.
public func progressBarStyle(_ style: ProgressBarStyle) -> ProgressView {
public func trackStyle(_ style: TrackStyle) -> ProgressView {
var copy = self
copy.style = style
return copy
}
/// Sets the visual style of the progress bar.
///
/// - Parameter style: The progress bar style.
/// - Returns: A progress view with the specified style.
@available(*, deprecated, renamed: "trackStyle(_:)")
public func progressBarStyle(_ style: TrackStyle) -> ProgressView {
trackStyle(style)
}
}
// MARK: - Equatable Conformance
@@ -254,7 +220,7 @@ extension ProgressView {
/// Internal view that handles the actual rendering of ProgressView.
private struct _ProgressViewCore<Label: View, CurrentValueLabel: View>: View, Renderable {
let fractionCompleted: Double?
let style: ProgressBarStyle
let style: TrackStyle
let label: Label?
let currentValueLabel: CurrentValueLabel?
@@ -317,144 +283,15 @@ private struct _ProgressViewCore<Label: View, CurrentValueLabel: View>: View, Re
// MARK: - Bar Line Rendering
/// Renders the progress bar line using the current style.
/// Renders the progress bar line using the shared TrackRenderer.
private func renderBarLine(width: Int, palette: any Palette) -> String {
let barWidth = max(0, width)
let fraction = fractionCompleted ?? 0.0
let filledColor = palette.foregroundSecondary
let emptyColor = palette.foregroundTertiary
switch style {
case .block:
return renderSimpleStyle(
fraction: fraction, barWidth: barWidth,
filledChar: "", emptyChar: "",
filledColor: filledColor, emptyColor: emptyColor
)
case .blockFine:
return renderBlockFineStyle(fraction: fraction, barWidth: barWidth, filledColor: filledColor, emptyColor: emptyColor)
case .shade:
return renderSimpleStyle(
fraction: fraction, barWidth: barWidth,
filledChar: "", emptyChar: "",
filledColor: filledColor, emptyColor: emptyColor
)
case .bar:
return renderSimpleStyle(
fraction: fraction, barWidth: barWidth,
filledChar: "", emptyChar: "",
filledColor: filledColor, emptyColor: emptyColor
)
case .dot:
return renderHeadStyle(
fraction: fraction, barWidth: barWidth,
filledChar: "", headChar: "", emptyChar: "",
filledColor: filledColor, headColor: palette.accent, emptyColor: emptyColor
)
}
}
/// Renders the `.blockFine` style with sub-character fractional precision.
private func renderBlockFineStyle(fraction: Double, barWidth: Int, filledColor: Color, emptyColor: Color) -> String {
guard barWidth > 0 else { return "" }
let totalEighths = fraction * Double(barWidth) * 8.0
let fullCells = Int(totalEighths) / 8
let remainderEighths = Int(totalEighths) % 8
let fractionalBlocks: [Character] = ["", "", "", "", "", "", ""]
var result = ""
if fullCells > 0 {
let filledBar = String(repeating: "", count: fullCells)
result += ANSIRenderer.colorize(filledBar, foreground: filledColor)
}
let cellsUsed: Int
if remainderEighths > 0 && fullCells < barWidth {
let partialChar = fractionalBlocks[remainderEighths - 1]
result += ANSIRenderer.colorize(String(partialChar), foreground: filledColor)
cellsUsed = fullCells + 1
} else {
cellsUsed = fullCells
}
let emptyCount = barWidth - cellsUsed
if emptyCount > 0 {
let emptyBar = String(repeating: "", count: emptyCount)
result += ANSIRenderer.colorize(emptyBar, foreground: emptyColor)
}
return result
}
/// Renders a simple two-character style (filled + empty, no head indicator).
private func renderSimpleStyle(
fraction: Double,
barWidth: Int,
filledChar: Character,
emptyChar: Character,
filledColor: Color,
emptyColor: Color
) -> String {
let filledCount = Int((fraction * Double(barWidth)).rounded())
let emptyCount = barWidth - filledCount
var result = ""
if filledCount > 0 {
result += ANSIRenderer.colorize(
String(repeating: filledChar, count: filledCount),
foreground: filledColor
)
}
if emptyCount > 0 {
result += ANSIRenderer.colorize(
String(repeating: emptyChar, count: emptyCount),
foreground: emptyColor
)
}
return result
}
/// Renders a head-indicator style (filled track + head + empty track).
private func renderHeadStyle(
fraction: Double,
barWidth: Int,
filledChar: Character,
headChar: Character,
emptyChar: Character,
filledColor: Color,
headColor: Color,
emptyColor: Color
) -> String {
guard barWidth > 0 else { return "" }
let filledCount = Int((fraction * Double(barWidth)).rounded())
var result = ""
let trackCount = max(0, filledCount - 1)
if trackCount > 0 {
result += ANSIRenderer.colorize(
String(repeating: filledChar, count: trackCount),
foreground: filledColor
)
}
if filledCount > 0 && filledCount <= barWidth {
result += ANSIRenderer.colorize(String(headChar), foreground: headColor)
}
let emptyCount = barWidth - max(filledCount, 0)
if emptyCount > 0 {
result += ANSIRenderer.colorize(
String(repeating: emptyChar, count: emptyCount),
foreground: emptyColor
)
}
return result
TrackRenderer.render(
fraction: fractionCompleted ?? 0.0,
width: width,
style: style,
filledColor: palette.foregroundSecondary,
emptyColor: palette.foregroundTertiary,
accentColor: palette.accent
)
}
}
+378
View File
@@ -0,0 +1,378 @@
// TUIKit - Terminal UI Kit for Swift
// Slider.swift
//
// Created by LAYERED.work
// License: MIT
import Foundation
// MARK: - Slider
/// A control for selecting a value from a bounded linear range of values.
///
/// A slider displays a visual track that the user can adjust using keyboard
/// controls. The track shows the current position within the range.
///
/// ## Rendering
///
/// ```
/// Unfocused: 50%
/// Focused: 50%
/// ```
///
/// ## Keyboard Controls
///
/// | Key | Action |
/// |-----|--------|
/// | `` or `+` | Increment by step |
/// | `` or `-` | Decrement by step |
/// | `Home` | Jump to minimum |
/// | `End` | Jump to maximum |
///
/// ## Basic Example
///
/// ```swift
/// @State var volume: Double = 0.5
///
/// Slider(value: $volume)
/// ```
///
/// ## With Range and Step
///
/// ```swift
/// @State var brightness: Double = 50
///
/// Slider(value: $brightness, in: 0...100, step: 5)
/// ```
///
/// ## With Title
///
/// ```swift
/// Slider("Volume", value: $volume, in: 0...1)
/// ```
///
/// ## With Editing Callback
///
/// ```swift
/// Slider(value: $volume, in: 0...1) { isEditing in
/// print("Editing: \(isEditing)")
/// }
/// ```
public struct Slider<Label: View, ValueLabel: View>: View {
/// The binding to the current value.
let value: Binding<Double>
/// The range of valid values.
let bounds: ClosedRange<Double>
/// The step size for increment/decrement.
let step: Double
/// The label view describing the slider's purpose.
let label: Label?
/// The value label showing the current value.
let valueLabel: ValueLabel?
/// The visual style of the track.
var trackStyle: TrackStyle
/// The unique focus identifier.
let focusID: String
/// Whether the slider is disabled.
let isDisabled: Bool
/// Callback when editing begins or ends.
let onEditingChanged: ((Bool) -> Void)?
/// Default track width when no explicit frame is set.
private static var defaultTrackWidth: Int { 20 }
public var body: some View {
_SliderCore(
value: value,
bounds: bounds,
step: step,
label: label,
valueLabel: valueLabel,
trackStyle: trackStyle,
focusID: focusID,
isDisabled: isDisabled,
onEditingChanged: onEditingChanged
)
}
}
// MARK: - Slider Initializers (No Label)
extension Slider where Label == EmptyView, ValueLabel == EmptyView {
/// Creates a slider to select a value from a given range.
///
/// - Parameters:
/// - value: The selected value within `bounds`.
/// - bounds: The range of valid values. Defaults to `0...1`.
/// - step: The distance between each valid value. Defaults to `0.01`.
/// - onEditingChanged: A callback for when editing begins and ends.
public init<V: BinaryFloatingPoint>(
value: Binding<V>,
in bounds: ClosedRange<V> = 0...1,
step: V.Stride = 0.01,
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) where V.Stride: BinaryFloatingPoint {
self.value = Binding(
get: { Double(value.wrappedValue) },
set: { value.wrappedValue = V($0) }
)
self.bounds = Double(bounds.lowerBound)...Double(bounds.upperBound)
self.step = Double(step)
self.label = nil
self.valueLabel = nil
self.trackStyle = .block
self.focusID = "slider-\(UUID().uuidString)"
self.isDisabled = false
self.onEditingChanged = onEditingChanged
}
}
// MARK: - Slider Initializers (String Title)
extension Slider where Label == Text, ValueLabel == EmptyView {
/// Creates a slider with a title string.
///
/// - Parameters:
/// - title: The title of the slider.
/// - value: The selected value within `bounds`.
/// - bounds: The range of valid values. Defaults to `0...1`.
/// - step: The distance between each valid value. Defaults to `0.01`.
/// - onEditingChanged: A callback for when editing begins and ends.
public init<S: StringProtocol, V: BinaryFloatingPoint>(
_ title: S,
value: Binding<V>,
in bounds: ClosedRange<V> = 0...1,
step: V.Stride = 0.01,
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) where V.Stride: BinaryFloatingPoint {
self.value = Binding(
get: { Double(value.wrappedValue) },
set: { value.wrappedValue = V($0) }
)
self.bounds = Double(bounds.lowerBound)...Double(bounds.upperBound)
self.step = Double(step)
self.label = Text(String(title))
self.valueLabel = nil
self.trackStyle = .block
self.focusID = "slider-\(title)"
self.isDisabled = false
self.onEditingChanged = onEditingChanged
}
}
// MARK: - Slider Initializers (ViewBuilder Label)
extension Slider where ValueLabel == EmptyView {
/// Creates a slider with a custom label.
///
/// - Parameters:
/// - value: The selected value within `bounds`.
/// - bounds: The range of valid values. Defaults to `0...1`.
/// - step: The distance between each valid value. Defaults to `0.01`.
/// - label: A view describing the purpose of the slider.
/// - onEditingChanged: A callback for when editing begins and ends.
public init<V: BinaryFloatingPoint>(
value: Binding<V>,
in bounds: ClosedRange<V> = 0...1,
step: V.Stride = 0.01,
@ViewBuilder label: () -> Label,
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) where V.Stride: BinaryFloatingPoint {
self.value = Binding(
get: { Double(value.wrappedValue) },
set: { value.wrappedValue = V($0) }
)
self.bounds = Double(bounds.lowerBound)...Double(bounds.upperBound)
self.step = Double(step)
self.label = label()
self.valueLabel = nil
self.trackStyle = .block
self.focusID = "slider-\(UUID().uuidString)"
self.isDisabled = false
self.onEditingChanged = onEditingChanged
}
}
// MARK: - Slider Modifiers
extension Slider {
/// Sets the visual style of the slider track.
///
/// ```swift
/// Slider(value: $volume)
/// .trackStyle(.dot)
/// ```
///
/// - Parameter style: The track style.
/// - Returns: A slider with the specified track style.
public func trackStyle(_ style: TrackStyle) -> Slider {
var copy = self
copy.trackStyle = style
return copy
}
/// Creates a disabled version of this slider.
///
/// - Parameter disabled: Whether the slider is disabled.
/// - Returns: A new slider with the disabled state.
public func disabled(_ disabled: Bool = true) -> Slider {
Slider(
value: value,
bounds: bounds,
step: step,
label: label,
valueLabel: valueLabel,
trackStyle: trackStyle,
focusID: focusID,
isDisabled: disabled,
onEditingChanged: onEditingChanged
)
}
}
// MARK: - Internal Core View
/// Internal view that handles the actual rendering of Slider.
private struct _SliderCore<Label: View, ValueLabel: View>: View, Renderable {
let value: Binding<Double>
let bounds: ClosedRange<Double>
let step: Double
let label: Label?
let valueLabel: ValueLabel?
let trackStyle: TrackStyle
let focusID: String
let isDisabled: Bool
let onEditingChanged: ((Bool) -> Void)?
/// Default track width when no explicit frame is set.
private let defaultTrackWidth = 20
var body: Never {
fatalError("_SliderCore renders via Renderable")
}
func renderToBuffer(context: RenderContext) -> FrameBuffer {
let focusManager = context.environment.focusManager
let stateStorage = context.tuiContext.stateStorage
let palette = context.environment.palette
// Determine track width: use available width minus arrows and value label space
// Layout: [label] [track] [value]
// Focus indicators: 2 chars each side ( + space)
// Arrows: 2 chars each ( + space, space + )
// Value label: ~5 chars (e.g., "100%")
let arrowsWidth = 4 // " " + " "
let focusWidth = 4 // " " on each side when focused (or " " when not)
let valueLabelWidth = 6 // " 100%"
let trackWidth: Int
if context.hasExplicitWidth {
let availableForTrack = context.availableWidth - arrowsWidth - focusWidth - valueLabelWidth
trackWidth = max(5, availableForTrack)
} else {
trackWidth = defaultTrackWidth
}
// Get or create persistent handler from state storage
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0)
let handlerBox: StateBox<SliderHandler<Double>> = stateStorage.storage(
for: handlerKey,
default: SliderHandler(
focusID: focusID,
value: value,
bounds: bounds,
step: step,
canBeFocused: !isDisabled
)
)
let handler = handlerBox.value
// Keep handler in sync with current values
handler.value = value
handler.canBeFocused = !isDisabled
handler.onEditingChanged = onEditingChanged
handler.clampValue()
// Register with focus manager
focusManager.register(handler, inSection: context.activeFocusSectionID)
stateStorage.markActive(context.identity)
// Determine focus state
let isFocused = focusManager.isFocused(id: focusID)
// Calculate fraction
let range = bounds.upperBound - bounds.lowerBound
let fraction = range > 0 ? (value.wrappedValue - bounds.lowerBound) / range : 0
// Build the slider content
let content = buildContent(
fraction: fraction,
isFocused: isFocused,
palette: palette,
pulsePhase: context.pulsePhase,
trackWidth: trackWidth
)
return FrameBuffer(text: content)
}
/// Builds the rendered slider content.
private func buildContent(
fraction: Double,
isFocused: Bool,
palette: any Palette,
pulsePhase: Double,
trackWidth: Int
) -> String {
// Arrow colors
let arrowColor: Color
if isDisabled {
arrowColor = palette.foregroundTertiary
} else if isFocused {
// Pulse between 35% and 100% accent
let dimAccent = palette.accent.opacity(0.35)
arrowColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase)
} else {
arrowColor = palette.foregroundTertiary
}
// Build track
let track = TrackRenderer.render(
fraction: fraction,
width: trackWidth,
style: trackStyle,
filledColor: isDisabled ? palette.foregroundTertiary : palette.foregroundSecondary,
emptyColor: palette.foregroundTertiary,
accentColor: palette.accent
)
// Build arrows
let leftArrow = ANSIRenderer.colorize("", foreground: arrowColor)
let rightArrow = ANSIRenderer.colorize("", foreground: arrowColor)
// Build value label (percentage)
let percentage = Int((fraction * 100).rounded())
let valueText = "\(percentage)%"
let valueLabelColor = isDisabled ? palette.foregroundTertiary : palette.foregroundSecondary
let valueLabel = ANSIRenderer.colorize(valueText, foreground: valueLabelColor)
// Build with focus indicators
if isFocused && !isDisabled {
let dimAccent = palette.accent.opacity(0.35)
let barColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase)
let bar = ANSIRenderer.colorize("", foreground: barColor)
return "\(bar) \(leftArrow) \(track) \(rightArrow) \(bar) \(valueLabel)"
}
// Unfocused: spaces instead of bars for alignment
return " \(leftArrow) \(track) \(rightArrow) \(valueLabel)"
}
}
+386
View File
@@ -0,0 +1,386 @@
// TUIKit - Terminal UI Kit for Swift
// Stepper.swift
//
// Created by LAYERED.work
// License: MIT
import Foundation
// MARK: - Stepper
/// A control that performs increment and decrement actions.
///
/// A stepper displays a value with left and right arrows that the user
/// can use to increment or decrement the value using keyboard controls.
///
/// ## Rendering
///
/// ```
/// Unfocused: 5
/// Focused: 5 (bars + arrows pulsing in accent)
/// ```
///
/// ## Keyboard Controls
///
/// | Key | Action |
/// |-----|--------|
/// | `->` or `+` | Increment by step |
/// | `<-` or `-` | Decrement by step |
/// | `Home` | Jump to minimum (if range defined) |
/// | `End` | Jump to maximum (if range defined) |
///
/// ## Basic Example
///
/// ```swift
/// @State var quantity: Int = 1
///
/// Stepper("Quantity", value: $quantity)
/// ```
///
/// ## With Range and Step
///
/// ```swift
/// @State var rating: Int = 3
///
/// Stepper("Rating", value: $rating, in: 1...5, step: 1)
/// ```
///
/// ## With Custom Callbacks
///
/// ```swift
/// Stepper("Color") {
/// nextColor()
/// } onDecrement: {
/// previousColor()
/// }
/// ```
public struct Stepper<Label: View>: View {
/// The binding to the current value.
let value: Binding<Int>
/// The optional range of valid values.
let bounds: ClosedRange<Int>?
/// The step size for increment/decrement.
let step: Int
/// The label view describing the stepper's purpose.
let label: Label?
/// Custom increment callback.
let onIncrement: (() -> Void)?
/// Custom decrement callback.
let onDecrement: (() -> Void)?
/// The unique focus identifier.
let focusID: String
/// Whether the stepper is disabled.
let isDisabled: Bool
/// Callback when editing begins or ends.
let onEditingChanged: ((Bool) -> Void)?
public var body: some View {
_StepperCore(
value: value,
bounds: bounds,
step: step,
label: label,
onIncrement: onIncrement,
onDecrement: onDecrement,
focusID: focusID,
isDisabled: isDisabled,
onEditingChanged: onEditingChanged
)
}
}
// MARK: - Stepper Initializers (Value Binding)
extension Stepper where Label == Text {
/// Creates a stepper with a title and value binding.
///
/// - Parameters:
/// - title: The title of the stepper.
/// - value: The binding to the current value.
/// - step: The step size. Defaults to `1`.
/// - onEditingChanged: A callback for when editing begins and ends.
public init<S: StringProtocol>(
_ title: S,
value: Binding<Int>,
step: Int = 1,
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) {
self.value = value
self.bounds = nil
self.step = step
self.label = Text(String(title))
self.onIncrement = nil
self.onDecrement = nil
self.focusID = "stepper-\(title)"
self.isDisabled = false
self.onEditingChanged = onEditingChanged
}
/// Creates a stepper with a title, value binding, and range.
///
/// - Parameters:
/// - title: The title of the stepper.
/// - value: The binding to the current value.
/// - bounds: The range of valid values.
/// - step: The step size. Defaults to `1`.
/// - onEditingChanged: A callback for when editing begins and ends.
public init<S: StringProtocol>(
_ title: S,
value: Binding<Int>,
in bounds: ClosedRange<Int>,
step: Int = 1,
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) {
self.value = value
self.bounds = bounds
self.step = step
self.label = Text(String(title))
self.onIncrement = nil
self.onDecrement = nil
self.focusID = "stepper-\(title)"
self.isDisabled = false
self.onEditingChanged = onEditingChanged
}
}
// MARK: - Stepper Initializers (Custom Callbacks)
extension Stepper where Label == Text {
/// Creates a stepper with a title and custom increment/decrement callbacks.
///
/// - Parameters:
/// - title: The title of the stepper.
/// - onIncrement: Callback when increment is requested.
/// - onDecrement: Callback when decrement is requested.
/// - onEditingChanged: A callback for when editing begins and ends.
public init<S: StringProtocol>(
_ title: S,
onIncrement: (() -> Void)?,
onDecrement: (() -> Void)?,
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) {
var dummy = 0
self.value = Binding(get: { dummy }, set: { dummy = $0 })
self.bounds = nil
self.step = 1
self.label = Text(String(title))
self.onIncrement = onIncrement
self.onDecrement = onDecrement
self.focusID = "stepper-\(title)"
self.isDisabled = false
self.onEditingChanged = onEditingChanged
}
}
// MARK: - Stepper Initializers (ViewBuilder Label)
extension Stepper {
/// Creates a stepper with a custom label and value binding.
///
/// - Parameters:
/// - value: The binding to the current value.
/// - step: The step size. Defaults to `1`.
/// - label: A view describing the purpose of the stepper.
/// - onEditingChanged: A callback for when editing begins and ends.
public init(
value: Binding<Int>,
step: Int = 1,
@ViewBuilder label: () -> Label,
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) {
self.value = value
self.bounds = nil
self.step = step
self.label = label()
self.onIncrement = nil
self.onDecrement = nil
self.focusID = "stepper-\(UUID().uuidString)"
self.isDisabled = false
self.onEditingChanged = onEditingChanged
}
/// Creates a stepper with a custom label, value binding, and range.
///
/// - Parameters:
/// - value: The binding to the current value.
/// - bounds: The range of valid values.
/// - step: The step size. Defaults to `1`.
/// - label: A view describing the purpose of the stepper.
/// - onEditingChanged: A callback for when editing begins and ends.
public init(
value: Binding<Int>,
in bounds: ClosedRange<Int>,
step: Int = 1,
@ViewBuilder label: () -> Label,
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) {
self.value = value
self.bounds = bounds
self.step = step
self.label = label()
self.onIncrement = nil
self.onDecrement = nil
self.focusID = "stepper-\(UUID().uuidString)"
self.isDisabled = false
self.onEditingChanged = onEditingChanged
}
/// Creates a stepper with a custom label and increment/decrement callbacks.
///
/// - Parameters:
/// - label: A view describing the purpose of the stepper.
/// - onIncrement: Callback when increment is requested.
/// - onDecrement: Callback when decrement is requested.
/// - onEditingChanged: A callback for when editing begins and ends.
public init(
@ViewBuilder label: () -> Label,
onIncrement: (() -> Void)?,
onDecrement: (() -> Void)?,
onEditingChanged: @escaping (Bool) -> Void = { _ in }
) {
var dummy = 0
self.value = Binding(get: { dummy }, set: { dummy = $0 })
self.bounds = nil
self.step = 1
self.label = label()
self.onIncrement = onIncrement
self.onDecrement = onDecrement
self.focusID = "stepper-\(UUID().uuidString)"
self.isDisabled = false
self.onEditingChanged = onEditingChanged
}
}
// MARK: - Stepper Modifiers
extension Stepper {
/// Creates a disabled version of this stepper.
///
/// - Parameter disabled: Whether the stepper is disabled.
/// - Returns: A new stepper with the disabled state.
public func disabled(_ disabled: Bool = true) -> Stepper {
Stepper(
value: value,
bounds: bounds,
step: step,
label: label,
onIncrement: onIncrement,
onDecrement: onDecrement,
focusID: focusID,
isDisabled: disabled,
onEditingChanged: onEditingChanged
)
}
}
// MARK: - Internal Core View
/// Internal view that handles the actual rendering of Stepper.
private struct _StepperCore<Label: View>: View, Renderable {
let value: Binding<Int>
let bounds: ClosedRange<Int>?
let step: Int
let label: Label?
let onIncrement: (() -> Void)?
let onDecrement: (() -> Void)?
let focusID: String
let isDisabled: Bool
let onEditingChanged: ((Bool) -> Void)?
var body: Never {
fatalError("_StepperCore renders via Renderable")
}
func renderToBuffer(context: RenderContext) -> FrameBuffer {
let focusManager = context.environment.focusManager
let stateStorage = context.tuiContext.stateStorage
let palette = context.environment.palette
// Get or create persistent handler from state storage
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0)
let handlerBox: StateBox<StepperHandler<Int>> = stateStorage.storage(
for: handlerKey,
default: StepperHandler(
focusID: focusID,
value: value,
bounds: bounds,
step: step,
canBeFocused: !isDisabled
)
)
let handler = handlerBox.value
// Keep handler in sync with current values
handler.value = value
handler.canBeFocused = !isDisabled
handler.onIncrement = onIncrement
handler.onDecrement = onDecrement
handler.onEditingChanged = onEditingChanged
handler.clampValue()
// Register with focus manager
focusManager.register(handler, inSection: context.activeFocusSectionID)
stateStorage.markActive(context.identity)
// Determine focus state
let isFocused = focusManager.isFocused(id: focusID)
// Build the stepper content
let content = buildContent(
isFocused: isFocused,
palette: palette,
pulsePhase: context.pulsePhase
)
return FrameBuffer(text: content)
}
/// Builds the rendered stepper content.
private func buildContent(
isFocused: Bool,
palette: any Palette,
pulsePhase: Double
) -> String {
// Arrow and value colors
let arrowColor: Color
let valueColor: Color
if isDisabled {
arrowColor = palette.foregroundTertiary
valueColor = palette.foregroundTertiary
} else if isFocused {
// Pulse between 35% and 100% accent
let dimAccent = palette.accent.opacity(0.35)
arrowColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase)
valueColor = palette.foreground
} else {
arrowColor = palette.foregroundTertiary
valueColor = palette.foregroundSecondary
}
// Build arrows
let leftArrow = ANSIRenderer.colorize("", foreground: arrowColor)
let rightArrow = ANSIRenderer.colorize("", foreground: arrowColor)
// Build value display
let valueText = ANSIRenderer.colorize(" \(value.wrappedValue) ", foreground: valueColor)
// Build with focus indicators
if isFocused && !isDisabled {
let dimAccent = palette.accent.opacity(0.35)
let barColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase)
let bar = ANSIRenderer.colorize("", foreground: barColor)
return "\(bar) \(leftArrow)\(valueText)\(rightArrow) \(bar)"
}
// Unfocused: spaces instead of bars for alignment
return " \(leftArrow)\(valueText)\(rightArrow) "
}
}
+16
View File
@@ -23,6 +23,8 @@ enum DemoPage: Int, CaseIterable {
case spinners
case lists
case tables
case sliders
case steppers
}
// MARK: - Content View (Page Router)
@@ -72,6 +74,14 @@ struct ContentView: View {
// Quick jump to Tables
currentPage = .tables
return true
case .character("["):
// Quick jump to Sliders
currentPage = .sliders
return true
case .character("]"):
// Quick jump to Steppers
currentPage = .steppers
return true
default:
return false // Let other handlers process
}
@@ -123,6 +133,12 @@ struct ContentView: View {
case .tables:
TablePage()
.statusBarItems(subPageItems(pageSetter: pageSetter))
case .sliders:
SliderPage()
.statusBarItems(subPageItems(pageSetter: pageSetter))
case .steppers:
StepperPage()
.statusBarItems(subPageItems(pageSetter: pageSetter))
}
}
@@ -65,6 +65,8 @@ struct MainMenuPage: View {
MenuItem(label: "Spinners", shortcut: "0"),
MenuItem(label: "Lists", shortcut: "-"),
MenuItem(label: "Tables", shortcut: "="),
MenuItem(label: "Sliders", shortcut: "["),
MenuItem(label: "Steppers", shortcut: "]"),
],
selection: $menuSelection,
onSelect: { index in
@@ -0,0 +1,113 @@
// TUIKit - Terminal UI Kit for Swift
// SliderPage.swift
//
// Created by LAYERED.work
// License: MIT
import TUIkit
/// Slider demo page.
///
/// Shows interactive slider features including:
/// - Different track styles (block, shade, dot, bar)
/// - Various ranges and step sizes
/// - Keyboard controls
/// - Live value display
struct SliderPage: View {
@State var volume: Double = 0.5
@State var brightness: Double = 75
@State var rating: Double = 3
@State var precision: Double = 0.5
var body: some View {
VStack(alignment: .leading, spacing: 1) {
DemoSection("Basic Slider (Block Style)") {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 1) {
Text("Volume:").foregroundStyle(.palette.foregroundSecondary)
Slider(value: $volume)
}
}
}
DemoSection("Track Styles") {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 1) {
Text("Block:").foregroundStyle(.palette.foregroundSecondary)
Slider(value: $volume).trackStyle(.block)
}
HStack(spacing: 1) {
Text("Shade:").foregroundStyle(.palette.foregroundSecondary)
Slider(value: $volume).trackStyle(.shade)
}
HStack(spacing: 1) {
Text("Dot: ").foregroundStyle(.palette.foregroundSecondary)
Slider(value: $volume).trackStyle(.dot)
}
HStack(spacing: 1) {
Text("Bar: ").foregroundStyle(.palette.foregroundSecondary)
Slider(value: $volume).trackStyle(.bar)
}
}
}
DemoSection("Custom Ranges") {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 1) {
Text("Brightness (0-100, step 5):").foregroundStyle(.palette.foregroundSecondary)
Slider(value: $brightness, in: 0...100, step: 5)
}
HStack(spacing: 1) {
Text("Rating (1-5, step 1): ").foregroundStyle(.palette.foregroundSecondary)
Slider(value: $rating, in: 1...5, step: 1)
}
HStack(spacing: 1) {
Text("Precision (0-1, step 0.05):").foregroundStyle(.palette.foregroundSecondary)
Slider(value: $precision, in: 0...1, step: 0.05)
}
}
}
DemoSection("Current Values") {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 1) {
Text("Volume:").foregroundStyle(.palette.foregroundSecondary)
Text(String(format: "%.0f%%", volume * 100)).foregroundStyle(.palette.accent)
}
HStack(spacing: 1) {
Text("Brightness:").foregroundStyle(.palette.foregroundSecondary)
Text(String(format: "%.0f", brightness)).foregroundStyle(.palette.accent)
}
HStack(spacing: 1) {
Text("Rating:").foregroundStyle(.palette.foregroundSecondary)
Text(String(format: "%.0f", rating)).foregroundStyle(.palette.accent)
}
HStack(spacing: 1) {
Text("Precision:").foregroundStyle(.palette.foregroundSecondary)
Text(String(format: "%.2f", precision)).foregroundStyle(.palette.accent)
}
}
}
DemoSection("Keyboard Controls") {
VStack(alignment: .leading) {
Text("[<-] [->] Decrease/Increase by step").dim()
Text("[-] [+] Decrease/Increase by step").dim()
Text("[Home] Jump to minimum").dim()
Text("[End] Jump to maximum").dim()
Text("[Tab] Move to next slider").dim()
}
}
Spacer()
}
.appHeader {
HStack {
Text("Slider Demo").bold().foregroundStyle(.palette.accent)
Spacer()
Text("TUIkit v\(tuiKitVersion)").foregroundStyle(.palette.foregroundTertiary)
}
}
}
}
@@ -0,0 +1,109 @@
// TUIKit - Terminal UI Kit for Swift
// StepperPage.swift
//
// Created by LAYERED.work
// License: MIT
import TUIkit
/// Stepper demo page.
///
/// Shows interactive stepper features including:
/// - Basic value stepping
/// - Range constraints
/// - Custom step sizes
/// - Custom callbacks
/// - Keyboard controls
struct StepperPage: View {
@State var quantity: Int = 1
@State var rating: Int = 3
@State var volume: Int = 50
@State var colorIndex: Int = 0
let colors = ["Red", "Green", "Blue", "Yellow", "Purple"]
var body: some View {
VStack(alignment: .leading, spacing: 1) {
DemoSection("Basic Stepper") {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 1) {
Text("Quantity:").foregroundStyle(.palette.foregroundSecondary)
Stepper("Quantity", value: $quantity)
}
}
}
DemoSection("With Range Constraints") {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 1) {
Text("Rating (1-5):").foregroundStyle(.palette.foregroundSecondary)
Stepper("Rating", value: $rating, in: 1...5)
}
HStack(spacing: 1) {
Text("Volume (0-100, step 10):").foregroundStyle(.palette.foregroundSecondary)
Stepper("Volume", value: $volume, in: 0...100, step: 10)
}
}
}
DemoSection("With Custom Callbacks") {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 1) {
Text("Color:").foregroundStyle(.palette.foregroundSecondary)
Stepper(
"Color",
onIncrement: {
colorIndex = (colorIndex + 1) % colors.count
},
onDecrement: {
colorIndex = (colorIndex - 1 + colors.count) % colors.count
}
)
Text(colors[colorIndex]).foregroundStyle(.palette.accent)
}
}
}
DemoSection("Current Values") {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 1) {
Text("Quantity:").foregroundStyle(.palette.foregroundSecondary)
Text("\(quantity)").foregroundStyle(.palette.accent)
}
HStack(spacing: 1) {
Text("Rating:").foregroundStyle(.palette.foregroundSecondary)
Text("\(rating)").foregroundStyle(.palette.accent)
}
HStack(spacing: 1) {
Text("Volume:").foregroundStyle(.palette.foregroundSecondary)
Text("\(volume)").foregroundStyle(.palette.accent)
}
HStack(spacing: 1) {
Text("Color:").foregroundStyle(.palette.foregroundSecondary)
Text(colors[colorIndex]).foregroundStyle(.palette.accent)
}
}
}
DemoSection("Keyboard Controls") {
VStack(alignment: .leading) {
Text("[<-] [->] Decrease/Increase by step").dim()
Text("[-] [+] Decrease/Increase by step").dim()
Text("[Home] Jump to minimum (if range defined)").dim()
Text("[End] Jump to maximum (if range defined)").dim()
Text("[Tab] Move to next stepper").dim()
}
}
Spacer()
}
.appHeader {
HStack {
Text("Stepper Demo").bold().foregroundStyle(.palette.accent)
Spacer()
Text("TUIkit v\(tuiKitVersion)").foregroundStyle(.palette.foregroundTertiary)
}
}
}
}
+269
View File
@@ -0,0 +1,269 @@
// TUIKit - Terminal UI Kit for Swift
// SliderHandlerTests.swift
//
// Created by LAYERED.work
// License: MIT
import Testing
@testable import TUIkit
@MainActor
@Suite("SliderHandler Tests")
struct SliderHandlerTests {
// MARK: - Initialization
@Test("Handler initializes with correct values")
func initializesCorrectly() {
var value = 0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
#expect(handler.focusID == "test")
#expect(handler.value.wrappedValue == 0.5)
#expect(handler.bounds == 0...1)
#expect(handler.step == 0.1)
#expect(handler.canBeFocused == true)
}
// MARK: - Increment/Decrement
@Test("Right arrow increments value by step")
func rightArrowIncrements() {
var value = 0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .right))
#expect(handled == true)
#expect(value == 0.6)
}
@Test("Left arrow decrements value by step")
func leftArrowDecrements() {
var value = 0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .left))
#expect(handled == true)
#expect(value == 0.4)
}
@Test("Plus key increments value")
func plusKeyIncrements() {
var value = 0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .character("+")))
#expect(handled == true)
#expect(value == 0.6)
}
@Test("Minus key decrements value")
func minusKeyDecrements() {
var value = 0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .character("-")))
#expect(handled == true)
#expect(value == 0.4)
}
// MARK: - Bounds Clamping
@Test("Increment clamps at upper bound")
func incrementClampsAtUpperBound() {
var value = 0.95
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
_ = handler.handleKeyEvent(KeyEvent(key: .right))
#expect(value == 1.0)
}
@Test("Decrement clamps at lower bound")
func decrementClampsAtLowerBound() {
var value = 0.05
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
_ = handler.handleKeyEvent(KeyEvent(key: .left))
#expect(value == 0.0)
}
// MARK: - Home/End Keys
@Test("Home key jumps to minimum")
func homeKeyJumpsToMinimum() {
var value = 0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .home))
#expect(handled == true)
#expect(value == 0.0)
}
@Test("End key jumps to maximum")
func endKeyJumpsToMaximum() {
var value = 0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .end))
#expect(handled == true)
#expect(value == 1.0)
}
// MARK: - Custom Range
@Test("Works with custom range 0 to 100")
func worksWithCustomRange() {
var value = 50.0
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...100,
step: 5
)
_ = handler.handleKeyEvent(KeyEvent(key: .right))
#expect(value == 55.0)
_ = handler.handleKeyEvent(KeyEvent(key: .left))
#expect(value == 50.0)
_ = handler.handleKeyEvent(KeyEvent(key: .end))
#expect(value == 100.0)
_ = handler.handleKeyEvent(KeyEvent(key: .home))
#expect(value == 0.0)
}
// MARK: - Unhandled Keys
@Test("Unhandled key returns false")
func unhandledKeyReturnsFalse() {
var value = 0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .character("x")))
#expect(handled == false)
#expect(value == 0.5)
}
@Test("Enter key is not handled")
func enterKeyNotHandled() {
var value = 0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .enter))
#expect(handled == false)
}
// MARK: - Clamp Value
@Test("clampValue fixes out-of-range value")
func clampValueFixesOutOfRange() {
var value = 1.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
handler.clampValue()
#expect(value == 1.0)
}
@Test("clampValue fixes negative value")
func clampValueFixesNegative() {
var value = -0.5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = SliderHandler(
focusID: "test",
value: binding,
bounds: 0...1,
step: 0.1
)
handler.clampValue()
#expect(value == 0.0)
}
}
+194
View File
@@ -0,0 +1,194 @@
// TUIKit - Terminal UI Kit for Swift
// SliderTests.swift
//
// Created by LAYERED.work
// License: MIT
import Testing
@testable import TUIkit
/// Creates a default render context for testing.
private func testContext(width: Int = 40, height: Int = 24) -> RenderContext {
RenderContext(availableWidth: width, availableHeight: height)
}
@MainActor
@Suite("Slider Tests")
struct SliderTests {
// MARK: - Basic Rendering
@Test("Slider renders as single line")
func rendersSingleLine() {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.height == 1)
}
@Test("Slider contains left arrow")
func containsLeftArrow() {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains(""))
}
@Test("Slider contains right arrow")
func containsRightArrow() {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains(""))
}
@Test("Slider shows percentage value")
func showsPercentageValue() {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains("50%"))
}
// MARK: - Track Styles
@Test("Default block style shows filled and empty blocks")
func blockStyleShowsBlocks() {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
let line = buffer.lines[0].stripped
#expect(line.contains(""))
#expect(line.contains(""))
}
@Test("Dot style shows track with dot head")
func dotStyleShowsDotHead() {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
.trackStyle(.dot)
let context = testContext()
let buffer = renderToBuffer(view, context: context)
let line = buffer.lines[0].stripped
#expect(line.contains(""))
#expect(line.contains("") || line.contains(""))
}
@Test("Shade style shows shade characters")
func shadeStyleShowsShadeChars() {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
.trackStyle(.shade)
let context = testContext()
let buffer = renderToBuffer(view, context: context)
let line = buffer.lines[0].stripped
#expect(line.contains(""))
#expect(line.contains(""))
}
// MARK: - Value Display
@Test("0% value shows 0%")
func zeroValueShowsZeroPercent() {
var value = 0.0
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains("0%"))
}
@Test("100% value shows 100%")
func fullValueShowsHundredPercent() {
var value = 1.0
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains("100%"))
}
@Test("Custom range shows correct percentage")
func customRangeShowsCorrectPercentage() {
var value = 50.0
let view = Slider(value: Binding(get: { value }, set: { value = $0 }), in: 0...100)
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains("50%"))
}
// MARK: - Track Width
@Test("Track uses default width without explicit frame")
func usesDefaultWidth() {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
// Default track width is 20, plus arrows, spaces, value label
// Total should be around 30+ characters
#expect(buffer.width > 25)
}
// MARK: - Title Initializer
@Test("Slider with title compiles and renders")
func titleInitializerWorks() {
var value = 0.5
let view = Slider("Volume", value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.height == 1)
#expect(buffer.lines[0].contains("50%"))
}
}
@MainActor
@Suite("Slider Track Style Tests")
struct SliderTrackStyleTests {
@Test("trackStyle modifier changes style")
func trackStyleModifierChangesStyle() {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
.trackStyle(.bar)
let context = testContext()
let buffer = renderToBuffer(view, context: context)
let line = buffer.lines[0].stripped
#expect(line.contains("") || line.contains(""))
}
@Test("All track styles render without crashing")
func allStylesRenderWithoutCrashing() {
let styles: [TrackStyle] = [.block, .blockFine, .shade, .bar, .dot]
for style in styles {
var value = 0.5
let view = Slider(value: Binding(get: { value }, set: { value = $0 }))
.trackStyle(style)
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.height == 1, "Style \(style) should render one line")
#expect(buffer.lines[0].contains(""), "Style \(style) should contain left arrow")
#expect(buffer.lines[0].contains(""), "Style \(style) should contain right arrow")
}
}
}
+348
View File
@@ -0,0 +1,348 @@
// TUIKit - Terminal UI Kit for Swift
// StepperHandlerTests.swift
//
// Created by LAYERED.work
// License: MIT
import Testing
@testable import TUIkit
@MainActor
@Suite("StepperHandler Tests")
struct StepperHandlerTests {
// MARK: - Initialization
@Test("Handler initializes with correct values")
func initializesCorrectly() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
#expect(handler.focusID == "test")
#expect(handler.value.wrappedValue == 5)
#expect(handler.bounds == 0...10)
#expect(handler.step == 1)
#expect(handler.canBeFocused == true)
}
// MARK: - Increment/Decrement
@Test("Right arrow increments value by step")
func rightArrowIncrements() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .right))
#expect(handled == true)
#expect(value == 6)
}
@Test("Left arrow decrements value by step")
func leftArrowDecrements() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .left))
#expect(handled == true)
#expect(value == 4)
}
@Test("Plus key increments value")
func plusKeyIncrements() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .character("+")))
#expect(handled == true)
#expect(value == 6)
}
@Test("Minus key decrements value")
func minusKeyDecrements() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .character("-")))
#expect(handled == true)
#expect(value == 4)
}
// MARK: - Bounds Clamping
@Test("Increment clamps at upper bound")
func incrementClampsAtUpperBound() {
var value = 9
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 2
)
_ = handler.handleKeyEvent(KeyEvent(key: .right))
#expect(value == 10)
}
@Test("Decrement clamps at lower bound")
func decrementClampsAtLowerBound() {
var value = 1
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 2
)
_ = handler.handleKeyEvent(KeyEvent(key: .left))
#expect(value == 0)
}
// MARK: - Home/End Keys
@Test("Home key jumps to minimum")
func homeKeyJumpsToMinimum() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .home))
#expect(handled == true)
#expect(value == 0)
}
@Test("End key jumps to maximum")
func endKeyJumpsToMaximum() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .end))
#expect(handled == true)
#expect(value == 10)
}
@Test("Home key does nothing without bounds")
func homeKeyNoBounds() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: nil,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .home))
#expect(handled == false)
#expect(value == 5)
}
@Test("End key does nothing without bounds")
func endKeyNoBounds() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: nil,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .end))
#expect(handled == false)
#expect(value == 5)
}
// MARK: - Custom Step Size
@Test("Works with custom step size")
func worksWithCustomStep() {
var value = 50
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...100,
step: 10
)
_ = handler.handleKeyEvent(KeyEvent(key: .right))
#expect(value == 60)
_ = handler.handleKeyEvent(KeyEvent(key: .left))
#expect(value == 50)
}
// MARK: - No Bounds
@Test("Works without bounds")
func worksWithoutBounds() {
var value = 0
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: nil,
step: 1
)
_ = handler.handleKeyEvent(KeyEvent(key: .right))
#expect(value == 1)
_ = handler.handleKeyEvent(KeyEvent(key: .left))
_ = handler.handleKeyEvent(KeyEvent(key: .left))
#expect(value == -1)
}
// MARK: - Unhandled Keys
@Test("Unhandled key returns false")
func unhandledKeyReturnsFalse() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .character("x")))
#expect(handled == false)
#expect(value == 5)
}
@Test("Enter key is not handled")
func enterKeyNotHandled() {
var value = 5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
let handled = handler.handleKeyEvent(KeyEvent(key: .enter))
#expect(handled == false)
}
// MARK: - Custom Callbacks
@Test("Custom onIncrement callback is called")
func customOnIncrementCalled() {
var incrementCalled = false
let handler = StepperHandler<Int>(
focusID: "test",
onIncrement: { incrementCalled = true },
onDecrement: nil
)
_ = handler.handleKeyEvent(KeyEvent(key: .right))
#expect(incrementCalled == true)
}
@Test("Custom onDecrement callback is called")
func customOnDecrementCalled() {
var decrementCalled = false
let handler = StepperHandler<Int>(
focusID: "test",
onIncrement: nil,
onDecrement: { decrementCalled = true }
)
_ = handler.handleKeyEvent(KeyEvent(key: .left))
#expect(decrementCalled == true)
}
// MARK: - Clamp Value
@Test("clampValue fixes out-of-range value")
func clampValueFixesOutOfRange() {
var value = 15
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
handler.clampValue()
#expect(value == 10)
}
@Test("clampValue fixes negative value")
func clampValueFixesNegative() {
var value = -5
let binding = Binding(get: { value }, set: { value = $0 })
let handler = StepperHandler(
focusID: "test",
value: binding,
bounds: 0...10,
step: 1
)
handler.clampValue()
#expect(value == 0)
}
}
+157
View File
@@ -0,0 +1,157 @@
// TUIKit - Terminal UI Kit for Swift
// StepperTests.swift
//
// Created by LAYERED.work
// License: MIT
import Testing
@testable import TUIkit
/// Creates a default render context for testing.
private func testContext(width: Int = 40, height: Int = 24) -> RenderContext {
RenderContext(availableWidth: width, availableHeight: height)
}
@MainActor
@Suite("Stepper Tests")
struct StepperTests {
// MARK: - Basic Rendering
@Test("Stepper renders as single line")
func rendersSingleLine() {
var value = 5
let view = Stepper("Count", value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.height == 1)
}
@Test("Stepper contains left arrow")
func containsLeftArrow() {
var value = 5
let view = Stepper("Count", value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains(""))
}
@Test("Stepper contains right arrow")
func containsRightArrow() {
var value = 5
let view = Stepper("Count", value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains(""))
}
@Test("Stepper shows current value")
func showsCurrentValue() {
var value = 42
let view = Stepper("Count", value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains("42"))
}
// MARK: - Different Values
@Test("Stepper shows zero value")
func showsZeroValue() {
var value = 0
let view = Stepper("Count", value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains(" 0 "))
}
@Test("Stepper shows negative value")
func showsNegativeValue() {
var value = -5
let view = Stepper("Count", value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains("-5"))
}
@Test("Stepper shows large value")
func showsLargeValue() {
var value = 999
let view = Stepper("Count", value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.lines[0].contains("999"))
}
// MARK: - Initializers
@Test("Title initializer works")
func titleInitializerWorks() {
var value = 5
let view = Stepper("Quantity", value: Binding(get: { value }, set: { value = $0 }))
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.height == 1)
#expect(buffer.lines[0].contains("5"))
}
@Test("Range initializer works")
func rangeInitializerWorks() {
var value = 5
let view = Stepper("Rating", value: Binding(get: { value }, set: { value = $0 }), in: 1...10)
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.height == 1)
#expect(buffer.lines[0].contains("5"))
}
@Test("ViewBuilder label initializer works")
func viewBuilderLabelWorks() {
var value = 5
let view = Stepper(value: Binding(get: { value }, set: { value = $0 })) {
Text("Custom Label")
}
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.height == 1)
}
@Test("Callback initializer works")
func callbackInitializerWorks() {
var incrementCount = 0
var decrementCount = 0
let view = Stepper(
"Counter",
onIncrement: { incrementCount += 1 },
onDecrement: { decrementCount += 1 }
)
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.height == 1)
}
// MARK: - Custom Step
@Test("Custom step size initializer works")
func customStepWorks() {
var value = 50
let view = Stepper("Volume", value: Binding(get: { value }, set: { value = $0 }), step: 10)
let context = testContext()
let buffer = renderToBuffer(view, context: context)
#expect(buffer.height == 1)
#expect(buffer.lines[0].contains("50"))
}
}
@@ -4,6 +4,44 @@
This plan implements Slider and Stepper, two essential numeric input controls for TUIKit. Both components allow users to adjust values using keyboard navigation. Slider displays a visual track with a thumb indicator, while Stepper shows increment/decrement arrows around a value. Both use consistent focus indicators (pulsing vertical bars) matching TextField. The existing `ProgressBarStyle` will be renamed to `TrackStyle` for reuse across ProgressView and Slider.
## Completed
**2026-02-09** - Slider and Stepper complete with 59 new tests (892 total). TrackStyle refactor done with backwards-compatible typealias. TrackRenderer utility extracted and shared between ProgressView and Slider. Both components feature SwiftUI-conformant API, keyboard controls, focus indicators, and Example app demo pages.
## Checklist
### Phase 1: TrackStyle Refactor
- [x] Rename `ProgressBarStyle` to `TrackStyle`
- [x] Add backwards-compatible typealias
- [x] Extract `TrackRenderer` utility
- [x] Update ProgressView to use TrackRenderer
- [x] Verify ProgressView tests pass
### Phase 2: Slider
- [x] Create SliderHandler class (14 tests)
- [x] Create Slider struct with body: some View
- [x] Implement _SliderCore with Renderable
- [x] Render track with arrows and value display
- [x] Focus indicators (pulsing bars)
- [x] Keyboard controls (arrow keys, +/-, Home/End)
- [x] `.trackStyle(_:)` modifier
- [x] `.disabled()` support
- [x] `onEditingChanged` callback
- [x] SliderPage in Example app (14 tests)
### Phase 3: Stepper
- [x] Create StepperHandler class (19 tests)
- [x] Create Stepper struct with body: some View
- [x] Implement _StepperCore with Renderable
- [x] Render arrows around value
- [x] Focus indicators (pulsing bars + arrows)
- [x] Keyboard controls (arrow keys, +/-, Home/End)
- [x] `init(_ title:, value:, step:)` - basic
- [x] `init(_ title:, value:, in:, step:)` - with range
- [x] `init(_ title:, onIncrement:, onDecrement:)` - callbacks
- [x] `.disabled()` support
- [x] StepperPage in Example app (12 tests)
## Context / Problem
TUIKit currently lacks controls for numeric value adjustment. Users need ways to:
@@ -165,39 +203,39 @@ enum TrackRenderer {
4. Rendering tests for Stepper
5. Example app demo pages
## Checklist
## Original Checklist (archived)
### Phase 1: TrackStyle Refactor
- [ ] Rename `ProgressBarStyle` to `TrackStyle`
- [ ] Add backwards-compatible typealias
- [ ] Extract `TrackRenderer` utility
- [ ] Update ProgressView to use TrackRenderer
- [ ] Verify ProgressView tests pass
- [x] Rename `ProgressBarStyle` to `TrackStyle`
- [x] Add backwards-compatible typealias
- [x] Extract `TrackRenderer` utility
- [x] Update ProgressView to use TrackRenderer
- [x] Verify ProgressView tests pass
### Phase 2: Slider
- [ ] Create SliderHandler class
- [ ] Create Slider struct with body: some View
- [ ] Implement _SliderCore with Renderable
- [ ] Render track with ◀ ▶ arrows
- [ ] Render value label (percentage or custom)
- [ ] Focus indicators (pulsing bars)
- [ ] Keyboard: ← → for increment/decrement
- [ ] Keyboard: - + for increment/decrement
- [ ] Keyboard: Home/End for min/max
- [ ] `.trackStyle(_:)` modifier
- [ ] `.disabled()` support
- [ ] `onEditingChanged` callback
- [ ] Default width + `.frame(width:)` support
- [x] Create SliderHandler class
- [x] Create Slider struct with body: some View
- [x] Implement _SliderCore with Renderable
- [x] Render track with arrows
- [x] Render value label (percentage or custom)
- [x] Focus indicators (pulsing bars)
- [x] Keyboard: arrow keys for increment/decrement
- [x] Keyboard: - + for increment/decrement
- [x] Keyboard: Home/End for min/max
- [x] `.trackStyle(_:)` modifier
- [x] `.disabled()` support
- [x] `onEditingChanged` callback
- [x] Default width + `.frame(width:)` support
### Phase 3: Stepper
- [ ] Create StepperHandler class
- [ ] Create Stepper struct with body: some View
- [ ] Implement _StepperCore with Renderable
- [ ] Render value
- [ ] Focus indicators (pulsing bars + arrows)
- [ ] Keyboard: ← → for increment/decrement
- [ ] Keyboard: - + for increment/decrement
- [ ] Keyboard: Home/End for min/max (when range defined)
- [x] Create StepperHandler class
- [x] Create Stepper struct with body: some View
- [x] Implement _StepperCore with Renderable
- [x] Render arrows around value
- [x] Focus indicators (pulsing bars + arrows)
- [x] Keyboard: arrow keys for increment/decrement
- [x] Keyboard: - + for increment/decrement
- [x] Keyboard: Home/End for min/max (when range defined)
- [ ] `init(_ title:, value:, step:)` - basic
- [ ] `init(_ title:, value:, in:, step:)` - with range
- [ ] `init(_ title:, onIncrement:, onDecrement:)` - callbacks