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:
phranck
2026-02-13 04:05:55 +01:00
parent 5660f5b696
commit cd2968e345
15 changed files with 127 additions and 81 deletions
+18 -5
View File
@@ -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)
}
}
}
+1
View File
@@ -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
+4
View File
@@ -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
+28 -12
View File
@@ -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()
+8 -4
View File
@@ -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
+20 -9
View File
@@ -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
}
+8 -4
View File
@@ -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 {
+9 -9
View File
@@ -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")
+3 -1
View File
@@ -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")
}
+2 -1
View File
@@ -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