Files
TUIkit/Sources/TUIKit/Views/Button.swift
T
phranck 2232fefc44 feat: Improve StatusBar, Buttons, and Theme styling for block appearance
- Add StatusBar.bordered style with theme colors and block appearance support
- Pass current environment to StatusBar rendering for dynamic theme updates
- Implement ButtonRow right-alignment with calculated left padding
- Add buttonBackground theme color for lighter buttons in block appearance
- Remove button borders and focus indicators for primary buttons in block appearance
- Add theme background colors to container body and button content
- Adjust theme colors: foregroundSecondary, statusBarForeground, buttonBackground
- Update Amber theme header/footer background to #1E110E
- Refactor Overlays demo to use real Button components with VStack layout
- Center alert modal with HStack and Spacer elements
2026-01-29 02:24:52 +01:00

499 lines
15 KiB
Swift

//
// Button.swift
// TUIKit
//
// An interactive button view that responds to keyboard input.
//
import Foundation
// MARK: - Button Style
/// Defines the visual style of a button.
public struct ButtonStyle: Sendable {
/// The foreground color for the label.
public var foregroundColor: Color?
/// The background color.
public var backgroundColor: Color?
/// The border style.
public var borderStyle: BorderStyle?
/// The border color.
public var borderColor: Color?
/// Whether the label is bold.
public var isBold: Bool
/// Horizontal padding inside the button.
public var horizontalPadding: Int
/// Creates a button style.
///
/// - Parameters:
/// - foregroundColor: The label color (default: theme accent).
/// - backgroundColor: The background color.
/// - borderStyle: The border style (default: appearance borderStyle).
/// - borderColor: The border color (default: theme border).
/// - isBold: Whether the label is bold.
/// - horizontalPadding: Horizontal padding inside the button.
public init(
foregroundColor: Color? = nil,
backgroundColor: Color? = nil,
borderStyle: BorderStyle? = nil,
borderColor: Color? = nil,
isBold: Bool = false,
horizontalPadding: Int = 2
) {
self.foregroundColor = foregroundColor
self.backgroundColor = backgroundColor
self.borderStyle = borderStyle
self.borderColor = borderColor
self.isBold = isBold
self.horizontalPadding = horizontalPadding
}
// MARK: - Preset Styles
/// Default button style with border.
public static let `default` = ButtonStyle()
/// Primary button style (cyan, bold).
public static let primary = ButtonStyle(
foregroundColor: .cyan,
borderColor: .cyan,
isBold: true
)
/// Destructive button style (red).
public static let destructive = ButtonStyle(
foregroundColor: .red,
borderColor: .red
)
/// Success button style (green).
public static let success = ButtonStyle(
foregroundColor: .green,
borderColor: .green
)
/// Plain button style (no border).
public static let plain = ButtonStyle(
borderStyle: BorderStyle.none,
horizontalPadding: 0
)
}
// MARK: - Button
/// An interactive button that triggers an action when pressed.
///
/// Buttons can receive focus and respond to keyboard input (Enter or Space).
/// They display differently when focused to indicate the current selection.
///
/// # Basic Example
///
/// ```swift
/// Button("Submit") {
/// handleSubmit()
/// }
/// ```
///
/// # Styled Button
///
/// ```swift
/// Button("Delete", style: .destructive) {
/// handleDelete()
/// }
/// ```
///
/// # Custom Focus ID
///
/// ```swift
/// Button("OK", focusID: "okButton") {
/// dismiss()
/// }
/// ```
public struct Button: View {
/// The button's label text.
public let label: String
/// The action to perform when pressed.
public let action: () -> Void
/// The normal (unfocused) style.
public let style: ButtonStyle
/// The focused style.
public let focusedStyle: ButtonStyle
/// The unique focus identifier.
public let focusID: String
/// Whether the button is disabled.
public let isDisabled: Bool
/// Creates a button with a label and action.
///
/// - Parameters:
/// - label: The button's label text.
/// - style: The button style (default: `.default`).
/// - focusedStyle: The style when focused (default: inverted colors).
/// - focusID: The unique focus identifier (default: auto-generated).
/// - isDisabled: Whether the button is disabled (default: false).
/// - action: The action to perform when pressed.
public init(
_ label: String,
style: ButtonStyle = .default,
focusedStyle: ButtonStyle? = nil,
focusID: String = UUID().uuidString,
isDisabled: Bool = false,
action: @escaping () -> Void
) {
self.label = label
self.action = action
self.style = style
self.focusID = focusID
self.isDisabled = isDisabled
// Default focused style: use theme accent color, bold
// Note: Theme colors are resolved at render time via Color.theme
self.focusedStyle = focusedStyle ?? ButtonStyle(
foregroundColor: nil, // Will use theme.accent at render time
backgroundColor: style.backgroundColor,
borderStyle: style.borderStyle,
borderColor: nil, // Will use theme.borderFocused at render time
isBold: true,
horizontalPadding: style.horizontalPadding
)
}
public var body: Never {
fatalError("Button renders via Renderable")
}
}
// MARK: - Button Handler
/// Internal handler class for button focus management.
final class ButtonHandler: Focusable {
let focusID: String
let action: () -> Void
var canBeFocused: Bool
init(focusID: String, action: @escaping () -> Void, canBeFocused: Bool) {
self.focusID = focusID
self.action = action
self.canBeFocused = canBeFocused
}
func handleKeyEvent(_ event: KeyEvent) -> Bool {
// Trigger action on Enter or Space
switch event.key {
case .enter:
action()
return true
case .character(" "):
action()
return true
default:
return false
}
}
}
// MARK: - Button Rendering
extension Button: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
// Get focus manager from environment
let focusManager = context.environment.focusManager
// Register this button with the focus manager
let handler = ButtonHandler(
focusID: focusID,
action: action,
canBeFocused: !isDisabled
)
focusManager.register(handler)
// Determine if focused
let isFocused = focusManager.isFocused(id: focusID)
let currentStyle = isFocused ? focusedStyle : style
// Build the label with padding
let padding = String(repeating: " ", count: currentStyle.horizontalPadding)
let paddedLabel = padding + label + padding
// Apply text styling
var textStyle = TextStyle()
if isDisabled {
textStyle.foregroundColor = Color.theme.disabled
} else {
// Use theme accent if no explicit color is set
textStyle.foregroundColor = currentStyle.foregroundColor ?? Color.theme.accent
}
textStyle.backgroundColor = currentStyle.backgroundColor
textStyle.isBold = currentStyle.isBold && !isDisabled
// In block appearance, add buttonBackground to button
let isBlockAppearance = context.environment.appearance.id == .block
if isBlockAppearance && textStyle.backgroundColor == nil {
textStyle.backgroundColor = context.environment.theme.buttonBackground
}
let styledLabel = ANSIRenderer.render(paddedLabel, with: textStyle)
// Create content buffer
var buffer = FrameBuffer(lines: [styledLabel])
// Apply border if specified
// Note: ButtonStyle.plain explicitly sets borderStyle to .none for no border
if let borderStyle = currentStyle.borderStyle {
// Skip if it's the "none" style (invisible border)
if borderStyle != .none {
let borderColor: Color
if isDisabled {
borderColor = Color.theme.disabled
} else {
borderColor = currentStyle.borderColor ?? Color.theme.border
}
buffer = applyBorder(
to: buffer,
style: borderStyle,
color: borderColor,
context: context
)
}
} else {
// nil means use appearance default, but skip border for block appearance
if !isBlockAppearance {
let effectiveBorderStyle = context.environment.appearance.borderStyle
let borderColor: Color
if isDisabled {
borderColor = Color.theme.disabled
} else {
borderColor = currentStyle.borderColor ?? Color.theme.border
}
buffer = applyBorder(
to: buffer,
style: effectiveBorderStyle,
color: borderColor,
context: context
)
}
}
// Add focus indicator if focused (but not for primary/bold buttons)
if isFocused && !isDisabled && !currentStyle.isBold {
buffer = addFocusIndicator(to: buffer)
}
return buffer
}
/// Applies a border to the buffer.
private func applyBorder(to buffer: FrameBuffer, style: BorderStyle, color: Color, context: RenderContext) -> FrameBuffer {
guard !buffer.isEmpty else { return buffer }
let innerWidth = buffer.width
var result: [String] = []
// Border characters (optionally colored)
let vertical = colorizeBorder(String(style.vertical), with: color)
let reset = "\u{1B}[0m"
// Top border
let topLine = String(style.topLeft)
+ String(repeating: style.horizontal, count: innerWidth)
+ String(style.topRight)
result.append(colorizeBorder(topLine, with: color))
// Content lines with side borders
for line in buffer.lines {
let paddedLine = line.padToVisibleWidth(innerWidth)
let borderedLine = vertical + paddedLine + reset + vertical
result.append(borderedLine)
}
// Bottom border
let bottomLine = String(style.bottomLeft)
+ String(repeating: style.horizontal, count: innerWidth)
+ String(style.bottomRight)
result.append(colorizeBorder(bottomLine, with: color))
return FrameBuffer(lines: result)
}
/// Colorizes border characters.
private func colorizeBorder(_ string: String, with color: Color) -> String {
var style = TextStyle()
style.foregroundColor = color
return ANSIRenderer.render(string, with: style)
}
/// Adds a focus indicator (▸) to the left of the button.
private func addFocusIndicator(to buffer: FrameBuffer) -> FrameBuffer {
guard buffer.height > 0 else { return buffer }
var lines = buffer.lines
// Add indicator on the middle line (or first line if single line)
let middleIndex = buffer.height / 2
let indicator = ANSIRenderer.render("▸ ", with: {
var style = TextStyle()
style.foregroundColor = Color.theme.accent
style.isBold = true
return style
}())
// Prepend indicator to the middle line, pad other lines
for index in 0..<lines.count {
if index == middleIndex {
lines[index] = indicator + lines[index]
} else {
lines[index] = " " + lines[index]
}
}
return FrameBuffer(lines: lines)
}
}
// MARK: - Button Convenience Modifiers
extension Button {
/// Creates a disabled version of this button.
///
/// - Parameter disabled: Whether the button is disabled.
/// - Returns: A new button with the disabled state.
public func disabled(_ disabled: Bool = true) -> Button {
Button(
label,
style: style,
focusedStyle: focusedStyle,
focusID: focusID,
isDisabled: disabled,
action: action
)
}
}
// MARK: - Button Row Helper
/// A horizontal row of buttons.
///
/// Use this to display multiple buttons side by side with consistent spacing.
///
/// # Example
///
/// ```swift
/// ButtonRow {
/// Button("Cancel", style: .plain) { dismiss() }
/// Button("OK", style: .primary) { confirm() }
/// }
/// ```
public struct ButtonRow: View {
private let buttons: [Button]
private let spacing: Int
/// Creates a button row.
///
/// - Parameters:
/// - spacing: The horizontal spacing between buttons (default: 2).
/// - buttons: The buttons to display.
public init(spacing: Int = 2, @ButtonRowBuilder _ buttons: () -> [Button]) {
self.spacing = spacing
self.buttons = buttons()
}
public var body: Never {
fatalError("ButtonRow renders via Renderable")
}
}
// MARK: - ButtonRow Builder
/// Result builder for creating button rows.
@resultBuilder
public struct ButtonRowBuilder {
public static func buildBlock(_ buttons: Button...) -> [Button] {
buttons
}
public static func buildArray(_ components: [[Button]]) -> [Button] {
components.flatMap { $0 }
}
public static func buildOptional(_ component: [Button]?) -> [Button] {
component ?? []
}
public static func buildEither(first component: [Button]) -> [Button] {
component
}
public static func buildEither(second component: [Button]) -> [Button] {
component
}
}
// MARK: - ButtonRow Rendering
extension ButtonRow: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
guard !buttons.isEmpty else {
return FrameBuffer(lines: [])
}
// Render each button
var buttonBuffers: [FrameBuffer] = []
for button in buttons {
let buffer = TUIKit.renderToBuffer(button, context: context)
buttonBuffers.append(buffer)
}
// Find the maximum height
let maxHeight = buttonBuffers.map { $0.height }.max() ?? 0
// Calculate total width needed (buttons + spacing)
let totalButtonWidth = buttonBuffers.reduce(0) { $0 + $1.width }
let totalSpacingWidth = max(0, buttonBuffers.count - 1) * spacing
let totalNeededWidth = totalButtonWidth + totalSpacingWidth
// Available width from context
let availableWidth = context.availableWidth
// Right-align: calculate left padding
let leftPadding = max(0, availableWidth - totalNeededWidth)
// Combine horizontally (right-aligned)
var resultLines: [String] = Array(repeating: "", count: maxHeight)
let spacer = String(repeating: " ", count: spacing)
for lineIndex in 0..<maxHeight {
// Add left padding
resultLines[lineIndex] = String(repeating: " ", count: leftPadding)
// Add buttons
for (index, buffer) in buttonBuffers.enumerated() {
let buttonWidth = buffer.width
if index > 0 {
resultLines[lineIndex] += spacer
}
if lineIndex < buffer.height {
resultLines[lineIndex] += buffer.lines[lineIndex]
} else {
// Pad with spaces if this button is shorter
resultLines[lineIndex] += String(repeating: " ", count: buttonWidth)
}
}
}
return FrameBuffer(lines: resultLines)
}
}