macos: add id to SplitTreeView to detect tree structural changes (#7547)

Fixes #7546

The comments explain what was going on here.
pull/7556/head
Mitchell Hashimoto 2025-06-08 20:18:12 -07:00 committed by GitHub
commit e25708fc43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 150 additions and 0 deletions

View File

@ -1116,3 +1116,148 @@ extension SplitTree: Collection {
return i + 1 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
}
}
}
}

View File

@ -10,6 +10,11 @@ struct TerminalSplitTreeView: View {
node: node, node: node,
isRoot: node == tree.root, isRoot: node == tree.root,
onResize: onResize) onResize: onResize)
// This is necessary because we can't rely on SwiftUI's implicit
// structural identity to detect changes to this view. Due to
// the tree structure of splits it could result in bad beaviors.
// See: https://github.com/ghostty-org/ghostty/issues/7546
.id(node.structuralIdentity)
} }
} }
} }