diff --git a/SimDirs.xcodeproj/project.pbxproj b/SimDirs.xcodeproj/project.pbxproj index ec9d306..c97e785 100644 --- a/SimDirs.xcodeproj/project.pbxproj +++ b/SimDirs.xcodeproj/project.pbxproj @@ -32,8 +32,8 @@ C966877529B7641F007BB3F5 /* FilteredNodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C966876929B7641F007BB3F5 /* FilteredNodeView.swift */; }; C966877629B7641F007BB3F5 /* NodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C966876A29B7641F007BB3F5 /* NodeView.swift */; }; C966877729B7641F007BB3F5 /* Node.swift in Sources */ = {isa = PBXBuildFile; fileRef = C966876B29B7641F007BB3F5 /* Node.swift */; }; - C966877829B76533007BB3F5 /* SourceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90BCE4F2861E9D000C2EF35 /* SourceState.swift */; }; C9779742284F6DE000706DFB /* ToolbarMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9779741284F6DE000706DFB /* ToolbarMenu.swift */; }; + C98048FC29C5FC2A00DE0DBD /* NodeAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98048FB29C5FC2A00DE0DBD /* NodeAB.swift */; }; C982F859283B9F9000D491F4 /* SimDirsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C982F858283B9F9000D491F4 /* SimDirsApp.swift */; }; C982F85B283B9F9000D491F4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C982F85A283B9F9000D491F4 /* ContentView.swift */; }; C982F85D283B9F9200D491F4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C982F85C283B9F9200D491F4 /* Assets.xcassets */; }; @@ -61,7 +61,6 @@ C90BCE492861DA6700C2EF35 /* RuntimeHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeHeader.swift; sourceTree = ""; }; C90BCE4B2861E37900C2EF35 /* AppHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHeader.swift; sourceTree = ""; }; C90BCE4D2861E4E400C2EF35 /* AppContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContent.swift; sourceTree = ""; }; - C90BCE4F2861E9D000C2EF35 /* SourceState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceState.swift; sourceTree = ""; }; C90BCE512861EDBF00C2EF35 /* SourceFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceFilter.swift; sourceTree = ""; }; C90DCC132896AAAA0072E403 /* ContentHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentHeader.swift; sourceTree = ""; }; C90DCC152896B0370072E403 /* AppearancePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePicker.swift; sourceTree = ""; }; @@ -82,6 +81,7 @@ C966876A29B7641F007BB3F5 /* NodeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NodeView.swift; sourceTree = ""; }; C966876B29B7641F007BB3F5 /* Node.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Node.swift; sourceTree = ""; }; C9779741284F6DE000706DFB /* ToolbarMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarMenu.swift; sourceTree = ""; }; + C98048FB29C5FC2A00DE0DBD /* NodeAB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAB.swift; sourceTree = ""; }; C982F855283B9F9000D491F4 /* SimDirs.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimDirs.app; sourceTree = BUILT_PRODUCTS_DIR; }; C982F858283B9F9000D491F4 /* SimDirsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimDirsApp.swift; sourceTree = ""; }; C982F85A283B9F9000D491F4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -133,6 +133,7 @@ C966875E29B7641F007BB3F5 /* FilteredNode.swift */, C966876629B7641F007BB3F5 /* NodeListBuilder.swift */, C966876B29B7641F007BB3F5 /* Node.swift */, + C98048FB29C5FC2A00DE0DBD /* NodeAB.swift */, ); path = Node; sourceTree = ""; @@ -238,7 +239,6 @@ isa = PBXGroup; children = ( C90BCE512861EDBF00C2EF35 /* SourceFilter.swift */, - C90BCE4F2861E9D000C2EF35 /* SourceState.swift */, ); path = Presentation; sourceTree = ""; @@ -342,7 +342,6 @@ C966877629B7641F007BB3F5 /* NodeView.swift in Sources */, C966877129B7641F007BB3F5 /* SimRuntime+Node.swift in Sources */, C90BCE4A2861DA6700C2EF35 /* RuntimeHeader.swift in Sources */, - C966877829B76533007BB3F5 /* SourceState.swift in Sources */, C9EE0CD42847B79E00E9B97A /* SimApp.swift in Sources */, C9DD54D22860A24B00D46AB3 /* RuntimeContent.swift in Sources */, C9779742284F6DE000706DFB /* ToolbarMenu.swift in Sources */, @@ -360,6 +359,7 @@ C90BCE482861D70500C2EF35 /* ErrorView.swift in Sources */, C982F877283D020C00D491F4 /* SimProductFamily.swift in Sources */, C982F859283B9F9000D491F4 /* SimDirsApp.swift in Sources */, + C98048FC29C5FC2A00DE0DBD /* NodeAB.swift in Sources */, C982F871283CE7B800D491F4 /* SimRuntime.swift in Sources */, C95CC0FA28B2414900928FAE /* SystemIconButtonStyle.swift in Sources */, C9EE0CD228478FDB00E9B97A /* PathRow.swift in Sources */, diff --git a/SimDirs/ContentView.swift b/SimDirs/ContentView.swift index e8f659d..c5c0f02 100644 --- a/SimDirs/ContentView.swift +++ b/SimDirs/ContentView.swift @@ -6,26 +6,61 @@ // import SwiftUI +import Combine struct ContentView: View { - @ObservedObject var state : SourceState - @State var filter = SourceFilter.restore() + enum Style: Int, CaseIterable, Identifiable { + case placeholder + case byDevice + case byRuntime + + var id : Int { rawValue } + var visible : Bool { self != .placeholder } + + var title : String { + switch self { + case .placeholder: return "Placeholder" + case .byDevice: return "By Device" + case .byRuntime: return "By Runtime" + } + } + } + + @State var filter = SourceFilter.restore() + @State var viewID = UUID().uuidString + @State var style = Style.byDevice + let model : SimModel init(model: SimModel) { - state = SourceState(model: model) + self.model = model } var body: some View { VStack { NavigationView { - FilteredNodeView(filter: $filter) { state.items } - .id(state.style) - .toolbar { ToolbarItem { ToolbarMenu(state: state, filter: $filter) } } + FilteredNodeView(filter: $filter) { items } + .id(viewID) + .toolbar { ToolbarItem { ToolbarMenu(style: $style, filter: $filter) } } .frame(minWidth: 200) Image("Icon-256") // Initial View } } + .onChange(of: style) { _ in resetView() } + .environment(\.deviceUpdates, model.deviceUpdates) + } + + @NodeListBuilder + var items: [some Node] { + switch style { + case .placeholder: [] as [LeafNode] + case .byDevice: SimProductFamily.presentation.map { $0.linked(from: model) } + case .byRuntime: SimPlatform.presentation.map { $0.linked(from: model) } + } + } + + func resetView() { + viewID = UUID().uuidString } } @@ -39,3 +74,14 @@ struct ContentView_Previews: PreviewProvider { .preferredColorScheme(.light) } } + +private struct DeviceUpdatesKey: EnvironmentKey { + static let defaultValue = PassthroughSubject() +} + +extension EnvironmentValues { + var deviceUpdates: PassthroughSubject { + get { self[DeviceUpdatesKey.self] } + set { self[DeviceUpdatesKey.self] = newValue } + } +} diff --git a/SimDirs/Model/SimDevice.swift b/SimDirs/Model/SimDevice.swift index 5951e0b..5988d83 100644 --- a/SimDirs/Model/SimDevice.swift +++ b/SimDirs/Model/SimDevice.swift @@ -40,7 +40,6 @@ class SimDevice: ObservableObject, Decodable { let dataPathSize : Int let logPath : String let deviceTypeIdentifier : String - var deviceType : SimDeviceType? var deviceModel : String? var apps = [SimApp]() var dataURL : URL { URL(fileURLWithPath: dataPath) } @@ -315,16 +314,6 @@ extension SimDevice { } extension Array where Element == SimDevice { - func linkingDeviceType(_ deviceType: SimDeviceType) -> Self { - let devices = filter { $0.isDeviceOfType(deviceType) } - - for device in devices { - device.deviceType = deviceType - } - - return devices - } - func of(deviceType: SimDeviceType) -> Self { filter { $0.isDeviceOfType(deviceType) } } diff --git a/SimDirs/Model/SimModel.swift b/SimDirs/Model/SimModel.swift index 67c2768..2c36856 100644 --- a/SimDirs/Model/SimModel.swift +++ b/SimDirs/Model/SimModel.swift @@ -13,13 +13,13 @@ enum SimError: Error { case invalidApp } -struct SimDevicesUpdates { - let runtime : SimRuntime - var additions : [SimDevice] - var removals : [SimDevice] -} - class SimModel { + struct Update { + let runtime : SimRuntime + var additions : [SimDevice] + var removals : [SimDevice] + } + var deviceTypes : [SimDeviceType] var runtimes : [SimRuntime] var monitor : Cancellable? @@ -28,7 +28,7 @@ class SimModel { var devices : [SimDevice] { runtimes.flatMap { $0.devices } } var apps : [SimApp] { devices.flatMap { $0.apps } } - var deviceUpdates = PassthroughSubject() + var deviceUpdates = PassthroughSubject() init() { let simctl = SimCtl() @@ -70,25 +70,13 @@ class SimModel { } .receive(on: DispatchQueue.main) .sink { [weak self] runtimeDevs in - guard let this = self else { return } + guard let this = self else { return } for (runtimeID, curDevices) in runtimeDevs { guard let runtime = this.runtimes.first(where: { $0.identifier == runtimeID }) else { print("missing runtime: \(runtimeID)"); continue } - let curDevIDs = curDevices.map { $0.udid } - let lastDevID = runtime.devices.map { $0.udid } - let updates = SimDevicesUpdates( - runtime: runtime, - additions: curDevices.filter { !lastDevID.contains($0.udid) }, - removals: runtime.devices.filter { !curDevIDs.contains($0.udid) }) - - if !updates.removals.isEmpty || !updates.additions.isEmpty { - let idsToRemove = updates.removals.map { $0.udid } - - updates.additions.completeSetup(with: this.deviceTypes) - runtime.devices.removeAll(where: { idsToRemove.contains($0.udid) }) - runtime.devices.append(contentsOf: updates.additions) - - this.deviceUpdates.send(updates) + + if let changes = runtime.reconcileDevices(curDevices, forTypes: this.deviceTypes) { + this.deviceUpdates.send(changes) } for srcDevice in curDevices { diff --git a/SimDirs/Model/SimRuntime.swift b/SimDirs/Model/SimRuntime.swift index 308e5d6..aa84edb 100644 --- a/SimDirs/Model/SimRuntime.swift +++ b/SimDirs/Model/SimRuntime.swift @@ -112,6 +112,26 @@ class SimRuntime: ObservableObject, Comparable, Decodable { } } } + + func reconcileDevices(_ curDevices: [SimDevice], forTypes deviceTypes: [SimDeviceType]) -> SimModel.Update? { + let curDevIDs = curDevices.map { $0.udid } + let ourDevIDs = devices.map { $0.udid } + let additions = curDevices.filter { !ourDevIDs.contains($0.udid) } + let removals = devices.filter { !curDevIDs.contains($0.udid) } + var result : SimModel.Update? = nil + + if !additions.isEmpty || !removals.isEmpty { + let idsToRemove = removals.map { $0.udid } + + additions.completeSetup(with: deviceTypes) + devices.removeAll(where: { idsToRemove.contains($0.udid) }) + devices.append(contentsOf: additions) + + result = SimModel.Update(runtime: self, additions: additions, removals: removals) + } + + return result + } } extension Array where Element == SimRuntime { diff --git a/SimDirs/Node/Conforming/SimDevice+Node.swift b/SimDirs/Node/Conforming/SimDevice+Node.swift index 5d04275..644b426 100644 --- a/SimDirs/Node/Conforming/SimDevice+Node.swift +++ b/SimDirs/Node/Conforming/SimDevice+Node.swift @@ -7,25 +7,38 @@ import SwiftUI -extension SimDevice: Node { - var title : String { return name } - var headerTitle : String { "Device: \(title)" } - - var header : some View { DeviceHeader(device: self) } - var content : some View { DeviceContent(self) } +// SimDevice requires a wrapper to simulate Node conformance because its +// icon is provided by a SimDeviceType -// var isEnabled : Bool { isBooted } - var iconName : String { deviceType?.productFamily.symbolName ?? "questionmark.circle" } +struct SimDeviceNode: Node { + let device : SimDevice + var iconName : String + + var title : String { device.name } + var headerTitle : String { "Device: \(title)" } + var header : some View { DeviceHeader(device) } + var content : some View { DeviceContent(device) } var items : [SimApp]? { - get { apps } - set { apps = newValue ?? [] } + get { device.apps } + set { device.apps = newValue ?? [] } } + init(_ device: SimDevice, iconName: String) { + self.device = device + self.iconName = iconName + } + func icon(forHeader: Bool) -> some View { - symbolIcon(iconName, color: isAvailable ? .green : .red, forHeader: forHeader) + symbolIcon(iconName, color: device.isAvailable ? .green : .red, forHeader: forHeader) } func matchedFilterOptions() -> SourceFilter.Options { - return !apps.isEmpty ? .withApps : [] + return !device.apps.isEmpty ? .withApps : [] + } +} + +extension Array where Element == SimDevice { + func nodesFor(deviceType: SimDeviceType) -> [SimDeviceNode] { + filter({ $0.isDeviceOfType(deviceType) }).map({ SimDeviceNode($0, iconName: deviceType.productFamily.symbolName) }) } } diff --git a/SimDirs/Node/Conforming/SimDeviceType+Node.swift b/SimDirs/Node/Conforming/SimDeviceType+Node.swift index cd6f9c6..d63bb21 100644 --- a/SimDirs/Node/Conforming/SimDeviceType+Node.swift +++ b/SimDirs/Node/Conforming/SimDeviceType+Node.swift @@ -17,4 +17,29 @@ extension SimDeviceType: Node { func icon(forHeader: Bool) -> some View { symbolIcon(productFamily.symbolName, forHeader: forHeader) } + + func linkedForDeviceStyle(from model: SimModel) -> some Node { + NodeLink(self) { + model.runtimes.supporting(deviceType: self).map { runtime in + runtime.linkedForDeviceStyle(from: model, deviceType: self) + } + } + } + + func linkedForRuntimeStyle(from model: SimModel, runtime: SimRuntime) -> some Node { + var node = NodeLink(self, items: runtime.devices.nodesFor(deviceType: self)) + + return node.onUpdate { update in + guard let runtime = model.runtimes.supporting(deviceType: self).first(where: { $0 == update.runtime }) else { return nil } + let ourAdditions = update.additions.filter({ $0.deviceTypeIdentifier == identifier }) + let ourRemovals = update.removals.filter({ $0.deviceTypeIdentifier == identifier }) + + if !ourAdditions.isEmpty || !ourRemovals.isEmpty { + return runtime.devices.nodesFor(deviceType: self) + } + else { + return nil + } + } + } } diff --git a/SimDirs/Node/Conforming/SimPlatform+Node.swift b/SimDirs/Node/Conforming/SimPlatform+Node.swift index 5b0a280..074b8b9 100644 --- a/SimDirs/Node/Conforming/SimPlatform+Node.swift +++ b/SimDirs/Node/Conforming/SimPlatform+Node.swift @@ -17,5 +17,12 @@ extension SimPlatform: Node { func icon(forHeader: Bool) -> some View { symbolIcon(symbolName, forHeader: forHeader) } -} + func linked(from model: SimModel) -> some Node { + NodeLink(self) { + model.runtimes.supporting(platform: self).map { runtime in + runtime.linkedForRuntimeStyle(from: model) + } + } + } +} diff --git a/SimDirs/Node/Conforming/SimProductFamily+Node.swift b/SimDirs/Node/Conforming/SimProductFamily+Node.swift index d8ab8ac..c83abd8 100644 --- a/SimDirs/Node/Conforming/SimProductFamily+Node.swift +++ b/SimDirs/Node/Conforming/SimProductFamily+Node.swift @@ -17,5 +17,12 @@ extension SimProductFamily: Node { func icon(forHeader: Bool) -> some View { symbolIcon(symbolName, forHeader: forHeader) } + + func linked(from model: SimModel) -> some Node { + NodeLink(self) { + model.deviceTypes.supporting(productFamily: self).map { deviceType in + deviceType.linkedForDeviceStyle(from: model) + } + } + } } - diff --git a/SimDirs/Node/Conforming/SimRuntime+Node.swift b/SimDirs/Node/Conforming/SimRuntime+Node.swift index 816d613..f0ff374 100644 --- a/SimDirs/Node/Conforming/SimRuntime+Node.swift +++ b/SimDirs/Node/Conforming/SimRuntime+Node.swift @@ -21,4 +21,30 @@ extension SimRuntime: Node { func matchedFilterOptions() -> SourceFilter.Options { return isAvailable ? .runtimeInstalled : [] } + + func linkedForDeviceStyle(from model: SimModel, deviceType: SimDeviceType) -> some Node { + var node = NodeLink(self) { devices.nodesFor(deviceType: deviceType) } + + return node.onUpdate { [weak self] update in + guard let this = self else { return nil } + guard update.runtime == this else { return nil } + let ourAdditions = update.additions.filter({ $0.deviceTypeIdentifier == deviceType.identifier }) + let ourRemovals = update.removals.filter({ $0.deviceTypeIdentifier == deviceType.identifier }) + + if !ourAdditions.isEmpty || !ourRemovals.isEmpty { + return this.devices.nodesFor(deviceType: deviceType) + } + else { + return nil + } + } + } + + func linkedForRuntimeStyle(from model: SimModel) -> some Node { + NodeLink(self) { + model.deviceTypes.supporting(runtime: self).map { devType in + devType.linkedForRuntimeStyle(from: model, runtime: self) + } + } + } } diff --git a/SimDirs/Node/FilteredNode.swift b/SimDirs/Node/FilteredNode.swift index 6f2e2e5..9a50951 100644 --- a/SimDirs/Node/FilteredNode.swift +++ b/SimDirs/Node/FilteredNode.swift @@ -9,17 +9,17 @@ import SwiftUI import Combine class FilteredNode: Node, ObservableObject { - typealias FilteredList = [FilteredNode] + typealias FilteredList = [FilteredNode] @Published var filtered : Bool @Published var isExpanded = false + @Published var items : FilteredList? var wrappedNode : T var title : String { wrappedNode.title } var headerTitle : String { wrappedNode.headerTitle } var header : some View { wrappedNode.header } var content : some View { wrappedNode.content } - var items : FilteredList? var children : FilteredList { items ?? [] } init(_ node: T) { @@ -63,9 +63,24 @@ class FilteredNode: Node, ObservableObject { return nodeMatch } + + @discardableResult + func processUpdate(_ update: SimModel.Update) -> Bool { + if wrappedNode.processUpdate(update) { + items = wrappedNode.items?.asFilteredNodes() + } + + if let items { + for node in items { + node.processUpdate(update) + } + } + + return false + } } -extension NodeList { +extension Array where Element: Node { func asFilteredNodes() -> [FilteredNode] { self.map { FilteredNode($0) } } diff --git a/SimDirs/Node/Node.swift b/SimDirs/Node/Node.swift index 0f95aef..32d4f58 100644 --- a/SimDirs/Node/Node.swift +++ b/SimDirs/Node/Node.swift @@ -7,11 +7,14 @@ import SwiftUI -protocol Node: NodeSource { +protocol Node { associatedtype Icon: View associatedtype Header: View associatedtype Content: View + associatedtype Child: Node + var items : [Child]? { get set } + var title : String { get } var headerTitle : String { get } @@ -23,14 +26,13 @@ protocol Node: NodeSource { func matchedFilterOptions() -> SourceFilter.Options func matchesFilter(_ filter: SourceFilter, inherited options: SourceFilter.Options) -> Bool + @discardableResult + mutating func processUpdate(_ update: SimModel.Update) -> Bool } extension Node { - var items : [LeafNode]? { - get { nil } - set { } - } - + var items : [LeafNode]? { get { nil } set { } } + @ViewBuilder func symbolIcon(_ systemName: String, color: Color? = nil, forHeader: Bool) -> some View { if forHeader { @@ -66,14 +68,11 @@ extension Node { func matchesFilter(_ filter: SourceFilter, inherited options: SourceFilter.Options) -> Bool { filter.options.isSubset(of: options) && matchesTerm(filter.searchTerm) } -} - -/// Indicates a type that owns a list of Nodes - -protocol NodeSource { - associatedtype List: NodeList - - var items : List? { get set } + + @discardableResult + mutating func processUpdate(_ update: SimModel.Update) -> Bool { + return false + } } /// Defines the requirements of a collection that can serve as a `NodeList`. @@ -82,14 +81,10 @@ protocol NodeList: RandomAccessCollection where Self.Element: Node, Index: Hasha extension NodeList { @NodeListBuilder - func linkEachTo(emptyIsNil: Bool = false, @NodeListBuilder items: (Element) -> [Item]) -> some NodeList { + func linkEachTo(emptyIsNil: Bool = false, @NodeListBuilder items: (Element) -> [Item]) -> [some Node] { map { item in item.link(emptyIsNil: emptyIsNil, to: { items(item) }) } -// Makes compiler unhappy. resultBuilder probably incorrect -// for item in self { -// item.link(to: { items(item) }) -// } } } @@ -124,17 +119,22 @@ struct RootNode: Node { self.items = items() } - func icon(forHeader: Bool) -> some View { symbolIcon("tree", forHeader: forHeader) } + func icon(forHeader: Bool) -> some View { + symbolIcon("tree", forHeader: forHeader) + } } struct NodeLink: Node { - var base : Base - var items : [Item]? - var title : String { base.title } - var headerTitle : String { base.headerTitle } - var header : Base.Header { base.header } - var content : Base.Content { base.content } + typealias UpdateHandler = (SimModel.Update) -> [Item]?? + var base : Base + var items : [Item]? + var title : String { base.title } + var headerTitle : String { base.headerTitle } + var header : Base.Header { base.header } + var content : Base.Content { base.content } + var updaterHandler : UpdateHandler? = nil + init(_ base: Base, emptyIsNil: Bool = false, @NodeListBuilder items: () -> [Item]) { let list = items() @@ -142,10 +142,9 @@ struct NodeLink: Node { self.items = emptyIsNil ? (list.isEmpty ? nil : list) : list } -@available(*, deprecated, message: "Consider using Root { items } instead") - init(@NodeListBuilder _ items: () -> [Item]) where Base == RootNode { - self.base = RootNode() - self.items = items() + init(_ base: Base, emptyIsNil: Bool = false, items: [Item]) { + self.base = base + self.items = emptyIsNil ? (items.isEmpty ? nil : items) : items } func icon(forHeader: Bool) -> some View { @@ -155,4 +154,19 @@ struct NodeLink: Node { func matchedFilterOptions() -> SourceFilter.Options { return base.matchedFilterOptions() } + + mutating func onUpdate(_ handler: @escaping UpdateHandler) -> Self { + updaterHandler = handler + + return self + } + + @discardableResult + mutating func processUpdate(_ update: SimModel.Update) -> Bool { + guard let newItems = updaterHandler?(update) else { return false } + + self.items = newItems + + return true + } } diff --git a/SimDirs/Node/NodeAB.swift b/SimDirs/Node/NodeAB.swift new file mode 100644 index 0000000..69f7511 --- /dev/null +++ b/SimDirs/Node/NodeAB.swift @@ -0,0 +1,119 @@ +// +// NodeAB.swift +// SimDirs +// +// Created by Casey Fleser on 3/18/23. +// + +import SwiftUI + +enum NodeAB: Node, CustomStringConvertible { + case a(A) + case b(B) + + var title : String { + switch self { + case .a(let node): return node.title + case .b(let node): return node.title + } + } + + var headerTitle : String { + switch self { + case .a(let node): return node.headerTitle + case .b(let node): return node.headerTitle + } + } + + @ViewBuilder + var header : some View { + switch self { + case .a(let node): node.header + case .b(let node): node.header + } + } + + @ViewBuilder + var content : some View { + switch self { + case .a(let node): node.content + case .b(let node): node.content + } + } + + var items : [NodeAB]? { + get { + switch self { + case .a(let node): return node.items?.map { .a($0) } + case .b(let node): return node.items?.map { .b($0) } + } + } + set { + switch self { + case .a(var node): + let items : [A.Child]? = newValue?.compactMap({ ab in + guard case .a(let a) = ab else { return nil } + + return a + }) + + node.items = items + self = .a(node) + + case .b(var node): + let items : [B.Child]? = newValue?.compactMap({ ab in + guard case .b(let b) = ab else { return nil } + + return b + }) + + node.items = items + self = .b(node) + } + } + } + + var description : String { + let valueDesc : String + + switch self { + case .a(let node): valueDesc = ".a: \(String(describing: node))" + case .b(let node): valueDesc = ".b: \(String(describing: node))" + } + + return "NodeAB: \(valueDesc)" + } + + func icon(forHeader: Bool) -> some View { + switch self { + case .a(let node): node.icon(forHeader: forHeader) + case .b(let node): node.icon(forHeader: forHeader) + } + } + + func matchedFilterOptions() -> SourceFilter.Options { + switch self { + case .a(let node): return node.matchedFilterOptions() + case .b(let node): return node.matchedFilterOptions() + } + } + + @discardableResult + mutating func processUpdate(_ update: SimModel.Update) -> Bool { + switch self { + case .a(var node): + let result = node.processUpdate(update) + + self = .a(node) + + return result + + case .b(var node): + let result = node.processUpdate(update) + + self = .b(node) + + return result + } + } +} diff --git a/SimDirs/Node/NodeListBuilder.swift b/SimDirs/Node/NodeListBuilder.swift index 070a71c..94eb390 100644 --- a/SimDirs/Node/NodeListBuilder.swift +++ b/SimDirs/Node/NodeListBuilder.swift @@ -9,123 +9,65 @@ import SwiftUI @resultBuilder struct NodeListBuilder { typealias P = Node + typealias OneOf = NodeAB - enum OneOf: P, CustomStringConvertible { - typealias List = [NodeListBuilder.OneOf] - - case a(A) - case b(B) - - var title : String { - switch self { - case .a(let node): return node.title - case .b(let node): return node.title - } - } - - var headerTitle : String { - switch self { - case .a(let node): return node.headerTitle - case .b(let node): return node.headerTitle - } - } - - @ViewBuilder - var header : some View { - switch self { - case .a(let node): node.header - case .b(let node): node.header - } - } - - @ViewBuilder - var content : some View { - switch self { - case .a(let node): node.content - case .b(let node): node.content - } - } - - var items : List? { - get { - switch self { - case .a(let node): return node.items?.map { .a($0) } - case .b(let node): return node.items?.map { .b($0) } - } - } - set { } - } - - var description : String { - let valueDesc : String - - switch self { - case .a(let node): valueDesc = ".a: \(String(describing: node))" - case .b(let node): valueDesc = ".b: \(String(describing: node))" - } - - return "OneOf: \(valueDesc)" - } - - func icon(forHeader: Bool) -> some View { - switch self { - case .a(let node): node.icon(forHeader: forHeader) - case .b(let node): node.icon(forHeader: forHeader) - } - } - - func matchedFilterOptions() -> SourceFilter.Options { - switch self { - case .a(let node): return node.matchedFilterOptions() - case .b(let node): return node.matchedFilterOptions() - } - } - } - - static func buildBlock(_ c: [C]) -> [C] { + static func buildPartialBlock(first c: [C]) -> [C] { c } - - static func buildBlock ( - _ c0: [C0], _ c1: [C1]) -> [OneOf] - { - [buildEither(first: c0), buildEither(second: c1)].flatMap { $0 } + + // matching types + static func buildPartialBlock(accumulated c0: [C], next c1: [C]) -> [C] { + c0 + c1 } - static func buildBlock ( - _ c0: [C0], _ c1: [C1], _ c2: [C2]) -> [OneOf, C2>] - { - [buildEither(first: buildBlock(c0, c1)), buildEither(second: c2)].flatMap { $0 } + // matches A of OneOf + static func buildPartialBlock(accumulated ab: [OneOf], next a: [A]) -> [OneOf] { + ab + a.map { .a($0) } + } + + // matches B of OneOf + static func buildPartialBlock(accumulated ab: [OneOf], next b: [B]) -> [OneOf] { + ab + b.map { .b($0) } + } + + // matches A of OneOf, C> + static func buildPartialBlock(accumulated abc: [OneOf, C>], next a: [A]) -> [OneOf, C>] { + buildPartialBlock(accumulated: [] as [OneOf], next: a).map { .a($0) } + } + + // matches B of OneOf, C> + static func buildPartialBlock(accumulated abc: [OneOf, C>], next b: [B]) -> [OneOf, C>] { + buildPartialBlock(accumulated: [] as [OneOf], next: b).map { .a($0) } + } + + // matches C of OneOf, C> + static func buildPartialBlock(accumulated abc: [OneOf, C>], next c: [C]) -> [OneOf, C>] { + abc + c.map { .b($0) } + } + + // matches A of OneOf, OneOf> + static func buildPartialBlock(accumulated abcd: [OneOf, OneOf>], next a: [A]) -> [OneOf, OneOf>] { + buildPartialBlock(accumulated: [] as [OneOf], next: a).map { .a($0) } + } + + // matches B of OneOf, OneOf> + static func buildPartialBlock(accumulated abcd: [OneOf, OneOf>], next b: [B]) -> [OneOf, OneOf>] { + buildPartialBlock(accumulated: [] as [OneOf], next: b).map { .a($0) } } - static func buildBlock ( - _ c0: [C0], _ c1: [C1], _ c2: [C2], _ c3: [C3]) -> [OneOf, OneOf>] - { - [buildEither(first: buildBlock(c0, c1)), buildEither(second: buildBlock(c2, c3))].flatMap { $0 } + // matches C of OneOf, OneOf> + static func buildPartialBlock(accumulated abcd: [OneOf, OneOf>], next c: [C]) -> [OneOf, OneOf>] { + buildPartialBlock(accumulated: [] as [OneOf], next: c).map { .b($0) } } - static func buildBlock ( - _ c0: [C0], _ c1: [C1], _ c2: [C2], _ c3: [C3], _ c4: [C4]) -> [OneOf, OneOf>, C4>] - { - [buildEither(first: buildBlock(c0, c1, c2, c3)), buildEither(second: c4)].flatMap { $0 } + // matches D of OneOf, OneOf> + static func buildPartialBlock(accumulated abcd: [OneOf, OneOf>], next d: [D]) -> [OneOf, OneOf>] { + buildPartialBlock(accumulated: [] as [OneOf], next: d).map { .b($0) } } - static func buildBlock ( - _ c0: [C0], _ c1: [C1], _ c2: [C2], _ c3: [C3], _ c4: [C4], _ c5: [C5]) -> [OneOf, OneOf>, OneOf>] - { - [buildEither(first: buildBlock(c0, c1, c2, c3)), buildEither(second: buildBlock(c4, c5))].flatMap { $0 } - } - - static func buildBlock ( - _ c0: [C0], _ c1: [C1], _ c2: [C2], _ c3: [C3], _ c4: [C4], _ c5: [C5], _ c6: [C6]) -> [OneOf, OneOf>, OneOf, C6>>] - { - [buildEither(first: buildBlock(c0, c1, c2, c3)), buildEither(second: buildBlock(c4, c5, c6))].flatMap { $0 } - } - - static func buildBlock ( - _ c0: [C0], _ c1: [C1], _ c2: [C2], _ c3: [C3], _ c4: [C4], _ c5: [C5], _ c6: [C6], _ c7: [C7]) -> [OneOf, OneOf>, OneOf, OneOf>>] - { - [buildEither(first: buildBlock(c0, c1, c2, c3)), buildEither(second: buildBlock(c4, c5, c6, c7))].flatMap { $0 } + // non-matching types + static func buildPartialBlock(accumulated c0: [C0], next c1: [C1]) -> [OneOf] { + c0.map({ OneOf.a($0) }) + c1.map({ OneOf.b($0) }) } // static func buildBlock(_ c: [C]...) -> [C] { @@ -159,15 +101,11 @@ import SwiftUI } static func buildExpression(_ node: N) -> [N] { - [node] + return [node] } static func buildExpression(_ nodeList: NL) -> [NL.Element] { - Array(nodeList) - } - - static func buildExpression(_ nodeSource: NS) -> [NS.List.Element] { - nodeSource.items.map({ buildExpression($0) }) ?? [] + return Array(nodeList) } } diff --git a/SimDirs/Node/Views/FilteredNodeView.swift b/SimDirs/Node/Views/FilteredNodeView.swift index f72f864..dc2eadd 100644 --- a/SimDirs/Node/Views/FilteredNodeView.swift +++ b/SimDirs/Node/Views/FilteredNodeView.swift @@ -6,8 +6,10 @@ // import SwiftUI +import Combine struct FilteredNodeView: View { + @Environment(\.deviceUpdates) var deviceUpdates @StateObject var node : FilteredNode @Binding var filter : SourceFilter @@ -25,6 +27,10 @@ struct FilteredNodeView: View { .searchable(text: $filter.searchTerm, placement: .sidebar) .onAppear { node.applyFilter(filter) } .onChange(of: filter) { node.applyFilter($0) } + .onReceive(deviceUpdates) { update in + node.processUpdate(update) + node.applyFilter(filter) + } } } @@ -32,7 +38,7 @@ extension FilteredNodeView { struct Root: View { @ObservedObject var node : FilteredNode - var visibleItems : FilteredNode.List { node.items.map { $0.filter { !$0.filtered} } ?? [] } + var visibleItems : [FilteredNode] { node.items.map { $0.filter { !$0.filtered} } ?? [] } var body: some View { let items = visibleItems diff --git a/SimDirs/Presentation/SourceState.swift b/SimDirs/Presentation/SourceState.swift deleted file mode 100644 index f4ed535..0000000 --- a/SimDirs/Presentation/SourceState.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// SourceState.swift -// SimDirs -// -// Created by Casey Fleser on 6/21/22. -// - -import Foundation -import Combine - -class SourceState: ObservableObject { - enum Style: Int, CaseIterable, Identifiable { - case placeholder - case byDevice - case byRuntime - - var id : Int { rawValue } - var title : String { - switch self { - case .placeholder: return "Placeholder" - case .byDevice: return "By Device" - case .byRuntime: return "By Runtime" - } - } - var visible : Bool { - switch self { - case .placeholder: return false - default: return true - } - } - } - - @Published var style = Style.placeholder // { didSet { rebuildBase() } } - @Published var selection : UUID? - - var model : SimModel - var deviceUpdates : Cancellable? - - init(model: SimModel) { - self.style = .byDevice - self.model = model - - deviceUpdates = model.deviceUpdates.sink(receiveValue: applyDeviceUpdates) - } - -#warning("TODO: still need to apply updates") - func applyDeviceUpdates(_ updates: SimDevicesUpdates) { -#if false - switch base { - case .placeholder: - break - - case let .device(_, item): - for prodFamily in item.children { - for devType in prodFamily.children { - for runtime in devType.children { - guard updates.runtime.identifier == runtime.data.identifier else { continue } - let devTypeDevices = updates.additions.filter { $0.isDeviceOfType(devType.data) } - - runtime.children = runtime.children.filter { device in !updates.removals.contains { $0.udid == device.data.udid } } - runtime.children.append(contentsOf: devTypeDevices.map { device in - let imageDesc = devType.imageDesc.withColor(device.isAvailable ? .green : .red) - - return Device(data: device, children: device.apps.map { app in App(data: app, children: []) }, customImgDesc: imageDesc) - }) - } - } - } - - case let .runtime(_, item): - for platform in item.children { - for runtime in platform.children { - guard updates.runtime.identifier == runtime.data.identifier else { continue } - - for devType in runtime.children { - let devTypeDevices = updates.additions.filter { $0.isDeviceOfType(devType.data) } - - devType.children = devType.children.filter { device in !updates.removals.contains { $0.udid == device.data.udid } } - devType.children.append(contentsOf: devTypeDevices.map { device in - let imageDesc = devType.imageDesc.withColor(device.isAvailable ? .green : .red) - - return Device(data: device, children: device.apps.map { app in App(data: app, children: []) }, customImgDesc: imageDesc) - }) - } - } - } - } - - applyFilter() -#endif - } - - @NodeListBuilder - var items: some NodeList { - switch style { - case .placeholder: [] as [LeafNode] - case .byDevice: deviceStyleItems - case .byRuntime: runtimeStyleItems - } - } - - @NodeListBuilder - var deviceStyleItems: some NodeList { - SimProductFamily.presentation.linkEachTo { family in - model.deviceTypes.supporting(productFamily: family).linkEachTo(emptyIsNil: true) { devType in - model.runtimes.supporting(deviceType: devType).linkEachTo(emptyIsNil: true) { runtime in - runtime.devices.linkingDeviceType(devType) - } - } - } - } - - @NodeListBuilder - var runtimeStyleItems: some NodeList { - SimPlatform.presentation.linkEachTo(emptyIsNil: true) { platform in - model.runtimes.supporting(platform: platform).linkEachTo(emptyIsNil: true) { runtime in - model.deviceTypes.supporting(runtime: runtime).linkEachTo(emptyIsNil: true) { devType in - runtime.devices.linkingDeviceType(devType) - } - } - } - } -} - diff --git a/SimDirs/Views/Model Views/DeviceHeader.swift b/SimDirs/Views/Model Views/DeviceHeader.swift index 240f3dd..8f89c7d 100644 --- a/SimDirs/Views/Model Views/DeviceHeader.swift +++ b/SimDirs/Views/Model Views/DeviceHeader.swift @@ -10,6 +10,10 @@ import SwiftUI struct DeviceHeader: View { @ObservedObject var device : SimDevice + init(_ device: SimDevice) { + self.device = device + } + var body: some View { VStack(alignment: .leading, spacing: 3.0) { HStack(spacing: 8.0) { @@ -36,8 +40,8 @@ struct DeviceHeader_Previews: PreviewProvider { static var previews: some View { if !devices.isEmpty { - DeviceHeader(device: devices[0]) - DeviceHeader(device: devices.randomElement() ?? devices[1]) + DeviceHeader(devices[0]) + DeviceHeader(devices.randomElement() ?? devices[1]) } else { Text("No devices") diff --git a/SimDirs/Views/ToolbarMenu.swift b/SimDirs/Views/ToolbarMenu.swift index 66907ed..72731fe 100644 --- a/SimDirs/Views/ToolbarMenu.swift +++ b/SimDirs/Views/ToolbarMenu.swift @@ -8,13 +8,13 @@ import SwiftUI struct ToolbarMenu: View { - @ObservedObject var state : SourceState + @Binding var style : ContentView.Style @Binding var filter : SourceFilter var body: some View { Menu { - Picker("Style", selection: $state.style) { - ForEach(SourceState.Style.allCases) { style in + Picker("Style", selection: $style) { + ForEach(ContentView.Style.allCases) { style in if style.visible { Text(style.title).tag(style) } @@ -30,10 +30,10 @@ struct ToolbarMenu: View { } struct ToolbarMenu_Previews: PreviewProvider { - static var state = SourceState(model: SimModel()) + @State static var style = ContentView.Style.byDevice @State static var filter = SourceFilter.restore() static var previews: some View { - ToolbarMenu(state: state, filter: $filter) + ToolbarMenu(style: $style, filter: $filter) } }