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 } } /// 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. /// /// Note that SplitTree implements Sequence on views so there's already a `contains` /// for views too. /// /// - Parameter node: The node to search for in the tree /// - Returns: True if the node exists in the tree, false otherwise func contains(_ node: Node) -> Bool { guard let root else { return false } return root.path(to: node) != 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 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: nil) } /// 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 .spatial(let spatialDirection): // Get spatial representation and find best candidate let spatial = root.spatial() let nodes = spatial.slots(in: spatialDirection, from: currentNode) // If we have no nodes in the direction specified then we don't do // anything. if nodes.isEmpty { return nil } // Extract the view from the best candidate node. The best candidate // node is the closest leaf node. If we have no leaves (impossible?) // just use the first node. 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: // If the best candidate is a split node, use its the leaf/rightmost // depending on our spatial direction. 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 equalize() -> 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. /// /// This method adjusts the split ratios of the tree to accommodate the requested resize /// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts /// its ratio. For left/right resizing, it finds the nearest parent horizontal split. /// The bounds parameter is used to construct the spatial tree representation which is /// needed to calculate the current pixel dimensions. /// /// This will always reset the zoomed state. /// /// - Parameters: /// - node: The node to resize /// - by: The number of pixels to resize by /// - direction: The direction to resize in (up, down, left, right) /// - bounds: The bounds used to construct the spatial tree representation /// - Returns: A new SplitTree with the adjusted split ratios /// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self { guard let root else { throw SplitError.viewNotFound } // Find the path to the target node guard let path = root.path(to: node) else { throw SplitError.viewNotFound } // Determine which type of split we need to find based on resize direction let targetSplitDirection: Direction = switch direction { case .up, .down: .vertical case .left, .right: .horizontal } // Find the nearest parent split of the correct type by walking up the path 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 } // Get current spatial representation to calculate pixel dimensions let spatial = root.spatial(within: bounds.size) guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else { throw SplitError.viewNotFound } // Calculate the new ratio based on pixel change let pixelOffset = Double(pixels) let newRatio: Double switch (split.direction, direction) { case (.horizontal, .left): // Moving left boundary: decrease left side newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width))) case (.horizontal, .right): // Moving right boundary: increase left side newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width))) case (.vertical, .up): // Moving top boundary: decrease top side newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.height))) case (.vertical, .down): // Moving bottom boundary: increase top side newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.height))) default: // Direction doesn't match split type - shouldn't happen due to earlier logic throw SplitError.viewNotFound } // Create new split with adjusted ratio let newSplit = Node.Split( direction: split.direction, ratio: newRatio, left: split.left, right: split.right ) // Replace the split node with the new one let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit)) return .init(root: newRoot, zoomed: nil) } /// Returns the total bounds of the split hierarchy using NSView bounds. /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. /// Also ignores any possible padding between views. /// - Returns: The total width and height needed to contain all views func viewBounds() -> CGSize { guard let root else { return .zero } return root.viewBounds() } } // 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 } /// 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. /// /// 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() } } /// Equalize this node and all its children, returning a new node with splits /// adjusted so that each split's ratio is based on the relative weight /// (number of leaves) of its children. func equalize() -> Node { let (equalizedNode, _) = equalizeWithWeight() return equalizedNode } /// Internal helper that equalizes and returns both the node and its weight. private func equalizeWithWeight() -> (node: Node, weight: Int) { switch self { case .leaf: // A leaf has weight 1 and doesn't change return (self, 1) case .split(let split): // Calculate weights based on split direction let leftWeight = split.left.weightForDirection(split.direction) let rightWeight = split.right.weightForDirection(split.direction) // Calculate new ratio based on relative weights let totalWeight = leftWeight + rightWeight let newRatio = Double(leftWeight) / Double(totalWeight) // Recursively equalize children let (leftNode, _) = split.left.equalizeWithWeight() let (rightNode, _) = split.right.equalizeWithWeight() // Create new split with equalized ratio let newSplit = Split( direction: split.direction, ratio: newRatio, left: leftNode, right: rightNode ) return (.split(newSplit), totalWeight) } } /// Calculate weight for equalization based on split direction. /// Children with the same direction contribute their full weight, /// children with different directions count as 1. 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) } else { return 1 } } } /// Calculate the bounds of all views in this subtree based on split ratios func calculateViewBounds(in bounds: CGRect) -> [(view: ViewType, bounds: CGRect)] { switch self { case .leaf(let view): return [(view, bounds)] case .split(let split): // Calculate bounds for left and right based on split direction and ratio let leftBounds: CGRect let rightBounds: CGRect switch split.direction { case .horizontal: // Split horizontally: left | right 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: // Split vertically: top / bottom // Note: In our normalized coordinate system, Y increases upward let splitY = bounds.minY + bounds.height * split.ratio leftBounds = CGRect( x: bounds.minX, y: splitY, width: bounds.width, height: bounds.height * (1 - split.ratio) ) rightBounds = CGRect( x: bounds.minX, y: bounds.minY, width: bounds.width, height: bounds.height * split.ratio ) } // Recursively calculate bounds for children return split.left.calculateViewBounds(in: leftBounds) + split.right.calculateViewBounds(in: rightBounds) } } /// Returns the total bounds of this subtree using NSView bounds. /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. /// - Returns: The total width and height needed to contain all views in this subtree func viewBounds() -> CGSize { switch self { case .leaf(let view): return view.bounds.size case .split(let split): let leftBounds = split.left.viewBounds() let rightBounds = split.right.viewBounds() switch split.direction { case .horizontal: // Horizontal split: width is sum, height is max return CGSize( width: leftBounds.width + rightBounds.width, height: Swift.max(leftBounds.height, rightBounds.height) ) case .vertical: // Vertical split: height is sum, width is max return CGSize( width: Swift.max(leftBounds.width, rightBounds.width), height: leftBounds.height + rightBounds.height ) } } } } // MARK: SplitTree.Node Spatial extension SplitTree.Node { /// Returns the spatial representation of this node and its subtree. /// /// This method creates a `Spatial` representation that maps the logical split tree structure /// to 2D coordinate space. The coordinate system uses (0,0) as the top-left corner with /// positive X extending right and positive Y extending down. /// /// The spatial representation provides: /// - Relative bounds for each node based on split ratios /// - Grid-like dimensions where each split adds 1 to the column/row count /// - Accurate positioning that reflects the actual layout structure /// /// The bounds are pixel perfect based on assuming that each row and column are 1 pixel /// tall or wide, respectively. This needs to be scaled up to the proper bounds for a real /// layout. /// /// Example: /// ``` /// // For a layout like: /// // +--------+----+ /// // | A | B | /// // +--------+----+ /// // | C | D | /// // +--------+----+ /// // /// // The spatial representation would have: /// // - Total dimensions: (width: 2, height: 2) /// // - Node bounds based on actual split ratios /// ``` /// /// - Parameter bounds: Optional size constraints for the spatial representation. If nil, uses artificial dimensions based /// on grid layout /// - Returns: A `Spatial` struct containing all slots with their calculated bounds func spatial(within bounds: CGSize? = nil) -> SplitTree.Spatial { // If we're not given bounds, we use artificial dimensions based on // the total width/height in columns/rows. 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) } // Calculate slots with relative bounds let slots = spatialSlots(in: CGRect(x: 0, y: 0, width: width, height: height)) return SplitTree.Spatial(slots: slots) } /// Calculates the grid dimensions (columns and rows) needed to represent this subtree. /// /// This method recursively analyzes the split tree structure to determine how many /// columns and rows are needed to represent the layout in a 2D grid. Each leaf node /// occupies one grid cell (1×1), and each split extends the grid in one direction: /// /// - **Horizontal splits**: Add columns (increase width) /// - **Vertical splits**: Add rows (increase height) /// /// The calculation rules are: /// - **Leaf nodes**: Always (1, 1) - one column, one row /// - **Horizontal splits**: Width = sum of children widths, Height = max of children heights /// - **Vertical splits**: Width = max of children widths, Height = sum of children heights /// /// Example: /// ``` /// // Single leaf: (1, 1) /// // Horizontal split with 2 leaves: (2, 1) /// // Vertical split with 2 leaves: (1, 2) /// // Complex layout with both: (2, 2) or larger /// ``` /// /// - Returns: A tuple containing (width: columns, height: rows) as unsigned integers 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: // Horizontal split: width is sum, height is max return ( width: leftDimensions.width + rightDimensions.width, height: Swift.max(leftDimensions.height, rightDimensions.height) ) case .vertical: // Vertical split: height is sum, width is max return ( width: Swift.max(leftDimensions.width, rightDimensions.width), height: leftDimensions.height + rightDimensions.height ) } } } /// Calculates the spatial slots (nodes with bounds) for this subtree within the given bounds. /// /// This method recursively traverses the split tree and calculates the precise bounds /// for each node based on the split ratios and directions. The bounds are calculated /// relative to the provided bounds rectangle. /// /// The calculation process: /// 1. **Leaf nodes**: Create a single slot with the provided bounds /// 2. **Split nodes**: /// - Divide the bounds according to the split ratio and direction /// - Create a slot for the split node itself /// - Recursively calculate slots for both children /// - Return all slots combined /// /// Split ratio interpretation: /// - **Horizontal splits**: Ratio determines left/right width distribution /// - Left child gets `ratio * width` /// - Right child gets `(1 - ratio) * width` /// - **Vertical splits**: Ratio determines top/bottom height distribution /// - Top (left) child gets `ratio * height` /// - Bottom (right) child gets `(1 - ratio) * height` /// /// Coordinate system: (0,0) is top-left, positive X goes right, positive Y goes down. /// /// - Parameter bounds: The bounding rectangle to subdivide for this subtree /// - Returns: An array of `Spatial.Slot` objects, each containing a node and its bounds private func spatialSlots(in bounds: CGRect) -> [SplitTree.Spatial.Slot] { switch self { case .leaf: // A leaf takes up our full bounds. return [.init(node: self, bounds: bounds)] case .split(let split): let leftBounds: CGRect let rightBounds: CGRect switch split.direction { case .horizontal: // Split horizontally: left | right using the ratio 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: // Split vertically: top / bottom using the ratio // Top-left is (0,0), so top (left) gets the upper portion 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) ) } // Recursively calculate slots for children and include a slot for this split 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 { /// Returns all slots in the specified direction relative to the reference node. /// /// This method finds all slots positioned in the given direction from the reference node: /// - **Left**: Slots with bounds to the left of the reference node /// - **Right**: Slots with bounds to the right of the reference node /// - **Up**: Slots with bounds above the reference node (Y=0 is top) /// - **Down**: Slots with bounds below the reference node /// /// Results are sorted by 2D euclidean distance from the reference node, with closest slots first. /// Distance is calculated from the top-left corners of the bounds, prioritizing nodes that are /// closer in both dimensions. /// /// **Important**: The returned array contains both split nodes and leaf nodes. When using this /// for navigation or focus management, you typically want to filter for leaf nodes first, as they /// represent the actual views that can receive focus. Split nodes are included in the results /// because they have bounds and occupy space in the layout, but they are structural elements /// that cannot themselves be focused. If no leaf nodes are found in the results, you may need /// to traverse into a split node to find its appropriate leaf child. /// /// - Parameters: /// - direction: The direction to search for slots /// - referenceNode: The node to use as the reference point /// - Returns: An array of slots in the specified direction, sorted by 2D distance (closest first) func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] { guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] } // Helper function to calculate 2D euclidean distance between top-left corners of two rectangles func distance(from rect1: CGRect, to rect2: CGRect) -> Double { // Calculate distance between top-left corners 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 to the left: their right edge is at or left of reference's left edge 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 to the right: their left edge is at or right of reference's right edge 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 above: their bottom edge is at or above reference's top edge 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 below: their top edge is at or below reference's bottom edge 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 } /// Returns whether the given node borders the specified side of the spatial bounds. /// /// This method checks if a node's bounds touch the edge of the overall spatial area: /// - **Up**: Node's top edge touches the top of the spatial area (Y=0) /// - **Down**: Node's bottom edge touches the bottom of the spatial area (Y=maxY) /// - **Left**: Node's left edge touches the left of the spatial area (X=0) /// - **Right**: Node's right edge touches the right of the spatial area (X=maxX) /// /// - Parameters: /// - side: The side of the spatial bounds to check /// - node: The node to check if it borders the specified side /// - Returns: True if the node borders the specified side, false otherwise func doesBorder(side: Direction, from node: SplitTree.Node) -> Bool { // Find the slot for this node guard let slot = slots.first(where: { $0.node == node }) else { return false } // Calculate the overall bounds of all slots let overallBounds = slots.reduce(CGRect.null) { result, slot in result.union(slot.bounds) } return switch side { case .up: slot.bounds.minY == overallBounds.minY case .down: slot.bounds.maxY == overallBounds.maxY case .left: slot.bounds.minX == overallBounds.minX case .right: slot.bounds.maxX == overallBounds.maxX } } } // 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() } } // MARK: SplitTree Collection extension SplitTree: Collection { typealias Index = Int typealias Element = ViewType var startIndex: Int { return 0 } var endIndex: Int { return root?.leaves().count ?? 0 } subscript(position: Int) -> ViewType { precondition(position >= 0 && position < endIndex, "Index out of bounds") let leaves = root?.leaves() ?? [] return leaves[position] } func index(after i: Int) -> Int { precondition(i < endIndex, "Cannot increment index beyond endIndex") return i + 1 } } // MARK: Structural Identity extension SplitTree.Node { /// Returns a hashable representation that captures this node's structural identity. var structuralIdentity: StructuralIdentity { StructuralIdentity(self) } /// A hashable representation of a node that captures its structural identity. /// /// This type provides a way to track changes to a node's structure in SwiftUI /// by implementing `Hashable` based on: /// - The node's hierarchical structure (splits and their directions) /// - The identity of view instances in leaf nodes (using object identity) /// - The split directions (but not ratios, as those may change slightly) /// /// This is useful for SwiftUI's `id()` modifier to detect when a node's structure /// has changed, triggering appropriate view updates while preserving view identity /// for unchanged portions of the tree. 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) } } /// Checks if this node is structurally equal to another node. /// Two nodes are structurally equal if they have the same tree structure /// and the same views (by identity) in the same positions. fileprivate func isStructurallyEqual(to other: Node) -> Bool { switch (self, other) { case let (.leaf(view1), .leaf(view2)): // Views must be the same instance return view1 === view2 case let (.split(split1), .split(split2)): // Splits must have same direction and structurally equal children // Note: We intentionally don't compare ratios as they may change slightly return split1.direction == split2.direction && split1.left.isStructurallyEqual(to: split2.left) && split1.right.isStructurallyEqual(to: split2.right) default: // Different node types return false } } /// Hash keys for structural identity private enum HashKey: UInt8 { case leaf = 0 case split = 1 } /// Hashes the structural identity of this node. /// Includes the tree structure and view identities in the hash. 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) // Note: We intentionally don't hash the ratio split.left.hashStructure(into: &hasher) split.right.hashStructure(into: &hasher) } } } extension SplitTree { /// Returns a hashable representation that captures this tree's structural identity. var structuralIdentity: StructuralIdentity { StructuralIdentity(self) } /// A hashable representation of a SplitTree that captures its structural identity. /// /// This type provides a way to track changes to a SplitTree's structure in SwiftUI /// by implementing `Hashable` based on: /// - The tree's hierarchical structure (splits and their directions) /// - The identity of view instances in leaf nodes (using object identity) /// - The zoomed node state (if any) /// /// This is useful for SwiftUI's `id()` modifier to detect when a tree's structure /// has changed, triggering appropriate view updates while preserving view identity /// for unchanged portions of the tree. /// /// Example usage: /// ```swift /// var body: some View { /// SplitTreeView(tree: splitTree) /// .id(splitTree.structuralIdentity) /// } /// ``` 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) // Tree marker if let root = root { root.hashStructure(into: &hasher) } hasher.combine(1) // Zoomed marker if let zoomed = zoomed { zoomed.hashStructure(into: &hasher) } } /// Helper to compare optional nodes for structural equality 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 } } } }