import AppKit /// SplitTree represents a tree of views that can be divided. struct SplitTree: Codable { /// The root of the tree. This can be nil to indicate the tree is empty. let root: Node? /// The node that is currently zoomed. A zoomed split is expected to take up the full /// size of the view area where the splits are shown. let zoomed: Node? /// A single node in the tree is either a leaf node (a view) or a split (has a /// left/right or top/bottom). indirect enum Node: Codable { case leaf(view: ViewType) case split(Split) struct Split: Equatable, Codable { let direction: Direction let ratio: Double let left: Node let right: Node } } enum Direction: Codable { case horizontal // Splits are laid out left and right case vertical // Splits are laid out top and bottom } /// The path to a specific node in the tree. struct Path { let path: [Component] var isEmpty: Bool { path.isEmpty } enum Component { case left case right } } enum SplitError: Error { case viewNotFound } enum NewDirection { case left case right case down case up } /// The direction that focus can move from a node. enum FocusDirection { // Follow a consistent tree-like structure. case previous case next // Geospatially-aware navigation targets. These take into account the // dimensions of the view to find the correct node to go to. case up case down case left case right } } // MARK: SplitTree extension SplitTree { var isEmpty: Bool { root == nil } /// Returns true if this tree is split. var isSplit: Bool { if case .split = root { true } else { false } } init() { self.init(root: nil, zoomed: nil) } init(view: ViewType) { self.init(root: .leaf(view: view), zoomed: nil) } /// Insert a new view at the given view point by creating a split in the given direction. func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { guard let root else { throw SplitError.viewNotFound } return .init( root: try root.insert(view: view, at: at, direction: direction), zoomed: zoomed) } /// Remove a node from the tree. If the node being removed is part of a split, /// the sibling node takes the place of the parent split. func remove(_ target: Node) -> Self { guard let root else { return self } // If we're removing the root itself, return an empty tree if root == target { return .init(root: nil, zoomed: nil) } // Otherwise, try to remove from the tree let newRoot = root.remove(target) // Update zoomed if it was the removed node let newZoomed = (zoomed == target) ? nil : zoomed return .init(root: newRoot, zoomed: newZoomed) } /// Replace a node in the tree with a new node. func replace(node: Node, with newNode: Node) throws -> Self { guard let root else { throw SplitError.viewNotFound } // Get the path to the node we want to replace guard let path = root.path(to: node) else { throw SplitError.viewNotFound } // Replace the node let newRoot = try root.replaceNode(at: path, with: newNode) // Update zoomed if it was the replaced node let newZoomed = (zoomed == node) ? newNode : zoomed return .init(root: newRoot, zoomed: newZoomed) } /// Find the next view to focus based on the current focused node and direction func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { guard let root else { return nil } switch direction { case .previous: // For previous, we traverse in order and find the previous leaf from our leftmost let allLeaves = root.leaves() let currentView = currentNode.leftmostLeaf() guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { // Shouldn't be possible leftmostLeaf can't return something that doesn't exist! return nil } let index = allLeaves.indexWrapping(before: currentIndex) return allLeaves[index] case .next: // For previous, we traverse in order and find the next leaf from our rightmost let allLeaves = root.leaves() let currentView = currentNode.rightmostLeaf() guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { return nil } let index = allLeaves.indexWrapping(after: currentIndex) return allLeaves[index] case .up, .down, .left, .right: // For directional movement, we need to traverse the tree structure return directionalTarget(for: direction, from: currentNode) } } /// Find focus target in a specific direction by traversing split boundaries private func directionalTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { // TODO return nil } } // MARK: SplitTree.Node extension SplitTree.Node { typealias Node = SplitTree.Node typealias NewDirection = SplitTree.NewDirection typealias SplitError = SplitTree.SplitError typealias Path = SplitTree.Path /// Returns the node in the tree that contains the given view. func node(view: ViewType) -> Node? { switch (self) { case .leaf(view): return self case .split(let split): if let result = split.left.node(view: view) { return result } else if let result = split.right.node(view: view) { return result } return nil default: return nil } } /// Returns the path to a given node in the tree. If the returned value is nil then the /// node doesn't exist. func path(to node: Self) -> Path? { var components: [Path.Component] = [] func search(_ current: Self) -> Bool { if current == node { return true } switch current { case .leaf: return false case .split(let split): // Try left branch components.append(.left) if search(split.left) { return true } components.removeLast() // Try right branch components.append(.right) if search(split.right) { return true } components.removeLast() return false } } return search(self) ? Path(path: components) : nil } /// Inserts a new view into the split tree by creating a split at the location of an existing view. /// /// This method creates a new split node containing both the existing view and the new view, /// The position of the new view relative to the existing view is determined by the direction parameter. /// /// - Parameters: /// - view: The new view to insert into the tree /// - at: The existing view at whose location the split should be created /// - direction: The direction relative to the existing view where the new view should be placed /// /// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should /// maybe throw instead but at the moment we just do nothing. func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { // Get the path to our insertion point. If it doesn't exist we do // nothing. guard let path = path(to: .leaf(view: at)) else { throw SplitError.viewNotFound } // Determine split direction and which side the new view goes on let splitDirection: SplitTree.Direction let newViewOnLeft: Bool switch direction { case .left: splitDirection = .horizontal newViewOnLeft = true case .right: splitDirection = .horizontal newViewOnLeft = false case .up: splitDirection = .vertical newViewOnLeft = true case .down: splitDirection = .vertical newViewOnLeft = false } // Create the new split node let newNode: Node = .leaf(view: view) let existingNode: Node = .leaf(view: at) let newSplit: Node = .split(.init( direction: splitDirection, ratio: 0.5, left: newViewOnLeft ? newNode : existingNode, right: newViewOnLeft ? existingNode : newNode )) // Replace the node at the path with the new split return try replaceNode(at: path, with: newSplit) } /// Helper function to replace a node at the given path from the root func replaceNode(at path: Path, with newNode: Self) throws -> Self { // If path is empty, replace the root if path.isEmpty { return newNode } // Otherwise, we need to replace the proper left/right all along // the way since Node is a value type (enum). To do that, we need // recursion. We can't use a simple iterative approach because we // can't update in-place. func replaceInner(current: Node, pathOffset: Int) throws -> Node { // Base case: if we've consumed the entire path, replace this node if pathOffset >= path.path.count { return newNode } // We need to go deeper, so current must be a split for the path // to be valid. Otherwise, the path is invalid. guard case .split(let split) = current else { throw SplitError.viewNotFound } let component = path.path[pathOffset] switch component { case .left: return .split(.init( direction: split.direction, ratio: split.ratio, left: try replaceInner(current: split.left, pathOffset: pathOffset + 1), right: split.right )) case .right: return .split(.init( direction: split.direction, ratio: split.ratio, left: split.left, right: try replaceInner(current: split.right, pathOffset: pathOffset + 1) )) } } return try replaceInner(current: self, pathOffset: 0) } /// Remove a node from the tree. Returns the modified tree, or nil if removing /// the node results in an empty tree. func remove(_ target: Node) -> Node? { // If we're removing ourselves, return nil if self == target { return nil } switch self { case .leaf: // A leaf that isn't the target stays as is return self case .split(let split): // Neither child is directly the target, so we need to recursively // try to remove from both children let newLeft = split.left.remove(target) let newRight = split.right.remove(target) // If both are nil then we remove everything. This shouldn't ever // happen because duplicate nodes shouldn't exist, but we want to // be robust against it. if newLeft == nil && newRight == nil { return nil } else if newLeft == nil { return newRight } else if newRight == nil { return newLeft } // Both children still exist after removal return .split(.init( direction: split.direction, ratio: split.ratio, left: newLeft!, right: newRight! )) } } /// Resize a split node to the specified ratio. /// For leaf nodes, this returns the node unchanged. /// For split nodes, this creates a new split with the updated ratio. func resize(to ratio: Double) -> Self { switch self { case .leaf: // Leaf nodes don't have a ratio to resize return self case .split(let split): // Create a new split with the updated ratio return .split(.init( direction: split.direction, ratio: ratio, left: split.left, right: split.right )) } } /// Get the leftmost leaf in this subtree func leftmostLeaf() -> ViewType { switch self { case .leaf(let view): return view case .split(let split): return split.left.leftmostLeaf() } } /// Get the rightmost leaf in this subtree func rightmostLeaf() -> ViewType { switch self { case .leaf(let view): return view case .split(let split): return split.right.rightmostLeaf() } } } // MARK: SplitTree.Node Protocols extension SplitTree.Node: Equatable { static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { case let (.leaf(leftView), .leaf(rightView)): // Compare NSView instances by object identity return leftView === rightView case let (.split(split1), .split(split2)): return split1 == split2 default: return false } } } // MARK: SplitTree Codable extension SplitTree.Node { enum CodingKeys: String, CodingKey { case view case split } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if container.contains(.view) { let view = try container.decode(ViewType.self, forKey: .view) self = .leaf(view: view) } else if container.contains(.split) { let split = try container.decode(Split.self, forKey: .split) self = .split(split) } else { throw DecodingError.dataCorrupted( DecodingError.Context( codingPath: decoder.codingPath, debugDescription: "No valid node type found" ) ) } } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .leaf(let view): try container.encode(view, forKey: .view) case .split(let split): try container.encode(split, forKey: .split) } } } // MARK: SplitTree Sequences extension SplitTree.Node { /// Returns all leaf views in this subtree func leaves() -> [ViewType] { switch self { case .leaf(let view): return [view] case .split(let split): return split.left.leaves() + split.right.leaves() } } } extension SplitTree: Sequence { func makeIterator() -> [ViewType].Iterator { return root?.leaves().makeIterator() ?? [].makeIterator() } } extension SplitTree.Node: Sequence { func makeIterator() -> [ViewType].Iterator { return leaves().makeIterator() } }