Files
phranck 5ad97132b8 Feat: Add @Observable support with Observation framework
- Replace custom Observable protocol and @Published with Apple's @Observable macro
- Add withObservationTracking in renderToBuffer for automatic per-property dependency tracking
- Add type-based @Environment(Type.self) and .environment(object) for observable objects
- Add ObjectEnvironmentModifier for injecting observable objects into the environment
- Add needsCacheClear flag to AppState for thread-safe cache invalidation
- Add cross-platform test script (scripts/test-linux.sh) for Docker-based Linux verification
- Add DemoAppHeader with system info display (OS, version, architecture)
- Consolidate Example App: extract ImageDemoHelpers, KeyboardHelpSection, ValueDisplayRow
- Add pre-push verification rule to CLAUDE.md
- Verified on both macOS and Linux (swift:6.0 container), 1155 tests passing
2026-02-15 23:49:34 +01:00

312 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 🖥 TUIKit Terminal UI Kit for Swift
// OverlaysPage.swift
//
// Created by LAYERED.work
// License: MIT
import TUIkit
// 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
case notification
/// 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)"
case .notification: "Notification"
}
}
/// 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."
case .notification:
"A fire-and-forget notification. Fades in, stays 3s, fades out. Posted via NotificationService."
}
}
/// 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 { ... } }"
case .notification:
"NotificationService.current.post(\"Saved!\")"
}
}
/// Whether this demo variant is a notification (not a modal).
var isNotification: Bool {
self == .notification
}
}
// MARK: - Overlays Page
/// Interactive overlays and modals demo page.
///
/// 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 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
.modal(isPresented: $showOverlay) {
overlayContent(for: selectedDemo)
}
.notificationHost()
.statusBarItems(statusBarItems)
}
/// 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(alignment: .leading, spacing: 1) {
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
if selectedDemo.isNotification {
NotificationService.current.post(
"Operation completed successfully!"
)
} else {
showOverlay = true
}
},
selectedColor: .palette.accent,
borderColor: .palette.border
)
// Right: Description of selected demo
descriptionPanel
}
DemoSection("How It Works") {
Text("All overlays use the SwiftUI-style presentation API:")
.foregroundStyle(.palette.foregroundSecondary)
Text(" .alert(isPresented:) — for Alert views")
.foregroundStyle(.palette.foregroundSecondary)
Text(" .modal(isPresented:) — for Dialog, custom content")
.foregroundStyle(.palette.foregroundSecondary)
Text(" NotificationService.current.post() — fire-and-forget with fade")
.foregroundStyle(.palette.foregroundSecondary)
Text("Modals dim the background. Notifications stay non-blocking.")
.bold()
.foregroundStyle(.palette.accent)
}
Spacer()
}
.appHeader {
DemoAppHeader("Overlays, Modals & Notifications Demo")
}
}
// 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)
.foregroundStyle(.palette.foreground)
Text("")
Text("API:")
.bold()
.foregroundStyle(.palette.accent)
Text(" \(selectedDemo.apiUsage)")
.foregroundStyle(.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, .alertWarning, .alertError,
.alertInfo, .alertSuccess:
alertContent(for: demo)
case .dialog:
Dialog(title: "Settings", borderColor: .palette.border, titleColor: .palette.accent) {
VStack(alignment: .leading) {
Text("Theme: Dark").foregroundStyle(.palette.foreground)
Text("Language: English").foregroundStyle(.palette.foreground)
Text("Notifications: On").foregroundStyle(.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?").foregroundStyle(.palette.foreground)
Text("This action cannot be undone.").foregroundStyle(.palette.foregroundSecondary)
} footer: {
dismissButton
}
.frame(width: 50)
case .modalCustom:
VStack(alignment: .leading, spacing: 1) {
Text("Custom Modal Content").bold().foregroundStyle(.palette.accent)
Text("")
Text("This modal uses .modal(isPresented:)").foregroundStyle(.palette.foreground)
Text("with completely custom view content.").foregroundStyle(.palette.foregroundSecondary)
Text("No Alert or Dialog — just any View!").foregroundStyle(.palette.foregroundSecondary)
Text("")
dismissButton
}
.padding(EdgeInsets(horizontal: 2, vertical: 1))
.border(color: .palette.border)
case .notification:
// Notifications are posted via NotificationService, not shown as modal content.
EmptyView()
}
}
/// Builds an alert for the given demo variant.
@ViewBuilder
private func alertContent(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.",
titleColor: .palette.warning
) { dismissButton }.frame(width: 50)
case .alertError:
Alert(
title: "Error",
message: "An unexpected error occurred. Please try again.",
titleColor: .palette.error
) { dismissButton }.frame(width: 50)
case .alertInfo:
Alert(
title: "Info",
message: "This is an informational message for the user.",
titleColor: .palette.info
) { dismissButton }.frame(width: 50)
case .alertSuccess:
Alert(
title: "Success",
message: "Operation completed successfully!",
titleColor: .palette.success
) { dismissButton }.frame(width: 50)
default:
EmptyView()
}
}
/// Reusable right-aligned dismiss button for all overlay variants.
private var dismissButton: some View {
HStack {
Spacer()
Button("Dismiss", style: .primary) {
showOverlay = false
}
}
}
}