From 45d08c447db88ce82ed41a618fde91cbc147193d Mon Sep 17 00:00:00 2001 From: phranck Date: Thu, 5 Feb 2026 23:18:38 +0100 Subject: [PATCH 1/3] Feat: Add ProgressView with 5 bar styles and SwiftUI-matching API Determinate progress bar with block, blockFine, shade, bar, and dot styles. 4 initializers matching SwiftUI signatures (value/total, label, currentValueLabel, string title). Style selection via .progressBarStyle() modifier. Colors: foregroundSecondary (filled), foregroundTertiary (empty), accent (dot head). 26 tests in 3 suites. --- Sources/TUIkit/Views/ProgressView.swift | 455 ++++++++++++++++++ .../TUIkitExample/Pages/ContainersPage.swift | 48 +- Tests/TUIkitTests/ProgressViewTests.swift | 314 ++++++++++++ plans/open/2026-02-05-progress-view.md | 108 +++++ 4 files changed, 924 insertions(+), 1 deletion(-) create mode 100644 Sources/TUIkit/Views/ProgressView.swift create mode 100644 Tests/TUIkitTests/ProgressViewTests.swift create mode 100644 plans/open/2026-02-05-progress-view.md diff --git a/Sources/TUIkit/Views/ProgressView.swift b/Sources/TUIkit/Views/ProgressView.swift new file mode 100644 index 0000000..b5bb7c3 --- /dev/null +++ b/Sources/TUIkit/Views/ProgressView.swift @@ -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: 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(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( + 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( + 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( + _ 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(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))) + } +} diff --git a/Sources/TUIkitExample/Pages/ContainersPage.swift b/Sources/TUIkitExample/Pages/ContainersPage.swift index 0dbf649..d6f4028 100644 --- a/Sources/TUIkitExample/Pages/ContainersPage.swift +++ b/Sources/TUIkitExample/Pages/ContainersPage.swift @@ -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) { diff --git a/Tests/TUIkitTests/ProgressViewTests.swift b/Tests/TUIkitTests/ProgressViewTests.swift new file mode 100644 index 0000000..e382ff5 --- /dev/null +++ b/Tests/TUIkitTests/ProgressViewTests.swift @@ -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 = ["▏", "β–Ž", "▍", "β–Œ", "β–‹", "β–Š", "β–‰"] + 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(value: Optional.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 == "β–ˆ") + } +} diff --git a/plans/open/2026-02-05-progress-view.md b/plans/open/2026-02-05-progress-view.md new file mode 100644 index 0000000..fe1ed24 --- /dev/null +++ b/plans/open/2026-02-05-progress-view.md @@ -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(value: V?, total: V = 1.0) + where Label == EmptyView, CurrentValueLabel == EmptyView + +init(value: V?, total: V = 1.0, @ViewBuilder label: () -> Label) + where CurrentValueLabel == EmptyView + +init(value: V?, total: V = 1.0, @ViewBuilder label: () -> Label, @ViewBuilder currentValueLabel: () -> CurrentValueLabel) + +init(_ 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` From 64940714a39a63944974b86b43c9979527a20ebb Mon Sep 17 00:00:00 2001 From: phranck Date: Thu, 5 Feb 2026 23:18:45 +0100 Subject: [PATCH 2/3] Refactor: Change darker/lighter to relative percentage scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously darker(by: 0.5) subtracted 50 absolute lightness points. Now it scales relative to current position: 0.5 means 'move halfway toward the extreme'. More intuitive for humans β€” 20% darker always means 20% of the remaining range toward black. --- Sources/TUIkit/Styling/Color.swift | 57 ++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/Sources/TUIkit/Styling/Color.swift b/Sources/TUIkit/Styling/Color.swift index d3d014d..8bc4ebe 100644 --- a/Sources/TUIkit/Styling/Color.swift +++ b/Sources/TUIkit/Styling/Color.swift @@ -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))) } } From 4980574151e020a3d0c05caa2a43b21dc7711450 Mon Sep 17 00:00:00 2001 From: phranck Date: Thu, 5 Feb 2026 23:19:18 +0100 Subject: [PATCH 3/3] Chore: Update status files after ProgressView implementation --- to-dos.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/to-dos.md b/to-dos.md index b422972..6d566c0 100644 --- a/to-dos.md +++ b/to-dos.md @@ -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