Merge pull request #79 from phranck/feature/progress-view

Feat: Add ProgressView with 5 bar styles
This commit is contained in:
phranck
2026-02-05 23:32:18 +01:00
committed by GitHub
6 changed files with 967 additions and 23 deletions
+39 -18
View File
@@ -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)))
}
}
+455
View File
@@ -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) {
+314
View File
@@ -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 == "█")
}
}
+108
View File
@@ -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`
+4 -4
View File
@@ -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