diff --git a/Sources/TUIkit/App/App.swift b/Sources/TUIkit/App/App.swift index 97dce820..c73b1531 100644 --- a/Sources/TUIkit/App/App.swift +++ b/Sources/TUIkit/App/App.swift @@ -91,6 +91,7 @@ internal final class AppRunner { self.inputHandler = InputHandler( statusBar: statusBar, keyEventDispatcher: tuiContext.keyEventDispatcher, + focusManager: focusManager, paletteManager: paletteManager, appearanceManager: appearanceManager, onQuit: { [weak self] in diff --git a/Sources/TUIkit/App/InputHandler.swift b/Sources/TUIkit/App/InputHandler.swift index e9b0bb79..62a142d6 100644 --- a/Sources/TUIkit/App/InputHandler.swift +++ b/Sources/TUIkit/App/InputHandler.swift @@ -7,12 +7,13 @@ // MARK: - Input Handler -/// Dispatches key events through a 3-layer priority chain. +/// Dispatches key events through a 4-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) +/// 3. **Focus system** — Tab/Shift+Tab navigation, Enter/Space on focused buttons +/// 4. **Default bindings** — `q` (quit), `t` (theme), `a` (appearance) /// /// If a layer consumes the event, subsequent layers are skipped. internal struct InputHandler { @@ -22,6 +23,9 @@ internal struct InputHandler { /// The key event dispatcher for view-registered handlers. let keyEventDispatcher: KeyEventDispatcher + /// The focus manager for Tab navigation and focused element activation. + let focusManager: FocusManager + /// The palette manager for theme cycling (`t` key). let paletteManager: ThemeManager @@ -31,7 +35,7 @@ internal struct InputHandler { /// Called when the user requests to quit the application. let onQuit: () -> Void - /// Dispatches a key event through the 3-layer priority chain. + /// Dispatches a key event through the 4-layer priority chain. /// /// - Parameter event: The key event to handle. func handle(_ event: KeyEvent) { @@ -40,12 +44,17 @@ internal struct InputHandler { return } - // Layer 2: View-registered key handlers + // Layer 2: View-registered key handlers (onKeyPress, Menu arrow keys) if keyEventDispatcher.dispatch(event) { return } - // Layer 3: Default key bindings + // Layer 3: Focus system (Tab navigation, Enter/Space on focused buttons) + if focusManager.dispatchKeyEvent(event) { + return + } + + // Layer 4: Default key bindings switch event.key { case .character(let character) where character == "q" || character == "Q": if statusBar.isQuitAllowed { diff --git a/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift b/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift index 1bf8f4a9..19f2307c 100644 --- a/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift +++ b/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift @@ -82,9 +82,27 @@ extension AlertPresentationModifier: Renderable { actions: { actions } ) - // Render dimmed base with centered alert overlay + // Render dimmed base with an isolated context. + // The base content's buttons and key handlers register into a + // throwaway FocusManager and KeyEventDispatcher so they don't + // interfere with the alert's interactive elements. let dimmedBase = DimmedModifier(content: content) - let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: context) + let isolatedContext = context.isolatedForBackground() + let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: isolatedContext) + + // Clear the real focus manager so the alert's buttons become + // the only registered focusables (auto-focus picks the first one). + context.environment.focusManager.clear() + + // Register ESC handler to dismiss the alert. + // Uses the real dispatcher so it takes priority over base content. + context.tuiContext.keyEventDispatcher.addHandler { [isPresented] event in + if event.key == .escape { + isPresented.wrappedValue = false + return true + } + return false + } let alertBuffer = TUIkit.renderToBuffer(alert, context: context) diff --git a/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift b/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift index 6e9792cd..c951f170 100644 --- a/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift +++ b/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift @@ -48,9 +48,27 @@ extension ModalPresentationModifier: Renderable { return TUIkit.renderToBuffer(content, context: context) } - // Render dimmed base with centered modal overlay + // Render dimmed base with an isolated context. + // The base content's buttons and key handlers register into a + // throwaway FocusManager and KeyEventDispatcher so they don't + // interfere with the modal's interactive elements. let dimmedBase = DimmedModifier(content: content) - let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: context) + let isolatedContext = context.isolatedForBackground() + let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: isolatedContext) + + // Clear the real focus manager so the modal's buttons become + // the only registered focusables (auto-focus picks the first one). + context.environment.focusManager.clear() + + // Register ESC handler to dismiss the modal. + // Uses the real dispatcher so it takes priority over base content. + context.tuiContext.keyEventDispatcher.addHandler { [isPresented] event in + if event.key == .escape { + isPresented.wrappedValue = false + return true + } + return false + } let modalBuffer = TUIkit.renderToBuffer(modal, context: context) diff --git a/Sources/TUIkit/Rendering/Renderable.swift b/Sources/TUIkit/Rendering/Renderable.swift index 5a14a8c1..4b0fc011 100644 --- a/Sources/TUIkit/Rendering/Renderable.swift +++ b/Sources/TUIkit/Rendering/Renderable.swift @@ -85,7 +85,9 @@ public struct RenderContext { /// /// Provides access to lifecycle tracking, key event dispatch, /// and preference storage via constructor injection. - let tuiContext: TUIContext + /// Mutable to allow modal presentation to substitute an isolated + /// context for background content rendering. + var tuiContext: TUIContext /// The current view's structural identity in the render tree. /// @@ -165,6 +167,25 @@ public struct RenderContext { copy.identity = identity.branch(label) return copy } + + /// Creates a context isolated from the real focus and key event systems. + /// + /// Used by modal presentation modifiers to render background content + /// visually without letting its buttons and key handlers interfere + /// with the modal's interactive elements. The returned context has a + /// throwaway ``FocusManager`` and ``KeyEventDispatcher`` while sharing + /// lifecycle, preferences, and state storage with the real context. + func isolatedForBackground() -> RenderContext { + var copy = self + copy.environment.focusManager = FocusManager() + copy.tuiContext = TUIContext( + lifecycle: tuiContext.lifecycle, + keyEventDispatcher: KeyEventDispatcher(), + preferences: tuiContext.preferences, + stateStorage: tuiContext.stateStorage + ) + return copy + } } // MARK: - Rendering Dispatch diff --git a/Sources/TUIkit/Views/Alert.swift b/Sources/TUIkit/Views/Alert.swift index 039de7d4..e34425b6 100644 --- a/Sources/TUIkit/Views/Alert.swift +++ b/Sources/TUIkit/Views/Alert.swift @@ -133,7 +133,7 @@ extension Alert where Actions == EmptyView { // MARK: - Preset Alert Styles extension Alert { - /// Creates a warning-style alert with yellow border. + /// Creates a warning-style alert with palette warning colors. /// /// - Parameters: /// - title: The alert title (default: "Warning"). @@ -148,13 +148,13 @@ extension Alert { Alert( title: title, message: message, - borderColor: .yellow, - titleColor: .yellow, + borderColor: .palette.warning, + titleColor: .palette.warning, actions: actions ) } - /// Creates an error-style alert with red border. + /// Creates an error-style alert with palette error colors. /// /// - Parameters: /// - title: The alert title (default: "Error"). @@ -169,13 +169,13 @@ extension Alert { Alert( title: title, message: message, - borderColor: .red, - titleColor: .red, + borderColor: .palette.error, + titleColor: .palette.error, actions: actions ) } - /// Creates an info-style alert with cyan border. + /// Creates an info-style alert with palette info colors. /// /// - Parameters: /// - title: The alert title (default: "Info"). @@ -190,13 +190,13 @@ extension Alert { Alert( title: title, message: message, - borderColor: .cyan, - titleColor: .cyan, + borderColor: .palette.info, + titleColor: .palette.info, actions: actions ) } - /// Creates a success-style alert with green border. + /// Creates a success-style alert with palette success colors. /// /// - Parameters: /// - title: The alert title (default: "Success"). @@ -211,8 +211,8 @@ extension Alert { Alert( title: title, message: message, - borderColor: .green, - titleColor: .green, + borderColor: .palette.success, + titleColor: .palette.success, actions: actions ) } @@ -223,21 +223,33 @@ extension Alert { extension Alert where Actions == EmptyView { /// Creates a warning-style alert without actions. public static func warning(title: String = "Warning", message: String) -> Alert { - Alert.warning(title: title, message: message) { EmptyView() } + Alert( + title: title, message: message, + borderColor: .palette.warning, titleColor: .palette.warning + ) } /// Creates an error-style alert without actions. public static func error(title: String = "Error", message: String) -> Alert { - Alert.error(title: title, message: message) { EmptyView() } + Alert( + title: title, message: message, + borderColor: .palette.error, titleColor: .palette.error + ) } /// Creates an info-style alert without actions. public static func info(title: String = "Info", message: String) -> Alert { - Alert.info(title: title, message: message) { EmptyView() } + Alert( + title: title, message: message, + borderColor: .palette.info, titleColor: .palette.info + ) } /// Creates a success-style alert without actions. public static func success(title: String = "Success", message: String) -> Alert { - Alert.success(title: title, message: message) { EmptyView() } + Alert( + title: title, message: message, + borderColor: .palette.success, titleColor: .palette.success + ) } } diff --git a/Sources/TUIkit/Views/ContainerView.swift b/Sources/TUIkit/Views/ContainerView.swift index 7ab8fc3d..f95c33bc 100644 --- a/Sources/TUIkit/Views/ContainerView.swift +++ b/Sources/TUIkit/Views/ContainerView.swift @@ -282,25 +282,36 @@ extension ContainerView: Renderable { let palette = context.environment.palette let borderColor = style.borderColor?.resolve(with: palette) ?? palette.border - // Render body content - let paddedContent = content.padding(padding) - let bodyBuffer = TUIkit.renderToBuffer(paddedContent, context: context) + // Context with reduced width for content inside borders. + // Subtract 2 for the left and right border characters so that + // Spacer() and other layout views calculate available space correctly. + var innerContext = context + innerContext.availableWidth = max(0, context.availableWidth - 2) - // Render footer if present + // Render body content first to determine its width. + let paddedContent = content.padding(padding) + let bodyBuffer = TUIkit.renderToBuffer(paddedContent, context: innerContext) + + // Calculate inner width from body and title (footer adapts to this). + let titleWidth = title.map { $0.count + 4 } ?? 0 // " Title " + borders + let innerWidth = max(titleWidth, bodyBuffer.width) + + // Render footer constrained to the actual inner width. + // PaddingModifier is post-processing (doesn't reduce availableWidth for + // its child), so we subtract the footer padding from the context width + // manually. This ensures Spacer() in the footer fills exactly the + // container's inner width. + let footerPadding = EdgeInsets(horizontal: 1, vertical: 0) let footerBuffer: FrameBuffer? if let footerView = footer { - let paddedFooter = footerView.padding(EdgeInsets(horizontal: 1, vertical: 0)) - footerBuffer = TUIkit.renderToBuffer(paddedFooter, context: context) + var footerContext = innerContext + footerContext.availableWidth = innerWidth - footerPadding.leading - footerPadding.trailing + let paddedFooter = footerView.padding(footerPadding) + footerBuffer = TUIkit.renderToBuffer(paddedFooter, context: footerContext) } else { footerBuffer = nil } - // Calculate inner width - let titleWidth = title.map { $0.count + 4 } ?? 0 // " Title " + borders - let bodyWidth = bodyBuffer.width - let footerWidth = footerBuffer?.width ?? 0 - let innerWidth = max(titleWidth, bodyWidth, footerWidth) - if isBlockAppearance { return renderBlockStyle( bodyBuffer: bodyBuffer, @@ -357,16 +368,14 @@ extension ContainerView: Renderable { ) } - // Body lines with theme background - let bodyBg = context.environment.palette.blockSurfaceBackground + // Body lines (no background — only block style uses distinct section colors) for line in bodyBuffer.lines { lines.append( BorderRenderer.standardContentLine( content: line, innerWidth: innerWidth, style: borderStyle, - color: borderColor, - backgroundColor: bodyBg + color: borderColor ) ) } diff --git a/Sources/TUIkitExample/Pages/OverlaysPage.swift b/Sources/TUIkitExample/Pages/OverlaysPage.swift index ac0d963b..4e015944 100644 --- a/Sources/TUIkitExample/Pages/OverlaysPage.swift +++ b/Sources/TUIkitExample/Pages/OverlaysPage.swift @@ -40,13 +40,13 @@ private enum OverlayDemo: Int, CaseIterable { case .alertStandard: "A standard alert with default theme colors. Uses .alert(isPresented:) modifier." case .alertWarning: - "A warning-style alert with yellow border and title. Uses Alert.warning() preset." + "A warning-style alert with palette warning colors. Uses Alert.warning() preset." case .alertError: - "An error-style alert with red border and title. Uses Alert.error() preset." + "An error-style alert with palette error colors. Uses Alert.error() preset." case .alertInfo: - "An info-style alert with cyan border and title. Uses Alert.info() preset." + "An info-style alert with palette info colors. Uses Alert.info() preset." case .alertSuccess: - "A success-style alert with green border and title. Uses Alert.success() preset." + "A success-style alert with palette success colors. Uses Alert.success() preset." case .dialog: "A Dialog view with custom content. More flexible than Alert — accepts any views." case .dialogWithFooter: @@ -186,8 +186,8 @@ struct OverlaysPage: View { Alert( title: "Warning", message: "Something might go wrong. Please check your input.", - borderColor: .yellow, - titleColor: .yellow + borderColor: .palette.warning, + titleColor: .palette.warning ) { dismissButton } @@ -197,8 +197,8 @@ struct OverlaysPage: View { Alert( title: "Error", message: "An unexpected error occurred. Please try again.", - borderColor: .red, - titleColor: .red + borderColor: .palette.error, + titleColor: .palette.error ) { dismissButton } @@ -208,8 +208,8 @@ struct OverlaysPage: View { Alert( title: "Info", message: "This is an informational message for the user.", - borderColor: .cyan, - titleColor: .cyan + borderColor: .palette.info, + titleColor: .palette.info ) { dismissButton } @@ -219,8 +219,8 @@ struct OverlaysPage: View { Alert( title: "Success", message: "Operation completed successfully!", - borderColor: .green, - titleColor: .green + borderColor: .palette.success, + titleColor: .palette.success ) { dismissButton } diff --git a/Sources/TUIkitExample/Pages/SpinnersPage.swift b/Sources/TUIkitExample/Pages/SpinnersPage.swift index 814c4a77..9da9c5ed 100644 --- a/Sources/TUIkitExample/Pages/SpinnersPage.swift +++ b/Sources/TUIkitExample/Pages/SpinnersPage.swift @@ -26,7 +26,7 @@ struct SpinnersPage: View { } DemoSection("Custom Color") { - Spinner("Installing...", style: .bouncing, color: .green) + Spinner("Installing...", style: .bouncing, color: .palette.success) } Spacer()