macos: set explicit identity for split tree view based on structure
Fixes #7546 SwiftUI uses type and structure to identify views, which can lead to issues with tree like structures where the shape and type is the same but the content changes. This was causing #7546. To fix this, we need to add explicit identity to the split tree view so that SwiftUI can differentiate when it needs to redraw the view. We don't want to blindly add Hashable to SplitTree because we don't want to take into account all the fields. Instead, we add an explicit "structural identity" to the SplitTreeView that can be used by SwiftUI.pull/7547/head
parent
804d270ba1
commit
e4cd90b8a0
|
|
@ -1116,3 +1116,148 @@ extension SplitTree: Collection {
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ struct TerminalSplitTreeView: View {
|
|||
node: node,
|
||||
isRoot: node == tree.root,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue