mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
Merge pull request #79 from phranck/feature/progress-view
Feat: Add ProgressView with 5 bar styles
This commit is contained in:
@@ -296,18 +296,26 @@ public extension Color {
|
||||
|
||||
/// Returns a lighter version of this color.
|
||||
///
|
||||
/// - Parameter amount: The amount to lighten (0-1, default 0.2).
|
||||
/// - Returns: A lighter color.
|
||||
func lighter(by amount: Double = 0.2) -> Self {
|
||||
adjusted(by: amount)
|
||||
/// The percentage is relative to the remaining lightness headroom.
|
||||
/// For example, a color with HSL lightness 60 lightened by 0.5 (50%)
|
||||
/// moves halfway toward 100: `60 + (100 − 60) × 0.5 = 80`.
|
||||
///
|
||||
/// - Parameter percentage: The fraction to lighten (0–1, default 0.2 = 20%).
|
||||
/// - Returns: A lighter color with preserved hue and saturation.
|
||||
func lighter(by percentage: Double = 0.2) -> Self {
|
||||
adjusted(by: percentage)
|
||||
}
|
||||
|
||||
/// Returns a darker version of this color.
|
||||
///
|
||||
/// - Parameter amount: The amount to darken (0-1, default 0.2).
|
||||
/// - Returns: A darker color.
|
||||
func darker(by amount: Double = 0.2) -> Self {
|
||||
adjusted(by: -amount)
|
||||
/// The percentage is relative to the current lightness.
|
||||
/// For example, a color with HSL lightness 60 darkened by 0.5 (50%)
|
||||
/// moves halfway toward 0: `60 × (1 − 0.5) = 30`.
|
||||
///
|
||||
/// - Parameter percentage: The fraction to darken (0–1, default 0.2 = 20%).
|
||||
/// - Returns: A darker color with preserved hue and saturation.
|
||||
func darker(by percentage: Double = 0.2) -> Self {
|
||||
adjusted(by: -percentage)
|
||||
}
|
||||
|
||||
/// Returns a color with adjusted opacity (simulated via color mixing).
|
||||
@@ -441,24 +449,37 @@ private extension Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjusts a color's lightness by the given amount in HSL space.
|
||||
/// Adjusts a color's lightness by a relative percentage in HSL space.
|
||||
///
|
||||
/// Positive values lighten, negative values darken. Converts to HSL first,
|
||||
/// adjusts only the lightness component, then converts back to RGB.
|
||||
/// This preserves hue and saturation, preventing colors from shifting
|
||||
/// toward gray when lightened or darkened.
|
||||
/// Positive values lighten (move toward 100), negative values darken
|
||||
/// (move toward 0). The adjustment is **relative** to the current position:
|
||||
///
|
||||
/// - Parameter amount: The lightness adjustment (-1 to 1).
|
||||
/// - Returns: The adjusted color as RGB, or self if semantic (unresolved).
|
||||
func adjusted(by amount: Double) -> Self {
|
||||
/// - Lighten: `newLightness = lightness + (100 − lightness) × percentage`
|
||||
/// - Darken: `newLightness = lightness × (1 − |percentage|)`
|
||||
///
|
||||
/// This means 0.5 always moves halfway to the target extreme, regardless
|
||||
/// of the starting lightness. Hue and saturation are preserved.
|
||||
///
|
||||
/// - Parameter percentage: The relative adjustment (−1 to 1).
|
||||
/// - Returns: The adjusted color as HSL, or self if semantic (unresolved).
|
||||
func adjusted(by percentage: Double) -> Self {
|
||||
guard let (red, green, blue) = rgbComponents else {
|
||||
return self
|
||||
}
|
||||
|
||||
let (hue, saturation, lightness) = Self.rgbToHSL(red: red, green: green, blue: blue)
|
||||
let newLightness = min(100, max(0, lightness + amount * 100))
|
||||
let clamped = min(1.0, max(-1.0, percentage))
|
||||
|
||||
return .hsl(hue, saturation, newLightness)
|
||||
let newLightness: Double
|
||||
if clamped >= 0 {
|
||||
// Lighten: move toward 100
|
||||
newLightness = lightness + (100.0 - lightness) * clamped
|
||||
} else {
|
||||
// Darken: move toward 0
|
||||
newLightness = lightness * (1.0 + clamped)
|
||||
}
|
||||
|
||||
return .hsl(hue, saturation, min(100, max(0, newLightness)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
// 🖥️ TUIKit — Terminal UI Kit for Swift
|
||||
// ProgressView.swift
|
||||
//
|
||||
// Created by LAYERED.work
|
||||
// CC BY-NC-SA 4.0
|
||||
|
||||
// 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.
|
||||
///
|
||||
/// `ProgressView` renders a horizontal bar using Unicode block characters.
|
||||
/// It matches SwiftUI's determinate progress API with `value` and `total`
|
||||
/// parameters.
|
||||
///
|
||||
/// ## Visual Output
|
||||
///
|
||||
/// ```
|
||||
/// Downloading 50%
|
||||
/// ████████████████▌░░░░░░░░░░░░░░░
|
||||
/// ```
|
||||
///
|
||||
/// - **Line 1** (optional): Label (left-aligned) + CurrentValueLabel (right-aligned)
|
||||
/// - **Line 2**: Progress bar
|
||||
///
|
||||
/// ## Styles
|
||||
///
|
||||
/// Set the style via the `progressBarStyle(_:)` modifier or pass it directly:
|
||||
///
|
||||
/// ```swift
|
||||
/// ProgressView(value: 0.5)
|
||||
/// .progressBarStyle(.shade)
|
||||
/// ```
|
||||
///
|
||||
/// See ``ProgressBarStyle`` for all available styles.
|
||||
///
|
||||
/// ## Examples
|
||||
///
|
||||
/// ```swift
|
||||
/// // Simple progress bar (50%)
|
||||
/// ProgressView(value: 0.5)
|
||||
///
|
||||
/// // With total
|
||||
/// ProgressView(value: 3, total: 10)
|
||||
///
|
||||
/// // With string title
|
||||
/// ProgressView("Loading...", value: 0.75)
|
||||
///
|
||||
/// // With label and current value label
|
||||
/// ProgressView(value: 0.5) {
|
||||
/// Text("Downloading")
|
||||
/// } currentValueLabel: {
|
||||
/// Text("50%")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## Colors
|
||||
///
|
||||
/// | Part | Color |
|
||||
/// |------|-------|
|
||||
/// | Filled bar | `palette.foregroundSecondary` |
|
||||
/// | Empty bar | `palette.foregroundTertiary` |
|
||||
/// | Dot head (`.dot` style only) | `palette.accent` |
|
||||
/// | Label | inherited from label view |
|
||||
/// | CurrentValueLabel | inherited from value label view |
|
||||
///
|
||||
/// ## Size Behavior
|
||||
///
|
||||
/// The bar fills the full `availableWidth`. When a label or currentValueLabel
|
||||
/// is provided, the view is 2 lines tall; otherwise 1 line.
|
||||
public struct ProgressView<Label: View, CurrentValueLabel: View>: View {
|
||||
/// The normalized fraction completed (0.0–1.0), or nil for indeterminate.
|
||||
let fractionCompleted: Double?
|
||||
|
||||
/// The visual style of the progress bar.
|
||||
let style: ProgressBarStyle
|
||||
|
||||
/// The label view displayed above the bar (left-aligned).
|
||||
let label: Label?
|
||||
|
||||
/// The current value label displayed above the bar (right-aligned).
|
||||
let currentValueLabel: CurrentValueLabel?
|
||||
|
||||
public var body: Never {
|
||||
fatalError("ProgressView renders via Renderable")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initializers (value/total)
|
||||
|
||||
extension ProgressView where Label == EmptyView, CurrentValueLabel == EmptyView {
|
||||
/// Creates a progress view with a fractional completion value.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The completed amount (nil for indeterminate).
|
||||
/// - total: The total amount (default: 1.0).
|
||||
public init<V: BinaryFloatingPoint>(value: V?, total: V = 1.0) {
|
||||
self.fractionCompleted = ProgressView.normalizedFraction(value: value, total: total)
|
||||
self.style = .block
|
||||
self.label = nil
|
||||
self.currentValueLabel = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ProgressView where CurrentValueLabel == EmptyView {
|
||||
/// Creates a progress view with a label.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The completed amount (nil for indeterminate).
|
||||
/// - total: The total amount (default: 1.0).
|
||||
/// - label: A view that describes the task in progress.
|
||||
public init<V: BinaryFloatingPoint>(
|
||||
value: V?, total: V = 1.0,
|
||||
@ViewBuilder label: () -> Label
|
||||
) {
|
||||
self.fractionCompleted = ProgressView.normalizedFraction(value: value, total: total)
|
||||
self.style = .block
|
||||
self.label = label()
|
||||
self.currentValueLabel = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ProgressView {
|
||||
/// Creates a progress view with a label and current value label.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The completed amount (nil for indeterminate).
|
||||
/// - total: The total amount (default: 1.0).
|
||||
/// - label: A view that describes the task in progress.
|
||||
/// - currentValueLabel: A view showing the current progress value.
|
||||
public init<V: BinaryFloatingPoint>(
|
||||
value: V?, total: V = 1.0,
|
||||
@ViewBuilder label: () -> Label,
|
||||
@ViewBuilder currentValueLabel: () -> CurrentValueLabel
|
||||
) {
|
||||
self.fractionCompleted = ProgressView.normalizedFraction(value: value, total: total)
|
||||
self.style = .block
|
||||
self.label = label()
|
||||
self.currentValueLabel = currentValueLabel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String Title Initializer
|
||||
|
||||
extension ProgressView where Label == Text, CurrentValueLabel == EmptyView {
|
||||
/// Creates a progress view with a string title.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - title: A string that describes the task in progress.
|
||||
/// - value: The completed amount (nil for indeterminate).
|
||||
/// - total: The total amount (default: 1.0).
|
||||
public init<S: StringProtocol, V: BinaryFloatingPoint>(
|
||||
_ title: S, value: V?, total: V = 1.0
|
||||
) {
|
||||
self.fractionCompleted = ProgressView.normalizedFraction(value: value, total: total)
|
||||
self.style = .block
|
||||
self.label = Text(String(title))
|
||||
self.currentValueLabel = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Style Modifier
|
||||
|
||||
extension ProgressView {
|
||||
/// Sets the visual style of the progress bar.
|
||||
///
|
||||
/// ```swift
|
||||
/// ProgressView(value: 0.5)
|
||||
/// .progressBarStyle(.shade)
|
||||
/// ```
|
||||
///
|
||||
/// - Parameter style: The progress bar style.
|
||||
/// - Returns: A progress view with the specified style.
|
||||
public func progressBarStyle(_ style: ProgressBarStyle) -> Self {
|
||||
var copy = self
|
||||
copy = ProgressView(
|
||||
fractionCompleted: fractionCompleted,
|
||||
style: style,
|
||||
label: label,
|
||||
currentValueLabel: currentValueLabel
|
||||
)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable Conformance
|
||||
|
||||
extension ProgressView: Equatable where Label: Equatable, CurrentValueLabel: Equatable {}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
extension ProgressView: Renderable {
|
||||
func renderToBuffer(context: RenderContext) -> FrameBuffer {
|
||||
let palette = context.environment.palette
|
||||
let width = context.availableWidth
|
||||
var lines: [String] = []
|
||||
|
||||
// Label line (optional): label left, currentValueLabel right
|
||||
let hasLabel = label != nil && !(label is EmptyView)
|
||||
let hasValueLabel = currentValueLabel != nil && !(currentValueLabel is EmptyView)
|
||||
|
||||
if hasLabel || hasValueLabel {
|
||||
lines.append(
|
||||
renderLabelLine(
|
||||
width: width,
|
||||
palette: palette,
|
||||
context: context
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Progress bar line
|
||||
lines.append(renderBarLine(width: width, palette: palette))
|
||||
|
||||
return FrameBuffer(lines: lines)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Rendering Helpers
|
||||
|
||||
private extension ProgressView {
|
||||
/// Renders the label line with label left-aligned and currentValueLabel right-aligned.
|
||||
func renderLabelLine(width: Int, palette: any Palette, context: RenderContext) -> String {
|
||||
let labelBuffer: FrameBuffer
|
||||
if let labelView = label, !(labelView is EmptyView) {
|
||||
labelBuffer = TUIkit.renderToBuffer(labelView, context: context)
|
||||
} else {
|
||||
labelBuffer = FrameBuffer()
|
||||
}
|
||||
|
||||
let valueBuffer: FrameBuffer
|
||||
if let valueView = currentValueLabel, !(valueView is EmptyView) {
|
||||
valueBuffer = TUIkit.renderToBuffer(valueView, context: context)
|
||||
} else {
|
||||
valueBuffer = FrameBuffer()
|
||||
}
|
||||
|
||||
let labelText = labelBuffer.lines.first ?? ""
|
||||
let valueText = valueBuffer.lines.first ?? ""
|
||||
|
||||
let labelWidth = labelText.strippedLength
|
||||
let valueWidth = valueText.strippedLength
|
||||
let gap = max(1, width - labelWidth - valueWidth)
|
||||
|
||||
return labelText + String(repeating: " ", count: gap) + valueText
|
||||
}
|
||||
|
||||
/// Renders the progress bar line using the current style.
|
||||
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.
|
||||
///
|
||||
/// Uses full blocks (`█`) for completed cells, one of 7 fractional
|
||||
/// blocks (`▉▊▋▌▍▎▏`) for the boundary cell, and light shade (`░`)
|
||||
/// for empty cells. This gives 8× finer visual resolution.
|
||||
func renderBlockFineStyle(fraction: Double, barWidth: Int, filledColor: Color, emptyColor: Color) -> String {
|
||||
guard barWidth > 0 else { return "" }
|
||||
|
||||
// Total progress in 1/8th units across the full bar width
|
||||
let totalEighths = fraction * Double(barWidth) * 8.0
|
||||
let fullCells = Int(totalEighths) / 8
|
||||
let remainderEighths = Int(totalEighths) % 8
|
||||
|
||||
// Fractional block characters indexed by 1/8th increments (1–7)
|
||||
// Index 0 is unused (0 eighths = no partial block)
|
||||
let fractionalBlocks: [Character] = ["▏", "▎", "▍", "▌", "▋", "▊", "▉"]
|
||||
|
||||
var result = ""
|
||||
|
||||
// Full filled blocks
|
||||
if fullCells > 0 {
|
||||
let filledBar = String(repeating: "█", count: fullCells)
|
||||
result += ANSIRenderer.colorize(filledBar, foreground: filledColor)
|
||||
}
|
||||
|
||||
// Fractional block at the boundary
|
||||
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
|
||||
}
|
||||
|
||||
// Empty blocks
|
||||
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).
|
||||
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).
|
||||
///
|
||||
/// The head uses a distinct color (typically `accent`) to stand out
|
||||
/// from the filled track.
|
||||
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 = ""
|
||||
|
||||
// Filled track (before head)
|
||||
let trackCount = max(0, filledCount - 1)
|
||||
if trackCount > 0 {
|
||||
result += ANSIRenderer.colorize(
|
||||
String(repeating: filledChar, count: trackCount),
|
||||
foreground: filledColor
|
||||
)
|
||||
}
|
||||
|
||||
// Head indicator
|
||||
if filledCount > 0 && filledCount <= barWidth {
|
||||
result += ANSIRenderer.colorize(String(headChar), foreground: headColor)
|
||||
} else if filledCount == 0 {
|
||||
// 0% — no head, all empty
|
||||
}
|
||||
|
||||
// Empty track (after head)
|
||||
let emptyCount = barWidth - max(filledCount, 0)
|
||||
if emptyCount > 0 {
|
||||
result += ANSIRenderer.colorize(
|
||||
String(repeating: emptyChar, count: emptyCount),
|
||||
foreground: emptyColor
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Normalizes value/total to a 0.0–1.0 fraction, clamping out-of-range values.
|
||||
static func normalizedFraction<V: BinaryFloatingPoint>(value: V?, total: V) -> Double? {
|
||||
guard let value else { return nil }
|
||||
guard total > 0 else { return 0.0 }
|
||||
return min(1.0, max(0.0, Double(value) / Double(total)))
|
||||
}
|
||||
}
|
||||
@@ -81,13 +81,58 @@ struct SettingsAndAlignmentRow: View, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static row showing ProgressView examples.
|
||||
///
|
||||
/// Purely palette-driven, no state — wrapped in `.equatable()` for
|
||||
/// subtree memoization during Spinner/Pulse animation frames.
|
||||
/// Static row showing ProgressView examples with all 6 styles.
|
||||
struct ProgressViewRow: View, Equatable {
|
||||
var body: some View {
|
||||
DemoSection("ProgressView") {
|
||||
VStack(spacing: 1) {
|
||||
ProgressView("Downloading files...", value: 0.73)
|
||||
|
||||
ProgressView(value: 0.4) {
|
||||
Text("Build progress").foregroundColor(.palette.foreground)
|
||||
} currentValueLabel: {
|
||||
Text("40%").foregroundColor(.palette.foregroundSecondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Styles:").dim()
|
||||
HStack(spacing: 1) {
|
||||
Text("block ").dim()
|
||||
ProgressView(value: 0.6).progressBarStyle(.block)
|
||||
}
|
||||
HStack(spacing: 1) {
|
||||
Text("blockFine").dim()
|
||||
ProgressView(value: 0.6).progressBarStyle(.blockFine)
|
||||
}
|
||||
HStack(spacing: 1) {
|
||||
Text("shade ").dim()
|
||||
ProgressView(value: 0.6).progressBarStyle(.shade)
|
||||
}
|
||||
HStack(spacing: 1) {
|
||||
Text("bar ").dim()
|
||||
ProgressView(value: 0.6).progressBarStyle(.bar)
|
||||
}
|
||||
HStack(spacing: 1) {
|
||||
Text("dot ").dim()
|
||||
ProgressView(value: 0.6).progressBarStyle(.dot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Container views demo page.
|
||||
///
|
||||
/// Shows various container views including:
|
||||
/// - Card (bordered container with padding)
|
||||
/// - Box (simple bordered container)
|
||||
/// - Panel (container with title in border)
|
||||
/// - ContainerView (with header and footer)
|
||||
/// - ProgressView (horizontal progress bar)
|
||||
/// - Collapsible detail section demonstrating `@State` toggle
|
||||
struct ContainersPage: View {
|
||||
@State var showDetails: Bool = false
|
||||
@@ -96,6 +141,7 @@ struct ContainersPage: View {
|
||||
VStack(spacing: 1) {
|
||||
ContainerTypesRow().equatable()
|
||||
SettingsAndAlignmentRow().equatable()
|
||||
ProgressViewRow().equatable()
|
||||
|
||||
DemoSection("Collapsible Detail (@State)") {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
// 🖥️ TUIKit — Terminal UI Kit for Swift
|
||||
// ProgressViewTests.swift
|
||||
//
|
||||
// Created by LAYERED.work
|
||||
// CC BY-NC-SA 4.0
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import TUIkit
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// Creates a default render context for testing.
|
||||
private func testContext(width: Int = 30, height: Int = 24) -> RenderContext {
|
||||
RenderContext(availableWidth: width, availableHeight: height)
|
||||
}
|
||||
|
||||
// MARK: - ProgressView Rendering Tests
|
||||
|
||||
@Suite("ProgressView Tests")
|
||||
struct ProgressViewTests {
|
||||
|
||||
@Test("Progress bar renders single line without label")
|
||||
func barOnlyIsSingleLine() {
|
||||
let view = ProgressView(value: 0.5)
|
||||
let context = testContext()
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
#expect(buffer.height == 1)
|
||||
#expect(buffer.width == 30)
|
||||
}
|
||||
|
||||
@Test("Progress bar with label renders two lines")
|
||||
func barWithLabelIsTwoLines() {
|
||||
let view = ProgressView("Loading", value: 0.5)
|
||||
let context = testContext()
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
#expect(buffer.height == 2)
|
||||
#expect(buffer.lines[0].contains("Loading"))
|
||||
}
|
||||
|
||||
@Test("Progress bar with ViewBuilder label renders two lines")
|
||||
func barWithViewBuilderLabel() {
|
||||
let view = ProgressView(value: 0.7) {
|
||||
Text("Downloading")
|
||||
}
|
||||
let context = testContext()
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
#expect(buffer.height == 2)
|
||||
#expect(buffer.lines[0].contains("Downloading"))
|
||||
}
|
||||
|
||||
@Test("Progress bar with label and currentValueLabel shows both")
|
||||
func barWithLabelAndValueLabel() {
|
||||
let view = ProgressView(value: 0.5) {
|
||||
Text("Task")
|
||||
} currentValueLabel: {
|
||||
Text("50%")
|
||||
}
|
||||
let context = testContext()
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
#expect(buffer.height == 2)
|
||||
#expect(buffer.lines[0].contains("Task"))
|
||||
#expect(buffer.lines[0].contains("50%"))
|
||||
}
|
||||
|
||||
@Test("Default line style contains filled and empty block characters")
|
||||
func lineStyleContainsBlockCharacters() {
|
||||
let view = ProgressView(value: 0.5)
|
||||
let context = testContext()
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(barLine.contains("█"))
|
||||
#expect(barLine.contains("░"))
|
||||
}
|
||||
|
||||
@Test("0% progress shows all empty blocks")
|
||||
func zeroProgressAllEmpty() {
|
||||
let view = ProgressView(value: 0.0)
|
||||
let context = testContext()
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(!barLine.contains("█"))
|
||||
#expect(barLine.contains("░"))
|
||||
}
|
||||
|
||||
@Test("100% progress shows all filled blocks")
|
||||
func fullProgressAllFilled() {
|
||||
let view = ProgressView(value: 1.0)
|
||||
let context = testContext()
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(barLine.contains("█"))
|
||||
#expect(!barLine.contains("░"))
|
||||
}
|
||||
|
||||
@Test("Bar width equals available width")
|
||||
func barFillsAvailableWidth() {
|
||||
let view = ProgressView(value: 0.5)
|
||||
let context = testContext(width: 20)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(barLine.count == 20)
|
||||
}
|
||||
|
||||
@Test("Filled count scales with fraction at 50%")
|
||||
func filledCountScalesWithFraction() {
|
||||
let view = ProgressView(value: 0.5)
|
||||
let context = testContext(width: 20)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
let filledCount = barLine.filter { $0 == "█" }.count
|
||||
let emptyCount = barLine.filter { $0 == "░" }.count
|
||||
|
||||
#expect(filledCount == 10)
|
||||
#expect(emptyCount == 10)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Style Tests
|
||||
|
||||
@Suite("ProgressView Style Tests")
|
||||
struct ProgressViewStyleTests {
|
||||
|
||||
@Test("Block style uses only █ and ░ characters")
|
||||
func blockStyleWholeBlocks() {
|
||||
let view = ProgressView(value: 0.33).progressBarStyle(.block)
|
||||
let context = testContext(width: 10)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
let allExpected = barLine.allSatisfy { $0 == "█" || $0 == "░" }
|
||||
#expect(allExpected)
|
||||
}
|
||||
|
||||
@Test("BlockFine style uses fractional blocks for sub-character precision")
|
||||
func blockFineStyleFractionalBlocks() {
|
||||
// 33% of 10 = 3.3 cells → 3 full + fractional
|
||||
let view = ProgressView(value: 0.33).progressBarStyle(.blockFine)
|
||||
let context = testContext(width: 10)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
let fractionalChars: Set<Character> = ["▏", "▎", "▍", "▌", "▋", "▊", "▉"]
|
||||
let hasFractional = barLine.contains { fractionalChars.contains($0) }
|
||||
#expect(hasFractional)
|
||||
}
|
||||
|
||||
@Test("Shade style uses ▓ and ░ characters")
|
||||
func shadeStyleCharacters() {
|
||||
let view = ProgressView(value: 0.5).progressBarStyle(.shade)
|
||||
let context = testContext(width: 20)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(barLine.contains("▓"))
|
||||
#expect(barLine.contains("░"))
|
||||
}
|
||||
|
||||
@Test("Bar style uses ▌ and ─ characters")
|
||||
func barStyleCharacters() {
|
||||
let view = ProgressView(value: 0.5).progressBarStyle(.bar)
|
||||
let context = testContext(width: 20)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(barLine.contains("▌"))
|
||||
#expect(barLine.contains("─"))
|
||||
}
|
||||
|
||||
@Test("Dot style uses ▬, ● head, and ─ characters")
|
||||
func dotStyleCharacters() {
|
||||
let view = ProgressView(value: 0.5).progressBarStyle(.dot)
|
||||
let context = testContext(width: 20)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(barLine.contains("▬"))
|
||||
#expect(barLine.contains("●"))
|
||||
#expect(barLine.contains("─"))
|
||||
}
|
||||
|
||||
@Test("Style modifier returns correct style")
|
||||
func styleModifierWorks() {
|
||||
let view = ProgressView(value: 0.5).progressBarStyle(.shade)
|
||||
#expect(view.style == .shade)
|
||||
}
|
||||
|
||||
@Test("All styles render correct width")
|
||||
func allStylesCorrectWidth() {
|
||||
let styles: [ProgressBarStyle] = [.block, .blockFine, .shade, .bar, .dot]
|
||||
let context = testContext(width: 20)
|
||||
|
||||
for style in styles {
|
||||
let view = ProgressView(value: 0.5).progressBarStyle(style)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(barLine.count == 20, "Style \(style) should render width 20, got \(barLine.count)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Dot style at 0% shows no head and all empty")
|
||||
func dotStyleZeroPercent() {
|
||||
let view = ProgressView(value: 0.0).progressBarStyle(.dot)
|
||||
let context = testContext(width: 10)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(!barLine.contains("●"))
|
||||
#expect(!barLine.contains("▬"))
|
||||
#expect(barLine.contains("─"))
|
||||
}
|
||||
|
||||
@Test("Dot style at 100% shows head at end")
|
||||
func dotStyleFullPercent() {
|
||||
let view = ProgressView(value: 1.0).progressBarStyle(.dot)
|
||||
let context = testContext(width: 10)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(barLine.contains("●"))
|
||||
#expect(barLine.contains("▬"))
|
||||
#expect(!barLine.contains("─"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edge Case Tests
|
||||
|
||||
@Suite("ProgressView Edge Cases")
|
||||
struct ProgressViewEdgeCaseTests {
|
||||
|
||||
@Test("Value greater than total clamps to 100%")
|
||||
func valueExceedsTotalClamped() {
|
||||
let view = ProgressView(value: 2.0, total: 1.0)
|
||||
let context = testContext(width: 10)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
let filledCount = barLine.filter { $0 == "█" }.count
|
||||
#expect(filledCount == 10)
|
||||
}
|
||||
|
||||
@Test("Negative value clamps to 0%")
|
||||
func negativeValueClamped() {
|
||||
let view = ProgressView(value: -0.5)
|
||||
let context = testContext(width: 10)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
let filledCount = barLine.filter { $0 == "█" }.count
|
||||
#expect(filledCount == 0)
|
||||
}
|
||||
|
||||
@Test("Zero total produces 0% bar")
|
||||
func zeroTotalShowsEmpty() {
|
||||
let view = ProgressView(value: 5.0, total: 0.0)
|
||||
let context = testContext(width: 10)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
let filledCount = barLine.filter { $0 == "█" }.count
|
||||
#expect(filledCount == 0)
|
||||
}
|
||||
|
||||
@Test("nil value renders empty bar (indeterminate fallback)")
|
||||
func nilValueRendersEmptyBar() {
|
||||
let view = ProgressView<EmptyView, EmptyView>(value: Optional<Double>.none)
|
||||
let context = testContext(width: 10)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
let filledCount = barLine.filter { $0 == "█" }.count
|
||||
#expect(filledCount == 0)
|
||||
}
|
||||
|
||||
@Test("Custom total works correctly")
|
||||
func customTotal() {
|
||||
let view = ProgressView(value: 3.0, total: 10.0)
|
||||
let context = testContext(width: 10)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
let filledCount = barLine.filter { $0 == "█" }.count
|
||||
#expect(filledCount == 3) // 30% of 10
|
||||
}
|
||||
|
||||
@Test("Float value works via BinaryFloatingPoint generic")
|
||||
func floatValueWorks() {
|
||||
let view = ProgressView(value: Float(0.5))
|
||||
let context = testContext()
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
#expect(buffer.height == 1)
|
||||
#expect(buffer.lines[0].contains("█"))
|
||||
}
|
||||
|
||||
@Test("Width of 1 renders single character")
|
||||
func singleCharWidth() {
|
||||
let view = ProgressView(value: 1.0)
|
||||
let context = testContext(width: 1)
|
||||
let buffer = renderToBuffer(view, context: context)
|
||||
|
||||
let barLine = buffer.lines[0].stripped
|
||||
#expect(barLine == "█")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# ProgressView — Determinate Progress Bar
|
||||
|
||||
## Completed
|
||||
|
||||
**Completed:** 2026-02-05
|
||||
**Branch:** `feature/progress-view` (from `main`)
|
||||
|
||||
## Goal
|
||||
|
||||
Add a `ProgressView` that matches SwiftUI's determinate progress API. Renders as a horizontal bar using Unicode block characters.
|
||||
|
||||
## SwiftUI API Parity
|
||||
|
||||
```swift
|
||||
// Minimal
|
||||
ProgressView(value: 0.5)
|
||||
|
||||
// With total
|
||||
ProgressView(value: 3, total: 10)
|
||||
|
||||
// With String title
|
||||
ProgressView("Loading...", value: 0.5)
|
||||
|
||||
// With ViewBuilder label
|
||||
ProgressView(value: 0.5) {
|
||||
Text("Downloading")
|
||||
}
|
||||
|
||||
// With label + currentValueLabel
|
||||
ProgressView(value: 0.5) {
|
||||
Text("Downloading")
|
||||
} currentValueLabel: {
|
||||
Text("50%")
|
||||
}
|
||||
```
|
||||
|
||||
### Signatures implemented
|
||||
|
||||
```swift
|
||||
init<V: BinaryFloatingPoint>(value: V?, total: V = 1.0)
|
||||
where Label == EmptyView, CurrentValueLabel == EmptyView
|
||||
|
||||
init<V: BinaryFloatingPoint>(value: V?, total: V = 1.0, @ViewBuilder label: () -> Label)
|
||||
where CurrentValueLabel == EmptyView
|
||||
|
||||
init<V: BinaryFloatingPoint>(value: V?, total: V = 1.0, @ViewBuilder label: () -> Label, @ViewBuilder currentValueLabel: () -> CurrentValueLabel)
|
||||
|
||||
init<S: StringProtocol, V: BinaryFloatingPoint>(_ title: S, value: V?, total: V = 1.0)
|
||||
where Label == Text, CurrentValueLabel == EmptyView
|
||||
```
|
||||
|
||||
## TUI Rendering
|
||||
|
||||
### Visual Output
|
||||
|
||||
```
|
||||
Downloading 50%
|
||||
████████████████▌░░░░░░░░░░░░░░░
|
||||
```
|
||||
|
||||
- **Line 1** (optional): Label (left) + CurrentValueLabel (right)
|
||||
- **Line 2**: Progress bar (no brackets)
|
||||
|
||||
### Styles
|
||||
|
||||
6 built-in styles via `.progressBarStyle(_:)` modifier:
|
||||
|
||||
```
|
||||
line: ████████████████░░░░░░░░░░░░░░░░ (whole blocks)
|
||||
lineSmooth: ████████████████▍░░░░░░░░░░░░░░░ (sub-character precision via ▉▊▋▌▍▎▏)
|
||||
thin: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░
|
||||
half: ▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌────────────────
|
||||
braille: ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀
|
||||
dot: ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬●──────────────── (● head in accent color)
|
||||
```
|
||||
|
||||
### Width
|
||||
|
||||
- Fills `availableWidth` (no brackets, no width reduction)
|
||||
|
||||
### Colors
|
||||
|
||||
| Part | Color |
|
||||
|------|-------|
|
||||
| Filled bar | `palette.foregroundSecondary` |
|
||||
| Empty bar | `palette.foregroundTertiary` |
|
||||
| Dot head (`.dot` only) | `palette.accent` |
|
||||
| Label | inherited from label view |
|
||||
| CurrentValueLabel | inherited from value label view |
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- `value: nil` → indeterminate → renders empty bar (0% fallback)
|
||||
- `value < 0` → clamp to 0
|
||||
- `value > total` → clamp to total
|
||||
- `total <= 0` → show empty bar
|
||||
- No label, no currentValueLabel → bar only (1 line)
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Create `Sources/TUIkit/Views/ProgressView.swift`
|
||||
- [x] Implement all 4 initializers with SwiftUI-matching signatures
|
||||
- [x] Implement `Renderable` with bar rendering
|
||||
- [x] Add `Equatable` conformance
|
||||
- [x] Implement 6 bar styles via `ProgressBarStyle` enum + `.progressBarStyle(_:)` modifier
|
||||
- [x] Create tests in `Tests/TUIkitTests/ProgressViewTests.swift` (27 tests / 3 suites)
|
||||
- [x] Add to example app (ContainersPage — ProgressViewRow)
|
||||
- [x] `swift build` + `swiftlint` + `swift test`
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
#### Medium
|
||||
|
||||
- [ ] **ProgressBar** — Progress bar with Unicode blocks (`▓░`)
|
||||
- [ ] **List (scrollable)** — Scrollable list with selection for arbitrary views
|
||||
- [ ] **Checkbox / Toggle** — `[x]`/`[ ]` with keyboard toggle
|
||||
|
||||
@@ -40,7 +39,8 @@
|
||||
|
||||
### 2026-02-05
|
||||
|
||||
- [x] **Remove Block/Flat Appearances** — Eliminated block, flat, and ascii appearances. 4 border-based styles remain (line, rounded, doubleLine, heavy). Removed BlockPalette, surface tokens, BorderedView (consolidated into ContainerView), stale DocC. Consistent 1-char padding in all containers.
|
||||
- [x] **ProgressView** — Determinate progress bar with 5 styles (block, blockFine, shade, bar, dot), SwiftUI-matching API, 26 tests. Also: `darker(by:)`/`lighter(by:)` changed to relative percentage scaling.
|
||||
- [x] **Remove Block/Flat Appearances** — Eliminated block, flat, ascii. BorderedView consolidated into ContainerView. Consistent 1-char padding in all containers. DocC overhauled. (PR #78)
|
||||
- [x] **Notification System** — Fire-and-forget `NotificationService`, fade-in/out animation, word-wrap, top-right overlay, Box rendering. No severity styles, no Binding. (PR #77)
|
||||
|
||||
- [x] **Render Performance Phase 2** — Cache invalidation fix, Equatable on 15 types/views, debug tooling, example app decomposition + `.equatable()`, DocC documentation (PR #74)
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
- [x] **Source Restructure** — Directory-Reorg, Phosphor→Palette Rename (PR #30)
|
||||
- [x] **EnvironmentStorage Elimination** — Singleton removed, SemanticColor system (PR #31)
|
||||
- [x] **Palette Protocol Split** — `Palette` + `BlockPalette`, ANSI→RGB (PR #48)
|
||||
- [x] **Palette Protocol Split** — `Palette` + `BlockPalette` (later removed), ANSI→RGB (PR #48)
|
||||
- [x] **Access-Level Refactor** — Public API surface restricted (PR #37)
|
||||
- [x] **DocC Documentation** — 8 guide articles, diagrams, palette/keyboard reference
|
||||
|
||||
@@ -144,4 +144,4 @@ Permanent architectural concern. Synthesized from the [SwiftUI performance artic
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-02-05 22:30
|
||||
**Last Updated:** 2026-02-05 23:30
|
||||
|
||||
Reference in New Issue
Block a user