mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: "═")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user