mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
Merge pull request #12 from phranck/feature/apprunner-decomposition
refactor: Decompose AppRunner God Class (B.4)
This commit is contained in:
+39
-540
@@ -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<Bool>` 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<A: App> {
|
||||
let app: A
|
||||
let terminal: Terminal
|
||||
@@ -428,6 +67,9 @@ internal final class AppRunner<A: App> {
|
||||
let paletteManager: ThemeManager
|
||||
let appearanceManager: ThemeManager
|
||||
let tuiContext: TUIContext
|
||||
private var signals = SignalManager()
|
||||
private var inputHandler: InputHandler!
|
||||
private var renderer: RenderLoop<A>!
|
||||
private var isRunning = false
|
||||
|
||||
init(app: A) {
|
||||
@@ -455,46 +97,66 @@ internal final class AppRunner<A: App> {
|
||||
|
||||
// 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<A: App> {
|
||||
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<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
|
||||
)
|
||||
|
||||
// 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<A: App> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<A: App> {
|
||||
/// 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<S: Scene>(_ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Bool>` 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user