mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
Feat: Redesign control styling and add foregroundQuaternary palette color
- Redesign Button rendering with half-block caps (U+2590/U+258C) and accent-tinted background - Apply matching cap style and background to TextField and SecureField - Add foregroundQuaternary to Palette for subtle UI elements like spinner tracks - Fix border saturation in all palette presets to preserve hue instead of going gray - Rework Spinner bouncing trail to interpolate from highlight to quaternary via Color.lerp - Fix Spinner label to use palette foreground color - Fix FrameDiffWriter to use ESC[2K for reliable line clearing - Add Layoutable conformance to NavigationSplitView for height-flexible layout - Update tests for new button cap style
This commit is contained in:
@@ -46,8 +46,11 @@ final class FrameDiffWriter {
|
||||
extension FrameDiffWriter {
|
||||
/// Converts a ``FrameBuffer`` into terminal-ready output lines.
|
||||
///
|
||||
/// Each output line includes background color, padding, and reset codes.
|
||||
/// Lines beyond the buffer's content are filled with background-colored spaces.
|
||||
/// Each output line begins with the background color followed by `ESC[2K`
|
||||
/// (Erase Entire Line). This fills the terminal line with the app background
|
||||
/// before any content is drawn, preventing stale content from previous pages
|
||||
/// from showing through when `strippedLength` miscalculates padding.
|
||||
///
|
||||
/// This is a **pure function** — no side effects.
|
||||
///
|
||||
/// - Parameters:
|
||||
@@ -67,7 +70,12 @@ extension FrameDiffWriter {
|
||||
var lines: [String] = []
|
||||
lines.reserveCapacity(terminalHeight)
|
||||
|
||||
let emptyLine = bgCode + String(repeating: " ", count: terminalWidth) + reset
|
||||
// ESC[2K erases the entire line using the current background color.
|
||||
// Placed after bgCode so the erase uses the app background, not the
|
||||
// terminal default. This acts as a safety net for any strippedLength
|
||||
// inaccuracies in the padding calculation.
|
||||
let eraseLine = "\u{1B}[2K"
|
||||
let emptyLine = bgCode + eraseLine + reset
|
||||
|
||||
for row in 0..<terminalHeight {
|
||||
if row < buffer.height {
|
||||
@@ -75,7 +83,7 @@ extension FrameDiffWriter {
|
||||
let visibleWidth = line.strippedLength
|
||||
let padding = max(0, terminalWidth - visibleWidth)
|
||||
let lineWithBg = line.replacingOccurrences(of: reset, with: reset + bgCode)
|
||||
let paddedLine = bgCode + lineWithBg + String(repeating: " ", count: padding) + reset
|
||||
let paddedLine = bgCode + eraseLine + lineWithBg + String(repeating: " ", count: padding) + reset
|
||||
lines.append(paddedLine)
|
||||
} else {
|
||||
lines.append(emptyLine)
|
||||
@@ -136,10 +144,15 @@ private extension FrameDiffWriter {
|
||||
terminal.write(newLines[row])
|
||||
}
|
||||
|
||||
// Clear excess old lines when the previous frame had more rows.
|
||||
// Each output line already contains ESC[2K (from buildOutputLines),
|
||||
// but these extra rows have no corresponding new line, so we erase
|
||||
// them explicitly with the terminal's default background.
|
||||
if previousLines.count > newLines.count {
|
||||
let eraseEntireLine = "\u{1B}[2K"
|
||||
for row in newLines.count..<previousLines.count {
|
||||
terminal.moveCursor(toRow: startRow + row, column: 1)
|
||||
terminal.write(String(repeating: " ", count: previousLines[row].strippedLength))
|
||||
terminal.write(eraseEntireLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ public struct Color: Sendable, Equatable {
|
||||
public static let foreground = Color(value: .semantic(.foreground))
|
||||
public static let foregroundSecondary = Color(value: .semantic(.foregroundSecondary))
|
||||
public static let foregroundTertiary = Color(value: .semantic(.foregroundTertiary))
|
||||
public static let foregroundQuaternary = Color(value: .semantic(.foregroundQuaternary))
|
||||
|
||||
// Accent colors
|
||||
public static let accent = Color(value: .semantic(.accent))
|
||||
|
||||
@@ -53,6 +53,7 @@ public struct SystemPalette: Palette {
|
||||
public let foreground: Color
|
||||
public let foregroundSecondary: Color
|
||||
public let foregroundTertiary: Color
|
||||
public let foregroundQuaternary: Color
|
||||
|
||||
// Accent
|
||||
public let accent: Color
|
||||
@@ -93,6 +94,7 @@ public struct SystemPalette: Palette {
|
||||
self.foreground = Color.hsl(tuning.fgHue, tuning.fgSaturation, tuning.fgLightness)
|
||||
self.foregroundSecondary = Color.hsl(tuning.fgHue, tuning.fgSecSaturation, tuning.fgSecLightness)
|
||||
self.foregroundTertiary = Color.hsl(tuning.fgHue, tuning.fgTerSaturation, tuning.fgTerLightness)
|
||||
self.foregroundQuaternary = Color.hsl(tuning.fgHue, tuning.fgQuatSaturation, tuning.fgQuatLightness)
|
||||
|
||||
// Accent
|
||||
self.accent = Color.hsl(tuning.accentHue, tuning.accentSaturation, tuning.accentLightness)
|
||||
@@ -128,6 +130,8 @@ private extension SystemPalette {
|
||||
let fgSecLightness: Double
|
||||
let fgTerSaturation: Double
|
||||
let fgTerLightness: Double
|
||||
let fgQuatSaturation: Double
|
||||
let fgQuatLightness: Double
|
||||
|
||||
// Accent
|
||||
let accentHue: Double
|
||||
@@ -174,12 +178,13 @@ private extension SystemPalette.Tuning {
|
||||
fgHue: 120, fgSaturation: 100, fgLightness: 60,
|
||||
fgSecSaturation: 67, fgSecLightness: 46,
|
||||
fgTerSaturation: 64, fgTerLightness: 34,
|
||||
fgQuatSaturation: 60, fgQuatLightness: 22,
|
||||
accentHue: 120, accentSaturation: 100, accentLightness: 70,
|
||||
successHue: 120, successSaturation: 100, successLightness: 60,
|
||||
warningHue: wrapHue(120 - 45), warningSaturation: 100, warningLightness: 60,
|
||||
errorHue: wrapHue(120 - 105), errorSaturation: 100, errorLightness: 60,
|
||||
infoHue: wrapHue(120 + 45), infoSaturation: 100, infoLightness: 60,
|
||||
borderSaturation: 33, borderLightness: 26,
|
||||
borderSaturation: 60, borderLightness: 26,
|
||||
focusBgLightness: 15,
|
||||
cursorHue: 120, cursorSaturation: 100, cursorLightness: 70
|
||||
)
|
||||
@@ -190,12 +195,13 @@ private extension SystemPalette.Tuning {
|
||||
fgHue: 40, fgSaturation: 100, fgLightness: 50,
|
||||
fgSecSaturation: 100, fgSecLightness: 40,
|
||||
fgTerSaturation: 100, fgTerLightness: 28,
|
||||
fgQuatSaturation: 100, fgQuatLightness: 18,
|
||||
accentHue: 45, accentSaturation: 100, accentLightness: 60,
|
||||
successHue: wrapHue(40 + 40), successSaturation: 100, successLightness: 60,
|
||||
warningHue: wrapHue(40 + 20), warningSaturation: 100, warningLightness: 70,
|
||||
errorHue: wrapHue(40 - 25), errorSaturation: 100, errorLightness: 60,
|
||||
infoHue: wrapHue(40 + 10), infoSaturation: 100, infoLightness: 70,
|
||||
borderSaturation: 33, borderLightness: 26,
|
||||
borderSaturation: 100, borderLightness: 26,
|
||||
focusBgLightness: 12,
|
||||
cursorHue: 45, cursorSaturation: 100, cursorLightness: 60
|
||||
)
|
||||
@@ -206,12 +212,13 @@ private extension SystemPalette.Tuning {
|
||||
fgHue: 0, fgSaturation: 100, fgLightness: 63,
|
||||
fgSecSaturation: 60, fgSecLightness: 50,
|
||||
fgTerSaturation: 62, fgTerLightness: 35,
|
||||
fgQuatSaturation: 60, fgQuatLightness: 22,
|
||||
accentHue: 0, accentSaturation: 100, accentLightness: 70,
|
||||
successHue: wrapHue(0 + 30), successSaturation: 100, successLightness: 75,
|
||||
warningHue: wrapHue(0 + 30), warningSaturation: 100, warningLightness: 70,
|
||||
errorHue: 0, errorSaturation: 0, errorLightness: 100,
|
||||
infoHue: 0, infoSaturation: 100, infoLightness: 80,
|
||||
borderSaturation: 33, borderLightness: 26,
|
||||
borderSaturation: 60, borderLightness: 26,
|
||||
focusBgLightness: 15,
|
||||
cursorHue: 0, cursorSaturation: 100, cursorLightness: 70
|
||||
)
|
||||
@@ -222,12 +229,13 @@ private extension SystemPalette.Tuning {
|
||||
fgHue: 270, fgSaturation: 80, fgLightness: 70,
|
||||
fgSecSaturation: 70, fgSecLightness: 55,
|
||||
fgTerSaturation: 60, fgTerLightness: 40,
|
||||
fgQuatSaturation: 55, fgQuatLightness: 26,
|
||||
accentHue: 270, accentSaturation: 85, accentLightness: 78,
|
||||
successHue: wrapHue(270 + 120), successSaturation: 70, successLightness: 65,
|
||||
warningHue: wrapHue(270 + 60), warningSaturation: 80, warningLightness: 70,
|
||||
errorHue: wrapHue(270 + 180), errorSaturation: 85, errorLightness: 65,
|
||||
infoHue: wrapHue(270 - 60), infoSaturation: 70, infoLightness: 70,
|
||||
borderSaturation: 40, borderLightness: 25,
|
||||
borderSaturation: 55, borderLightness: 25,
|
||||
focusBgLightness: 18,
|
||||
cursorHue: 270, cursorSaturation: 85, cursorLightness: 78
|
||||
)
|
||||
@@ -238,12 +246,13 @@ private extension SystemPalette.Tuning {
|
||||
fgHue: 200, fgSaturation: 100, fgLightness: 50,
|
||||
fgSecSaturation: 100, fgSecLightness: 40,
|
||||
fgTerSaturation: 100, fgTerLightness: 30,
|
||||
fgQuatSaturation: 100, fgQuatLightness: 20,
|
||||
accentHue: 200, accentSaturation: 100, accentLightness: 60,
|
||||
successHue: wrapHue(200 + 10), successSaturation: 100, successLightness: 60,
|
||||
warningHue: wrapHue(200 + 20), warningSaturation: 100, warningLightness: 70,
|
||||
errorHue: wrapHue(200 - 185), errorSaturation: 100, errorLightness: 60,
|
||||
infoHue: wrapHue(200 + 5), infoSaturation: 100, infoLightness: 75,
|
||||
borderSaturation: 33, borderLightness: 26,
|
||||
borderSaturation: 100, borderLightness: 26,
|
||||
focusBgLightness: 13,
|
||||
cursorHue: 200, cursorSaturation: 100, cursorLightness: 60
|
||||
)
|
||||
@@ -254,6 +263,7 @@ private extension SystemPalette.Tuning {
|
||||
fgHue: 0, fgSaturation: 0, fgLightness: 91,
|
||||
fgSecSaturation: 0, fgSecLightness: 69,
|
||||
fgTerSaturation: 0, fgTerLightness: 47,
|
||||
fgQuatSaturation: 0, fgQuatLightness: 32,
|
||||
accentHue: 0, accentSaturation: 0, accentLightness: 100,
|
||||
successHue: 120, successSaturation: 50, successLightness: 75,
|
||||
warningHue: 40, warningSaturation: 60, warningLightness: 75,
|
||||
|
||||
@@ -27,6 +27,7 @@ enum SemanticColor: String, Sendable, Equatable {
|
||||
case foreground
|
||||
case foregroundSecondary
|
||||
case foregroundTertiary
|
||||
case foregroundQuaternary
|
||||
|
||||
// Accent
|
||||
case accent
|
||||
@@ -57,6 +58,7 @@ extension SemanticColor {
|
||||
case .foreground: palette.foreground
|
||||
case .foregroundSecondary: palette.foregroundSecondary
|
||||
case .foregroundTertiary: palette.foregroundTertiary
|
||||
case .foregroundQuaternary: palette.foregroundQuaternary
|
||||
case .accent: palette.accent
|
||||
case .success: palette.success
|
||||
case .warning: palette.warning
|
||||
|
||||
@@ -51,6 +51,9 @@ public protocol Palette: Cyclable {
|
||||
/// Tertiary text color (even less prominent).
|
||||
var foregroundTertiary: Color { get }
|
||||
|
||||
/// Quaternary text color (dimmest foreground, used for subtle UI elements like spinner tracks).
|
||||
var foregroundQuaternary: Color { get }
|
||||
|
||||
// MARK: - Accent Colors
|
||||
|
||||
/// Primary accent color for interactive elements.
|
||||
@@ -98,6 +101,7 @@ extension Palette {
|
||||
|
||||
public var foregroundSecondary: Color { foreground }
|
||||
public var foregroundTertiary: Color { foreground }
|
||||
public var foregroundQuaternary: Color { foregroundTertiary }
|
||||
|
||||
// MARK: - UI Element Defaults
|
||||
|
||||
|
||||
@@ -331,23 +331,39 @@ private struct _ButtonCore: View, Renderable {
|
||||
let fullLine = focusPrefix + styledLabel
|
||||
return FrameBuffer(lines: [fullLine])
|
||||
} else {
|
||||
// Standard: brackets change to accent color when focused (with subtle pulse)
|
||||
let bracketColor: Color
|
||||
// Standard: half-block caps with accent-tinted background
|
||||
let buttonBg = palette.accent.opacity(0.2)
|
||||
|
||||
// Label foreground: primary = accent/highlight, others = dimmed foreground
|
||||
let labelFg: Color
|
||||
if isDisabled {
|
||||
bracketColor = palette.foregroundTertiary
|
||||
} else if isFocused {
|
||||
// Subtle pulse: interpolate between 35% and 100% accent
|
||||
let dimAccent = palette.accent.opacity(0.35)
|
||||
bracketColor = Color.lerp(dimAccent, palette.accent, phase: context.pulsePhase)
|
||||
labelFg = palette.foregroundTertiary.opacity(0.5)
|
||||
} else if currentStyle.isBold {
|
||||
labelFg = currentStyle.foregroundColor?.resolve(with: palette) ?? palette.accent
|
||||
} else {
|
||||
bracketColor = palette.border
|
||||
labelFg = palette.foregroundSecondary
|
||||
}
|
||||
|
||||
let openBracket = ANSIRenderer.colorize("[", foreground: bracketColor, bold: isFocused)
|
||||
let closeBracket = ANSIRenderer.colorize("]", foreground: bracketColor, bold: isFocused)
|
||||
let styledLabel = ANSIRenderer.render(paddedLabel, with: textStyle)
|
||||
// Caps: match button background normally, pulse to accent when focused
|
||||
let resolvedCapColor: Color
|
||||
if isDisabled {
|
||||
resolvedCapColor = buttonBg
|
||||
} else if isFocused {
|
||||
resolvedCapColor = Color.lerp(buttonBg, palette.accent.opacity(0.45), phase: context.pulsePhase)
|
||||
} else {
|
||||
resolvedCapColor = buttonBg
|
||||
}
|
||||
|
||||
let line = openBracket + styledLabel + closeBracket
|
||||
let openCap = ANSIRenderer.colorize("\u{2590}", foreground: resolvedCapColor)
|
||||
let closeCap = ANSIRenderer.colorize("\u{258C}", foreground: resolvedCapColor)
|
||||
let styledLabel = ANSIRenderer.colorize(
|
||||
paddedLabel,
|
||||
foreground: labelFg,
|
||||
background: buttonBg,
|
||||
bold: currentStyle.isBold && !isDisabled
|
||||
)
|
||||
|
||||
let line = openCap + styledLabel + closeCap
|
||||
return FrameBuffer(lines: [line])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ extension NavigationSplitView {
|
||||
// MARK: - Internal Core
|
||||
|
||||
/// Internal view that handles the actual rendering of NavigationSplitView.
|
||||
private struct _NavigationSplitViewCore<Sidebar: View, Content: View, Detail: View>: View, Renderable {
|
||||
private struct _NavigationSplitViewCore<Sidebar: View, Content: View, Detail: View>: View, Renderable, Layoutable {
|
||||
let sidebar: Sidebar
|
||||
let content: Content
|
||||
let detail: Detail
|
||||
@@ -213,6 +213,11 @@ private struct _NavigationSplitViewCore<Sidebar: View, Content: View, Detail: Vi
|
||||
fatalError("_NavigationSplitViewCore renders via Renderable")
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedSize, context: RenderContext) -> ViewSize {
|
||||
let minWidth = minimumColumnWidth * (isThreeColumn ? 3 : 2)
|
||||
return ViewSize(width: minWidth, height: 1, isWidthFlexible: true, isHeightFlexible: true)
|
||||
}
|
||||
|
||||
func renderToBuffer(context: RenderContext) -> FrameBuffer {
|
||||
let style = context.environment.navigationSplitViewStyle
|
||||
let visibility = resolveVisibility()
|
||||
|
||||
@@ -214,8 +214,8 @@ private struct _SecureFieldCore: View, Renderable, Layoutable {
|
||||
let palette = context.environment.palette
|
||||
let cursorStyle = context.environment.textCursorStyle
|
||||
|
||||
// SecureField expands to fill available width (with minimum)
|
||||
let contentWidth = max(minContentWidth, context.availableWidth)
|
||||
// SecureField expands to fill available width (reserve 2 chars for caps)
|
||||
let contentWidth = max(minContentWidth, context.availableWidth - 2)
|
||||
|
||||
// Get or create persistent focusID from state storage.
|
||||
// focusID must be stable across renders for focus state to persist.
|
||||
@@ -265,7 +265,11 @@ private struct _SecureFieldCore: View, Renderable, Layoutable {
|
||||
contentWidth: contentWidth
|
||||
)
|
||||
|
||||
return FrameBuffer(text: content)
|
||||
// Wrap with half-block caps (matching Button style)
|
||||
let capColor = palette.accent.opacity(0.2)
|
||||
let openCap = ANSIRenderer.colorize("\u{2590}", foreground: capColor)
|
||||
let closeCap = ANSIRenderer.colorize("\u{258C}", foreground: capColor)
|
||||
return FrameBuffer(text: openCap + content + closeCap)
|
||||
}
|
||||
|
||||
/// Builds the rendered secure field content.
|
||||
@@ -279,7 +283,7 @@ private struct _SecureFieldCore: View, Renderable, Layoutable {
|
||||
) -> String {
|
||||
let textValue = text.wrappedValue
|
||||
let isEmpty = textValue.isEmpty
|
||||
let backgroundColor = palette.focusBackground
|
||||
let backgroundColor = palette.accent.opacity(0.2)
|
||||
|
||||
// Build inner content with background
|
||||
let innerContent: String
|
||||
|
||||
@@ -109,11 +109,13 @@ extension SpinnerStyle {
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - frameIndex: The current frame index in the bounce sequence.
|
||||
/// - color: The resolved highlight color.
|
||||
/// - color: The resolved highlight color for the leading dot.
|
||||
/// - trackColor: The color for inactive track positions.
|
||||
/// - Returns: An ANSI-colored string representing the track.
|
||||
static func renderBouncingFrame(
|
||||
frameIndex: Int,
|
||||
color: Color
|
||||
color: Color,
|
||||
trackColor: Color
|
||||
) -> String {
|
||||
let positions = bouncingPositions(trackLength: trackWidth)
|
||||
let currentPos = positions[frameIndex % positions.count]
|
||||
@@ -132,10 +134,17 @@ extension SpinnerStyle {
|
||||
)
|
||||
|
||||
if let distance, distance < trailOpacities.count {
|
||||
let fadedColor = color.opacity(trailOpacities[distance])
|
||||
result += ANSIRenderer.colorize("●", foreground: fadedColor)
|
||||
if distance == 0 {
|
||||
// Leading highlight dot uses accent color
|
||||
result += ANSIRenderer.colorize("●", foreground: color)
|
||||
} else {
|
||||
// Trail interpolates from highlight to trackColor
|
||||
let phase = 1.0 - trailOpacities[distance]
|
||||
let fadedColor = Color.lerp(color, trackColor, phase: phase)
|
||||
result += ANSIRenderer.colorize("●", foreground: fadedColor)
|
||||
}
|
||||
} else {
|
||||
result += ANSIRenderer.colorize("●", foreground: color.opacity(0.15))
|
||||
result += ANSIRenderer.colorize("●", foreground: trackColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,8 +310,8 @@ private struct _SpinnerCore: View, Renderable {
|
||||
}
|
||||
let frameIndex = Int(elapsed / style.interval) % frameCount
|
||||
|
||||
// Resolve color - use environment foregroundColor if no explicit color set
|
||||
let effectiveColor = color ?? context.environment.foregroundStyle ?? .palette.accent
|
||||
// Resolve color: explicit color > environment foregroundStyle > palette accent
|
||||
let effectiveColor = color ?? context.environment.foregroundStyle ?? context.environment.palette.accent
|
||||
let resolvedColor = effectiveColor.resolve(with: context.environment.palette)
|
||||
|
||||
// Build spinner text — bouncing renders with colored trail, others are plain.
|
||||
@@ -311,7 +320,8 @@ private struct _SpinnerCore: View, Renderable {
|
||||
case .bouncing:
|
||||
coloredSpinner = SpinnerStyle.renderBouncingFrame(
|
||||
frameIndex: frameIndex,
|
||||
color: resolvedColor
|
||||
color: resolvedColor,
|
||||
trackColor: context.environment.palette.foregroundQuaternary.opacity(0.4)
|
||||
)
|
||||
case .dots, .line:
|
||||
coloredSpinner = ANSIRenderer.colorize(
|
||||
@@ -322,7 +332,8 @@ private struct _SpinnerCore: View, Renderable {
|
||||
|
||||
let output: String
|
||||
if let label {
|
||||
output = coloredSpinner + " " + label
|
||||
let styledLabel = ANSIRenderer.colorize(label, foreground: context.environment.palette.foreground)
|
||||
output = coloredSpinner + " " + styledLabel
|
||||
} else {
|
||||
output = coloredSpinner
|
||||
}
|
||||
|
||||
@@ -257,8 +257,8 @@ private struct _TextFieldCore<Label: View>: View, Renderable, Layoutable {
|
||||
let palette = context.environment.palette
|
||||
let cursorStyle = context.environment.textCursorStyle
|
||||
|
||||
// TextField expands to fill available width (with minimum)
|
||||
let contentWidth = max(minContentWidth, context.availableWidth)
|
||||
// TextField expands to fill available width (reserve 2 chars for caps)
|
||||
let contentWidth = max(minContentWidth, context.availableWidth - 2)
|
||||
|
||||
// Get or create persistent focusID from state storage.
|
||||
// focusID must be stable across renders for focus state to persist.
|
||||
@@ -307,7 +307,11 @@ private struct _TextFieldCore<Label: View>: View, Renderable, Layoutable {
|
||||
contentWidth: contentWidth
|
||||
)
|
||||
|
||||
return FrameBuffer(text: fieldContent)
|
||||
// Wrap with half-block caps (matching Button style)
|
||||
let capColor = palette.accent.opacity(0.2)
|
||||
let openCap = ANSIRenderer.colorize("\u{2590}", foreground: capColor)
|
||||
let closeCap = ANSIRenderer.colorize("\u{258C}", foreground: capColor)
|
||||
return FrameBuffer(text: openCap + fieldContent + closeCap)
|
||||
}
|
||||
|
||||
/// Builds the rendered text field content.
|
||||
@@ -321,7 +325,7 @@ private struct _TextFieldCore<Label: View>: View, Renderable, Layoutable {
|
||||
) -> String {
|
||||
let textValue = text.wrappedValue
|
||||
let isEmpty = textValue.isEmpty
|
||||
let backgroundColor = palette.focusBackground
|
||||
let backgroundColor = palette.accent.opacity(0.2)
|
||||
|
||||
// Build inner content with background
|
||||
let innerContent: String
|
||||
|
||||
@@ -109,33 +109,6 @@ struct SplitViewPage: View {
|
||||
detailColumn
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
|
||||
// Navigation hints
|
||||
DemoSection("Navigation") {
|
||||
HStack(spacing: 2) {
|
||||
Text("[Tab]").foregroundStyle(.palette.accent)
|
||||
Text("switch columns").dim()
|
||||
Spacer()
|
||||
Text("[1/2/3]").foregroundStyle(.palette.accent)
|
||||
Text("visibility:").dim()
|
||||
Text(visibilityLabel).bold()
|
||||
}
|
||||
}
|
||||
.onKeyPress { event in
|
||||
switch event.key {
|
||||
case .character("1"):
|
||||
visibility = .all
|
||||
return true
|
||||
case .character("2"):
|
||||
visibility = .doubleColumn
|
||||
return true
|
||||
case .character("3"):
|
||||
visibility = .detailOnly
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
.appHeader {
|
||||
HStack {
|
||||
|
||||
@@ -68,12 +68,12 @@ struct ButtonTests {
|
||||
let button = Button("OK") {}
|
||||
let buffer = renderToBuffer(button, context: context)
|
||||
|
||||
// Bracket-style buttons are single line: [ OK ]
|
||||
// Cap-style buttons are single line: ▐ OK ▌
|
||||
#expect(buffer.height == 1)
|
||||
let allContent = buffer.lines.joined()
|
||||
#expect(allContent.contains("OK"))
|
||||
#expect(allContent.contains("["))
|
||||
#expect(allContent.contains("]"))
|
||||
#expect(allContent.stripped.contains("\u{2590}"))
|
||||
#expect(allContent.stripped.contains("\u{258C}"))
|
||||
}
|
||||
|
||||
@Test("Default button is single line height")
|
||||
@@ -107,10 +107,10 @@ struct ButtonTests {
|
||||
let button = Button("Focus Me", focusID: "focused-button") {}
|
||||
let buffer = renderToBuffer(button, context: context)
|
||||
|
||||
// First button is auto-focused — brackets should be styled (contain ANSI codes)
|
||||
// First button is auto-focused — caps should be styled (contain ANSI codes)
|
||||
let allContent = buffer.lines.joined()
|
||||
#expect(allContent.contains("["), "Button should have opening bracket")
|
||||
#expect(allContent.contains("]"), "Button should have closing bracket")
|
||||
#expect(allContent.stripped.contains("\u{2590}"), "Button should have opening cap")
|
||||
#expect(allContent.stripped.contains("\u{258C}"), "Button should have closing cap")
|
||||
#expect(allContent.contains("\u{1b}["), "Focused button should have ANSI styling")
|
||||
}
|
||||
|
||||
@@ -126,10 +126,10 @@ struct ButtonTests {
|
||||
_ = renderToBuffer(button1, context: context)
|
||||
let buffer2 = renderToBuffer(button2, context: context)
|
||||
|
||||
// Second button is not focused — should still have brackets with styling
|
||||
// Second button is not focused — should still have caps with styling
|
||||
let allContent = buffer2.lines.joined()
|
||||
#expect(allContent.contains("["), "Unfocused button should have opening bracket")
|
||||
#expect(allContent.contains("]"), "Unfocused button should have closing bracket")
|
||||
#expect(allContent.stripped.contains("\u{2590}"), "Unfocused button should have opening cap")
|
||||
#expect(allContent.stripped.contains("\u{258C}"), "Unfocused button should have closing cap")
|
||||
}
|
||||
|
||||
@Test("Destructive button uses palette error color, not hardcoded red")
|
||||
|
||||
@@ -64,7 +64,9 @@ struct BuildOutputLinesTests {
|
||||
reset: "[R]"
|
||||
)
|
||||
|
||||
let expected = "[BG]" + String(repeating: " ", count: 5) + "[R]"
|
||||
// Empty lines use bgCode + ESC[2K (erase entire line with bg color) + reset
|
||||
let eraseLine = "\u{1B}[2K"
|
||||
let expected = "[BG]" + eraseLine + "[R]"
|
||||
#expect(lines[0] == expected)
|
||||
#expect(lines[1] == expected)
|
||||
}
|
||||
|
||||
@@ -90,10 +90,10 @@ struct ModifierPropagationTests {
|
||||
let context = testContext()
|
||||
let buffer = renderToBuffer(button, context: context)
|
||||
|
||||
// Button should render with brackets
|
||||
#expect(buffer.lines[0].stripped.contains("["))
|
||||
// Button should render with caps
|
||||
#expect(buffer.lines[0].stripped.contains("\u{2590}"))
|
||||
#expect(buffer.lines[0].stripped.contains("Test"))
|
||||
#expect(buffer.lines[0].stripped.contains("]"))
|
||||
#expect(buffer.lines[0].stripped.contains("\u{258C}"))
|
||||
#expect(!actionCalled, "Action should not be called during render")
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@ struct SpinnerStyleTests {
|
||||
func bouncingFrameRendering() {
|
||||
let frame = SpinnerStyle.renderBouncingFrame(
|
||||
frameIndex: 3,
|
||||
color: .red
|
||||
color: .red,
|
||||
trackColor: .white
|
||||
)
|
||||
|
||||
// All positions use ● with varying opacity
|
||||
|
||||
Reference in New Issue
Block a user