mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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) "
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user