diff --git a/Sources/TUIKit/App/App.swift b/Sources/TUIKit/App/App.swift index 6ba7e77..1f14180 100644 --- a/Sources/TUIKit/App/App.swift +++ b/Sources/TUIKit/App/App.swift @@ -50,376 +50,15 @@ extension App { } } -// MARK: - Quit Behavior - -/// Controls when the quit shortcut (`q`) is active. -public enum QuitBehavior: Sendable { - /// Quit works from any screen. - /// - /// Pressing `q` will always exit the application, regardless of - /// the current navigation state. - case always - - /// Quit only works from the root/main screen. - /// - /// Pressing `q` will only exit when no context is pushed onto the - /// status bar stack. On subpages, `q` does nothing, allowing the - /// app to handle navigation (e.g., ESC to go back). - case rootOnly -} - -// MARK: - Status Bar State - -/// Manages the status bar state for the running application. -/// -/// This class is created by the `AppRunner` and injected into the -/// environment for views to access. -/// -/// # Usage -/// -/// ```swift -/// struct MyView: View { -/// @Environment(\.statusBar) var statusBar -/// -/// var body: some View { -/// Button("Action") { -/// statusBar.setItems([ -/// StatusBarItem(shortcut: "⎋", label: "cancel") -/// ]) -/// } -/// } -/// } -/// ``` -public final class StatusBarState: @unchecked Sendable { - // MARK: - User Items - - /// Stack of user contexts with their items. - private var userContextStack: [(context: String, items: [any StatusBarItemProtocol])] = [] - - /// Global user items that are always shown (lowest priority). - private var userGlobalItems: [any StatusBarItemProtocol] = [] - - // MARK: - System Items Configuration - - /// Whether system items are shown at all. - /// - /// Set to `false` to hide all system items (quit, help, theme). - /// Default is `true`. - public var showSystemItems: Bool = true - - /// Whether the appearance item (`a`) is shown. - /// - /// When `true`, pressing `a` cycles through available appearances (border styles). - /// Default is `true`. - public var showAppearanceItem: Bool = true - - /// Whether the theme item (`t`) is shown. - /// - /// When `true`, pressing `t` cycles through available themes. - /// Default is `true`. - public var showThemeItem: Bool = true - - /// Controls when the quit shortcut (`q`) is active. - /// - /// - `.always`: Quit works from any screen (default). - /// - `.rootOnly`: Quit only works when no context is pushed (main screen). - /// - /// When set to `.rootOnly`, pressing `q` on a subpage does nothing, - /// allowing the app to handle navigation (e.g., go back) instead. - public var quitBehavior: QuitBehavior = .always - - // MARK: - Appearance - - /// The current status bar style. - public var style: StatusBarStyle = .compact - - /// The horizontal alignment of items. - public var alignment: StatusBarAlignment = .justified - - /// The highlight color for shortcut keys. - public var highlightColor: Color = .cyan - - /// The label color. - public var labelColor: Color? - - /// Creates a new status bar state. - public init() { - // System items are built dynamically based on flags - } - - // MARK: - System Items Access - - /// Whether we are at the root level (no context pushed). - public var isAtRoot: Bool { - userContextStack.isEmpty - } - - /// Whether quit is currently allowed based on `quitBehavior`. - public var isQuitAllowed: Bool { - switch quitBehavior { - case .always: - return true - case .rootOnly: - return isAtRoot - } - } - - /// The current system items based on configuration flags. - /// - /// Returns items filtered by `showSystemItems`, `showAppearanceItem`, `showThemeItem`, - /// and `quitBehavior`. The quit item is only included when quit is allowed. - public var currentSystemItems: [StatusBarItem] { - guard showSystemItems else { return [] } - - var items: [StatusBarItem] = [] - - // Quit item respects quitBehavior - if isQuitAllowed { - items.append(SystemStatusBarItem.quit) - } - - if showAppearanceItem { - items.append(SystemStatusBarItem.appearance) - } - - if showThemeItem { - items.append(SystemStatusBarItem.theme) - } - - return items - } - - // MARK: - User Items Management - - /// Sets the global user items. - /// - /// These items are shown when no context is active. - /// System items are always shown in addition to these (unless disabled). - /// Triggers a re-render. - /// - /// - Parameter items: The user items to display. - public func setItems(_ items: [any StatusBarItemProtocol]) { - userGlobalItems = items - AppState.shared.setNeedsRender() - } - - /// Sets the global user items using a builder. - /// - /// Triggers a re-render. - /// - /// - Parameter builder: A closure that returns items. - public func setItems(@StatusBarItemBuilder _ builder: () -> [any StatusBarItemProtocol]) { - userGlobalItems = builder() - AppState.shared.setNeedsRender() - } - - /// Sets the global user items without triggering a re-render. - /// - /// Use this during rendering (e.g., from modifiers) to avoid render loops. - /// - /// - Parameter items: The items to display. - internal func setItemsSilently(_ items: [any StatusBarItemProtocol]) { - userGlobalItems = items - } - - /// The current user items (topmost context or global user items). - /// - /// Does not include system items. - public var currentUserItems: [any StatusBarItemProtocol] { - if let topContext = userContextStack.last { - return topContext.items - } - return userGlobalItems - } - - // MARK: - User Context Stack - - /// Pushes a new user context with its items onto the stack. - /// - /// Items from the most recent context are displayed instead of global user items. - /// System items are always shown in addition to context items. - /// Triggers a re-render. - /// - /// - Parameters: - /// - context: A unique identifier for this context. - /// - items: The user items to display for this context. - public func push(context: String, items: [any StatusBarItemProtocol]) { - userContextStack.removeAll { $0.context == context } - userContextStack.append((context, items)) - AppState.shared.setNeedsRender() - } - - /// Pushes a new user context without triggering a re-render. - /// - /// Use this during rendering (e.g., from modifiers) to avoid render loops. - /// - /// - Parameters: - /// - context: A unique identifier for this context. - /// - items: The items to display for this context. - internal func pushSilently(context: String, items: [any StatusBarItemProtocol]) { - userContextStack.removeAll { $0.context == context } - userContextStack.append((context, items)) - } - - /// Pushes a new user context using a builder. - /// - /// Triggers a re-render. - /// - /// - Parameters: - /// - context: A unique identifier for this context. - /// - builder: A closure that returns items. - public func push(context: String, @StatusBarItemBuilder _ builder: () -> [any StatusBarItemProtocol]) { - push(context: context, items: builder()) - } - - /// Pops a user context from the stack. - /// - /// Triggers a re-render. - /// - /// - Parameter context: The context identifier to remove. - public func pop(context: String) { - userContextStack.removeAll { $0.context == context } - AppState.shared.setNeedsRender() - } - - /// Clears all user contexts (keeps global user items and system items). - /// - /// Triggers a re-render. - public func clearContexts() { - userContextStack.removeAll() - AppState.shared.setNeedsRender() - } - - /// Clears all user items (global and contexts). - /// - /// System items remain visible unless `showSystemItems` is set to false. - public func clearUserItems() { - userContextStack.removeAll() - userGlobalItems.removeAll() - } - - /// Clears everything including user items and hides system items. - /// - /// After calling this, the status bar will be empty until new items are set - /// or `showSystemItems` is set back to `true`. - public func clear() { - userContextStack.removeAll() - userGlobalItems.removeAll() - showSystemItems = false - } - - // MARK: - Combined Items - - /// All currently active items for rendering and event handling. - /// - /// Layout: `[sorted user items] + [system items with fixed order]` - /// - /// If a user item has the same shortcut as a system item, the user item - /// replaces the system item (user items take priority). - public var currentItems: [any StatusBarItemProtocol] { - // Get shortcuts used by user items (for deduplication) - let userShortcuts = Set(currentUserItems.map { $0.shortcut }) - - // Filter out system items that are overridden by user items - let filteredSystemItems = currentSystemItems.filter { !userShortcuts.contains($0.shortcut) } - - // Sort user items by order, then append system items (fixed order) - let sortedUserItems = currentUserItems.sorted { $0.order < $1.order } - - return sortedUserItems + filteredSystemItems - } - - /// Whether the status bar has any items to display. - public var hasItems: Bool { - !currentItems.isEmpty - } - - /// Whether there are any user items (ignoring system items). - public var hasUserItems: Bool { - !currentUserItems.isEmpty - } - - /// The height of the status bar in lines. - /// - /// Returns 0 only if no items are present. - public var height: Int { - guard hasItems else { return 0 } - switch style { - case .compact: return 1 - case .bordered: return 3 - } - } - - // MARK: - Event Handling - - /// Handles a key event, checking if any current item matches. - /// - /// Only returns true if the item has an action to execute. - /// Items without actions (informational items) don't consume the event, - /// allowing default handlers to process it. - /// - /// - Parameter event: The key event to handle. - /// - Returns: True if an item with an action handled the event. - @discardableResult - public func handleKeyEvent(_ event: KeyEvent) -> Bool { - for item in currentItems where item.matches(event) { - if let statusBarItem = item as? StatusBarItem { - // Only consume the event if the item has an action - if statusBarItem.hasAction { - statusBarItem.execute() - return true - } - } - } - return false - } -} - -// MARK: - StatusBar Environment Key - -/// Environment key for accessing the status bar state. -private struct StatusBarKey: EnvironmentKey { - static let defaultValue = StatusBarState() -} - -extension EnvironmentValues { - /// The status bar state for the current application. - /// - /// Use this to set status bar items from within your views: - /// - /// ```swift - /// @Environment(\.statusBar) var statusBar - /// - /// statusBar.setItems([ - /// StatusBarItem(shortcut: "q", label: "quit") - /// ]) - /// ``` - public var statusBar: StatusBarState { - get { self[StatusBarKey.self] } - set { self[StatusBarKey.self] = newValue } - } -} - -// MARK: - Signal Handler Flag - -/// Flag set by the SIGWINCH signal handler to request a re-render. -/// -/// Marked `nonisolated(unsafe)` because it is written from a signal handler -/// and read from the main loop. A single-word Bool write/read is practically -/// atomic on arm64/x86_64. Using `Atomic` from the `Synchronization` -/// module would be cleaner but requires macOS 15+. -nonisolated(unsafe) private var needsRerender = false - -/// Flag set by the SIGINT signal handler to request a graceful shutdown. -/// -/// The actual cleanup (disabling raw mode, restoring cursor, exiting -/// alternate screen) happens in the main loop — signal handlers must -/// not call non-async-signal-safe functions like `write()` or `fflush()`. -nonisolated(unsafe) private var needsShutdown = false - // MARK: - App Runner /// Runs an App. +/// +/// `AppRunner` is the main coordinator that owns the run loop and +/// delegates to specialized managers: +/// - ``SignalManager`` — POSIX signal handling (SIGINT, SIGWINCH) +/// - ``InputHandler`` — Key event dispatch (status bar → views → defaults) +/// - ``RenderLoop`` — Rendering pipeline (scene + status bar) internal final class AppRunner { let app: A let terminal: Terminal @@ -428,6 +67,9 @@ internal final class AppRunner { let paletteManager: ThemeManager let appearanceManager: ThemeManager let tuiContext: TUIContext + private var signals = SignalManager() + private var inputHandler: InputHandler! + private var renderer: RenderLoop! private var isRunning = false init(app: A) { @@ -455,46 +97,66 @@ internal final class AppRunner { // Configure status bar style self.statusBar.style = .bordered + + // These reference self or other stored properties, + // so they are created after all stored properties are initialized. + self.inputHandler = InputHandler( + statusBar: statusBar, + keyEventDispatcher: tuiContext.keyEventDispatcher, + paletteManager: paletteManager, + appearanceManager: appearanceManager, + onQuit: { [weak self] in + self?.isRunning = false + } + ) + self.renderer = RenderLoop( + app: app, + terminal: terminal, + statusBar: statusBar, + focusManager: focusManager, + paletteManager: paletteManager, + appearanceManager: appearanceManager, + tuiContext: tuiContext + ) } func run() { // Setup - setupSignalHandlers() + signals.install() terminal.enterAlternateScreen() terminal.hideCursor() terminal.enableRawMode() // Set up environment with all managed subsystems - EnvironmentStorage.shared.environment = buildEnvironment() + EnvironmentStorage.shared.environment = renderer.buildEnvironment() // Register for state changes - AppState.shared.observe { - needsRerender = true + AppState.shared.observe { [signals] in + signals.requestRerender() } isRunning = true // Initial render - render() + renderer.render() // Main loop while isRunning { // Check for graceful shutdown request (from SIGINT handler) - if needsShutdown { + if signals.shouldShutdown { isRunning = false break } // Check if terminal was resized or state changed - if needsRerender || AppState.shared.needsRender { - needsRerender = false + if signals.consumeRerenderFlag() || AppState.shared.needsRender { AppState.shared.didRender() - render() + renderer.render() } // Read key events if let keyEvent = terminal.readKeyEvent() { - handleKeyEvent(keyEvent) + inputHandler.handle(keyEvent) } } @@ -502,155 +164,6 @@ internal final class AppRunner { cleanup() } - private func render() { - // Clear event handlers before re-rendering - tuiContext.keyEventDispatcher.clearHandlers() - focusManager.clear() - - // Begin lifecycle tracking for this render pass - tuiContext.lifecycle.beginRenderPass() - - // Calculate available height (reserve space for status bar) - let statusBarHeight = statusBar.height - let contentHeight = terminal.height - statusBarHeight - - // Create render context with environment - let environment = buildEnvironment() - - let context = RenderContext( - terminal: terminal, - availableWidth: terminal.width, - availableHeight: contentHeight, - environment: environment, - tuiContext: tuiContext - ) - - // Update global environment storage - EnvironmentStorage.shared.environment = environment - - // Render main content (background fill happens in renderScene) - let scene = app.body - renderScene(scene, context: context) - - // End lifecycle tracking - triggers onDisappear for removed views - tuiContext.lifecycle.endRenderPass() - - // Render status bar separately (never dimmed) - if statusBar.hasItems { - renderStatusBar(atRow: terminal.height - statusBarHeight + 1) - } - } - - /// Builds a complete `EnvironmentValues` with all managed subsystems. - /// - /// Centralizes the environment setup that was previously duplicated - /// in `run()`, `render()`, and `renderStatusBar()`. - private func buildEnvironment() -> EnvironmentValues { - var environment = EnvironmentValues() - environment.statusBar = statusBar - environment.focusManager = focusManager - environment.paletteManager = paletteManager - if let palette = paletteManager.currentPalette { - environment.palette = palette - } - environment.appearanceManager = appearanceManager - if let appearance = appearanceManager.currentAppearance { - environment.appearance = appearance - } - return environment - } - - private func renderScene(_ scene: S, context: RenderContext) { - if let renderable = scene as? SceneRenderable { - renderable.renderScene(context: context) - } - } - - /// Renders the status bar at the specified row. - private func renderStatusBar(atRow row: Int) { - // Use theme colors for status bar (if not explicitly overridden) - let highlightColor = - statusBar.highlightColor == .cyan - ? Color.theme.statusBarHighlight - : statusBar.highlightColor - let labelColor = statusBar.labelColor ?? Color.theme.statusBarForeground - - let statusBarView = StatusBar( - userItems: statusBar.currentUserItems, - systemItems: statusBar.currentSystemItems, - style: statusBar.style, - alignment: statusBar.alignment, - highlightColor: highlightColor, - labelColor: labelColor - ) - - // Create render context with current environment for palette colors - let environment = buildEnvironment() - - let context = RenderContext( - terminal: terminal, - availableWidth: terminal.width, - availableHeight: statusBarView.height, - environment: environment, - tuiContext: tuiContext - ) - - let buffer = renderToBuffer(statusBarView, context: context) - - // Get background color from palette - let bgColor = paletteManager.currentPalette?.background ?? .black - let bgCode = ANSIRenderer.backgroundCode(for: bgColor) - let reset = ANSIRenderer.reset - let terminalWidth = terminal.width - - // Write status bar with theme background - for (index, line) in buffer.lines.enumerated() { - terminal.moveCursor(toRow: row + index, column: 1) - - let visibleWidth = line.strippedLength - let padding = max(0, terminalWidth - visibleWidth) - - // Replace all reset codes with "reset + restore background" - let lineWithBg = line.replacingOccurrences(of: reset, with: reset + bgCode) - let paddedLine = bgCode + lineWithBg + String(repeating: " ", count: padding) + reset - terminal.write(paddedLine) - } - } - - private func handleKeyEvent(_ event: KeyEvent) { - // First, let the status bar handle the event - if statusBar.handleKeyEvent(event) { - return - } - - // Then, let registered handlers try to handle the event - if tuiContext.keyEventDispatcher.dispatch(event) { - return - } - - // Default handling (only if no handler consumed the event) - switch event.key { - case .character(let character) where character == "q" || character == "Q": - // 'q' is the only way to quit (respects quitBehavior setting) - if statusBar.isQuitAllowed { - isRunning = false - } - - case .character(let character) where character == "t" || character == "T": - // 't' cycles palette (if theme item is enabled) - if statusBar.showThemeItem { - paletteManager.cycleNext() - } - - case .character(let character) where character == "a" || character == "A": - // 'a' cycles appearance - appearanceManager.cycleNext() - - default: - break - } - } - private func cleanup() { terminal.disableRawMode() terminal.showCursor() @@ -661,20 +174,6 @@ internal final class AppRunner { tuiContext.reset() } - private func setupSignalHandlers() { - // Catch SIGINT (Ctrl+C) — set a flag and let the main loop - // handle cleanup. Signal handlers must only use async-signal-safe - // operations; writing ANSI escapes or calling fflush() is NOT safe. - signal(SIGINT) { _ in - needsShutdown = true - } - - // Catch SIGWINCH (terminal size change) — sets a flag - // that the main loop picks up safely. - signal(SIGWINCH) { _ in - needsRerender = true - } - } } // MARK: - Scene Rendering Protocol diff --git a/Sources/TUIKit/App/InputHandler.swift b/Sources/TUIKit/App/InputHandler.swift new file mode 100644 index 0000000..8a2b268 --- /dev/null +++ b/Sources/TUIKit/App/InputHandler.swift @@ -0,0 +1,67 @@ +// +// InputHandler.swift +// TUIKit +// +// Dispatches key events through a 3-layer priority chain. +// + +// MARK: - Input Handler + +/// Dispatches key events through a 3-layer priority chain. +/// +/// The dispatch order is: +/// 1. **Status bar** — items with actions get first priority +/// 2. **View handlers** — registered via `onKeyPress` modifiers +/// 3. **Default bindings** — `q` (quit), `t` (theme), `a` (appearance) +/// +/// If a layer consumes the event, subsequent layers are skipped. +internal struct InputHandler { + /// The status bar state for item-level event handling. + let statusBar: StatusBarState + + /// The key event dispatcher for view-registered handlers. + let keyEventDispatcher: KeyEventDispatcher + + /// The palette manager for theme cycling (`t` key). + let paletteManager: ThemeManager + + /// The appearance manager for appearance cycling (`a` key). + let appearanceManager: ThemeManager + + /// Called when the user requests to quit the application. + let onQuit: () -> Void + + /// Dispatches a key event through the 3-layer priority chain. + /// + /// - Parameter event: The key event to handle. + func handle(_ event: KeyEvent) { + // Layer 1: Status bar items with actions + if statusBar.handleKeyEvent(event) { + return + } + + // Layer 2: View-registered key handlers + if keyEventDispatcher.dispatch(event) { + return + } + + // Layer 3: Default key bindings + switch event.key { + case .character(let character) where character == "q" || character == "Q": + if statusBar.isQuitAllowed { + onQuit() + } + + case .character(let character) where character == "t" || character == "T": + if statusBar.showThemeItem { + paletteManager.cycleNext() + } + + case .character(let character) where character == "a" || character == "A": + appearanceManager.cycleNext() + + default: + break + } + } +} diff --git a/Sources/TUIKit/App/RenderLoop.swift b/Sources/TUIKit/App/RenderLoop.swift new file mode 100644 index 0000000..f836662 --- /dev/null +++ b/Sources/TUIKit/App/RenderLoop.swift @@ -0,0 +1,177 @@ +// +// RenderLoop.swift +// TUIKit +// +// Manages the rendering pipeline: scene rendering, environment +// assembly, and status bar output. +// + +// MARK: - Render Loop + +/// Manages the full rendering pipeline for each frame. +/// +/// Responsibilities: +/// - Assembling the ``EnvironmentValues`` from all subsystems +/// - Rendering the main scene content +/// - Rendering the status bar separately (never dimmed) +/// - Coordinating lifecycle tracking (appear/disappear) +/// +/// `RenderLoop` is owned by ``AppRunner`` and called once per frame. +internal struct RenderLoop { + /// The user's app instance (provides `body`). + let app: A + + /// The terminal for output and size queries. + let terminal: Terminal + + /// The status bar state (height, items, appearance). + let statusBar: StatusBarState + + /// The focus manager (cleared each frame). + let focusManager: FocusManager + + /// The palette manager (current theme for environment). + let paletteManager: ThemeManager + + /// The appearance manager (current border style for environment). + let appearanceManager: ThemeManager + + /// The central dependency container (lifecycle, key dispatch, preferences). + let tuiContext: TUIContext + + // MARK: - Rendering + + /// Performs a full render pass: scene content + status bar. + /// + /// Each call: + /// 1. Clears key event handlers and focus state + /// 2. Begins lifecycle tracking + /// 3. Renders the scene into the terminal + /// 4. Ends lifecycle tracking (triggers `onDisappear` for removed views) + /// 5. Renders the status bar at the bottom + func render() { + // Clear event handlers before re-rendering + tuiContext.keyEventDispatcher.clearHandlers() + focusManager.clear() + + // Begin lifecycle tracking for this render pass + tuiContext.lifecycle.beginRenderPass() + + // Calculate available height (reserve space for status bar) + let statusBarHeight = statusBar.height + let contentHeight = terminal.height - statusBarHeight + + // Create render context with environment + let environment = buildEnvironment() + + let context = RenderContext( + terminal: terminal, + availableWidth: terminal.width, + availableHeight: contentHeight, + environment: environment, + tuiContext: tuiContext + ) + + // Update global environment storage + EnvironmentStorage.shared.environment = environment + + // Render main content (background fill happens in renderScene) + let scene = app.body + renderScene(scene, context: context) + + // End lifecycle tracking - triggers onDisappear for removed views + tuiContext.lifecycle.endRenderPass() + + // Render status bar separately (never dimmed) + if statusBar.hasItems { + renderStatusBar(atRow: terminal.height - statusBarHeight + 1) + } + } + + // MARK: - Environment Assembly + + /// Builds a complete ``EnvironmentValues`` with all managed subsystems. + /// + /// Called once per render pass for the scene, and again for the status bar + /// (which needs its own render context with different available height). + /// + /// - Returns: A fully populated environment. + func buildEnvironment() -> EnvironmentValues { + var environment = EnvironmentValues() + environment.statusBar = statusBar + environment.focusManager = focusManager + environment.paletteManager = paletteManager + if let palette = paletteManager.currentPalette { + environment.palette = palette + } + environment.appearanceManager = appearanceManager + if let appearance = appearanceManager.currentAppearance { + environment.appearance = appearance + } + return environment + } + + // MARK: - Private Helpers + + /// Renders a scene by delegating to ``SceneRenderable``. + private func renderScene(_ scene: S, context: RenderContext) { + if let renderable = scene as? SceneRenderable { + renderable.renderScene(context: context) + } + } + + /// Renders the status bar at the specified terminal row. + /// + /// The status bar gets its own render context because its available + /// height differs from the main content area. Theme background is + /// applied line-by-line with ANSI code injection. + private func renderStatusBar(atRow row: Int) { + // Use theme colors for status bar (if not explicitly overridden) + let highlightColor = + statusBar.highlightColor == .cyan + ? Color.theme.statusBarHighlight + : statusBar.highlightColor + let labelColor = statusBar.labelColor ?? Color.theme.statusBarForeground + + let statusBarView = StatusBar( + userItems: statusBar.currentUserItems, + systemItems: statusBar.currentSystemItems, + style: statusBar.style, + alignment: statusBar.alignment, + highlightColor: highlightColor, + labelColor: labelColor + ) + + // Create render context with current environment for palette colors + let environment = buildEnvironment() + + let context = RenderContext( + terminal: terminal, + availableWidth: terminal.width, + availableHeight: statusBarView.height, + environment: environment, + tuiContext: tuiContext + ) + + let buffer = renderToBuffer(statusBarView, context: context) + + // Get background color from palette + let bgColor = paletteManager.currentPalette?.background ?? .black + let bgCode = ANSIRenderer.backgroundCode(for: bgColor) + let reset = ANSIRenderer.reset + let terminalWidth = terminal.width + + // Write status bar with theme background + for (index, line) in buffer.lines.enumerated() { + terminal.moveCursor(toRow: row + index, column: 1) + + let visibleWidth = line.strippedLength + let padding = max(0, terminalWidth - visibleWidth) + + // Replace all reset codes with "reset + restore background" + let lineWithBg = line.replacingOccurrences(of: reset, with: reset + bgCode) + let paddedLine = bgCode + lineWithBg + String(repeating: " ", count: padding) + reset + terminal.write(paddedLine) + } + } +} diff --git a/Sources/TUIKit/App/SignalManager.swift b/Sources/TUIKit/App/SignalManager.swift new file mode 100644 index 0000000..336c21c --- /dev/null +++ b/Sources/TUIKit/App/SignalManager.swift @@ -0,0 +1,88 @@ +// +// SignalManager.swift +// TUIKit +// +// Manages POSIX signal handlers for terminal resize and graceful shutdown. +// + +import Foundation + +// MARK: - Signal Flags + +/// Flag set by the SIGWINCH signal handler to request a re-render. +/// +/// Marked `nonisolated(unsafe)` because it is written from a signal handler +/// and read from the main loop. A single-word Bool write/read is practically +/// atomic on arm64/x86_64. Using `Atomic` from the `Synchronization` +/// module would be cleaner but requires macOS 15+. +nonisolated(unsafe) private var signalNeedsRerender = false + +/// Flag set by the SIGINT signal handler to request a graceful shutdown. +/// +/// The actual cleanup (disabling raw mode, restoring cursor, exiting +/// alternate screen) happens in the main loop — signal handlers must +/// not call non-async-signal-safe functions like `write()` or `fflush()`. +nonisolated(unsafe) private var signalNeedsShutdown = false + +// MARK: - Signal Manager + +/// Manages POSIX signal handlers for the application lifecycle. +/// +/// Encapsulates the global signal flags and handler installation. +/// The flags remain file-private globals because C signal handlers +/// cannot capture Swift object references. +/// +/// ## Usage +/// +/// ```swift +/// let signals = SignalManager() +/// signals.install() +/// +/// while running { +/// if signals.shouldShutdown { break } +/// if signals.consumeRerenderFlag() { render() } +/// } +/// ``` +internal struct SignalManager { + /// Whether a graceful shutdown was requested (SIGINT). + var shouldShutdown: Bool { + signalNeedsShutdown + } + + /// Checks and resets the rerender flag (SIGWINCH). + /// + /// Returns `true` if a re-render was requested since the last call, + /// then resets the flag. This consume-on-read pattern prevents + /// redundant renders. + /// + /// - Returns: `true` if a terminal resize triggered a rerender request. + mutating func consumeRerenderFlag() -> Bool { + guard signalNeedsRerender else { return false } + signalNeedsRerender = false + return true + } + + /// Requests a re-render programmatically. + /// + /// Called by the `AppState` observer to signal that application + /// state has changed and the UI needs updating. + func requestRerender() { + signalNeedsRerender = true + } + + /// Installs POSIX signal handlers for SIGINT and SIGWINCH. + /// + /// - SIGINT (Ctrl+C): Sets the shutdown flag for graceful cleanup. + /// - SIGWINCH (terminal resize): Sets the rerender flag. + /// + /// Signal handlers only set boolean flags — all actual work + /// happens in the main loop, which is async-signal-safe. + func install() { + signal(SIGINT) { _ in + signalNeedsShutdown = true + } + signal(SIGWINCH) { _ in + signalNeedsRerender = true + } + } +} diff --git a/Sources/TUIKit/App/StatusBarState.swift b/Sources/TUIKit/App/StatusBarState.swift new file mode 100644 index 0000000..3f50ad5 --- /dev/null +++ b/Sources/TUIKit/App/StatusBarState.swift @@ -0,0 +1,356 @@ +// +// StatusBarState.swift +// TUIKit +// +// Manages the status bar state for the running application. +// + +// MARK: - Quit Behavior + +/// Controls when the quit shortcut (`q`) is active. +public enum QuitBehavior: Sendable { + /// Quit works from any screen. + /// + /// Pressing `q` will always exit the application, regardless of + /// the current navigation state. + case always + + /// Quit only works from the root/main screen. + /// + /// Pressing `q` will only exit when no context is pushed onto the + /// status bar stack. On subpages, `q` does nothing, allowing the + /// app to handle navigation (e.g., ESC to go back). + case rootOnly +} + +// MARK: - Status Bar State + +/// Manages the status bar state for the running application. +/// +/// This class is created by the `AppRunner` and injected into the +/// environment for views to access. +/// +/// # Usage +/// +/// ```swift +/// struct MyView: View { +/// @Environment(\.statusBar) var statusBar +/// +/// var body: some View { +/// Button("Action") { +/// statusBar.setItems([ +/// StatusBarItem(shortcut: "⎋", label: "cancel") +/// ]) +/// } +/// } +/// } +/// ``` +public final class StatusBarState: @unchecked Sendable { + // MARK: - User Items + + /// Stack of user contexts with their items. + private var userContextStack: [(context: String, items: [any StatusBarItemProtocol])] = [] + + /// Global user items that are always shown (lowest priority). + private var userGlobalItems: [any StatusBarItemProtocol] = [] + + // MARK: - System Items Configuration + + /// Whether system items are shown at all. + /// + /// Set to `false` to hide all system items (quit, help, theme). + /// Default is `true`. + public var showSystemItems: Bool = true + + /// Whether the appearance item (`a`) is shown. + /// + /// When `true`, pressing `a` cycles through available appearances (border styles). + /// Default is `true`. + public var showAppearanceItem: Bool = true + + /// Whether the theme item (`t`) is shown. + /// + /// When `true`, pressing `t` cycles through available themes. + /// Default is `true`. + public var showThemeItem: Bool = true + + /// Controls when the quit shortcut (`q`) is active. + /// + /// - `.always`: Quit works from any screen (default). + /// - `.rootOnly`: Quit only works when no context is pushed (main screen). + /// + /// When set to `.rootOnly`, pressing `q` on a subpage does nothing, + /// allowing the app to handle navigation (e.g., go back) instead. + public var quitBehavior: QuitBehavior = .always + + // MARK: - Appearance + + /// The current status bar style. + public var style: StatusBarStyle = .compact + + /// The horizontal alignment of items. + public var alignment: StatusBarAlignment = .justified + + /// The highlight color for shortcut keys. + public var highlightColor: Color = .cyan + + /// The label color. + public var labelColor: Color? + + /// Creates a new status bar state. + public init() { + // System items are built dynamically based on flags + } + + // MARK: - System Items Access + + /// Whether we are at the root level (no context pushed). + public var isAtRoot: Bool { + userContextStack.isEmpty + } + + /// Whether quit is currently allowed based on `quitBehavior`. + public var isQuitAllowed: Bool { + switch quitBehavior { + case .always: + return true + case .rootOnly: + return isAtRoot + } + } + + /// The current system items based on configuration flags. + /// + /// Returns items filtered by `showSystemItems`, `showAppearanceItem`, `showThemeItem`, + /// and `quitBehavior`. The quit item is only included when quit is allowed. + public var currentSystemItems: [StatusBarItem] { + guard showSystemItems else { return [] } + + var items: [StatusBarItem] = [] + + // Quit item respects quitBehavior + if isQuitAllowed { + items.append(SystemStatusBarItem.quit) + } + + if showAppearanceItem { + items.append(SystemStatusBarItem.appearance) + } + + if showThemeItem { + items.append(SystemStatusBarItem.theme) + } + + return items + } + + // MARK: - User Items Management + + /// Sets the global user items. + /// + /// These items are shown when no context is active. + /// System items are always shown in addition to these (unless disabled). + /// Triggers a re-render. + /// + /// - Parameter items: The user items to display. + public func setItems(_ items: [any StatusBarItemProtocol]) { + userGlobalItems = items + AppState.shared.setNeedsRender() + } + + /// Sets the global user items using a builder. + /// + /// Triggers a re-render. + /// + /// - Parameter builder: A closure that returns items. + public func setItems(@StatusBarItemBuilder _ builder: () -> [any StatusBarItemProtocol]) { + userGlobalItems = builder() + AppState.shared.setNeedsRender() + } + + /// Sets the global user items without triggering a re-render. + /// + /// Use this during rendering (e.g., from modifiers) to avoid render loops. + /// + /// - Parameter items: The items to display. + internal func setItemsSilently(_ items: [any StatusBarItemProtocol]) { + userGlobalItems = items + } + + /// The current user items (topmost context or global user items). + /// + /// Does not include system items. + public var currentUserItems: [any StatusBarItemProtocol] { + if let topContext = userContextStack.last { + return topContext.items + } + return userGlobalItems + } + + // MARK: - User Context Stack + + /// Pushes a new user context with its items onto the stack. + /// + /// Items from the most recent context are displayed instead of global user items. + /// System items are always shown in addition to context items. + /// Triggers a re-render. + /// + /// - Parameters: + /// - context: A unique identifier for this context. + /// - items: The user items to display for this context. + public func push(context: String, items: [any StatusBarItemProtocol]) { + userContextStack.removeAll { $0.context == context } + userContextStack.append((context, items)) + AppState.shared.setNeedsRender() + } + + /// Pushes a new user context without triggering a re-render. + /// + /// Use this during rendering (e.g., from modifiers) to avoid render loops. + /// + /// - Parameters: + /// - context: A unique identifier for this context. + /// - items: The items to display for this context. + internal func pushSilently(context: String, items: [any StatusBarItemProtocol]) { + userContextStack.removeAll { $0.context == context } + userContextStack.append((context, items)) + } + + /// Pushes a new user context using a builder. + /// + /// Triggers a re-render. + /// + /// - Parameters: + /// - context: A unique identifier for this context. + /// - builder: A closure that returns items. + public func push(context: String, @StatusBarItemBuilder _ builder: () -> [any StatusBarItemProtocol]) { + push(context: context, items: builder()) + } + + /// Pops a user context from the stack. + /// + /// Triggers a re-render. + /// + /// - Parameter context: The context identifier to remove. + public func pop(context: String) { + userContextStack.removeAll { $0.context == context } + AppState.shared.setNeedsRender() + } + + /// Clears all user contexts (keeps global user items and system items). + /// + /// Triggers a re-render. + public func clearContexts() { + userContextStack.removeAll() + AppState.shared.setNeedsRender() + } + + /// Clears all user items (global and contexts). + /// + /// System items remain visible unless `showSystemItems` is set to false. + public func clearUserItems() { + userContextStack.removeAll() + userGlobalItems.removeAll() + } + + /// Clears everything including user items and hides system items. + /// + /// After calling this, the status bar will be empty until new items are set + /// or `showSystemItems` is set back to `true`. + public func clear() { + userContextStack.removeAll() + userGlobalItems.removeAll() + showSystemItems = false + } + + // MARK: - Combined Items + + /// All currently active items for rendering and event handling. + /// + /// Layout: `[sorted user items] + [system items with fixed order]` + /// + /// If a user item has the same shortcut as a system item, the user item + /// replaces the system item (user items take priority). + public var currentItems: [any StatusBarItemProtocol] { + // Get shortcuts used by user items (for deduplication) + let userShortcuts = Set(currentUserItems.map { $0.shortcut }) + + // Filter out system items that are overridden by user items + let filteredSystemItems = currentSystemItems.filter { !userShortcuts.contains($0.shortcut) } + + // Sort user items by order, then append system items (fixed order) + let sortedUserItems = currentUserItems.sorted { $0.order < $1.order } + + return sortedUserItems + filteredSystemItems + } + + /// Whether the status bar has any items to display. + public var hasItems: Bool { + !currentItems.isEmpty + } + + /// Whether there are any user items (ignoring system items). + public var hasUserItems: Bool { + !currentUserItems.isEmpty + } + + /// The height of the status bar in lines. + /// + /// Returns 0 only if no items are present. + public var height: Int { + guard hasItems else { return 0 } + switch style { + case .compact: return 1 + case .bordered: return 3 + } + } + + // MARK: - Event Handling + + /// Handles a key event, checking if any current item matches. + /// + /// Only returns true if the item has an action to execute. + /// Items without actions (informational items) don't consume the event, + /// allowing default handlers to process it. + /// + /// - Parameter event: The key event to handle. + /// - Returns: True if an item with an action handled the event. + @discardableResult + public func handleKeyEvent(_ event: KeyEvent) -> Bool { + for item in currentItems where item.matches(event) { + if let statusBarItem = item as? StatusBarItem { + // Only consume the event if the item has an action + if statusBarItem.hasAction { + statusBarItem.execute() + return true + } + } + } + return false + } +} + +// MARK: - StatusBar Environment Key + +/// Environment key for accessing the status bar state. +private struct StatusBarKey: EnvironmentKey { + static let defaultValue = StatusBarState() +} + +extension EnvironmentValues { + /// The status bar state for the current application. + /// + /// Use this to set status bar items from within your views: + /// + /// ```swift + /// @Environment(\.statusBar) var statusBar + /// + /// statusBar.setItems([ + /// StatusBarItem(shortcut: "q", label: "quit") + /// ]) + /// ``` + public var statusBar: StatusBarState { + get { self[StatusBarKey.self] } + set { self[StatusBarKey.self] = newValue } + } +}