mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
Merge pull request #67 from phranck/feat/overlays-demo-redesign
Feat: Redesign OverlaysPage with interactive demo menu
This commit is contained in:
@@ -91,6 +91,7 @@ internal final class AppRunner<A: App> {
|
||||
self.inputHandler = InputHandler(
|
||||
statusBar: statusBar,
|
||||
keyEventDispatcher: tuiContext.keyEventDispatcher,
|
||||
focusManager: focusManager,
|
||||
paletteManager: paletteManager,
|
||||
appearanceManager: appearanceManager,
|
||||
onQuit: { [weak self] in
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -82,9 +82,17 @@ 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()
|
||||
|
||||
let alertBuffer = TUIkit.renderToBuffer(alert, context: context)
|
||||
|
||||
|
||||
@@ -48,9 +48,17 @@ 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()
|
||||
|
||||
let modalBuffer = TUIkit.renderToBuffer(modal, context: context)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<A>(
|
||||
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<A>(
|
||||
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<A>(
|
||||
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<A>(
|
||||
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<EmptyView> {
|
||||
Alert.warning(title: title, message: message) { EmptyView() }
|
||||
Alert<EmptyView>(
|
||||
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<EmptyView> {
|
||||
Alert.error(title: title, message: message) { EmptyView() }
|
||||
Alert<EmptyView>(
|
||||
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<EmptyView> {
|
||||
Alert.info(title: title, message: message) { EmptyView() }
|
||||
Alert<EmptyView>(
|
||||
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<EmptyView> {
|
||||
Alert.success(title: title, message: message) { EmptyView() }
|
||||
Alert<EmptyView>(
|
||||
title: title, message: message,
|
||||
borderColor: .palette.success, titleColor: .palette.success
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,8 +75,7 @@ struct ContentView: View {
|
||||
ContainersPage()
|
||||
.statusBarItems(subPageItems(pageSetter: pageSetter))
|
||||
case .overlays:
|
||||
OverlaysPage()
|
||||
.statusBarItems(subPageItems(pageSetter: pageSetter))
|
||||
OverlaysPage(onBack: { pageSetter.wrappedValue = .menu })
|
||||
case .layout:
|
||||
LayoutPage()
|
||||
.statusBarItems(subPageItems(pageSetter: pageSetter))
|
||||
|
||||
@@ -2,78 +2,312 @@
|
||||
// OverlaysPage.swift
|
||||
// TUIkitExample
|
||||
//
|
||||
// Demonstrates overlay and modal capabilities.
|
||||
// Demonstrates overlay and modal capabilities with an interactive menu.
|
||||
//
|
||||
|
||||
import TUIkit
|
||||
|
||||
/// Overlays and modals demo page.
|
||||
// MARK: - Overlay Demo Variants
|
||||
|
||||
/// Available overlay demo variants.
|
||||
private enum OverlayDemo: Int, CaseIterable {
|
||||
case alertStandard
|
||||
case alertWarning
|
||||
case alertError
|
||||
case alertInfo
|
||||
case alertSuccess
|
||||
case dialog
|
||||
case dialogWithFooter
|
||||
case modalCustom
|
||||
|
||||
/// Display label for the menu.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .alertStandard: "Alert (Standard)"
|
||||
case .alertWarning: "Alert (Warning)"
|
||||
case .alertError: "Alert (Error)"
|
||||
case .alertInfo: "Alert (Info)"
|
||||
case .alertSuccess: "Alert (Success)"
|
||||
case .dialog: "Dialog"
|
||||
case .dialogWithFooter: "Dialog with Footer"
|
||||
case .modalCustom: "Modal (Custom)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Description text for the detail panel.
|
||||
var description: String {
|
||||
switch self {
|
||||
case .alertStandard:
|
||||
"A standard alert with default theme colors. Uses .alert(isPresented:) modifier."
|
||||
case .alertWarning:
|
||||
"A warning-style alert with palette warning colors. Uses Alert.warning() preset."
|
||||
case .alertError:
|
||||
"An error-style alert with palette error colors. Uses Alert.error() preset."
|
||||
case .alertInfo:
|
||||
"An info-style alert with palette info colors. Uses Alert.info() preset."
|
||||
case .alertSuccess:
|
||||
"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:
|
||||
"A Dialog with a footer section for action buttons, separated by a divider line."
|
||||
case .modalCustom:
|
||||
"A custom modal overlay using .modal(isPresented:). Accepts any view as content."
|
||||
}
|
||||
}
|
||||
|
||||
/// API usage example for the detail panel.
|
||||
var apiUsage: String {
|
||||
switch self {
|
||||
case .alertStandard:
|
||||
".alert(\"Title\", isPresented: $show) { actions } message: { Text(\"...\") }"
|
||||
case .alertWarning:
|
||||
".modal(isPresented: $show) { Alert.warning(message: \"...\") { actions } }"
|
||||
case .alertError:
|
||||
".modal(isPresented: $show) { Alert.error(message: \"...\") { actions } }"
|
||||
case .alertInfo:
|
||||
".modal(isPresented: $show) { Alert.info(message: \"...\") { actions } }"
|
||||
case .alertSuccess:
|
||||
".modal(isPresented: $show) { Alert.success(message: \"...\") { actions } }"
|
||||
case .dialog:
|
||||
".modal(isPresented: $show) { Dialog(title: \"...\") { content } }"
|
||||
case .dialogWithFooter:
|
||||
".modal(isPresented: $show) { Dialog(title: \"...\") { content } footer: { buttons } }"
|
||||
case .modalCustom:
|
||||
".modal(isPresented: $show) { VStack { ... } }"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overlays Page
|
||||
|
||||
/// Interactive overlays and modals demo page.
|
||||
///
|
||||
/// Shows the overlay system including:
|
||||
/// - `.alert(isPresented:)` modifier - SwiftUI-style alert presentation
|
||||
/// - `.modal(isPresented:)` modifier - SwiftUI-style modal presentation
|
||||
/// - `.dimmed()` modifier - visual de-emphasis
|
||||
/// - Note: The status bar is NOT dimmed by modals!
|
||||
/// Displays a menu of overlay variants on the left and a description
|
||||
/// panel on the right. Pressing Enter shows the selected overlay
|
||||
/// with dimmed background content.
|
||||
struct OverlaysPage: View {
|
||||
@State var showModal: Bool = true
|
||||
@State var menuSelection: Int = 0
|
||||
@State var showOverlay: Bool = false
|
||||
|
||||
/// Callback to navigate back to the main menu.
|
||||
let onBack: () -> Void
|
||||
|
||||
/// The currently selected demo variant.
|
||||
private var selectedDemo: OverlayDemo {
|
||||
OverlayDemo.allCases[menuSelection]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
backgroundContent
|
||||
.alert(
|
||||
"Alert Demo",
|
||||
isPresented: $showModal,
|
||||
actions: {
|
||||
Button("Dismiss", style: .primary) {
|
||||
showModal = false
|
||||
}
|
||||
},
|
||||
message: {
|
||||
Text("This alert uses the new .alert(isPresented:) API!")
|
||||
},
|
||||
borderColor: .palette.border,
|
||||
titleColor: .palette.accent
|
||||
)
|
||||
.modal(isPresented: $showOverlay) {
|
||||
overlayContent(for: selectedDemo)
|
||||
}
|
||||
.statusBarItems(statusBarItems)
|
||||
}
|
||||
|
||||
var backgroundContent: some View {
|
||||
/// Status bar items change depending on whether a modal is open.
|
||||
/// When a modal is presented, ESC closes the modal instead of navigating back.
|
||||
private var statusBarItems: [any StatusBarItemProtocol] {
|
||||
if showOverlay {
|
||||
return [
|
||||
StatusBarItem(shortcut: Shortcut.escape, label: "close") {
|
||||
showOverlay = false
|
||||
},
|
||||
StatusBarItem(shortcut: Shortcut.enter, label: "dismiss"),
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
StatusBarItem(shortcut: Shortcut.escape, label: "back") {
|
||||
onBack()
|
||||
},
|
||||
StatusBarItem(shortcut: Shortcut.arrowsUpDown, label: "nav"),
|
||||
StatusBarItem(shortcut: Shortcut.enter, label: "show"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Content
|
||||
|
||||
/// The main background content with menu and description.
|
||||
private var backgroundContent: some View {
|
||||
VStack(spacing: 1) {
|
||||
HeaderView(title: "Overlays & Modals Demo")
|
||||
|
||||
DemoSection("Presentation APIs (SwiftUI-style)") {
|
||||
Text(" .alert(isPresented:) - declarative alert presentation")
|
||||
Text(" .modal(isPresented:) - declarative modal presentation")
|
||||
Text(" .overlay() - layer content on top")
|
||||
Text(" .dimmed() - reduce visual emphasis")
|
||||
}
|
||||
HStack(spacing: 3) {
|
||||
// Left: Demo menu
|
||||
Menu(
|
||||
title: "Select a Demo",
|
||||
items: OverlayDemo.allCases.map { demo in
|
||||
MenuItem(label: demo.label, shortcut: nil)
|
||||
},
|
||||
selection: $menuSelection,
|
||||
onSelect: { _ in
|
||||
showOverlay = true
|
||||
},
|
||||
selectedColor: .palette.accent,
|
||||
borderColor: .palette.border
|
||||
)
|
||||
|
||||
DemoSection("Modal Toggle (@State)") {
|
||||
HStack(spacing: 2) {
|
||||
if showModal {
|
||||
Text("Modal is visible")
|
||||
.foregroundColor(.palette.accent)
|
||||
} else {
|
||||
Text("Modal dismissed")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Button("Show Again", style: .primary) {
|
||||
showModal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Right: Description of selected demo
|
||||
descriptionPanel
|
||||
}
|
||||
|
||||
DemoSection("How It Works") {
|
||||
Text("Uses .alert(isPresented: $showModal) { ... }")
|
||||
Text("All overlays use the SwiftUI-style presentation API:")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text("No manual if/else needed - SwiftUI-style API!")
|
||||
Text(" .alert(isPresented:) — for Alert views")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text(" .modal(isPresented:) — for Dialog, custom content")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text("The background is automatically dimmed. Status bar stays visible.")
|
||||
.bold()
|
||||
.foregroundColor(.palette.accent)
|
||||
Text("Pressing 'Dismiss' sets showModal = false")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text("Status bar is NOT dimmed (separate render layer)")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Description Panel
|
||||
|
||||
/// Detail panel showing the selected demo's description and API usage.
|
||||
private var descriptionPanel: some View {
|
||||
Panel(selectedDemo.label, titleColor: .palette.accent) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(selectedDemo.description)
|
||||
.foregroundColor(.palette.foreground)
|
||||
|
||||
Text("")
|
||||
|
||||
Text("API:")
|
||||
.bold()
|
||||
.foregroundColor(.palette.accent)
|
||||
Text(" \(selectedDemo.apiUsage)")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 55)
|
||||
}
|
||||
|
||||
// MARK: - Overlay Content
|
||||
|
||||
/// Builds the overlay content for the selected demo variant.
|
||||
@ViewBuilder
|
||||
private func overlayContent(for demo: OverlayDemo) -> some View {
|
||||
switch demo {
|
||||
case .alertStandard:
|
||||
Alert(
|
||||
title: "Standard Alert",
|
||||
message: "This is a standard alert with default theme colors.",
|
||||
borderColor: .palette.border,
|
||||
titleColor: .palette.accent
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .alertWarning:
|
||||
Alert(
|
||||
title: "Warning",
|
||||
message: "Something might go wrong. Please check your input.",
|
||||
borderColor: .palette.warning,
|
||||
titleColor: .palette.warning
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .alertError:
|
||||
Alert(
|
||||
title: "Error",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
borderColor: .palette.error,
|
||||
titleColor: .palette.error
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .alertInfo:
|
||||
Alert(
|
||||
title: "Info",
|
||||
message: "This is an informational message for the user.",
|
||||
borderColor: .palette.info,
|
||||
titleColor: .palette.info
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .alertSuccess:
|
||||
Alert(
|
||||
title: "Success",
|
||||
message: "Operation completed successfully!",
|
||||
borderColor: .palette.success,
|
||||
titleColor: .palette.success
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .dialog:
|
||||
Dialog(title: "Settings", borderColor: .palette.border, titleColor: .palette.accent) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Theme: Dark")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("Language: English")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("Notifications: On")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("")
|
||||
dismissButton
|
||||
}
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .dialogWithFooter:
|
||||
Dialog(
|
||||
title: "Confirm Action",
|
||||
borderColor: .palette.border,
|
||||
titleColor: .palette.accent
|
||||
) {
|
||||
Text("Are you sure you want to proceed?")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("This action cannot be undone.")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
} footer: {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .modalCustom:
|
||||
VStack(spacing: 1) {
|
||||
Text("Custom Modal Content")
|
||||
.bold()
|
||||
.foregroundColor(.palette.accent)
|
||||
Text("")
|
||||
Text("This modal uses .modal(isPresented:)")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("with completely custom view content.")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text("No Alert or Dialog — just any View!")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text("")
|
||||
dismissButton
|
||||
}
|
||||
.padding(EdgeInsets(horizontal: 2, vertical: 1))
|
||||
.border(color: .palette.border)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable right-aligned dismiss button for all overlay variants.
|
||||
private var dismissButton: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Dismiss", style: .primary) {
|
||||
showOverlay = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ struct SpinnersPage: View {
|
||||
}
|
||||
|
||||
DemoSection("Custom Color") {
|
||||
Spinner("Installing...", style: .bouncing, color: .green)
|
||||
Spinner("Installing...", style: .bouncing, color: .palette.success)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -9,34 +9,25 @@ import Testing
|
||||
|
||||
@testable import TUIkit
|
||||
|
||||
@Suite("AppState Tests", .serialized)
|
||||
@Suite("AppState Tests")
|
||||
struct AppStateTests {
|
||||
|
||||
/// Creates a fresh AppState instance to isolate tests from shared global state.
|
||||
private func isolatedAppState() -> AppState {
|
||||
let fresh = AppState()
|
||||
RenderNotifier.current = fresh
|
||||
return fresh
|
||||
}
|
||||
|
||||
@Test("AppState initially does not need render")
|
||||
func initialState() {
|
||||
let appState = isolatedAppState()
|
||||
appState.didRender()
|
||||
let appState = AppState()
|
||||
#expect(appState.needsRender == false)
|
||||
}
|
||||
|
||||
@Test("setNeedsRender marks state as dirty")
|
||||
func setNeedsRender() {
|
||||
let appState = isolatedAppState()
|
||||
appState.didRender()
|
||||
let appState = AppState()
|
||||
appState.setNeedsRender()
|
||||
#expect(appState.needsRender == true)
|
||||
}
|
||||
|
||||
@Test("didRender resets needsRender flag")
|
||||
func didRenderResets() {
|
||||
let appState = isolatedAppState()
|
||||
let appState = AppState()
|
||||
appState.setNeedsRender()
|
||||
#expect(appState.needsRender == true)
|
||||
appState.didRender()
|
||||
@@ -45,31 +36,29 @@ struct AppStateTests {
|
||||
|
||||
@Test("setNeedsRender notifies observers")
|
||||
func observerNotified() {
|
||||
let appState = isolatedAppState()
|
||||
let appState = AppState()
|
||||
nonisolated(unsafe) var notified = false
|
||||
appState.observe {
|
||||
notified = true
|
||||
}
|
||||
appState.setNeedsRender()
|
||||
#expect(notified == true)
|
||||
appState.clearObservers()
|
||||
}
|
||||
|
||||
@Test("Multiple observers all get notified")
|
||||
func multipleObservers() {
|
||||
let appState = isolatedAppState()
|
||||
let appState = AppState()
|
||||
nonisolated(unsafe) var count = 0
|
||||
appState.observe { count += 1 }
|
||||
appState.observe { count += 1 }
|
||||
appState.observe { count += 1 }
|
||||
appState.setNeedsRender()
|
||||
#expect(count == 3)
|
||||
appState.clearObservers()
|
||||
}
|
||||
|
||||
@Test("clearObservers removes all observers")
|
||||
func clearObservers() {
|
||||
let appState = isolatedAppState()
|
||||
let appState = AppState()
|
||||
nonisolated(unsafe) var notified = false
|
||||
appState.observe { notified = true }
|
||||
appState.clearObservers()
|
||||
|
||||
@@ -9,16 +9,9 @@ import Testing
|
||||
|
||||
@testable import TUIkit
|
||||
|
||||
@Suite("State Property Wrapper Tests", .serialized)
|
||||
@Suite("State Property Wrapper Tests")
|
||||
struct StatePropertyWrapperTests {
|
||||
|
||||
/// Creates a fresh AppState instance to isolate tests from shared global state.
|
||||
private func isolatedAppState() -> AppState {
|
||||
let fresh = AppState()
|
||||
RenderNotifier.current = fresh
|
||||
return fresh
|
||||
}
|
||||
|
||||
@Test("State can be mutated")
|
||||
func stateMutation() {
|
||||
let state = State(wrappedValue: 0)
|
||||
@@ -26,12 +19,20 @@ struct StatePropertyWrapperTests {
|
||||
#expect(state.wrappedValue == 10)
|
||||
}
|
||||
|
||||
@Test("State mutation triggers render")
|
||||
@Test("State mutation triggers render via RenderNotifier")
|
||||
func stateTriggerRender() {
|
||||
let appState = isolatedAppState()
|
||||
// StateBox.didSet calls RenderNotifier.current.setNeedsRender().
|
||||
// We swap in a fresh AppState, mutate, and check immediately.
|
||||
// This is a single-expression sequence with no yield points,
|
||||
// so no parallel test can interfere between set and check.
|
||||
let appState = AppState()
|
||||
let previous = RenderNotifier.current
|
||||
RenderNotifier.current = appState
|
||||
let state = State(wrappedValue: "initial")
|
||||
state.wrappedValue = "changed"
|
||||
#expect(appState.needsRender == true)
|
||||
let triggered = appState.needsRender
|
||||
RenderNotifier.current = previous
|
||||
#expect(triggered == true)
|
||||
}
|
||||
|
||||
@Test("Binding from State updates original")
|
||||
|
||||
@@ -13,19 +13,20 @@ import Testing
|
||||
@Suite("State Storage Identity Tests", .serialized)
|
||||
struct StateStorageIdentityTests {
|
||||
|
||||
/// Creates an isolated test environment with fresh AppState and StateStorage.
|
||||
private func testEnvironment() -> (AppState, StateStorage) {
|
||||
let appState = AppState()
|
||||
RenderNotifier.current = appState
|
||||
let storage = StateStorage()
|
||||
return (appState, storage)
|
||||
/// Creates a fresh StateStorage for test isolation.
|
||||
///
|
||||
/// Does NOT touch `RenderNotifier.current`. State mutations during
|
||||
/// tests call `setNeedsRender()` on the default global AppState —
|
||||
/// that's harmless and avoids race conditions with parallel suites.
|
||||
private func testStorage() -> StateStorage {
|
||||
StateStorage()
|
||||
}
|
||||
|
||||
// MARK: - Self-Hydrating State
|
||||
|
||||
@Test("State self-hydrates from StateStorage when active context is set")
|
||||
func selfHydrationFromStorage() {
|
||||
let (_, storage) = testEnvironment()
|
||||
let storage = testStorage()
|
||||
let identity = ViewIdentity(path: "TestView")
|
||||
|
||||
// First construction: creates new entry in storage
|
||||
@@ -48,7 +49,6 @@ struct StateStorageIdentityTests {
|
||||
|
||||
@Test("State uses local box when no active context is set")
|
||||
func localBoxWithoutContext() {
|
||||
let (_, _) = testEnvironment()
|
||||
StateRegistration.activeContext = nil
|
||||
|
||||
let state = State(wrappedValue: "hello")
|
||||
@@ -60,7 +60,7 @@ struct StateStorageIdentityTests {
|
||||
|
||||
@Test("Multiple @State properties get distinct indices")
|
||||
func multipleStateDistinctIndices() {
|
||||
let (_, storage) = testEnvironment()
|
||||
let storage = testStorage()
|
||||
let identity = ViewIdentity(path: "MultiStateView")
|
||||
|
||||
// Simulate a view with two @State properties
|
||||
@@ -117,7 +117,7 @@ struct StateStorageIdentityTests {
|
||||
|
||||
@Test("StateStorage returns same box for same key")
|
||||
func storageSameKey() {
|
||||
let storage = StateStorage()
|
||||
let storage = testStorage()
|
||||
let key = StateStorage.StateKey(
|
||||
identity: ViewIdentity(path: "V"),
|
||||
propertyIndex: 0
|
||||
@@ -133,7 +133,7 @@ struct StateStorageIdentityTests {
|
||||
|
||||
@Test("StateStorage returns different boxes for different keys")
|
||||
func storageDifferentKeys() {
|
||||
let storage = StateStorage()
|
||||
let storage = testStorage()
|
||||
let key1 = StateStorage.StateKey(
|
||||
identity: ViewIdentity(path: "V"),
|
||||
propertyIndex: 0
|
||||
@@ -155,7 +155,7 @@ struct StateStorageIdentityTests {
|
||||
|
||||
@Test("invalidateDescendants removes state under a branch")
|
||||
func branchInvalidation() {
|
||||
let (_, storage) = testEnvironment()
|
||||
let storage = testStorage()
|
||||
let branchIdentity = ViewIdentity(path: "Root#true")
|
||||
let childIdentity = ViewIdentity(path: "Root#true/Child")
|
||||
|
||||
@@ -177,7 +177,7 @@ struct StateStorageIdentityTests {
|
||||
|
||||
@Test("endRenderPass removes state for views not marked active")
|
||||
func renderPassGarbageCollection() {
|
||||
let storage = StateStorage()
|
||||
let storage = testStorage()
|
||||
let activeIdentity = ViewIdentity(path: "Active")
|
||||
let staleIdentity = ViewIdentity(path: "Stale")
|
||||
|
||||
@@ -206,7 +206,6 @@ struct StateStorageIdentityTests {
|
||||
|
||||
@Test("State survives reconstruction through renderToBuffer")
|
||||
func stateSurvivesRenderToBuffer() {
|
||||
let (_, _) = testEnvironment()
|
||||
let tuiContext = TUIContext()
|
||||
let context = RenderContext(
|
||||
availableWidth: 80,
|
||||
@@ -226,7 +225,6 @@ struct StateStorageIdentityTests {
|
||||
|
||||
@Test("Nested views get independent state identities")
|
||||
func nestedViewsIndependentState() {
|
||||
let (_, _) = testEnvironment()
|
||||
let tuiContext = TUIContext()
|
||||
let context = RenderContext(
|
||||
availableWidth: 80,
|
||||
|
||||
Reference in New Issue
Block a user