From 1c896fb1f8f94cd46ea136ef3b14bc06382382e1 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 28 Jan 2026 17:16:44 +0100 Subject: [PATCH] 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.) --- Sources/SwiftTUI/App/TApp.swift | 66 ++- Sources/SwiftTUI/Views/StatusBar.swift | 528 +++++++++++++++++ Sources/SwiftTUIExample/AppState.swift | 85 +++ .../Components/DemoSection.swift | 40 ++ .../Components/HeaderView.swift | 50 ++ Sources/SwiftTUIExample/ContentView.swift | 58 ++ .../SwiftTUIExample/Pages/ButtonsPage.swift | 73 +++ .../SwiftTUIExample/Pages/ColorsPage.swift | 67 +++ .../Pages/ContainersPage.swift | 62 ++ .../SwiftTUIExample/Pages/LayoutPage.swift | 72 +++ .../SwiftTUIExample/Pages/MainMenuPage.swift | 81 +++ .../SwiftTUIExample/Pages/OverlaysPage.swift | 58 ++ .../Pages/TextStylesPage.swift | 46 ++ Sources/SwiftTUIExample/main.swift | 546 +----------------- Tests/SwiftTUITests/TViewTests.swift | 290 ++++++++++ 15 files changed, 1571 insertions(+), 551 deletions(-) create mode 100644 Sources/SwiftTUI/Views/StatusBar.swift create mode 100644 Sources/SwiftTUIExample/AppState.swift create mode 100644 Sources/SwiftTUIExample/Components/DemoSection.swift create mode 100644 Sources/SwiftTUIExample/Components/HeaderView.swift create mode 100644 Sources/SwiftTUIExample/ContentView.swift create mode 100644 Sources/SwiftTUIExample/Pages/ButtonsPage.swift create mode 100644 Sources/SwiftTUIExample/Pages/ColorsPage.swift create mode 100644 Sources/SwiftTUIExample/Pages/ContainersPage.swift create mode 100644 Sources/SwiftTUIExample/Pages/LayoutPage.swift create mode 100644 Sources/SwiftTUIExample/Pages/MainMenuPage.swift create mode 100644 Sources/SwiftTUIExample/Pages/OverlaysPage.swift create mode 100644 Sources/SwiftTUIExample/Pages/TextStylesPage.swift diff --git a/Sources/SwiftTUI/App/TApp.swift b/Sources/SwiftTUI/App/TApp.swift index 5760cff..046105d 100644 --- a/Sources/SwiftTUI/App/TApp.swift +++ b/Sources/SwiftTUI/App/TApp.swift @@ -105,22 +105,62 @@ internal final class AppRunner { // 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(_ scene: S, with renderer: ViewRenderer) { + private func renderScene(_ 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 { 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 { /// 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) + } } } diff --git a/Sources/SwiftTUI/Views/StatusBar.swift b/Sources/SwiftTUI/Views/StatusBar.swift new file mode 100644 index 0000000..cb4228b --- /dev/null +++ b/Sources/SwiftTUI/Views/StatusBar.swift @@ -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 + } + } +} diff --git a/Sources/SwiftTUIExample/AppState.swift b/Sources/SwiftTUIExample/AppState.swift new file mode 100644 index 0000000..ee94d4f --- /dev/null +++ b/Sources/SwiftTUIExample/AppState.swift @@ -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 { + 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") + ]) + } + } +} diff --git a/Sources/SwiftTUIExample/Components/DemoSection.swift b/Sources/SwiftTUIExample/Components/DemoSection.swift new file mode 100644 index 0000000..df3cbc5 --- /dev/null +++ b/Sources/SwiftTUIExample/Components/DemoSection.swift @@ -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: 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 + } + } +} diff --git a/Sources/SwiftTUIExample/Components/HeaderView.swift b/Sources/SwiftTUIExample/Components/HeaderView.swift new file mode 100644 index 0000000..bbaf129 --- /dev/null +++ b/Sources/SwiftTUIExample/Components/HeaderView.swift @@ -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: "═") + } + } +} diff --git a/Sources/SwiftTUIExample/ContentView.swift b/Sources/SwiftTUIExample/ContentView.swift new file mode 100644 index 0000000..6e2479f --- /dev/null +++ b/Sources/SwiftTUIExample/ContentView.swift @@ -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() + } + } +} diff --git a/Sources/SwiftTUIExample/Pages/ButtonsPage.swift b/Sources/SwiftTUIExample/Pages/ButtonsPage.swift new file mode 100644 index 0000000..87e7c9f --- /dev/null +++ b/Sources/SwiftTUIExample/Pages/ButtonsPage.swift @@ -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() + } + } +} diff --git a/Sources/SwiftTUIExample/Pages/ColorsPage.swift b/Sources/SwiftTUIExample/Pages/ColorsPage.swift new file mode 100644 index 0000000..a4533f9 --- /dev/null +++ b/Sources/SwiftTUIExample/Pages/ColorsPage.swift @@ -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() + } + } +} diff --git a/Sources/SwiftTUIExample/Pages/ContainersPage.swift b/Sources/SwiftTUIExample/Pages/ContainersPage.swift new file mode 100644 index 0000000..5733396 --- /dev/null +++ b/Sources/SwiftTUIExample/Pages/ContainersPage.swift @@ -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() + } + } +} diff --git a/Sources/SwiftTUIExample/Pages/LayoutPage.swift b/Sources/SwiftTUIExample/Pages/LayoutPage.swift new file mode 100644 index 0000000..dd4a71c --- /dev/null +++ b/Sources/SwiftTUIExample/Pages/LayoutPage.swift @@ -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() + } + } +} diff --git a/Sources/SwiftTUIExample/Pages/MainMenuPage.swift b/Sources/SwiftTUIExample/Pages/MainMenuPage.swift new file mode 100644 index 0000000..8721a74 --- /dev/null +++ b/Sources/SwiftTUIExample/Pages/MainMenuPage.swift @@ -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) + } +} diff --git a/Sources/SwiftTUIExample/Pages/OverlaysPage.swift b/Sources/SwiftTUIExample/Pages/OverlaysPage.swift new file mode 100644 index 0000000..e69e1be --- /dev/null +++ b/Sources/SwiftTUIExample/Pages/OverlaysPage.swift @@ -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() + } + } +} diff --git a/Sources/SwiftTUIExample/Pages/TextStylesPage.swift b/Sources/SwiftTUIExample/Pages/TextStylesPage.swift new file mode 100644 index 0000000..15fe7ae --- /dev/null +++ b/Sources/SwiftTUIExample/Pages/TextStylesPage.swift @@ -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() + } + } +} diff --git a/Sources/SwiftTUIExample/main.swift b/Sources/SwiftTUIExample/main.swift index 0d3a6ce..6c28713 100644 --- a/Sources/SwiftTUIExample/main.swift +++ b/Sources/SwiftTUIExample/main.swift @@ -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 { - 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: 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. diff --git a/Tests/SwiftTUITests/TViewTests.swift b/Tests/SwiftTUITests/TViewTests.swift index dcb6c60..64842cc 100644 --- a/Tests/SwiftTUITests/TViewTests.swift +++ b/Tests/SwiftTUITests/TViewTests.swift @@ -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) + } +}