mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
ESC should only be used for navigation (back to menu), not for quitting. The 'q' key is the only way to quit the app.
678 lines
22 KiB
Swift
678 lines
22 KiB
Swift
//
|
|
// App.swift
|
|
// TUIKit
|
|
//
|
|
// The base protocol for TUIKit applications.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
// MARK: - App Protocol
|
|
|
|
/// The base protocol for TUIKit applications.
|
|
///
|
|
/// `App` is the entry point for every TUIKit application,
|
|
/// similar to `App` in SwiftUI.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```swift
|
|
/// @main
|
|
/// struct MyApp: App {
|
|
/// var body: some Scene {
|
|
/// WindowGroup {
|
|
/// ContentView()
|
|
/// }
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
public protocol App {
|
|
/// The type of the main scene.
|
|
associatedtype Body: Scene
|
|
|
|
/// The main scene of the app.
|
|
@SceneBuilder
|
|
var body: Body { get }
|
|
|
|
/// Initializes the app.
|
|
init()
|
|
}
|
|
|
|
extension App {
|
|
/// Starts the app.
|
|
///
|
|
/// This method is called by the `@main` attribute and starts
|
|
/// the main run loop of the application.
|
|
public static func main() {
|
|
let app = Self()
|
|
let runner = AppRunner<Self>(app: app)
|
|
runner.run()
|
|
}
|
|
}
|
|
|
|
// 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 help item (`?`) is shown.
|
|
///
|
|
/// When `true`, pressing `?` shows available shortcuts.
|
|
/// Default is `true`.
|
|
public var showHelpItem: 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? = nil
|
|
|
|
/// 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`, `showHelpItem`, `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 showHelpItem {
|
|
items.append(SystemStatusBarItem.help)
|
|
}
|
|
|
|
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 {
|
|
if 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 = 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.
|
|
/// Must be an atomic type safe for signal context.
|
|
private nonisolated(unsafe) var needsRerender = false
|
|
|
|
// MARK: - App Runner
|
|
|
|
/// Runs an App.
|
|
internal final class AppRunner<A: App> {
|
|
let app: A
|
|
let terminal: Terminal
|
|
let statusBar: StatusBarState
|
|
let focusManager: FocusManager
|
|
let themeManager: ThemeManager
|
|
let appearanceManager: AppearanceManager
|
|
private var isRunning = false
|
|
|
|
init(app: A) {
|
|
self.app = app
|
|
self.terminal = Terminal.shared
|
|
self.statusBar = StatusBarState()
|
|
self.focusManager = FocusManager()
|
|
self.themeManager = ThemeManager()
|
|
self.appearanceManager = AppearanceManager()
|
|
}
|
|
|
|
func run() {
|
|
// Setup
|
|
setupSignalHandlers()
|
|
terminal.enterAlternateScreen()
|
|
terminal.hideCursor()
|
|
terminal.enableRawMode()
|
|
|
|
// Set up environment with status bar, focus manager, theme manager, and appearance manager
|
|
var environment = EnvironmentValues()
|
|
environment.statusBar = statusBar
|
|
environment.focusManager = focusManager
|
|
environment.themeManager = themeManager
|
|
environment.appearanceManager = appearanceManager
|
|
EnvironmentStorage.shared.environment = environment
|
|
|
|
// Register for state changes
|
|
AppState.shared.observe { [weak self] in
|
|
needsRerender = true
|
|
_ = self // Silence warning
|
|
}
|
|
|
|
isRunning = true
|
|
|
|
// Initial render
|
|
render()
|
|
|
|
// Main loop
|
|
while isRunning {
|
|
// Check if terminal was resized or state changed
|
|
if needsRerender || AppState.shared.needsRender {
|
|
needsRerender = false
|
|
AppState.shared.didRender()
|
|
render()
|
|
}
|
|
|
|
// Read key events
|
|
if let keyEvent = terminal.readKeyEvent() {
|
|
handleKeyEvent(keyEvent)
|
|
}
|
|
}
|
|
|
|
// Cleanup
|
|
cleanup()
|
|
}
|
|
|
|
private func render() {
|
|
// Clear event handlers before re-rendering
|
|
KeyEventDispatcher.shared.clearHandlers()
|
|
focusManager.clear()
|
|
|
|
// Begin lifecycle tracking for this render pass
|
|
LifecycleTracker.shared.beginRenderPass()
|
|
|
|
// Calculate available height (reserve space for status bar)
|
|
let statusBarHeight = statusBar.height
|
|
let contentHeight = terminal.height - statusBarHeight
|
|
|
|
// Create render context with environment
|
|
var environment = EnvironmentValues()
|
|
environment.statusBar = statusBar
|
|
environment.focusManager = focusManager
|
|
environment.themeManager = themeManager
|
|
environment.theme = themeManager.currentTheme // Apply current theme
|
|
environment.appearanceManager = appearanceManager
|
|
environment.appearance = appearanceManager.currentAppearance // Apply current appearance
|
|
|
|
let context = RenderContext(
|
|
terminal: terminal,
|
|
availableWidth: terminal.width,
|
|
availableHeight: contentHeight,
|
|
environment: environment
|
|
)
|
|
|
|
// 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
|
|
LifecycleTracker.shared.endRenderPass(onDisappear: DisappearCallbackStorage.shared.allCallbacks)
|
|
|
|
// Render status bar separately (never dimmed)
|
|
if statusBar.hasItems {
|
|
renderStatusBar(atRow: terminal.height - statusBarHeight + 1)
|
|
}
|
|
}
|
|
|
|
private func renderScene<S: Scene>(_ 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
|
|
)
|
|
let context = RenderContext(
|
|
terminal: terminal,
|
|
availableWidth: terminal.width,
|
|
availableHeight: statusBarView.height
|
|
)
|
|
|
|
let buffer = renderToBuffer(statusBarView, context: context)
|
|
|
|
// Get background color from theme
|
|
let bgColor = themeManager.currentTheme.background
|
|
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 KeyEventDispatcher.shared.dispatch(event) {
|
|
return
|
|
}
|
|
|
|
// Default handling (only if no handler consumed the event)
|
|
switch event.key {
|
|
case .character(let char) where char == "q" || char == "Q":
|
|
// 'q' is the only way to quit (respects quitBehavior setting)
|
|
if statusBar.isQuitAllowed {
|
|
isRunning = false
|
|
}
|
|
|
|
case .character(let char) where char == "t" || char == "T":
|
|
// 't' cycles theme (if theme item is enabled)
|
|
if statusBar.showThemeItem {
|
|
themeManager.cycleTheme()
|
|
}
|
|
|
|
case .character(let char) where char == "a" || char == "A":
|
|
// 'a' cycles appearance
|
|
appearanceManager.cycleAppearance()
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func cleanup() {
|
|
terminal.disableRawMode()
|
|
terminal.showCursor()
|
|
terminal.exitAlternateScreen()
|
|
AppState.shared.clearObservers()
|
|
KeyEventDispatcher.shared.clearHandlers()
|
|
EnvironmentStorage.shared.reset()
|
|
focusManager.clear()
|
|
LifecycleTracker.shared.reset()
|
|
DisappearCallbackStorage.shared.reset()
|
|
TaskStorage.shared.reset()
|
|
}
|
|
|
|
private func setupSignalHandlers() {
|
|
// Catch SIGINT (Ctrl+C)
|
|
signal(SIGINT) { _ in
|
|
Terminal.shared.disableRawMode()
|
|
Terminal.shared.showCursor()
|
|
Terminal.shared.exitAlternateScreen()
|
|
exit(0)
|
|
}
|
|
|
|
// Catch SIGWINCH (terminal size change) — sets a flag
|
|
// that the main loop picks up safely.
|
|
signal(SIGWINCH) { _ in
|
|
needsRerender = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Scene Rendering Protocol
|
|
|
|
/// Internal protocol for renderable scenes.
|
|
internal protocol SceneRenderable {
|
|
func renderScene(context: RenderContext)
|
|
}
|
|
|
|
extension WindowGroup: SceneRenderable {
|
|
func renderScene(context: RenderContext) {
|
|
let buffer = renderToBuffer(content, context: context)
|
|
let terminal = Terminal.shared
|
|
let terminalWidth = terminal.width
|
|
let terminalHeight = context.availableHeight
|
|
|
|
// Get background color from theme
|
|
let bgColor = context.environment.theme.background
|
|
let bgCode = ANSIRenderer.backgroundCode(for: bgColor)
|
|
let reset = ANSIRenderer.reset
|
|
|
|
// Write buffer to terminal, ensuring consistent background color
|
|
for row in 0..<terminalHeight {
|
|
terminal.moveCursor(toRow: 1 + row, column: 1)
|
|
|
|
if row < buffer.lines.count {
|
|
let line = buffer.lines[row]
|
|
let visibleWidth = line.strippedLength
|
|
let padding = max(0, terminalWidth - visibleWidth)
|
|
|
|
// Replace all reset codes with "reset + restore background"
|
|
// This ensures background color persists after styled text
|
|
let lineWithBg = line.replacingOccurrences(of: reset, with: reset + bgCode)
|
|
|
|
// Wrap entire line with background
|
|
let paddedLine = bgCode + lineWithBg + String(repeating: " ", count: padding) + reset
|
|
terminal.write(paddedLine)
|
|
} else {
|
|
// Empty row - fill with background color
|
|
let emptyLine = bgCode + String(repeating: " ", count: terminalWidth) + reset
|
|
terminal.write(emptyLine)
|
|
}
|
|
}
|
|
}
|
|
}
|