From 9dd8e41586648d2fd8ab7a05179b78b915ef0f67 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sat, 27 Sep 2025 16:00:43 -0700 Subject: [PATCH 01/12] initial AppKitDemo app --- Examples/AppKitDemo/AppDelegate.swift | 79 +++++++++++ .../AccentColor.colorset/Contents.json | 11 ++ .../AppIcon.appiconset/Contents.json | 58 ++++++++ .../AppKitDemo/Assets.xcassets/Contents.json | 6 + Examples/AppKitDemo/main.swift | 10 ++ Examples/Examples.xcodeproj/project.pbxproj | 130 +++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 20 +-- 7 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 Examples/AppKitDemo/AppDelegate.swift create mode 100644 Examples/AppKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/AppKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/AppKitDemo/Assets.xcassets/Contents.json create mode 100644 Examples/AppKitDemo/main.swift diff --git a/Examples/AppKitDemo/AppDelegate.swift b/Examples/AppKitDemo/AppDelegate.swift new file mode 100644 index 00000000..a4faafe0 --- /dev/null +++ b/Examples/AppKitDemo/AppDelegate.swift @@ -0,0 +1,79 @@ +import AppKit +import SwiftUI + +@MainActor +public final class AppDelegate: NSObject, NSApplicationDelegate { + private var windowControllers: [DemoWindowController] = [] + + public func applicationWillFinishLaunching(_ notification: Notification) { + let appMenu = NSMenuItem() + appMenu.submenu = NSMenu() + appMenu.submenu?.items = [ + NSMenuItem( + title: "New Window", + action: #selector(AppDelegate.newDemoWindow), + keyEquivalent: "n" + ), + NSMenuItem( + title: "Close Window", + action: #selector(NSWindow.performClose(_:)), + keyEquivalent: "w" + ), + NSMenuItem( + title: "Quit", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ), + ] + let mainMenu = NSMenu() + mainMenu.items = [appMenu] + NSApplication.shared.mainMenu = mainMenu + } + public func applicationDidFinishLaunching(_ notification: Notification) { + newDemoWindow() + } +} + +extension AppDelegate { + @objc func newDemoWindow() { + let windowController = DemoWindowController() + windowController.showWindow(nil) + windowControllers.append(windowController) + } + func removeWindowController(_ controller: DemoWindowController) { + windowControllers.removeAll { $0 === controller } + } +} + +final class DemoWindowController: NSWindowController { + init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [ + .fullSizeContentView, + .closable, + .miniaturizable, + .resizable, + .titled, + ], + backing: .buffered, + defer: false + ) + + window.titleVisibility = .visible + window.toolbarStyle = .unified + window.center() + + super.init(window: window) + + window.contentView = NSHostingView(rootView: Color.red) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + public func windowWillClose(_ notification: Notification) { + if let appDelegate = NSApp.delegate as? AppDelegate { + appDelegate.removeWindowController(self) + } + } +} diff --git a/Examples/AppKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/AppKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Examples/AppKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/AppKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/AppKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..3f00db43 --- /dev/null +++ b/Examples/AppKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/AppKitDemo/Assets.xcassets/Contents.json b/Examples/AppKitDemo/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/AppKitDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/AppKitDemo/main.swift b/Examples/AppKitDemo/main.swift new file mode 100644 index 00000000..195b6235 --- /dev/null +++ b/Examples/AppKitDemo/main.swift @@ -0,0 +1,10 @@ +import AppKit + +MainActor.assumeIsolated { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.setActivationPolicy(.regular) +} + +_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 2abda09e..5c2d07ce 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + C4CD9A252E88A20900172F37 /* AppKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; CA2BDE272E71C42B000974D3 /* sqlite-data */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "sqlite-data"; path = "/Users/brandon/projects/sqlite-data"; sourceTree = ""; }; CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RemindersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -94,6 +95,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + C4CD9A262E88A20900172F37 /* AppKitDemo */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = AppKitDemo; + sourceTree = ""; + }; CA2BDD9E2E71C30B000974D3 /* CloudKitDemo */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -144,6 +150,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + C4CD9A222E88A20900172F37 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CA2BDD9A2E71C30B000974D3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -221,6 +234,7 @@ DCBE89CD2D483FB90071F499 /* SyncUps */, CAD0017E2D874E6F00FA977A /* SyncUpTests */, CA2BDD9E2E71C30B000974D3 /* CloudKitDemo */, + C4CD9A262E88A20900172F37 /* AppKitDemo */, CAF837022D4735C00047AEB5 /* Frameworks */, CAF836992D4735620047AEB5 /* Products */, ); @@ -236,6 +250,7 @@ CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */, CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */, CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */, + C4CD9A252E88A20900172F37 /* AppKitDemo.app */, ); name = Products; sourceTree = ""; @@ -251,6 +266,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + C4CD9A242E88A20900172F37 /* AppKitDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = C4CD9A2F2E88A20A00172F37 /* Build configuration list for PBXNativeTarget "AppKitDemo" */; + buildPhases = ( + C4CD9A212E88A20900172F37 /* Sources */, + C4CD9A222E88A20900172F37 /* Frameworks */, + C4CD9A232E88A20900172F37 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + C4CD9A262E88A20900172F37 /* AppKitDemo */, + ); + name = AppKitDemo; + packageProductDependencies = ( + ); + productName = AppKitDemo; + productReference = C4CD9A252E88A20900172F37 /* AppKitDemo.app */; + productType = "com.apple.product-type.application"; + }; CA2BDD9C2E71C30B000974D3 /* CloudKitDemo */ = { isa = PBXNativeTarget; buildConfigurationList = CA2BDDA72E71C30D000974D3 /* Build configuration list for PBXNativeTarget "CloudKitDemo" */; @@ -429,9 +466,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1620; TargetAttributes = { + C4CD9A242E88A20900172F37 = { + CreatedOnToolsVersion = 26.0; + }; CA2BDD9C2E71C30B000974D3 = { CreatedOnToolsVersion = 16.4; }; @@ -486,11 +526,19 @@ DCBE89CB2D483FB90071F499 /* SyncUps */, CAD0017C2D874E6F00FA977A /* SyncUpTests */, CA2BDD9C2E71C30B000974D3 /* CloudKitDemo */, + C4CD9A242E88A20900172F37 /* AppKitDemo */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + C4CD9A232E88A20900172F37 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CA2BDD9B2E71C30B000974D3 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -543,6 +591,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + C4CD9A212E88A20900172F37 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CA2BDD992E71C30B000974D3 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -613,6 +668,70 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + C4CD9A2D2E88A20A00172F37 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.7; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.AppKitDemo.AppKitDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C4CD9A2E2E88A20A00172F37 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.7; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.AppKitDemo.AppKitDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; CA2BDDA52E71C30D000974D3 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1077,6 +1196,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + C4CD9A2F2E88A20A00172F37 /* Build configuration list for PBXNativeTarget "AppKitDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C4CD9A2D2E88A20A00172F37 /* Debug */, + C4CD9A2E2E88A20A00172F37 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; CA2BDDA72E71C30D000974D3 /* Build configuration list for PBXNativeTarget "CloudKitDemo" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 744551e2..ce3e994e 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1a549785266ada7e3202edc79fe44d94e238bd6e6494c714e09a66c2d74bc59f", + "originHash" : "c0c3d5ab113340382333da4987204cf76d535b45abf7b597fe71ed3662386803", "pins" : [ { "identity" : "combine-schedulers", @@ -73,24 +73,6 @@ "version" : "1.9.4" } }, - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-plugin", - "state" : { - "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", - "version" : "1.4.5" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", From 764c9474bfbae56ded19983affb58b15370d10f0 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sat, 27 Sep 2025 16:14:50 -0700 Subject: [PATCH 02/12] ui scaffolding --- Examples/AppKitDemo/AppDelegate.swift | 2 +- Examples/AppKitDemo/RootViewController.swift | 45 +++++++++++++++++++ Examples/Examples.xcodeproj/project.pbxproj | 9 ++++ .../xcshareddata/swiftpm/Package.resolved | 20 ++++++++- 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 Examples/AppKitDemo/RootViewController.swift diff --git a/Examples/AppKitDemo/AppDelegate.swift b/Examples/AppKitDemo/AppDelegate.swift index a4faafe0..b4a1c060 100644 --- a/Examples/AppKitDemo/AppDelegate.swift +++ b/Examples/AppKitDemo/AppDelegate.swift @@ -66,7 +66,7 @@ final class DemoWindowController: NSWindowController { super.init(window: window) - window.contentView = NSHostingView(rootView: Color.red) + window.contentViewController = RootViewController() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") diff --git a/Examples/AppKitDemo/RootViewController.swift b/Examples/AppKitDemo/RootViewController.swift new file mode 100644 index 00000000..e1bd82bc --- /dev/null +++ b/Examples/AppKitDemo/RootViewController.swift @@ -0,0 +1,45 @@ +import AppKit +import SQLiteData + +final class RootViewController: NSSplitViewController { + init() { + super.init(nibName: nil, bundle: nil) + + let sidebarViewController = SidebarViewController() + let contentViewController = ContentViewController() + + let sidebarItem = NSSplitViewItem(sidebarWithViewController: sidebarViewController) + let detailItem = NSSplitViewItem(viewController: contentViewController) + + sidebarItem.minimumThickness = 250 + sidebarItem.maximumThickness = 350 + + self.addSplitViewItem(sidebarItem) + self.addSplitViewItem(detailItem) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class SidebarViewController: NSViewController { + init() { + super.init(nibName: nil, bundle: nil) + self.view.wantsLayer = true + self.view.layer?.backgroundColor = .black + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class ContentViewController: NSViewController { + init() { + super.init(nibName: nil, bundle: nil) + self.view.wantsLayer = true + self.view.layer?.backgroundColor = .white + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 5c2d07ce..0ba4fefd 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + C404E30F2E88A5F5000D23D2 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = C404E30E2E88A5F5000D23D2 /* SQLiteData */; }; CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE292E71C469000974D3 /* SQLiteData */; }; @@ -49,6 +50,7 @@ /* Begin PBXFileReference section */ C4CD9A252E88A20900172F37 /* AppKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C4CD9BE72E88A57D00172F37 /* sqlite-data */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "sqlite-data"; path = "/Users/rcarver/Code/OpenSource/sqlite-data"; sourceTree = ""; }; CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; CA2BDE272E71C42B000974D3 /* sqlite-data */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "sqlite-data"; path = "/Users/brandon/projects/sqlite-data"; sourceTree = ""; }; CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RemindersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -154,6 +156,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C404E30F2E88A5F5000D23D2 /* SQLiteData in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -258,6 +261,7 @@ CAF837022D4735C00047AEB5 /* Frameworks */ = { isa = PBXGroup; children = ( + C4CD9BE72E88A57D00172F37 /* sqlite-data */, CA2BDE272E71C42B000974D3 /* sqlite-data */, ); name = Frameworks; @@ -283,6 +287,7 @@ ); name = AppKitDemo; packageProductDependencies = ( + C404E30E2E88A5F5000D23D2 /* SQLiteData */, ); productName = AppKitDemo; productReference = C4CD9A252E88A20900172F37 /* AppKitDemo.app */; @@ -1322,6 +1327,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + C404E30E2E88A5F5000D23D2 /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + productName = SQLiteData; + }; CA14DBC82DA884C400E36852 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ce3e994e..fc47316e 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c0c3d5ab113340382333da4987204cf76d535b45abf7b597fe71ed3662386803", + "originHash" : "5b6a9f5c8c2b757451c31f80c25e528163092e701c82d5f6f1c82ca48fb617dc", "pins" : [ { "identity" : "combine-schedulers", @@ -73,6 +73,24 @@ "version" : "1.9.4" } }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", From d3937ac5d7b20a247b6f3c1dd1413825af5acfda Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sat, 27 Sep 2025 21:42:08 -0700 Subject: [PATCH 03/12] wip simple schema --- Examples/AppKitDemo/AppDelegate.swift | 4 + Examples/AppKitDemo/RootViewController.swift | 4 + Examples/AppKitDemo/Schema.swift | 186 +++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 Examples/AppKitDemo/Schema.swift diff --git a/Examples/AppKitDemo/AppDelegate.swift b/Examples/AppKitDemo/AppDelegate.swift index b4a1c060..d772171d 100644 --- a/Examples/AppKitDemo/AppDelegate.swift +++ b/Examples/AppKitDemo/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import Dependencies import SwiftUI @MainActor @@ -30,6 +31,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { NSApplication.shared.mainMenu = mainMenu } public func applicationDidFinishLaunching(_ notification: Notification) { + try! prepareDependencies { + try $0.bootstrapDatabase() + } newDemoWindow() } } diff --git a/Examples/AppKitDemo/RootViewController.swift b/Examples/AppKitDemo/RootViewController.swift index e1bd82bc..17616c17 100644 --- a/Examples/AppKitDemo/RootViewController.swift +++ b/Examples/AppKitDemo/RootViewController.swift @@ -33,6 +33,10 @@ final class SidebarViewController: NSViewController { } } +final class OutlineView: NSTreeController { + +} + final class ContentViewController: NSViewController { init() { super.init(nibName: nil, bundle: nil) diff --git a/Examples/AppKitDemo/Schema.swift b/Examples/AppKitDemo/Schema.swift new file mode 100644 index 00000000..010c9b63 --- /dev/null +++ b/Examples/AppKitDemo/Schema.swift @@ -0,0 +1,186 @@ +import Dependencies +import Foundation +import IssueReporting +import OSLog +import SQLiteData +import SwiftUI +import Synchronization + +@Table +struct RemindersList: Hashable, Identifiable { + let id: UUID + var title = "" + + static var defaultColor: Color { Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255) } + static var defaultTitle: String { "Personal" } +} + +extension RemindersList.Draft: Identifiable {} + +@Table +struct Reminder: Hashable, Identifiable { + let id: UUID + var remindersListID: RemindersList.ID + var title = "" + var isCompleted: Bool = false +} + +extension Reminder.Draft: Identifiable {} + +extension DependencyValues { + mutating func bootstrapDatabase() throws { + defaultDatabase = try AppKitDemo.appDatabase() + // defaultSyncEngine = try SyncEngine( + // for: defaultDatabase, + // tables: RemindersList.self, + // Reminder.self, + // ) + } +} + +func appDatabase() throws -> any DatabaseWriter { + @Dependency(\.context) var context + var configuration = Configuration() + configuration.foreignKeysEnabled = true + configuration.prepareDatabase { db in + //try db.attachMetadatabase() + #if DEBUG + db.trace(options: .profile) { + if context == .live { + logger.debug("\($0.expandedDescription)") + } else { + print("\($0.expandedDescription)") + } + } + #endif + } + let database = try SQLiteData.defaultDatabase(configuration: configuration) + logger.debug( + """ + App database: + open "\(database.path)" + """ + ) + var migrator = DatabaseMigrator() + #if DEBUG + migrator.eraseDatabaseOnSchemaChange = true + #endif + migrator.registerMigration("Create initial tables") { db in + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "reminders" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE, + "isCompleted" INTEGER NOT NULL DEFAULT 0, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' + ) STRICT + """ + ) + .execute(db) + } + + try migrator.migrate(database) + + try database.write { db in + + if context != .live { + try db.seedSampleData() + } + } + + return database +} + +private let logger = Logger(subsystem: "Reminders", category: "Database") + +#if DEBUG + extension Database { + func seedSampleData() throws { + @Dependency(\.date.now) var now + @Dependency(\.uuid) var uuid + let remindersListIDs = (0...2).map { _ in uuid() } + let reminderIDs = (0...10).map { _ in uuid() } + try seed { + RemindersList( + id: remindersListIDs[0], + title: "Personal" + ) + RemindersList( + id: remindersListIDs[1], + title: "Family" + ) + RemindersList( + id: remindersListIDs[2], + title: "Business" + ) + Reminder( + id: reminderIDs[0], + remindersListID: remindersListIDs[0], + title: "Groceries" + ) + Reminder( + id: reminderIDs[1], + remindersListID: remindersListIDs[0], + title: "Haircut" + ) + Reminder( + id: reminderIDs[2], + remindersListID: remindersListIDs[0], + title: "Doctor appointment" + ) + Reminder( + id: reminderIDs[3], + remindersListID: remindersListIDs[0], + title: "Take a walk", + isCompleted: true, + ) + Reminder( + id: reminderIDs[4], + remindersListID: remindersListIDs[0], + title: "Buy concert tickets" + ) + Reminder( + id: reminderIDs[5], + remindersListID: remindersListIDs[1], + title: "Pick up kids from school" + ) + Reminder( + id: reminderIDs[6], + remindersListID: remindersListIDs[1], + title: "Get laundry", + isCompleted: true, + ) + Reminder( + id: reminderIDs[7], + remindersListID: remindersListIDs[1], + title: "Take out trash" + ) + Reminder( + id: reminderIDs[8], + remindersListID: remindersListIDs[2], + title: "Call accountant" + ) + Reminder( + id: reminderIDs[9], + remindersListID: remindersListIDs[2], + title: "Send weekly emails", + isCompleted: true, + ) + Reminder( + id: reminderIDs[10], + remindersListID: remindersListIDs[2], + title: "Prepare for WWDC" + ) + } + } + } +#endif From ecec52d7b76e6df62e1b0bffcdb78a3b0cd9f167 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sat, 27 Sep 2025 22:16:43 -0700 Subject: [PATCH 04/12] wip --- Examples/AppKitDemo/RootViewController.swift | 15 --- .../AppKitDemo/SidebarViewController.swift | 116 ++++++++++++++++++ 2 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 Examples/AppKitDemo/SidebarViewController.swift diff --git a/Examples/AppKitDemo/RootViewController.swift b/Examples/AppKitDemo/RootViewController.swift index 17616c17..9f1e0dc2 100644 --- a/Examples/AppKitDemo/RootViewController.swift +++ b/Examples/AppKitDemo/RootViewController.swift @@ -22,21 +22,6 @@ final class RootViewController: NSSplitViewController { } } -final class SidebarViewController: NSViewController { - init() { - super.init(nibName: nil, bundle: nil) - self.view.wantsLayer = true - self.view.layer?.backgroundColor = .black - } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -final class OutlineView: NSTreeController { - -} - final class ContentViewController: NSViewController { init() { super.init(nibName: nil, bundle: nil) diff --git a/Examples/AppKitDemo/SidebarViewController.swift b/Examples/AppKitDemo/SidebarViewController.swift new file mode 100644 index 00000000..c1e883ad --- /dev/null +++ b/Examples/AppKitDemo/SidebarViewController.swift @@ -0,0 +1,116 @@ +import AppKit + +final class SidebarViewController: NSViewController { + private var outlineView: NSOutlineView! + private var scrollView: NSScrollView! + private let dataSource = OutlineDataSource() + + init() { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = NSView() + + scrollView = NSScrollView() + scrollView.autoresizingMask = [.width, .height] + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + + outlineView = NSOutlineView() + outlineView.autoresizingMask = [.width, .height] + + outlineView.dataSource = dataSource + outlineView.delegate = self + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("OutlineColumn")) + column.title = "Schema" + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + + //outlineView.headerView = nil + + scrollView.documentView = outlineView + self.view.addSubview(scrollView) + } + + override func viewDidLoad() { + super.viewDidLoad() + outlineView.reloadData() + } +} + +extension SidebarViewController: NSOutlineViewDelegate { + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + guard let item = item as? OutlineDataSource.OutlineItem else { return nil } + + let cellView = NSTableCellView() + + let textField = NSTextField(labelWithString: item.title) + textField.translatesAutoresizingMaskIntoConstraints = false + + cellView.addSubview(textField) + cellView.textField = textField + + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 5), + textField.trailingAnchor.constraint(equalTo: cellView.trailingAnchor), + textField.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), + ]) + + return cellView + } +} + +final class OutlineDataSource: NSObject, NSOutlineViewDataSource { + + struct OutlineItem { + let title: String + let children: [OutlineItem]? + } + + // Sample data + private let outlineData: [OutlineItem] = [ + OutlineItem( + title: "Tables", + children: [ + OutlineItem(title: "Users", children: nil), + OutlineItem(title: "Posts", children: nil), + OutlineItem(title: "Comments", children: nil), + ] + ), + OutlineItem( + title: "Views", + children: [ + OutlineItem(title: "User Posts", children: nil), + OutlineItem(title: "Popular Posts", children: nil), + ] + ), + OutlineItem(title: "Indexes", children: nil), + ] + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if let item = item as? OutlineItem { + return item.children?.count ?? 0 + } + return outlineData.count + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if let item = item as? OutlineItem { + return item.children![index] + } + return outlineData[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + if let item = item as? OutlineItem { + return item.children != nil && !item.children!.isEmpty + } + return false + } +} From 8b3c5b8cda05a9a3021c80d06ee15ca6361f68ac Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sat, 27 Sep 2025 23:08:26 -0700 Subject: [PATCH 05/12] working data-driven outline --- Examples/AppKitDemo/AppDelegate.swift | 6 +- .../AppKitDemo/SidebarViewController.swift | 81 ++++++++++--------- Examples/Examples.xcodeproj/project.pbxproj | 8 ++ 3 files changed, 58 insertions(+), 37 deletions(-) diff --git a/Examples/AppKitDemo/AppDelegate.swift b/Examples/AppKitDemo/AppDelegate.swift index d772171d..505f7262 100644 --- a/Examples/AppKitDemo/AppDelegate.swift +++ b/Examples/AppKitDemo/AppDelegate.swift @@ -1,6 +1,6 @@ import AppKit import Dependencies -import SwiftUI +import SQLiteData @MainActor public final class AppDelegate: NSObject, NSApplicationDelegate { @@ -34,6 +34,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { try! prepareDependencies { try $0.bootstrapDatabase() } + @Dependency(\.defaultDatabase) var database + try! database.write { db in + try db.seedSampleData() + } newDemoWindow() } } diff --git a/Examples/AppKitDemo/SidebarViewController.swift b/Examples/AppKitDemo/SidebarViewController.swift index c1e883ad..947db7e1 100644 --- a/Examples/AppKitDemo/SidebarViewController.swift +++ b/Examples/AppKitDemo/SidebarViewController.swift @@ -1,12 +1,46 @@ import AppKit +import AppKitNavigation +import SQLiteData final class SidebarViewController: NSViewController { private var outlineView: NSOutlineView! private var scrollView: NSScrollView! - private let dataSource = OutlineDataSource() + private var outlineItems: [OutlineItem] = [] + @FetchAll private var rows: [Row] + + @Selection + struct Row { + let remindersListTitle: String + @Column(as: [String].JSONRepresentation.self) + let reminderTitles: [String] + } init() { super.init(nibName: nil, bundle: nil) + + $rows = FetchAll( + RemindersList.all + .group(by: \.id) + .join(Reminder.all) { $0.id.eq($1.remindersListID) } + .select { + Row.Columns( + remindersListTitle: $0.title, + reminderTitles: $1.title.jsonGroupArray() + ) + } + ) + + observe { [weak self] in + guard let self else { return } + self.outlineItems = rows.map { record in + OutlineItem( + title: record.remindersListTitle, + children: record.reminderTitles.map { + OutlineItem(title: $0, children: []) + } + ) + } + } } required init?(coder: NSCoder) { @@ -24,7 +58,7 @@ final class SidebarViewController: NSViewController { outlineView = NSOutlineView() outlineView.autoresizingMask = [.width, .height] - outlineView.dataSource = dataSource + outlineView.dataSource = self outlineView.delegate = self let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("OutlineColumn")) @@ -32,21 +66,21 @@ final class SidebarViewController: NSViewController { outlineView.addTableColumn(column) outlineView.outlineTableColumn = column - //outlineView.headerView = nil + outlineView.headerView = nil scrollView.documentView = outlineView self.view.addSubview(scrollView) } +} - override func viewDidLoad() { - super.viewDidLoad() - outlineView.reloadData() - } +fileprivate struct OutlineItem { + let title: String + let children: [OutlineItem]? } extension SidebarViewController: NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { - guard let item = item as? OutlineDataSource.OutlineItem else { return nil } + guard let item = item as? OutlineItem else { return nil } let cellView = NSTableCellView() @@ -66,45 +100,20 @@ extension SidebarViewController: NSOutlineViewDelegate { } } -final class OutlineDataSource: NSObject, NSOutlineViewDataSource { - - struct OutlineItem { - let title: String - let children: [OutlineItem]? - } - - // Sample data - private let outlineData: [OutlineItem] = [ - OutlineItem( - title: "Tables", - children: [ - OutlineItem(title: "Users", children: nil), - OutlineItem(title: "Posts", children: nil), - OutlineItem(title: "Comments", children: nil), - ] - ), - OutlineItem( - title: "Views", - children: [ - OutlineItem(title: "User Posts", children: nil), - OutlineItem(title: "Popular Posts", children: nil), - ] - ), - OutlineItem(title: "Indexes", children: nil), - ] +extension SidebarViewController: NSOutlineViewDataSource { func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { if let item = item as? OutlineItem { return item.children?.count ?? 0 } - return outlineData.count + return outlineItems.count } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { if let item = item as? OutlineItem { return item.children![index] } - return outlineData[index] + return outlineItems[index] } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 0ba4fefd..2185f99b 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ C404E30F2E88A5F5000D23D2 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = C404E30E2E88A5F5000D23D2 /* SQLiteData */; }; + C43A01E72E88FDB800E5168E /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = C43A01E62E88FDB800E5168E /* AppKitNavigation */; }; CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE292E71C469000974D3 /* SQLiteData */; }; @@ -157,6 +158,7 @@ buildActionMask = 2147483647; files = ( C404E30F2E88A5F5000D23D2 /* SQLiteData in Frameworks */, + C43A01E72E88FDB800E5168E /* AppKitNavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -288,6 +290,7 @@ name = AppKitDemo; packageProductDependencies = ( C404E30E2E88A5F5000D23D2 /* SQLiteData */, + C43A01E62E88FDB800E5168E /* AppKitNavigation */, ); productName = AppKitDemo; productReference = C4CD9A252E88A20900172F37 /* AppKitDemo.app */; @@ -1331,6 +1334,11 @@ isa = XCSwiftPackageProductDependency; productName = SQLiteData; }; + C43A01E62E88FDB800E5168E /* AppKitNavigation */ = { + isa = XCSwiftPackageProductDependency; + package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; + productName = AppKitNavigation; + }; CA14DBC82DA884C400E36852 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; From 891d42d884f87e62fd9faeca00e96611674f1f59 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sun, 28 Sep 2025 08:02:02 -0700 Subject: [PATCH 06/12] initial detail view --- .../AppKitDemo/DetailViewController.swift | 38 +++++++++++++++++++ Examples/AppKitDemo/RootViewController.swift | 16 ++------ 2 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 Examples/AppKitDemo/DetailViewController.swift diff --git a/Examples/AppKitDemo/DetailViewController.swift b/Examples/AppKitDemo/DetailViewController.swift new file mode 100644 index 00000000..8713a2bc --- /dev/null +++ b/Examples/AppKitDemo/DetailViewController.swift @@ -0,0 +1,38 @@ +import AppKit +import SwiftUI + +final class DetailViewController: NSViewController { + init() { + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func loadView() { + let hostingView = FullSizeHostingView( + rootView: DetailView() + .frame( + minWidth: 600, + maxWidth: .infinity, + minHeight: 500, + maxHeight: .infinity + ) + ) + self.view = hostingView + } + class FullSizeHostingView: NSHostingView { + override var intrinsicContentSize: NSSize { + return NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) + } + } +} + +struct DetailView: View { + var body: some View { + Color.blue + } +} + +#Preview { + DetailViewController() +} diff --git a/Examples/AppKitDemo/RootViewController.swift b/Examples/AppKitDemo/RootViewController.swift index 9f1e0dc2..035430a7 100644 --- a/Examples/AppKitDemo/RootViewController.swift +++ b/Examples/AppKitDemo/RootViewController.swift @@ -6,11 +6,12 @@ final class RootViewController: NSSplitViewController { super.init(nibName: nil, bundle: nil) let sidebarViewController = SidebarViewController() - let contentViewController = ContentViewController() + let detailViewController = DetailViewController() let sidebarItem = NSSplitViewItem(sidebarWithViewController: sidebarViewController) - let detailItem = NSSplitViewItem(viewController: contentViewController) + let detailItem = NSSplitViewItem(viewController: detailViewController) + sidebarItem.canCollapse = false sidebarItem.minimumThickness = 250 sidebarItem.maximumThickness = 350 @@ -21,14 +22,3 @@ final class RootViewController: NSSplitViewController { fatalError("init(coder:) has not been implemented") } } - -final class ContentViewController: NSViewController { - init() { - super.init(nibName: nil, bundle: nil) - self.view.wantsLayer = true - self.view.layer?.backgroundColor = .white - } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} From 211cafea6eb705e5c719be300079ea450d196970 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sun, 28 Sep 2025 11:13:30 -0700 Subject: [PATCH 07/12] detail views and app model --- Examples/AppKitDemo/App.swift | 18 +++++ Examples/AppKitDemo/AppDelegate.swift | 4 +- .../AppKitDemo/DetailViewController.swift | 71 +++++++++++++++++-- Examples/AppKitDemo/ReminderDetail.swift | 36 ++++++++++ Examples/AppKitDemo/RemindersListDetail.swift | 36 ++++++++++ Examples/AppKitDemo/RootViewController.swift | 9 +-- .../AppKitDemo/SidebarViewController.swift | 4 +- Examples/Examples.xcodeproj/project.pbxproj | 8 +++ 8 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 Examples/AppKitDemo/App.swift create mode 100644 Examples/AppKitDemo/ReminderDetail.swift create mode 100644 Examples/AppKitDemo/RemindersListDetail.swift diff --git a/Examples/AppKitDemo/App.swift b/Examples/AppKitDemo/App.swift new file mode 100644 index 00000000..fa59133f --- /dev/null +++ b/Examples/AppKitDemo/App.swift @@ -0,0 +1,18 @@ +import CasePaths +import Observation + +@Observable +final class AppModel { + + var destination: Destination? + + init(destination: Destination? = nil) { + self.destination = destination + } + + @CasePathable + enum Destination { + case reminder(ReminderDetailModel) + case remindersList(RemindersListDetailModel) + } +} diff --git a/Examples/AppKitDemo/AppDelegate.swift b/Examples/AppKitDemo/AppDelegate.swift index 505f7262..07928eae 100644 --- a/Examples/AppKitDemo/AppDelegate.swift +++ b/Examples/AppKitDemo/AppDelegate.swift @@ -74,7 +74,9 @@ final class DemoWindowController: NSWindowController { super.init(window: window) - window.contentViewController = RootViewController() + window.contentViewController = RootViewController( + model: AppModel() + ) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") diff --git a/Examples/AppKitDemo/DetailViewController.swift b/Examples/AppKitDemo/DetailViewController.swift index 8713a2bc..fec9d3be 100644 --- a/Examples/AppKitDemo/DetailViewController.swift +++ b/Examples/AppKitDemo/DetailViewController.swift @@ -1,8 +1,12 @@ import AppKit +import CasePaths +import SQLiteData import SwiftUI final class DetailViewController: NSViewController { - init() { + let model: AppModel + init(model: AppModel) { + self.model = model super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { @@ -10,7 +14,7 @@ final class DetailViewController: NSViewController { } override func loadView() { let hostingView = FullSizeHostingView( - rootView: DetailView() + rootView: DetailView(model: model) .frame( minWidth: 600, maxWidth: .infinity, @@ -27,12 +31,67 @@ final class DetailViewController: NSViewController { } } -struct DetailView: View { +private struct DetailView: View { + let model: AppModel var body: some View { - Color.blue + switch model.destination { + case .remindersList(let model): + RemindersListDetailView(model: model) + case .reminder(let model): + ReminderDetailView(model: model) + case .none: + ContentUnavailableView( + "Choose a reminder or list", + systemImage: "list.bullet" + ) + } + } +} + +#Preview("Empty") { + let remindersList = try! prepareDependencies { + try $0.bootstrapDatabase() + return try $0.defaultDatabase.read { db in + try RemindersList.all.fetchOne(db)! + } } + DetailViewController( + model: AppModel() + ) } -#Preview { - DetailViewController() +#Preview("RemindersList") { + let remindersList = try! prepareDependencies { + try $0.bootstrapDatabase() + return try $0.defaultDatabase.read { db in + try RemindersList.all.fetchOne(db)! + } + } + DetailViewController( + model: AppModel( + destination: .remindersList( + RemindersListDetailModel( + remindersList: remindersList + ) + ) + ) + ) +} + +#Preview("Reminder") { + let reminder = try! prepareDependencies { + try $0.bootstrapDatabase() + return try $0.defaultDatabase.read { db in + try Reminder.all.fetchOne(db)! + } + } + DetailViewController( + model: AppModel( + destination: .reminder( + ReminderDetailModel( + reminder: reminder + ) + ) + ) + ) } diff --git a/Examples/AppKitDemo/ReminderDetail.swift b/Examples/AppKitDemo/ReminderDetail.swift new file mode 100644 index 00000000..8bc0460a --- /dev/null +++ b/Examples/AppKitDemo/ReminderDetail.swift @@ -0,0 +1,36 @@ +import SQLiteData +import SwiftUI + +@MainActor +@Observable +final class ReminderDetailModel { + @ObservationIgnored @FetchOne var reminder: Reminder + init(reminder: Reminder) { + _reminder = FetchOne( + wrappedValue: reminder, + Reminder.find(reminder.id) + ) + } +} + +struct ReminderDetailView: View { + let model: ReminderDetailModel + var body: some View { + Text("REMIN") + Text(model.reminder.title) + } +} + +#Preview("Reminder") { + let reminder = try! prepareDependencies { + try $0.bootstrapDatabase() + return try $0.defaultDatabase.read { db in + try Reminder.all.fetchOne(db)! + } + } + ReminderDetailView( + model: ReminderDetailModel( + reminder: reminder + ) + ) +} diff --git a/Examples/AppKitDemo/RemindersListDetail.swift b/Examples/AppKitDemo/RemindersListDetail.swift new file mode 100644 index 00000000..d1159e8b --- /dev/null +++ b/Examples/AppKitDemo/RemindersListDetail.swift @@ -0,0 +1,36 @@ +import SQLiteData +import SwiftUI + +@MainActor +@Observable +final class RemindersListDetailModel { + @ObservationIgnored @FetchOne var remindersList: RemindersList + init(remindersList: RemindersList) { + _remindersList = FetchOne( + wrappedValue: remindersList, + RemindersList.find(remindersList.id) + ) + } +} + +struct RemindersListDetailView: View { + let model: RemindersListDetailModel + var body: some View { + Text("LIST") + Text(model.remindersList.title) + } +} + +#Preview { + let remindersList = try! prepareDependencies { + try $0.bootstrapDatabase() + return try $0.defaultDatabase.read { db in + try RemindersList.all.fetchOne(db)! + } + } + RemindersListDetailView( + model: RemindersListDetailModel( + remindersList: remindersList + ) + ) +} diff --git a/Examples/AppKitDemo/RootViewController.swift b/Examples/AppKitDemo/RootViewController.swift index 035430a7..6eaf1ba1 100644 --- a/Examples/AppKitDemo/RootViewController.swift +++ b/Examples/AppKitDemo/RootViewController.swift @@ -1,12 +1,13 @@ import AppKit -import SQLiteData final class RootViewController: NSSplitViewController { - init() { + let model: AppModel + init(model: AppModel) { + self.model = model super.init(nibName: nil, bundle: nil) - let sidebarViewController = SidebarViewController() - let detailViewController = DetailViewController() + let sidebarViewController = SidebarViewController(model: model) + let detailViewController = DetailViewController(model: model) let sidebarItem = NSSplitViewItem(sidebarWithViewController: sidebarViewController) let detailItem = NSSplitViewItem(viewController: detailViewController) diff --git a/Examples/AppKitDemo/SidebarViewController.swift b/Examples/AppKitDemo/SidebarViewController.swift index 947db7e1..753ab33a 100644 --- a/Examples/AppKitDemo/SidebarViewController.swift +++ b/Examples/AppKitDemo/SidebarViewController.swift @@ -3,6 +3,7 @@ import AppKitNavigation import SQLiteData final class SidebarViewController: NSViewController { + private let model: AppModel private var outlineView: NSOutlineView! private var scrollView: NSScrollView! private var outlineItems: [OutlineItem] = [] @@ -15,7 +16,8 @@ final class SidebarViewController: NSViewController { let reminderTitles: [String] } - init() { + init(model: AppModel) { + self.model = model super.init(nibName: nil, bundle: nil) $rows = FetchAll( diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 2185f99b..76ecd913 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ C404E30F2E88A5F5000D23D2 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = C404E30E2E88A5F5000D23D2 /* SQLiteData */; }; C43A01E72E88FDB800E5168E /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = C43A01E62E88FDB800E5168E /* AppKitNavigation */; }; + C43A02052E8999C100E5168E /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = C43A02042E8999C100E5168E /* CasePaths */; }; CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE292E71C469000974D3 /* SQLiteData */; }; @@ -159,6 +160,7 @@ files = ( C404E30F2E88A5F5000D23D2 /* SQLiteData in Frameworks */, C43A01E72E88FDB800E5168E /* AppKitNavigation in Frameworks */, + C43A02052E8999C100E5168E /* CasePaths in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -291,6 +293,7 @@ packageProductDependencies = ( C404E30E2E88A5F5000D23D2 /* SQLiteData */, C43A01E62E88FDB800E5168E /* AppKitNavigation */, + C43A02042E8999C100E5168E /* CasePaths */, ); productName = AppKitDemo; productReference = C4CD9A252E88A20900172F37 /* AppKitDemo.app */; @@ -1339,6 +1342,11 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = AppKitNavigation; }; + C43A02042E8999C100E5168E /* CasePaths */ = { + isa = XCSwiftPackageProductDependency; + package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; + productName = CasePaths; + }; CA14DBC82DA884C400E36852 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; From 4ef5d50400531ad883c0fb64157065e285d89b35 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sun, 28 Sep 2025 12:43:28 -0700 Subject: [PATCH 08/12] coordinate selection from outline --- Examples/AppKitDemo/App.swift | 8 ++ Examples/AppKitDemo/Schema.swift | 2 +- .../AppKitDemo/SidebarViewController.swift | 92 +++++++++++++------ 3 files changed, 72 insertions(+), 30 deletions(-) diff --git a/Examples/AppKitDemo/App.swift b/Examples/AppKitDemo/App.swift index fa59133f..5cd5f86e 100644 --- a/Examples/AppKitDemo/App.swift +++ b/Examples/AppKitDemo/App.swift @@ -10,6 +10,14 @@ final class AppModel { self.destination = destination } + func reminderSelectedInOutline(_ reminder: Reminder) { + self.destination = .reminder(ReminderDetailModel(reminder: reminder)) + } + + func remindersListSelectedInOutline(_ remindersList: RemindersList) { + self.destination = .remindersList(RemindersListDetailModel(remindersList: remindersList)) + } + @CasePathable enum Destination { case reminder(ReminderDetailModel) diff --git a/Examples/AppKitDemo/Schema.swift b/Examples/AppKitDemo/Schema.swift index 010c9b63..10d7bd5c 100644 --- a/Examples/AppKitDemo/Schema.swift +++ b/Examples/AppKitDemo/Schema.swift @@ -18,7 +18,7 @@ struct RemindersList: Hashable, Identifiable { extension RemindersList.Draft: Identifiable {} @Table -struct Reminder: Hashable, Identifiable { +struct Reminder: Hashable, Identifiable, Codable { let id: UUID var remindersListID: RemindersList.ID var title = "" diff --git a/Examples/AppKitDemo/SidebarViewController.swift b/Examples/AppKitDemo/SidebarViewController.swift index 753ab33a..d38df9d6 100644 --- a/Examples/AppKitDemo/SidebarViewController.swift +++ b/Examples/AppKitDemo/SidebarViewController.swift @@ -11,9 +11,9 @@ final class SidebarViewController: NSViewController { @Selection struct Row { - let remindersListTitle: String - @Column(as: [String].JSONRepresentation.self) - let reminderTitles: [String] + let remindersList: RemindersList + @Column(as: [Reminder].JSONRepresentation.self) + let reminders: [Reminder] } init(model: AppModel) { @@ -26,22 +26,21 @@ final class SidebarViewController: NSViewController { .join(Reminder.all) { $0.id.eq($1.remindersListID) } .select { Row.Columns( - remindersListTitle: $0.title, - reminderTitles: $1.title.jsonGroupArray() + remindersList: $0, + reminders: $1.jsonGroupArray() ) } ) observe { [weak self] in guard let self else { return } - self.outlineItems = rows.map { record in - OutlineItem( - title: record.remindersListTitle, - children: record.reminderTitles.map { - OutlineItem(title: $0, children: []) - } + self.outlineItems = rows.map { row in + OutlineItem.remindersList( + row.remindersList, + row.reminders ) } + self.outlineView?.reloadData() } } @@ -75,13 +74,24 @@ final class SidebarViewController: NSViewController { } } -fileprivate struct OutlineItem { - let title: String - let children: [OutlineItem]? +private enum OutlineItem { + case reminder(Reminder) + case remindersList(RemindersList, [Reminder]) + + var title: String { + switch self { + case .reminder(let reminder): reminder.title + case .remindersList(let remindersList, _): remindersList.title + } + } } extension SidebarViewController: NSOutlineViewDelegate { - func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + func outlineView( + _ outlineView: NSOutlineView, + viewFor tableColumn: NSTableColumn?, + item: Any + ) -> NSView? { guard let item = item as? OutlineItem else { return nil } let cellView = NSTableCellView() @@ -100,28 +110,52 @@ extension SidebarViewController: NSOutlineViewDelegate { return cellView } + func outlineViewSelectionDidChange(_ notification: Notification) { + let row = outlineView.selectedRow + guard + row >= 0, + let item = outlineView.item(atRow: row) as? OutlineItem + else { + return + } + switch item { + case .reminder(let reminder): + model.reminderSelectedInOutline(reminder) + case .remindersList(let remindersList, _): + model.remindersListSelectedInOutline(remindersList) + } + } } extension SidebarViewController: NSOutlineViewDataSource { - - func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { - if let item = item as? OutlineItem { - return item.children?.count ?? 0 + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + switch item as? OutlineItem { + case .reminder: outlineItems[index] + case .remindersList(_, let reminders): OutlineItem.reminder(reminders[index]) + case .none: outlineItems[index] } - return outlineItems.count } - - func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { - if let item = item as? OutlineItem { - return item.children![index] + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + switch item as? OutlineItem { + case .reminder: 0 + case .remindersList(_, let reminders): reminders.count + case .none: outlineItems.count } - return outlineItems[index] } - func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { - if let item = item as? OutlineItem { - return item.children != nil && !item.children!.isEmpty + switch item as? OutlineItem { + case .reminder: false + case .remindersList(_, let reminders): !reminders.isEmpty + case .none: false } - return false } } + +#Preview { + let _ = try! prepareDependencies { + try $0.bootstrapDatabase() + } + SidebarViewController( + model: AppModel() + ) +} From 1cc94e819977778fd33b7a37ceb53dccaa9dfd5c Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sun, 28 Sep 2025 15:33:21 -0700 Subject: [PATCH 09/12] wip --- .../AppKitDemo/SidebarViewController.swift | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/Examples/AppKitDemo/SidebarViewController.swift b/Examples/AppKitDemo/SidebarViewController.swift index d38df9d6..e85682c2 100644 --- a/Examples/AppKitDemo/SidebarViewController.swift +++ b/Examples/AppKitDemo/SidebarViewController.swift @@ -23,11 +23,12 @@ final class SidebarViewController: NSViewController { $rows = FetchAll( RemindersList.all .group(by: \.id) + .order(by: \.title) .join(Reminder.all) { $0.id.eq($1.remindersListID) } .select { Row.Columns( remindersList: $0, - reminders: $1.jsonGroupArray() + reminders: $1.jsonGroupArray() // FIXME: order reminders ) } ) @@ -77,13 +78,6 @@ final class SidebarViewController: NSViewController { private enum OutlineItem { case reminder(Reminder) case remindersList(RemindersList, [Reminder]) - - var title: String { - switch self { - case .reminder(let reminder): reminder.title - case .remindersList(let remindersList, _): remindersList.title - } - } } extension SidebarViewController: NSOutlineViewDelegate { @@ -96,17 +90,52 @@ extension SidebarViewController: NSOutlineViewDelegate { let cellView = NSTableCellView() - let textField = NSTextField(labelWithString: item.title) - textField.translatesAutoresizingMaskIntoConstraints = false + switch item { + case .reminder(let reminder): + let textField = NSTextField(labelWithString: reminder.title) + textField.translatesAutoresizingMaskIntoConstraints = false + cellView.textField = textField + + if reminder.isCompleted { + let checkmark = NSImageView( + image: NSImage( + systemSymbolName: "checkmark", + accessibilityDescription: "Completed" + )! + ) + + let stack = NSStackView(views: [checkmark, textField]) + cellView.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: cellView.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: cellView.trailingAnchor), + stack.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), + ]) + } else { + cellView.addSubview(textField) + + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: cellView.leadingAnchor), + textField.trailingAnchor.constraint(equalTo: cellView.trailingAnchor), + textField.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), + ]) + } + + case .remindersList(let remindersList, _): + + let textField = NSTextField(labelWithString: remindersList.title) + textField.translatesAutoresizingMaskIntoConstraints = false + cellView.textField = textField - cellView.addSubview(textField) - cellView.textField = textField + cellView.addSubview(textField) - NSLayoutConstraint.activate([ - textField.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 5), - textField.trailingAnchor.constraint(equalTo: cellView.trailingAnchor), - textField.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), - ]) + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 3), + textField.trailingAnchor.constraint(equalTo: cellView.trailingAnchor), + textField.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), + ]) + } return cellView } From fd8250d3c5d7ce6de1dbb7601769b33f5bdda2a4 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sun, 28 Sep 2025 15:51:13 -0700 Subject: [PATCH 10/12] stub out list data --- Examples/AppKitDemo/RemindersListDetail.swift | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Examples/AppKitDemo/RemindersListDetail.swift b/Examples/AppKitDemo/RemindersListDetail.swift index d1159e8b..ec5612bf 100644 --- a/Examples/AppKitDemo/RemindersListDetail.swift +++ b/Examples/AppKitDemo/RemindersListDetail.swift @@ -5,19 +5,35 @@ import SwiftUI @Observable final class RemindersListDetailModel { @ObservationIgnored @FetchOne var remindersList: RemindersList + @ObservationIgnored @FetchAll var reminders: [Reminder] + var editableRemindersList: RemindersList.Draft? init(remindersList: RemindersList) { _remindersList = FetchOne( wrappedValue: remindersList, RemindersList.find(remindersList.id) ) + _reminders = FetchAll( + Reminder.all + .where { $0.remindersListID.eq(remindersList.id) } + .order { + ($0.isCompleted, $0.title) + } + ) } } struct RemindersListDetailView: View { let model: RemindersListDetailModel var body: some View { - Text("LIST") - Text(model.remindersList.title) + List { + ForEach(model.reminders) { reminder in + Text(reminder.title) + } + } + .safeAreaInset(edge: .top) { + Text(model.remindersList.title) + .font(.headline) + } } } From b97ce8cb7fd2d63d737edbcd1fe8967280555d0f Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sun, 28 Sep 2025 15:53:58 -0700 Subject: [PATCH 11/12] wip --- Examples/AppKitDemo/ReminderDetail.swift | 1 - Examples/AppKitDemo/SidebarViewController.swift | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Examples/AppKitDemo/ReminderDetail.swift b/Examples/AppKitDemo/ReminderDetail.swift index 8bc0460a..b897d426 100644 --- a/Examples/AppKitDemo/ReminderDetail.swift +++ b/Examples/AppKitDemo/ReminderDetail.swift @@ -16,7 +16,6 @@ final class ReminderDetailModel { struct ReminderDetailView: View { let model: ReminderDetailModel var body: some View { - Text("REMIN") Text(model.reminder.title) } } diff --git a/Examples/AppKitDemo/SidebarViewController.swift b/Examples/AppKitDemo/SidebarViewController.swift index e85682c2..edab955b 100644 --- a/Examples/AppKitDemo/SidebarViewController.swift +++ b/Examples/AppKitDemo/SidebarViewController.swift @@ -63,8 +63,8 @@ final class SidebarViewController: NSViewController { outlineView.dataSource = self outlineView.delegate = self - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("OutlineColumn")) - column.title = "Schema" + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("RemindersColumn")) + column.title = "Reminders" outlineView.addTableColumn(column) outlineView.outlineTableColumn = column From 3f9353fa964fb5e8b075ff034f48709d7f4833e2 Mon Sep 17 00:00:00 2001 From: Ryan Carver Date: Sun, 28 Sep 2025 15:59:15 -0700 Subject: [PATCH 12/12] add AppKitDemo to Examples readme --- Examples/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Examples/README.md b/Examples/README.md index 01accf0d..47bab48b 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -30,3 +30,7 @@ project. To work on each example app individually, select its scheme in Xcode. [scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger [reminders-app-store]: https://apps.apple.com/us/app/reminders/id1108187841 + +* **AppKitDemo** +
This application is a simplified Reminders app built with AppKit + SwiftUI. It shows basic patterns + for fetching, observing, and modifying data.