import CoreGraphics import Foundation /// SplitTree represents a tree of views that can be divided. struct SplitTree { /// 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 { case leaf(view: ViewType) case split(Split) struct Split: Equatable { let direction: Direction let ratio: Double let left: Node let right: Node } } enum Direction: Hashable { 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 } } /// Spatial representation of the split tree. This can be used to better understand /// its physical representation to perform tasks such as navigation. struct Spatial { let slots: [Slot] /// A single slot within the spatial mapping of a tree. Note that the bounds are /// _relative_. They can't be mapped to physical pixels because the SplitTree /// isn't aware of actual rendering. But relative to each other the bounds are /// correct. struct Slot { let node: Node let bounds: CGRect } /// Direction for spatial navigation within the split tree. enum Direction { case left case right case up case down } } 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 // Spatially-aware navigation targets. These take into account the // layout to find the spatially correct node to move to. Spatial navigation // is always from the top-left corner for now. case spatial(Spatial.Direction) } } // 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) } /// Checks if the tree contains the specified node. func contains(_ node: Node) -> Bool { guard let root else { return false } return root.path(to: node) != nil } /// Checks if the tree contains the specified view. func contains(_ view: ViewType) -> Bool { guard let root else { return false } return root.node(view: view) != nil } /// Insert a new view at the given view point by creating a split in the given direction. /// This will always reset the zoomed state of the tree. func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { guard let root else { throw SplitError.viewNotFound } return .init( root: try root.inserting(view: view, at: at, direction: direction), zoomed: nil) } /// Find a node containing a view with the specified ID. func find(id: ViewType.ID) -> Node? { guard let root else { return nil } return root.find(id: id) } /// 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 removing(_ target: Node) -> Self { guard let root else { return self } if root == target { return .init(root: nil, zoomed: nil) } let newRoot = root.remove(target) let newZoomed = (zoomed == target) ? nil : zoomed return .init(root: newRoot, zoomed: newZoomed) } /// Replace a node in the tree with a new node. func replacing(node: Node, with newNode: Node) throws -> Self { guard let root else { throw SplitError.viewNotFound } guard let path = root.path(to: node) else { throw SplitError.viewNotFound } let newRoot = try root.replacingNode(at: path, with: newNode) 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: let allLeaves = root.leaves() let currentView = currentNode.leftmostLeaf() guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { return nil } let index = allLeaves.indexWrapping(before: currentIndex) return allLeaves[index] case .next: 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 .spatial(let spatialDirection): let spatial = root.spatial() let nodes = spatial.slots(in: spatialDirection, from: currentNode) if nodes.isEmpty { return nil } let bestNode = nodes.first(where: { if case .leaf = $0.node { return true } else { return false } }) ?? nodes[0] switch bestNode.node { case .leaf(let view): return view case .split: return switch (spatialDirection) { case .up, .left: bestNode.node.leftmostLeaf() case .down, .right: bestNode.node.rightmostLeaf() } } } } /// Equalize all splits in the tree so that each split's ratio is based on the /// relative weight (number of leaves) of its children. func equalized() -> Self { guard let root else { return self } let newRoot = root.equalize() return .init(root: newRoot, zoomed: zoomed) } /// Resize a node in the tree by the given pixel amount in the specified direction. func resizing(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self { guard let root else { throw SplitError.viewNotFound } guard let path = root.path(to: node) else { throw SplitError.viewNotFound } let targetSplitDirection: Direction = switch direction { case .up, .down: .vertical case .left, .right: .horizontal } var splitPath: Path? var splitNode: Node? for i in stride(from: path.path.count - 1, through: 0, by: -1) { let parentPath = Path(path: Array(path.path.prefix(i))) if let parent = root.node(at: parentPath), case .split(let split) = parent { if split.direction == targetSplitDirection { splitPath = parentPath splitNode = parent break } } } guard let splitPath = splitPath, let splitNode = splitNode, case .split(let split) = splitNode else { throw SplitError.viewNotFound } let spatial = root.spatial(within: bounds.size) guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else { throw SplitError.viewNotFound } let pixelOffset = Double(pixels) let newRatio: Double switch (split.direction, direction) { case (.horizontal, .left): newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width))) case (.horizontal, .right): newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width))) case (.vertical, .up): newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.height))) case (.vertical, .down): newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.height))) default: throw SplitError.viewNotFound } let newSplit = Node.Split( direction: split.direction, ratio: newRatio, left: split.left, right: split.right ) let newRoot = try root.replacingNode(at: splitPath, with: .split(newSplit)) return .init(root: newRoot, zoomed: nil) } } // MARK: SplitTree.Node extension SplitTree.Node { typealias Node = SplitTree.Node typealias NewDirection = SplitTree.NewDirection typealias SplitError = SplitTree.SplitError typealias Path = SplitTree.Path /// Find a node containing a view with the specified ID. func find(id: ViewType.ID) -> Node? { switch self { case .leaf(let view): return view.id == id ? self : nil case .split(let split): if let found = split.left.find(id: id) { return found } return split.right.find(id: id) } } /// Returns the node in the tree that contains the given view. func node(view: ViewType) -> Node? { switch self { case .leaf(let nodeView): return nodeView === view ? self : nil 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 } } /// Returns the path to a given node in the tree. 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): components.append(.left) if search(split.left) { return true } components.removeLast() components.append(.right) if search(split.right) { return true } components.removeLast() return false } } return search(self) ? Path(path: components) : nil } /// Returns the node at the given path from this node as root. func node(at path: Path) -> Node? { if path.isEmpty { return self } guard case .split(let split) = self else { return nil } let component = path.path[0] let remainingPath = Path(path: Array(path.path.dropFirst())) switch component { case .left: return split.left.node(at: remainingPath) case .right: return split.right.node(at: remainingPath) } } /// Inserts a new view into the split tree by creating a split at the location of an existing view. func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { guard let path = path(to: .leaf(view: at)) else { throw SplitError.viewNotFound } 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 } let newNode: Node = .split(.init( direction: splitDirection, ratio: 0.5, left: newViewOnLeft ? .leaf(view: view) : .leaf(view: at), right: newViewOnLeft ? .leaf(view: at) : .leaf(view: view) )) return try replacingNode(at: path, with: newNode) } /// Replace a node at the specified path with a new node. func replacingNode(at path: Path, with newNode: Node) throws -> Node { if path.isEmpty { return newNode } guard case .split(let split) = self else { throw SplitError.viewNotFound } let component = path.path[0] let remainingPath = Path(path: Array(path.path.dropFirst())) switch component { case .left: return .split(.init( direction: split.direction, ratio: split.ratio, left: try split.left.replacingNode(at: remainingPath, with: newNode), right: split.right )) case .right: return .split(.init( direction: split.direction, ratio: split.ratio, left: split.left, right: try split.right.replacingNode(at: remainingPath, with: newNode) )) } } /// Remove a node from the tree. func remove(_ target: Node) -> Node? { if self == target { return nil } switch self { case .leaf: return self case .split(let split): let newLeft = split.left.remove(target) let newRight = split.right.remove(target) if newLeft == nil && newRight == nil { return nil } else if newLeft == nil { return newRight } else if newRight == nil { return newLeft } return .split(.init( direction: split.direction, ratio: split.ratio, left: newLeft!, right: newRight! )) } } /// Resize a split node to the specified ratio. func resizing(to ratio: Double) -> Self { switch self { case .leaf: return self case .split(let split): 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() } } /// Equalize this node and all its children. func equalize() -> Node { let (equalizedNode, _) = equalizeWithWeight() return equalizedNode } private func equalizeWithWeight() -> (node: Node, weight: Int) { switch self { case .leaf: return (self, 1) case .split(let split): let leftWeight = split.left.weightForDirection(split.direction) let rightWeight = split.right.weightForDirection(split.direction) let totalWeight = leftWeight + rightWeight let newRatio = Double(leftWeight) / Double(totalWeight) let (leftNode, _) = split.left.equalizeWithWeight() let (rightNode, _) = split.right.equalizeWithWeight() let newSplit = Split( direction: split.direction, ratio: newRatio, left: leftNode, right: rightNode ) return (.split(newSplit), totalWeight) } } private func weightForDirection(_ direction: SplitTree.Direction) -> Int { switch self { case .leaf: return 1 case .split(let split): if split.direction == direction { return split.left.weightForDirection(direction) + split.right.weightForDirection(direction) } return 1 } } /// Returns all leaf nodes in order. func leaves() -> [ViewType] { switch self { case .leaf(let view): return [view] case .split(let split): return split.left.leaves() + split.right.leaves() } } } // MARK: SplitTree.Node Spatial extension SplitTree.Node { func spatial(within bounds: CGSize? = nil) -> SplitTree.Spatial { let width: Double let height: Double if let bounds { width = bounds.width height = bounds.height } else { let (w, h) = self.dimensions() width = Double(w) height = Double(h) } let slots = spatialSlots(in: CGRect(x: 0, y: 0, width: width, height: height)) return SplitTree.Spatial(slots: slots) } private func dimensions() -> (width: UInt, height: UInt) { switch self { case .leaf: return (1, 1) case .split(let split): let leftDimensions = split.left.dimensions() let rightDimensions = split.right.dimensions() switch split.direction { case .horizontal: return ( width: leftDimensions.width + rightDimensions.width, height: Swift.max(leftDimensions.height, rightDimensions.height) ) case .vertical: return ( width: Swift.max(leftDimensions.width, rightDimensions.width), height: leftDimensions.height + rightDimensions.height ) } } } private func spatialSlots(in bounds: CGRect) -> [SplitTree.Spatial.Slot] { switch self { case .leaf: return [.init(node: self, bounds: bounds)] case .split(let split): let leftBounds: CGRect let rightBounds: CGRect switch split.direction { case .horizontal: let splitX = bounds.minX + bounds.width * split.ratio leftBounds = CGRect( x: bounds.minX, y: bounds.minY, width: bounds.width * split.ratio, height: bounds.height ) rightBounds = CGRect( x: splitX, y: bounds.minY, width: bounds.width * (1 - split.ratio), height: bounds.height ) case .vertical: let splitY = bounds.minY + bounds.height * split.ratio leftBounds = CGRect( x: bounds.minX, y: bounds.minY, width: bounds.width, height: bounds.height * split.ratio ) rightBounds = CGRect( x: bounds.minX, y: splitY, width: bounds.width, height: bounds.height * (1 - split.ratio) ) } var slots: [SplitTree.Spatial.Slot] = [.init(node: self, bounds: bounds)] slots += split.left.spatialSlots(in: leftBounds) slots += split.right.spatialSlots(in: rightBounds) return slots } } } // MARK: SplitTree.Spatial extension SplitTree.Spatial { func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] { guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] } func distance(from rect1: CGRect, to rect2: CGRect) -> Double { let dx = rect2.minX - rect1.minX let dy = rect2.minY - rect1.minY return sqrt(dx * dx + dy * dy) } let result = switch direction { case .left: slots.filter { $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } case .right: slots.filter { $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } case .up: slots.filter { $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } case .down: slots.filter { $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } } return result } } // MARK: SplitTree.Node Protocols extension SplitTree.Node: Equatable { static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { case let (.leaf(leftView), .leaf(rightView)): return leftView === rightView case let (.split(split1), .split(split2)): return split1 == split2 default: return false } } } // MARK: Structural Identity extension SplitTree.Node { var structuralIdentity: StructuralIdentity { StructuralIdentity(self) } struct StructuralIdentity: Hashable { private let node: SplitTree.Node init(_ node: SplitTree.Node) { self.node = node } static func == (lhs: Self, rhs: Self) -> Bool { lhs.node.isStructurallyEqual(to: rhs.node) } func hash(into hasher: inout Hasher) { node.hashStructure(into: &hasher) } } fileprivate func isStructurallyEqual(to other: Node) -> Bool { switch (self, other) { case let (.leaf(view1), .leaf(view2)): return view1 === view2 case let (.split(split1), .split(split2)): return split1.direction == split2.direction && split1.left.isStructurallyEqual(to: split2.left) && split1.right.isStructurallyEqual(to: split2.right) default: return false } } private enum HashKey: UInt8 { case leaf = 0 case split = 1 } fileprivate func hashStructure(into hasher: inout Hasher) { switch self { case .leaf(let view): hasher.combine(HashKey.leaf) hasher.combine(ObjectIdentifier(view)) case .split(let split): hasher.combine(HashKey.split) hasher.combine(split.direction) split.left.hashStructure(into: &hasher) split.right.hashStructure(into: &hasher) } } } extension SplitTree { var structuralIdentity: StructuralIdentity { StructuralIdentity(self) } struct StructuralIdentity: Hashable { private let root: Node? private let zoomed: Node? init(_ tree: SplitTree) { self.root = tree.root self.zoomed = tree.zoomed } static func == (lhs: Self, rhs: Self) -> Bool { areNodesStructurallyEqual(lhs.root, rhs.root) && areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed) } func hash(into hasher: inout Hasher) { hasher.combine(0) if let root = root { root.hashStructure(into: &hasher) } hasher.combine(1) if let zoomed = zoomed { zoomed.hashStructure(into: &hasher) } } private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool { switch (lhs, rhs) { case (nil, nil): return true case let (node1?, node2?): return node1.isStructurallyEqual(to: node2) default: return false } } } } // MARK: SplitTree Sequence extension SplitTree: Sequence { func makeIterator() -> IndexingIterator<[ViewType]> { return (root?.leaves() ?? []).makeIterator() } } // MARK: Array Helpers extension Array { /// Returns the index before i, with wraparound. Assumes i is a valid index. func indexWrapping(before i: Int) -> Int { if i == 0 { return count - 1 } return i - 1 } /// Returns the index after i, with wraparound. Assumes i is a valid index. func indexWrapping(after i: Int) -> Int { if i == count - 1 { return 0 } return i + 1 } }