feat: Add TStatusBar with dynamic context-sensitive shortcuts

- Add TStatusBarItemProtocol for custom status bar items
- Add TStatusBarItem with shortcut, label, and action callback
- Add TStatusBarStyle (compact, bordered) for visual customization
- Add StatusBarManager singleton for context-dependent item management
  - Global items (always shown)
  - Context stack (push/pop for dialogs, modals, etc.)
  - Automatic shortcut handling with case-sensitive matching
- Modify AppRunner to render StatusBar separately (never dimmed by overlays)
- Reserve space for StatusBar in content area calculation
- Add 20 new tests for StatusBar system (94 total)
- Restructure Example App into separate files:
  - main.swift (entry point)
  - AppState.swift (state management)
  - ContentView.swift (page router)
  - Components/ (HeaderView, DemoSection)
  - Pages/ (MainMenuPage, TextStylesPage, ColorsPage, etc.)
This commit is contained in:
phranck
2026-01-28 17:16:44 +01:00
parent 5ca3ce2de1
commit 1c896fb1f8
15 changed files with 1571 additions and 551 deletions
+57 -9
View File
@@ -105,22 +105,62 @@ internal final class AppRunner<App: TApp> {
// Clear event handlers before re-rendering
KeyEventDispatcher.shared.clearHandlers()
FocusManager.shared.clear()
let renderer = ViewRenderer(terminal: terminal)
// Calculate available height (reserve space for status bar)
let statusBarHeight = StatusBarManager.shared.hasItems
? (StatusBarManager.shared.style == .bordered ? 3 : 1)
: 0
let contentHeight = terminal.height - statusBarHeight
// Extract the root view from the scene
// Create renderer with adjusted height
let context = RenderContext(
terminal: terminal,
availableWidth: terminal.width,
availableHeight: contentHeight
)
// Render main content
let scene = app.body
renderScene(scene, with: renderer)
renderScene(scene, context: context)
// Render status bar separately (never dimmed)
if StatusBarManager.shared.hasItems {
renderStatusBar(atRow: terminal.height - statusBarHeight + 1)
}
}
private func renderScene<S: TScene>(_ scene: S, with renderer: ViewRenderer) {
private func renderScene<S: TScene>(_ scene: S, context: RenderContext) {
if let renderable = scene as? SceneRenderable {
renderable.renderScene(with: renderer)
renderable.renderScene(context: context)
}
}
/// Renders the status bar at the specified row.
private func renderStatusBar(atRow row: Int) {
let statusBar = TStatusBar()
let context = RenderContext(
terminal: terminal,
availableWidth: terminal.width,
availableHeight: statusBar.height
)
let buffer = renderToBuffer(statusBar, context: context)
// Write directly to terminal at the bottom
for (index, line) in buffer.lines.enumerated() {
terminal.moveCursor(toRow: row + index, column: 1)
terminal.write(line)
}
}
private func handleKeyEvent(_ event: KeyEvent) {
// First, let registered handlers try to handle the event
// First, let the status bar handle the event
if StatusBarManager.shared.handleKeyEvent(event) {
return
}
// Then, let registered handlers try to handle the event
if KeyEventDispatcher.shared.dispatch(event) {
return
}
@@ -144,6 +184,8 @@ internal final class AppRunner<App: TApp> {
terminal.exitAlternateScreen()
AppState.shared.clearObservers()
KeyEventDispatcher.shared.clearHandlers()
StatusBarManager.shared.clear()
FocusManager.shared.clear()
}
private func setupSignalHandlers() {
@@ -167,11 +209,17 @@ internal final class AppRunner<App: TApp> {
/// Internal protocol for renderable scenes.
internal protocol SceneRenderable {
func renderScene(with renderer: ViewRenderer)
func renderScene(context: RenderContext)
}
extension WindowGroup: SceneRenderable {
func renderScene(with renderer: ViewRenderer) {
renderer.render(content)
func renderScene(context: RenderContext) {
let buffer = renderToBuffer(content, context: context)
// Write buffer to terminal
let terminal = Terminal.shared
for (index, line) in buffer.lines.enumerated() {
terminal.moveCursor(toRow: 1 + index, column: 1)
terminal.write(line)
}
}
}
+528
View File
@@ -0,0 +1,528 @@
//
// StatusBar.swift
// SwiftTUI
//
// A status bar that displays keyboard shortcuts and context-sensitive actions.
// Always rendered at the bottom of the terminal, never dimmed by overlays.
//
import Foundation
// MARK: - Status Bar Style
/// The visual style of the status bar.
public enum TStatusBarStyle: Sendable {
/// A single line with horizontal padding.
case compact
/// Block-style border (like `BorderStyle.block`).
case bordered
}
// MARK: - Status Bar Item Protocol
/// A protocol for items that can be displayed in a status bar.
///
/// Implement this protocol to create custom status bar items.
/// The default `TStatusBarItem` already conforms to this protocol.
public protocol TStatusBarItemProtocol: Sendable {
/// The unique identifier for this item.
var id: String { get }
/// The shortcut key(s) to display (e.g., "q", "↑↓", "⎋").
var shortcut: String { get }
/// A short description (one word, e.g., "quit", "nav", "close").
var label: String { get }
/// The key event that triggers this item's action.
///
/// Return nil if the item is purely informational (no action).
var triggerKey: Key? { get }
/// Whether this item matches a given key event.
///
/// Override this for complex matching (e.g., arrow keys).
func matches(_ event: KeyEvent) -> Bool
}
// Default implementation for triggerKey matching
public extension TStatusBarItemProtocol {
func matches(_ event: KeyEvent) -> Bool {
guard let trigger = triggerKey else { return false }
return event.key == trigger
}
}
// MARK: - Status Bar Item
/// A status bar item displaying a shortcut and its description.
///
/// # Example
///
/// ```swift
/// TStatusBarItem(shortcut: "q", label: "quit") {
/// app.quit()
/// }
///
/// TStatusBarItem(shortcut: "↑↓", label: "nav", key: .up) // Info only, no action
/// ```
public struct TStatusBarItem: TStatusBarItemProtocol, Identifiable {
public let id: String
public let shortcut: String
public let label: String
public let triggerKey: Key?
/// The action to perform when the shortcut is triggered.
private let action: (@Sendable () -> Void)?
/// Creates a status bar item with an action.
///
/// - Parameters:
/// - shortcut: The shortcut key(s) to display.
/// - label: A short description (one word).
/// - key: The key that triggers the action (derived from shortcut if not provided).
/// - action: The action to perform.
public init(
shortcut: String,
label: String,
key: Key? = nil,
action: (@Sendable () -> Void)? = nil
) {
self.id = "\(shortcut)-\(label)"
self.shortcut = shortcut
self.label = label
self.action = action
// Derive trigger key from shortcut if not explicitly provided
if let explicitKey = key {
self.triggerKey = explicitKey
} else if let mappedKey = Self.keyFromShortcut(shortcut) {
// First try to map special symbols to keys
self.triggerKey = mappedKey
} else if shortcut.count == 1, let char = shortcut.first {
// Single character becomes a character key
self.triggerKey = .character(char)
} else {
self.triggerKey = nil
}
}
/// Creates an informational status bar item (no action).
///
/// - Parameters:
/// - shortcut: The shortcut key(s) to display.
/// - label: A short description.
public init(shortcut: String, label: String) {
self.init(shortcut: shortcut, label: label, key: nil, action: nil)
}
/// Executes the item's action.
public func execute() {
action?()
}
/// Maps common shortcut symbols to Key values.
private static func keyFromShortcut(_ shortcut: String) -> Key? {
switch shortcut.lowercased() {
case "⎋", "esc", "escape":
return .escape
case "↵", "⏎", "enter", "return":
return .enter
case "⇥", "tab":
return .tab
case "⌫", "backspace", "del":
return .backspace
case "↑":
return .up
case "↓":
return .down
case "←":
return .left
case "→":
return .right
default:
return nil
}
}
/// Override matching for special cases.
public func matches(_ event: KeyEvent) -> Bool {
// Handle arrow key combinations like "↑↓"
if shortcut.contains("↑") && event.key == .up { return true }
if shortcut.contains("↓") && event.key == .down { return true }
if shortcut.contains("←") && event.key == .left { return true }
if shortcut.contains("→") && event.key == .right { return true }
// Standard matching
guard let trigger = triggerKey else { return false }
// For character keys, do case-sensitive matching
// "n" only matches 'n', "N" only matches 'N' (Shift+n)
if case .character(let triggerChar) = trigger,
case .character(let eventChar) = event.key {
return triggerChar == eventChar
}
return event.key == trigger
}
}
// MARK: - Status Bar Manager
/// Manages the status bar state and context-dependent items.
///
/// The StatusBarManager is a singleton that tracks which items should
/// be displayed based on the current context (focused view, active dialog, etc.).
///
/// # Usage
///
/// ```swift
/// // Push a new context with items
/// StatusBarManager.shared.push(context: "dialog") {
/// TStatusBarItem(shortcut: "⎋", label: "close") { dismiss() }
/// TStatusBarItem(shortcut: "↵", label: "confirm") { confirm() }
/// }
///
/// // Pop the context when done
/// StatusBarManager.shared.pop(context: "dialog")
/// ```
public final class StatusBarManager: @unchecked Sendable {
/// The shared manager instance.
public static let shared = StatusBarManager()
/// Stack of contexts with their items.
private var contextStack: [(context: String, items: [any TStatusBarItemProtocol])] = []
/// Global items that are always shown (lowest priority).
private var globalItems: [any TStatusBarItemProtocol] = []
/// The current status bar style.
public var style: TStatusBarStyle = .compact
/// The highlight color for shortcut keys.
public var highlightColor: Color = .cyan
/// The label color.
public var labelColor: Color? = nil // Default terminal color
/// Callback when items change (triggers re-render).
public var onItemsChanged: (() -> Void)?
private init() {}
// MARK: - Global Items
/// Sets the global status bar items (always shown when no context overrides).
///
/// - Parameter items: The items to set.
public func setGlobalItems(_ items: [any TStatusBarItemProtocol]) {
globalItems = items
notifyChange()
}
/// Sets global items using a builder.
///
/// - Parameter builder: A closure that returns items.
public func setGlobalItems(@StatusBarItemBuilder _ builder: () -> [any TStatusBarItemProtocol]) {
globalItems = builder()
notifyChange()
}
// MARK: - Context Stack
/// Pushes a new context with its items onto the stack.
///
/// Items from the most recent context are displayed.
///
/// - Parameters:
/// - context: A unique identifier for this context.
/// - items: The items to display for this context.
public func push(context: String, items: [any TStatusBarItemProtocol]) {
// Remove existing context with same name (if any)
contextStack.removeAll { $0.context == context }
contextStack.append((context, items))
notifyChange()
}
/// Pushes a new context using a builder.
///
/// - Parameters:
/// - context: A unique identifier for this context.
/// - builder: A closure that returns items.
public func push(context: String, @StatusBarItemBuilder _ builder: () -> [any TStatusBarItemProtocol]) {
push(context: context, items: builder())
}
/// Pops a context from the stack.
///
/// - Parameter context: The context identifier to remove.
public func pop(context: String) {
contextStack.removeAll { $0.context == context }
notifyChange()
}
/// Clears all contexts (keeps global items).
public func clearContexts() {
contextStack.removeAll()
notifyChange()
}
/// Clears everything including global items.
public func clear() {
contextStack.removeAll()
globalItems.removeAll()
notifyChange()
}
// MARK: - Current Items
/// The currently active items (topmost context or global).
public var currentItems: [any TStatusBarItemProtocol] {
if let topContext = contextStack.last {
return topContext.items
}
return globalItems
}
/// Whether the status bar has any items to display.
public var hasItems: Bool {
!currentItems.isEmpty
}
// MARK: - Event Handling
/// Handles a key event, checking if any current item matches.
///
/// - Parameter event: The key event to handle.
/// - Returns: True if an item handled the event.
@discardableResult
public func handleKeyEvent(_ event: KeyEvent) -> Bool {
for item in currentItems {
if item.matches(event) {
if let statusBarItem = item as? TStatusBarItem {
statusBarItem.execute()
return true
}
}
}
return false
}
// MARK: - Private
private func notifyChange() {
onItemsChanged?()
AppState.shared.setNeedsRender()
}
}
// MARK: - Status Bar Item Builder
/// Result builder for creating status bar items.
@resultBuilder
public struct StatusBarItemBuilder {
public static func buildBlock(_ items: any TStatusBarItemProtocol...) -> [any TStatusBarItemProtocol] {
items
}
public static func buildBlock(_ items: [any TStatusBarItemProtocol]) -> [any TStatusBarItemProtocol] {
items
}
public static func buildArray(_ components: [[any TStatusBarItemProtocol]]) -> [any TStatusBarItemProtocol] {
components.flatMap { $0 }
}
public static func buildOptional(_ component: [any TStatusBarItemProtocol]?) -> [any TStatusBarItemProtocol] {
component ?? []
}
public static func buildEither(first component: [any TStatusBarItemProtocol]) -> [any TStatusBarItemProtocol] {
component
}
public static func buildEither(second component: [any TStatusBarItemProtocol]) -> [any TStatusBarItemProtocol] {
component
}
public static func buildExpression(_ expression: any TStatusBarItemProtocol) -> [any TStatusBarItemProtocol] {
[expression]
}
}
// MARK: - TStatusBar View
/// A status bar that displays at the bottom of the terminal.
///
/// The status bar shows keyboard shortcuts and their descriptions.
/// It's rendered separately from the main view tree and is never
/// affected by overlays or dimming.
///
/// # Example
///
/// ```swift
/// // The status bar is typically managed via StatusBarManager,
/// // but can also be used directly:
/// TStatusBar(items: [
/// TStatusBarItem(shortcut: "q", label: "quit"),
/// TStatusBarItem(shortcut: "↑↓", label: "nav"),
/// ])
/// ```
public struct TStatusBar: TView {
/// The items to display.
public let items: [any TStatusBarItemProtocol]
/// The visual style.
public let style: TStatusBarStyle
/// The highlight color for shortcut keys.
public let highlightColor: Color
/// The label color.
public let labelColor: Color?
/// Creates a status bar with explicit items.
///
/// - Parameters:
/// - items: The items to display.
/// - style: The visual style (default: `.compact`).
/// - highlightColor: The color for shortcut keys (default: `.cyan`).
/// - labelColor: The color for labels (default: nil, terminal default).
public init(
items: [any TStatusBarItemProtocol],
style: TStatusBarStyle = .compact,
highlightColor: Color = .cyan,
labelColor: Color? = nil
) {
self.items = items
self.style = style
self.highlightColor = highlightColor
self.labelColor = labelColor
}
/// Creates a status bar using the StatusBarManager's current items.
///
/// - Parameter style: The visual style (default: from manager).
public init(style: TStatusBarStyle? = nil) {
let manager = StatusBarManager.shared
self.items = manager.currentItems
self.style = style ?? manager.style
self.highlightColor = manager.highlightColor
self.labelColor = manager.labelColor
}
/// Creates a status bar using a builder.
///
/// - Parameters:
/// - style: The visual style.
/// - highlightColor: The color for shortcut keys.
/// - labelColor: The color for labels.
/// - builder: A closure that returns items.
public init(
style: TStatusBarStyle = .compact,
highlightColor: Color = .cyan,
labelColor: Color? = nil,
@StatusBarItemBuilder _ builder: () -> [any TStatusBarItemProtocol]
) {
self.items = builder()
self.style = style
self.highlightColor = highlightColor
self.labelColor = labelColor
}
public var body: Never {
fatalError("TStatusBar renders via Renderable")
}
}
// MARK: - TStatusBar Rendering
extension TStatusBar: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
guard !items.isEmpty else {
return FrameBuffer()
}
// Build item strings
let itemStrings = items.map { item -> String in
let shortcutStyled = ANSIRenderer.render(item.shortcut, with: {
var style = TextStyle()
style.foregroundColor = highlightColor
style.isBold = true
return style
}())
let labelStyled: String
if let color = labelColor {
labelStyled = ANSIRenderer.render(" " + item.label, with: {
var style = TextStyle()
style.foregroundColor = color
return style
}())
} else {
labelStyled = " " + item.label
}
return shortcutStyled + labelStyled
}
let separator = " " // Two spaces between items
let content = itemStrings.joined(separator: separator)
switch style {
case .compact:
return renderCompact(content: content, width: context.availableWidth)
case .bordered:
return renderBordered(content: content, width: context.availableWidth)
}
}
/// Renders the compact style (single line with padding).
private func renderCompact(content: String, width: Int) -> FrameBuffer {
let padding = " "
let paddedContent = padding + content
let line = paddedContent.padToVisibleWidth(width)
return FrameBuffer(lines: [line])
}
/// Renders the bordered style (block border).
private func renderBordered(content: String, width: Int) -> FrameBuffer {
let border = BorderStyle.block
let innerWidth = width - 2 // Account for left and right border
let padding = " "
let paddedContent = padding + content
// Build the three lines
let topBorder = String(border.topLeft)
+ String(repeating: border.horizontal, count: innerWidth)
+ String(border.topRight)
let contentLine = String(border.vertical)
+ paddedContent.padToVisibleWidth(innerWidth)
+ ANSIRenderer.reset // Prevent color bleeding
+ String(border.vertical)
let bottomBorder = String(border.bottomLeft)
+ String(repeating: border.horizontal, count: innerWidth)
+ String(border.bottomRight)
return FrameBuffer(lines: [topBorder, contentLine, bottomBorder])
}
}
// MARK: - Status Bar Height Helper
extension TStatusBar {
/// The height of the status bar in lines.
public var height: Int {
switch style {
case .compact:
return 1
case .bordered:
return 3
}
}
}
+85
View File
@@ -0,0 +1,85 @@
//
// AppState.swift
// SwiftTUIExample
//
// Global state management for the example app.
//
import SwiftTUI
// MARK: - Demo Page Enum
/// The available demo pages in the example app.
enum DemoPage: Int, CaseIterable {
case menu = 0
case textStyles = 1
case colors = 2
case containers = 3
case overlays = 4
case layout = 5
case buttons = 6
}
// MARK: - App State
/// Global state for the example app.
///
/// This class manages the current page and menu selection.
/// Changes trigger automatic re-renders via `AppState`.
final class ExampleAppState: @unchecked Sendable {
static let shared = ExampleAppState()
/// The current page being displayed.
var currentPage: DemoPage = .menu {
didSet {
updateStatusBar()
AppState.shared.setNeedsRender()
}
}
/// The selected menu index.
var menuSelection: Int = 0 {
didSet { AppState.shared.setNeedsRender() }
}
/// Binding for menu selection.
var menuSelectionBinding: Binding<Int> {
Binding(
get: { self.menuSelection },
set: { self.menuSelection = $0 }
)
}
private init() {
// Set up initial status bar
updateStatusBar()
}
/// Updates the status bar based on the current page.
///
/// The status bar shows context-sensitive shortcuts:
/// - Main menu: navigation, selection, and quit
/// - Sub-pages: back navigation and quit
func updateStatusBar() {
switch currentPage {
case .menu:
// Main menu: navigation + quit
StatusBarManager.shared.setGlobalItems([
TStatusBarItem(shortcut: "↑↓", label: "nav"),
TStatusBarItem(shortcut: "↵", label: "select", key: .enter),
TStatusBarItem(shortcut: "1-6", label: "jump"),
TStatusBarItem(shortcut: "q", label: "quit")
])
default:
// Sub-pages: back + quit
StatusBarManager.shared.setGlobalItems([
TStatusBarItem(shortcut: "⎋", label: "back") { [weak self] in
self?.currentPage = .menu
},
TStatusBarItem(shortcut: "↑↓", label: "scroll"),
TStatusBarItem(shortcut: "q", label: "quit")
])
}
}
}
@@ -0,0 +1,40 @@
//
// DemoSection.swift
// SwiftTUIExample
//
// A reusable section component for organizing demo content.
//
import SwiftTUI
/// A section with a styled title and content.
///
/// Used to group related demo content with a yellow underlined title.
///
/// # Example
///
/// ```swift
/// DemoSection("Basic Features") {
/// Text("Feature 1")
/// Text("Feature 2")
/// }
/// ```
struct DemoSection<Content: TView>: TView {
let title: String
let content: Content
init(_ title: String, @TViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some TView {
VStack(alignment: .leading) {
Text(title)
.bold()
.underline()
.foregroundColor(.yellow)
content
}
}
}
@@ -0,0 +1,50 @@
//
// HeaderView.swift
// SwiftTUIExample
//
// A reusable header component for demo pages.
//
import SwiftTUI
/// A styled header with title on the left and version on the right.
///
/// Used at the top of each demo page to provide consistent branding
/// and optional subtitle.
///
/// # Example
///
/// ```swift
/// HeaderView(
/// title: "My Demo",
/// subtitle: "An optional description"
/// )
/// ```
struct HeaderView: TView {
let title: String
let subtitle: String?
init(title: String, subtitle: String? = nil) {
self.title = title
self.subtitle = subtitle
}
var body: some TView {
VStack {
HStack {
Text(title)
.bold()
.foregroundColor(.cyan)
Spacer()
Text("SwiftTUI v\(swiftTUIVersion)")
.dim()
}
if let sub = subtitle {
Text(sub)
.dim()
.italic()
}
Divider(character: "═")
}
}
}
+58
View File
@@ -0,0 +1,58 @@
//
// ContentView.swift
// SwiftTUIExample
//
// The main content view that routes between demo pages.
//
import SwiftTUI
// MARK: - Content View (Page Router)
/// The main content view that switches between pages.
///
/// This view acts as a router, displaying the appropriate demo page
/// based on the current state. It also handles the ESC key to
/// navigate back to the main menu.
struct ContentView: TView {
var body: some TView {
let state = ExampleAppState.shared
// Show current page based on state
pageContent(for: state.currentPage)
.onKeyPress { event in
switch event.key {
case .escape:
// ESC goes back to menu (or exits if already on menu)
if state.currentPage != .menu {
state.currentPage = .menu
return true // Consumed
}
return false // Let default handler exit the app
default:
return false // Let other handlers process
}
}
}
@TViewBuilder
private func pageContent(for page: DemoPage) -> some TView {
switch page {
case .menu:
MainMenuPage()
case .textStyles:
TextStylesPage()
case .colors:
ColorsPage()
case .containers:
ContainersPage()
case .overlays:
OverlaysPage()
case .layout:
LayoutPage()
case .buttons:
ButtonsPage()
}
}
}
@@ -0,0 +1,73 @@
//
// ButtonsPage.swift
// SwiftTUIExample
//
// Demonstrates button and focus system capabilities.
//
import SwiftTUI
/// Buttons and focus demo page.
///
/// Shows interactive button features including:
/// - Different button styles (default, primary, success, destructive)
/// - Disabled buttons
/// - Plain style (no border)
/// - ButtonRow for horizontal groups
/// - Focus navigation with Tab
struct ButtonsPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Buttons & Focus Demo")
DemoSection("Button Styles") {
HStack(spacing: 2) {
Button("Default") {
// Default style button action
}
Button("Primary", style: .primary) {
// Primary button action
}
Button("Success", style: .success) {
// Success button action
}
Button("Destructive", style: .destructive) {
// Destructive button action
}
}
}
DemoSection("Disabled Button") {
HStack(spacing: 2) {
Button("Enabled") { }
Button("Disabled") { }.disabled()
}
}
DemoSection("Plain Style (No Border)") {
HStack(spacing: 2) {
Button("Link 1", style: .plain) { }
Button("Link 2", style: .plain) { }
}
}
DemoSection("ButtonRow (Horizontal Group)") {
ButtonRow(spacing: 3) {
Button("Cancel") { }
Button("Save", style: .primary) { }
}
}
DemoSection("Focus Navigation") {
VStack {
Text("Use [Tab] to move focus between buttons")
.dim()
Text("Use [Enter] or [Space] to press the focused button")
.dim()
}
}
Spacer()
}
}
}
@@ -0,0 +1,67 @@
//
// ColorsPage.swift
// SwiftTUIExample
//
// Demonstrates color capabilities.
//
import SwiftTUI
/// Colors demo page.
///
/// Shows various color options including:
/// - Standard ANSI colors (8 colors)
/// - Bright colors (8 colors)
/// - RGB colors (24-bit true color)
/// - Semantic colors (primary, success, warning, error)
struct ColorsPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Colors Demo")
DemoSection("Standard ANSI Colors") {
HStack(spacing: 2) {
Text("Black").foregroundColor(.black).background(.white)
Text("Red").foregroundColor(.red)
Text("Green").foregroundColor(.green)
Text("Yellow").foregroundColor(.yellow)
}
HStack(spacing: 2) {
Text("Blue").foregroundColor(.blue)
Text("Magenta").foregroundColor(.magenta)
Text("Cyan").foregroundColor(.cyan)
Text("White").foregroundColor(.white)
}
}
DemoSection("Bright Colors") {
HStack(spacing: 2) {
Text("Bright Red").foregroundColor(.brightRed)
Text("Bright Green").foregroundColor(.brightGreen)
Text("Bright Yellow").foregroundColor(.brightYellow)
Text("Bright Blue").foregroundColor(.brightBlue)
}
}
DemoSection("RGB Colors (24-bit)") {
HStack(spacing: 2) {
Text("Orange").foregroundColor(.rgb(255, 128, 0))
Text("Pink").foregroundColor(.rgb(255, 105, 180))
Text("Teal").foregroundColor(.rgb(0, 128, 128))
Text("Purple").foregroundColor(.rgb(128, 0, 128))
}
}
DemoSection("Semantic Colors") {
HStack(spacing: 2) {
Text("Primary").foregroundColor(.primary)
Text("Success").foregroundColor(.success)
Text("Warning").foregroundColor(.warning)
Text("Error").foregroundColor(.error)
}
}
Spacer()
}
}
}
@@ -0,0 +1,62 @@
//
// ContainersPage.swift
// SwiftTUIExample
//
// Demonstrates container view capabilities.
//
import SwiftTUI
/// Container views demo page.
///
/// Shows various container views including:
/// - Card (bordered container with padding)
/// - Box (simple bordered container)
/// - Panel (container with title in border)
/// - All available border styles
struct ContainersPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Container Views Demo")
HStack(spacing: 2) {
// Card example
VStack(alignment: .leading) {
Text("Card").bold().foregroundColor(.yellow)
Card(borderStyle: .rounded, borderColor: .cyan) {
Text("A Card view")
Text("with padding").dim()
}
}
// Box example
VStack(alignment: .leading) {
Text("Box").bold().foregroundColor(.yellow)
Box(.doubleLine, color: .green) {
Text("Simple Box")
}
}
// Panel example
VStack(alignment: .leading) {
Text("Panel").bold().foregroundColor(.yellow)
Panel("Info", borderStyle: .line, titleColor: .magenta) {
Text("Title in border")
}
}
}
DemoSection("Border Styles") {
HStack(spacing: 1) {
Box(.line) { Text("line") }
Box(.rounded) { Text("rounded") }
Box(.doubleLine) { Text("double") }
Box(.heavy) { Text("heavy") }
Box(.block) { Text("block") }
}
}
Spacer()
}
}
}
@@ -0,0 +1,72 @@
//
// LayoutPage.swift
// SwiftTUIExample
//
// Demonstrates layout system capabilities.
//
import SwiftTUI
/// Layout system demo page.
///
/// Shows various layout options including:
/// - VStack (vertical stacking)
/// - HStack (horizontal stacking)
/// - Spacer (flexible space)
/// - Padding and frame modifiers
struct LayoutPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Layout System Demo")
DemoSection("VStack (Vertical)") {
Box(.rounded, color: .brightBlack) {
VStack(spacing: 0) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
}
}
DemoSection("HStack (Horizontal)") {
Box(.rounded, color: .brightBlack) {
HStack(spacing: 2) {
Text("Left")
Text("Center")
Text("Right")
}
}
}
DemoSection("Spacer") {
Box(.rounded, color: .brightBlack) {
HStack {
Text("Start")
Spacer()
Text("End")
}
}
}
DemoSection("Padding & Frame") {
HStack(spacing: 2) {
VStack {
Text(".padding()").dim()
Text("Padded")
.padding(EdgeInsets(all: 1))
.border(.line)
}
VStack {
Text(".frame()").dim()
Text("Framed")
.frame(width: 15, alignment: .center)
.border(.line)
}
}
}
Spacer()
}
}
}
@@ -0,0 +1,81 @@
//
// MainMenuPage.swift
// SwiftTUIExample
//
// The main menu page with navigation to all demos.
//
import SwiftTUI
/// The main menu page.
///
/// Displays a centered menu with all available demos and
/// feature highlight boxes at the bottom.
struct MainMenuPage: TView {
var body: some TView {
let state = ExampleAppState.shared
VStack(spacing: 1) {
HeaderView(
title: "SwiftTUI Example App",
subtitle: "A SwiftUI-like framework for Terminal User Interfaces"
)
Spacer(minLength: 1)
HStack {
Spacer()
Menu(
title: "Select a Demo",
items: [
MenuItem(label: "Text Styles", shortcut: "1"),
MenuItem(label: "Colors", shortcut: "2"),
MenuItem(label: "Container Views", shortcut: "3"),
MenuItem(label: "Overlays & Modals", shortcut: "4"),
MenuItem(label: "Layout System", shortcut: "5"),
MenuItem(label: "Buttons & Focus", shortcut: "6")
],
selection: state.menuSelectionBinding,
onSelect: { index in
// Navigate to the selected page
if let page = DemoPage(rawValue: index + 1) {
state.currentPage = page
}
},
selectedColor: .cyan,
borderStyle: .rounded,
borderColor: .brightBlack
)
Spacer()
}
Spacer(minLength: 1)
// Feature highlights (centered)
HStack {
Spacer()
HStack(spacing: 3) {
featureBox("Pure Swift", "No ncurses")
featureBox("Declarative", "SwiftUI-like")
featureBox("Composable", "View protocol")
}
Spacer()
}
Spacer()
}
}
/// Creates a small feature highlight box.
private func featureBox(_ title: String, _ subtitle: String) -> some TView {
VStack {
Text(title)
.bold()
.foregroundColor(.green)
Text(subtitle)
.dim()
}
.padding(EdgeInsets(horizontal: 2, vertical: 1))
.border(.rounded, color: .brightBlack)
}
}
@@ -0,0 +1,58 @@
//
// OverlaysPage.swift
// SwiftTUIExample
//
// Demonstrates overlay and modal capabilities.
//
import SwiftTUI
/// Overlays and modals demo page.
///
/// Shows the overlay system including:
/// - `.overlay()` modifier
/// - `.dimmed()` modifier
/// - `.modal()` helper
/// - Note: The status bar is NOT dimmed by modals!
struct OverlaysPage: TView {
var body: some TView {
// Background content with modal overlay
backgroundContent
.modal {
Alert(
title: "Modal Alert",
message: "This alert overlays dimmed content!",
borderStyle: .rounded,
borderColor: .yellow,
titleColor: .yellow
) {
HStack {
Text("[OK]").bold().foregroundColor(.green)
Spacer()
Text("[Cancel]").foregroundColor(.red)
}
}
}
}
var backgroundContent: some TView {
VStack(spacing: 1) {
HeaderView(title: "Overlays & Modals Demo")
DemoSection("Overlay System Features") {
Text("• .overlay() modifier - layer content on top")
Text("• .dimmed() modifier - reduce visual emphasis")
Text("• .modal() helper - combines dimmed + centered overlay")
}
DemoSection("This page demonstrates a modal overlay") {
Text("The content behind is dimmed automatically")
Text("Note: The status bar is NOT dimmed!")
.bold()
.foregroundColor(.green)
}
Spacer()
}
}
}
@@ -0,0 +1,46 @@
//
// TextStylesPage.swift
// SwiftTUIExample
//
// Demonstrates text styling capabilities.
//
import SwiftTUI
/// Text styles demo page.
///
/// Shows various text styling options including:
/// - Basic styles (bold, italic, underline, etc.)
/// - Combined styles
/// - Special effects (blink, inverted)
struct TextStylesPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Text Styles Demo")
DemoSection("Basic Styles") {
Text("Normal text - no styling applied")
Text("Bold text").bold()
Text("Italic text").italic()
Text("Underlined text").underline()
Text("Strikethrough text").strikethrough()
Text("Dimmed text").dim()
}
DemoSection("Combined Styles") {
Text("Bold + Italic").bold().italic()
Text("Bold + Underline").bold().underline()
Text("Bold + Color").bold().foregroundColor(.cyan)
Text("Italic + Dim").italic().dim()
Text("All combined").bold().italic().underline().foregroundColor(.magenta)
}
DemoSection("Special Effects") {
Text("Blinking text (if terminal supports)").blink()
Text("Inverted colors").inverted()
}
Spacer()
}
}
}
+4 -542
View File
@@ -2,552 +2,14 @@
// main.swift
// SwiftTUIExample
//
// A comprehensive example app demonstrating SwiftTUI capabilities.
// Features a main menu with multiple demo pages.
// Entry point for the SwiftTUI example application.
//
// This app demonstrates SwiftTUI capabilities through various demo pages.
// Use the menu to navigate between demos.
//
import SwiftTUI
// MARK: - Demo Page Enum
/// The available demo pages in the example app.
enum DemoPage: Int, CaseIterable {
case menu = 0
case textStyles = 1
case colors = 2
case containers = 3
case overlays = 4
case layout = 5
case buttons = 6
}
// MARK: - App State
/// Global state for the example app.
/// Using a simple class with manual AppState notification.
final class ExampleAppState: @unchecked Sendable {
static let shared = ExampleAppState()
/// The current page being displayed.
var currentPage: DemoPage = .menu {
didSet { AppState.shared.setNeedsRender() }
}
/// The selected menu index.
var menuSelection: Int = 0 {
didSet { AppState.shared.setNeedsRender() }
}
/// Binding for menu selection.
var menuSelectionBinding: Binding<Int> {
Binding(
get: { self.menuSelection },
set: { self.menuSelection = $0 }
)
}
private init() {}
}
// MARK: - Shared Components
/// A styled header with title on the left and version on the right.
struct HeaderView: TView {
let title: String
let subtitle: String?
init(title: String, subtitle: String? = nil) {
self.title = title
self.subtitle = subtitle
}
var body: some TView {
VStack {
HStack {
Text(title)
.bold()
.foregroundColor(.cyan)
Spacer()
Text("SwiftTUI v\(swiftTUIVersion)")
.dim()
}
if let sub = subtitle {
Text(sub)
.dim()
.italic()
}
Divider(character: "═")
}
}
}
/// A footer with navigation hints.
struct FooterView: TView {
let showBackHint: Bool
var body: some TView {
VStack {
Divider(character: "─")
HStack {
if showBackHint {
Text("[ESC] Back")
.dim()
Text(" ")
}
Text("[↑↓] Navigate [Enter] Select [Q] Quit")
.dim()
Spacer()
Text("SwiftTUI")
.dim()
.italic()
}
}
}
}
/// A section with a title and content.
struct DemoSection<Content: TView>: TView {
let title: String
let content: Content
init(_ title: String, @TViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some TView {
VStack(alignment: .leading) {
Text(title)
.bold()
.underline()
.foregroundColor(.yellow)
content
}
}
}
// MARK: - Content View (Page Router)
/// The main content view that switches between pages.
struct ContentView: TView {
var body: some TView {
let state = ExampleAppState.shared
// Show current page based on state
pageContent(for: state.currentPage)
.onKeyPress { event in
switch event.key {
case .escape:
// ESC goes back to menu (or exits if already on menu)
if state.currentPage != .menu {
state.currentPage = .menu
return true // Consumed
}
return false // Let default handler exit the app
default:
return false // Let other handlers process
}
}
}
@TViewBuilder
private func pageContent(for page: DemoPage) -> some TView {
switch page {
case .menu:
MainMenuPage()
case .textStyles:
TextStylesPage()
case .colors:
ColorsPage()
case .containers:
ContainersPage()
case .overlays:
OverlaysPage()
case .layout:
LayoutPage()
case .buttons:
ButtonsPage()
}
}
}
// MARK: - Main Menu Page
struct MainMenuPage: TView {
var body: some TView {
let state = ExampleAppState.shared
VStack(spacing: 1) {
HeaderView(
title: "SwiftTUI Example App",
subtitle: "A SwiftUI-like framework for Terminal User Interfaces"
)
Spacer(minLength: 1)
HStack {
Spacer()
Menu(
title: "Select a Demo",
items: [
MenuItem(label: "Text Styles", shortcut: "1"),
MenuItem(label: "Colors", shortcut: "2"),
MenuItem(label: "Container Views", shortcut: "3"),
MenuItem(label: "Overlays & Modals", shortcut: "4"),
MenuItem(label: "Layout System", shortcut: "5"),
MenuItem(label: "Buttons & Focus", shortcut: "6")
],
selection: state.menuSelectionBinding,
onSelect: { index in
// Navigate to the selected page
if let page = DemoPage(rawValue: index + 1) {
state.currentPage = page
}
},
selectedColor: .cyan,
borderStyle: .rounded,
borderColor: .brightBlack
)
Spacer()
}
Spacer(minLength: 1)
// Feature highlights (centered)
HStack {
Spacer()
HStack(spacing: 3) {
featureBox("Pure Swift", "No ncurses")
featureBox("Declarative", "SwiftUI-like")
featureBox("Composable", "View protocol")
}
Spacer()
}
Spacer()
FooterView(showBackHint: false)
}
}
private func featureBox(_ title: String, _ subtitle: String) -> some TView {
VStack {
Text(title)
.bold()
.foregroundColor(.green)
Text(subtitle)
.dim()
}
.padding(EdgeInsets(horizontal: 2, vertical: 1))
.border(.rounded, color: .brightBlack)
}
}
// MARK: - Text Styles Demo Page
struct TextStylesPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Text Styles Demo")
DemoSection("Basic Styles") {
Text("Normal text - no styling applied")
Text("Bold text").bold()
Text("Italic text").italic()
Text("Underlined text").underline()
Text("Strikethrough text").strikethrough()
Text("Dimmed text").dim()
}
DemoSection("Combined Styles") {
Text("Bold + Italic").bold().italic()
Text("Bold + Underline").bold().underline()
Text("Bold + Color").bold().foregroundColor(.cyan)
Text("Italic + Dim").italic().dim()
Text("All combined").bold().italic().underline().foregroundColor(.magenta)
}
DemoSection("Special Effects") {
Text("Blinking text (if terminal supports)").blink()
Text("Inverted colors").inverted()
}
Spacer()
FooterView(showBackHint: true)
}
}
}
// MARK: - Colors Demo Page
struct ColorsPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Colors Demo")
DemoSection("Standard ANSI Colors") {
HStack(spacing: 2) {
Text("Black").foregroundColor(.black).background(.white)
Text("Red").foregroundColor(.red)
Text("Green").foregroundColor(.green)
Text("Yellow").foregroundColor(.yellow)
}
HStack(spacing: 2) {
Text("Blue").foregroundColor(.blue)
Text("Magenta").foregroundColor(.magenta)
Text("Cyan").foregroundColor(.cyan)
Text("White").foregroundColor(.white)
}
}
DemoSection("Bright Colors") {
HStack(spacing: 2) {
Text("Bright Red").foregroundColor(.brightRed)
Text("Bright Green").foregroundColor(.brightGreen)
Text("Bright Yellow").foregroundColor(.brightYellow)
Text("Bright Blue").foregroundColor(.brightBlue)
}
}
DemoSection("RGB Colors (24-bit)") {
HStack(spacing: 2) {
Text("Orange").foregroundColor(.rgb(255, 128, 0))
Text("Pink").foregroundColor(.rgb(255, 105, 180))
Text("Teal").foregroundColor(.rgb(0, 128, 128))
Text("Purple").foregroundColor(.rgb(128, 0, 128))
}
}
DemoSection("Semantic Colors") {
HStack(spacing: 2) {
Text("Primary").foregroundColor(.primary)
Text("Success").foregroundColor(.success)
Text("Warning").foregroundColor(.warning)
Text("Error").foregroundColor(.error)
}
}
Spacer()
FooterView(showBackHint: true)
}
}
}
// MARK: - Containers Demo Page
struct ContainersPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Container Views Demo")
HStack(spacing: 2) {
// Card example
VStack(alignment: .leading) {
Text("Card").bold().foregroundColor(.yellow)
Card(borderStyle: .rounded, borderColor: .cyan) {
Text("A Card view")
Text("with padding").dim()
}
}
// Box example
VStack(alignment: .leading) {
Text("Box").bold().foregroundColor(.yellow)
Box(.doubleLine, color: .green) {
Text("Simple Box")
}
}
// Panel example
VStack(alignment: .leading) {
Text("Panel").bold().foregroundColor(.yellow)
Panel("Info", borderStyle: .line, titleColor: .magenta) {
Text("Title in border")
}
}
}
DemoSection("Border Styles") {
HStack(spacing: 1) {
Box(.line) { Text("line") }
Box(.rounded) { Text("rounded") }
Box(.doubleLine) { Text("double") }
Box(.heavy) { Text("heavy") }
Box(.block) { Text("block") }
}
}
Spacer()
FooterView(showBackHint: true)
}
}
}
// MARK: - Overlays Demo Page
struct OverlaysPage: TView {
var body: some TView {
// Background content with modal overlay
backgroundContent
.modal {
Alert(
title: "Modal Alert",
message: "This alert overlays dimmed content!",
borderStyle: .rounded,
borderColor: .yellow,
titleColor: .yellow
) {
HStack {
Text("[OK]").bold().foregroundColor(.green)
Spacer()
Text("[Cancel]").foregroundColor(.red)
}
}
}
}
var backgroundContent: some TView {
VStack(spacing: 1) {
HeaderView(title: "Overlays & Modals Demo")
DemoSection("Overlay System Features") {
Text("• .overlay() modifier - layer content on top")
Text("• .dimmed() modifier - reduce visual emphasis")
Text("• .modal() helper - combines dimmed + centered overlay")
}
DemoSection("This page demonstrates a modal overlay") {
Text("The content behind is dimmed automatically")
Text("Press [B] to go back to the menu")
}
Spacer()
FooterView(showBackHint: true)
}
}
}
// MARK: - Buttons Demo Page
struct ButtonsPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Buttons & Focus Demo")
DemoSection("Button Styles") {
HStack(spacing: 2) {
Button("Default") {
// Default style button action
}
Button("Primary", style: .primary) {
// Primary button action
}
Button("Success", style: .success) {
// Success button action
}
Button("Destructive", style: .destructive) {
// Destructive button action
}
}
}
DemoSection("Disabled Button") {
HStack(spacing: 2) {
Button("Enabled") { }
Button("Disabled") { }.disabled()
}
}
DemoSection("Plain Style (No Border)") {
HStack(spacing: 2) {
Button("Link 1", style: .plain) { }
Button("Link 2", style: .plain) { }
}
}
DemoSection("ButtonRow (Horizontal Group)") {
ButtonRow(spacing: 3) {
Button("Cancel") { }
Button("Save", style: .primary) { }
}
}
DemoSection("Focus Navigation") {
VStack {
Text("Use [Tab] to move focus between buttons")
.dim()
Text("Use [Enter] or [Space] to press the focused button")
.dim()
}
}
Spacer()
FooterView(showBackHint: true)
}
}
}
// MARK: - Layout Demo Page
struct LayoutPage: TView {
var body: some TView {
VStack(spacing: 1) {
HeaderView(title: "Layout System Demo")
DemoSection("VStack (Vertical)") {
Box(.rounded, color: .brightBlack) {
VStack(spacing: 0) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
}
}
DemoSection("HStack (Horizontal)") {
Box(.rounded, color: .brightBlack) {
HStack(spacing: 2) {
Text("Left")
Text("Center")
Text("Right")
}
}
}
DemoSection("Spacer") {
Box(.rounded, color: .brightBlack) {
HStack {
Text("Start")
Spacer()
Text("End")
}
}
}
DemoSection("Padding & Frame") {
HStack(spacing: 2) {
VStack {
Text(".padding()").dim()
Text("Padded")
.padding(EdgeInsets(all: 1))
.border(.line)
}
VStack {
Text(".frame()").dim()
Text("Framed")
.frame(width: 15, alignment: .center)
.border(.line)
}
}
}
Spacer()
FooterView(showBackHint: true)
}
}
}
// MARK: - Main App
/// The main example application.
+290
View File
@@ -807,3 +807,293 @@ struct ButtonRowTests {
#expect(buffer.isEmpty)
}
}
@Suite("StatusBar Item Tests")
struct StatusBarItemTests {
init() {
StatusBarManager.shared.clear()
}
@Test("TStatusBarItem can be created with shortcut and label")
func statusBarItemCreation() {
let item = TStatusBarItem(shortcut: "q", label: "quit")
#expect(item.shortcut == "q")
#expect(item.label == "quit")
#expect(item.id == "q-quit")
}
@Test("TStatusBarItem derives trigger key from single character shortcut")
func statusBarItemTriggerKey() {
let item = TStatusBarItem(shortcut: "x", label: "delete")
#expect(item.triggerKey == .character("x"))
}
@Test("TStatusBarItem with escape shortcut")
func statusBarItemEscapeKey() {
let item = TStatusBarItem(shortcut: "⎋", label: "close")
#expect(item.triggerKey == .escape)
}
@Test("TStatusBarItem with enter shortcut")
func statusBarItemEnterKey() {
let item = TStatusBarItem(shortcut: "↵", label: "confirm")
#expect(item.triggerKey == .enter)
}
@Test("TStatusBarItem matches arrow key combinations")
func statusBarItemArrowKeys() {
let item = TStatusBarItem(shortcut: "↑↓", label: "nav")
let upEvent = KeyEvent(key: .up)
let downEvent = KeyEvent(key: .down)
let leftEvent = KeyEvent(key: .left)
#expect(item.matches(upEvent) == true)
#expect(item.matches(downEvent) == true)
#expect(item.matches(leftEvent) == false)
}
@Test("TStatusBarItem is case-sensitive for character shortcuts")
func statusBarItemCaseSensitive() {
let lowerItem = TStatusBarItem(shortcut: "n", label: "next")
let upperItem = TStatusBarItem(shortcut: "N", label: "new")
let lowerEvent = KeyEvent(key: .character("n"))
let upperEvent = KeyEvent(key: .character("N"))
// Lowercase "n" should only match lowercase
#expect(lowerItem.matches(lowerEvent) == true)
#expect(lowerItem.matches(upperEvent) == false)
// Uppercase "N" should only match uppercase
#expect(upperItem.matches(lowerEvent) == false)
#expect(upperItem.matches(upperEvent) == true)
}
@Test("TStatusBarItem executes action")
func statusBarItemAction() {
// Use a class to track execution (reference type is Sendable-safe)
final class ExecutionTracker: @unchecked Sendable {
var wasExecuted = false
}
let tracker = ExecutionTracker()
let item = TStatusBarItem(shortcut: "t", label: "test") {
tracker.wasExecuted = true
}
item.execute()
#expect(tracker.wasExecuted == true)
}
@Test("TStatusBarItem without action does not crash")
func statusBarItemNoAction() {
let item = TStatusBarItem(shortcut: "i", label: "info")
item.execute() // Should not crash
}
}
@Suite("StatusBar Manager Tests")
struct StatusBarManagerTests {
/// Clears the manager and removes any callbacks that might interfere.
private func resetManager() {
StatusBarManager.shared.onItemsChanged = nil
StatusBarManager.shared.clear()
}
@Test("StatusBarManager is singleton")
func managerSingleton() {
let manager1 = StatusBarManager.shared
let manager2 = StatusBarManager.shared
#expect(manager1 === manager2)
}
@Test("StatusBarManager can be cleared")
func managerCanBeCleared() {
resetManager()
// Add some items
StatusBarManager.shared.setGlobalItems([
TStatusBarItem(shortcut: "x", label: "test")
])
#expect(StatusBarManager.shared.hasItems == true)
// Clear and verify
StatusBarManager.shared.clear()
#expect(StatusBarManager.shared.hasItems == false)
#expect(StatusBarManager.shared.currentItems.isEmpty)
}
@Test("StatusBarManager global items can be set")
func managerGlobalItems() {
resetManager()
StatusBarManager.shared.setGlobalItems([
TStatusBarItem(shortcut: "q", label: "quit"),
TStatusBarItem(shortcut: "h", label: "help")
])
#expect(StatusBarManager.shared.hasItems == true)
#expect(StatusBarManager.shared.currentItems.count == 2)
}
@Test("StatusBarManager context stack")
func managerContextStack() {
resetManager()
// Set global items
StatusBarManager.shared.setGlobalItems([
TStatusBarItem(shortcut: "q", label: "quit")
])
#expect(StatusBarManager.shared.currentItems.count == 1)
// Push a context
StatusBarManager.shared.push(context: "dialog", items: [
TStatusBarItem(shortcut: "⎋", label: "close"),
TStatusBarItem(shortcut: "↵", label: "confirm")
])
#expect(StatusBarManager.shared.currentItems.count == 2)
#expect(StatusBarManager.shared.currentItems[0].shortcut == "⎋")
// Pop the context
StatusBarManager.shared.pop(context: "dialog")
#expect(StatusBarManager.shared.currentItems.count == 1)
#expect(StatusBarManager.shared.currentItems[0].shortcut == "q")
}
@Test("StatusBarManager handles key events")
func managerKeyEvents() {
resetManager()
// Use a class to track execution (reference type is Sendable-safe)
final class ExecutionTracker: @unchecked Sendable {
var wasHandled = false
}
let tracker = ExecutionTracker()
StatusBarManager.shared.setGlobalItems([
TStatusBarItem(shortcut: "t", label: "test") {
tracker.wasHandled = true
}
])
let event = KeyEvent(key: .character("t"))
let handled = StatusBarManager.shared.handleKeyEvent(event)
#expect(handled == true)
#expect(tracker.wasHandled == true)
}
@Test("StatusBarManager does not handle unmatched events")
func managerUnmatchedEvents() {
resetManager()
StatusBarManager.shared.setGlobalItems([
TStatusBarItem(shortcut: "a", label: "action")
])
let event = KeyEvent(key: .character("z"))
let handled = StatusBarManager.shared.handleKeyEvent(event)
#expect(handled == false)
}
}
@Suite("TStatusBar Tests")
struct TStatusBarTests {
init() {
StatusBarManager.shared.clear()
}
@Test("TStatusBar compact style renders single line")
func statusBarCompactStyle() {
let statusBar = TStatusBar(
items: [TStatusBarItem(shortcut: "q", label: "quit")],
style: .compact
)
#expect(statusBar.height == 1)
let context = RenderContext(availableWidth: 80, availableHeight: 24)
let buffer = renderToBuffer(statusBar, context: context)
#expect(buffer.height == 1)
#expect(buffer.lines[0].stripped.contains("q"))
#expect(buffer.lines[0].stripped.contains("quit"))
}
@Test("TStatusBar bordered style renders three lines")
func statusBarBorderedStyle() {
let statusBar = TStatusBar(
items: [TStatusBarItem(shortcut: "q", label: "quit")],
style: .bordered
)
#expect(statusBar.height == 3)
let context = RenderContext(availableWidth: 80, availableHeight: 24)
let buffer = renderToBuffer(statusBar, context: context)
#expect(buffer.height == 3)
// Check for block border characters
let topLine = buffer.lines[0]
#expect(topLine.contains("█") || topLine.contains("▀"))
}
@Test("TStatusBar with multiple items")
func statusBarMultipleItems() {
let statusBar = TStatusBar(
items: [
TStatusBarItem(shortcut: "q", label: "quit"),
TStatusBarItem(shortcut: "↑↓", label: "nav"),
TStatusBarItem(shortcut: "⎋", label: "close")
],
style: .compact
)
let context = RenderContext(availableWidth: 80, availableHeight: 24)
let buffer = renderToBuffer(statusBar, context: context)
let content = buffer.lines[0].stripped
#expect(content.contains("quit"))
#expect(content.contains("nav"))
#expect(content.contains("close"))
}
@Test("TStatusBar with empty items renders nothing")
func statusBarEmptyItems() {
let statusBar = TStatusBar(items: [], style: .compact)
let context = RenderContext(availableWidth: 80, availableHeight: 24)
let buffer = renderToBuffer(statusBar, context: context)
#expect(buffer.isEmpty)
}
@Test("TStatusBar uses StatusBarManager items")
func statusBarFromManager() {
StatusBarManager.shared.clear()
StatusBarManager.shared.setGlobalItems([
TStatusBarItem(shortcut: "h", label: "help")
])
let statusBar = TStatusBar()
let context = RenderContext(availableWidth: 80, availableHeight: 24)
let buffer = renderToBuffer(statusBar, context: context)
#expect(buffer.lines[0].stripped.contains("help"))
}
@Test("TStatusBar with explicit items array")
func statusBarExplicitItems() {
let items: [any TStatusBarItemProtocol] = [
TStatusBarItem(shortcut: "1", label: "one"),
TStatusBarItem(shortcut: "2", label: "two")
]
let statusBar = TStatusBar(items: items, style: .compact)
#expect(statusBar.items.count == 2)
}
}