macos: hook up previous/next split focus

This commit is contained in:
Mitchell Hashimoto
2023-03-11 17:02:01 -08:00
parent b582691185
commit 4a5d92056f

View File

@ -73,6 +73,34 @@ extension Ghostty {
self.bottomRight = .noSplit(.init(app)) self.bottomRight = .noSplit(.init(app))
} }
} }
/// This keeps track of the "neighbors" of a split: the immediately above/below/left/right
/// nodes. This is purposely weak so we don't have to worry about memory management
/// with this (although, it should always be correct).
struct Neighbors {
var left: SplitNode?
var right: SplitNode?
var top: SplitNode?
var bottom: SplitNode?
/// These are the previous/next nodes. It will certainly be one of the above as well
/// but we keep track of these separately because depending on the split direction
/// of the containing node, previous may be left OR top (same for next).
var previous: SplitNode?
var next: SplitNode?
/// No neighbors, used by the root node.
static let empty: Self = .init()
/// Update multiple keys and return a new copy.
func update(_ attrs: [WritableKeyPath<Self, SplitNode?>: SplitNode?]) -> Self {
var clone = self
attrs.forEach { (key, value) in
clone[keyPath: key] = value
}
return clone
}
}
} }
/// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever /// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever
@ -93,18 +121,33 @@ extension Ghostty {
ZStack { ZStack {
switch (node) { switch (node) {
case .noSplit(let leaf): case .noSplit(let leaf):
TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $requestClose) TerminalSplitLeaf(
.onChange(of: requestClose) { value in leaf: leaf,
guard value else { return } neighbors: .empty,
guard let onClose = self.onClose else { return } node: $node,
onClose() requestClose: $requestClose
} )
.onChange(of: requestClose) { value in
guard value else { return }
guard let onClose = self.onClose else { return }
onClose()
}
case .horizontal(let container): case .horizontal(let container):
TerminalSplitContainer(direction: .horizontal, node: $node, container: container) TerminalSplitContainer(
direction: .horizontal,
neighbors: .empty,
node: $node,
container: container
)
case .vertical(let container): case .vertical(let container):
TerminalSplitContainer(direction: .vertical, node: $node, container: container) TerminalSplitContainer(
direction: .vertical,
neighbors: .empty,
node: $node,
container: container
)
} }
} }
.navigationTitle(surfaceTitle ?? "Ghostty") .navigationTitle(surfaceTitle ?? "Ghostty")
@ -116,6 +159,9 @@ extension Ghostty {
/// The leaf to draw the surface for. /// The leaf to draw the surface for.
let leaf: SplitNode.Leaf let leaf: SplitNode.Leaf
/// The neighbors, used for navigation.
let neighbors: SplitNode.Neighbors
/// The SplitNode that the leaf belongs to. /// The SplitNode that the leaf belongs to.
@Binding var node: SplitNode @Binding var node: SplitNode
@ -161,29 +207,38 @@ extension Ghostty {
node = .vertical(container) node = .vertical(container)
} }
// See fixFocus comment, we have to run this whenever split changes. // See moveFocus comment, we have to run this whenever split changes.
Self.fixFocus(container.bottomRight, previous: node) Self.moveFocus(container.bottomRight, previous: node)
} }
/// This handles the event to move the split focus (i.e. previous/next) from a keyboard event.
private func onMoveFocus(notification: SwiftUI.Notification) { private func onMoveFocus(notification: SwiftUI.Notification) {
// Determine our desired direction // Determine our desired direction
guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return } guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return }
guard let direction = directionAny as? SplitFocusDirection else { return } guard let direction = directionAny as? SplitFocusDirection else { return }
print("MOVE FOCUS: \(direction)") switch (direction) {
case .previous:
guard let next = neighbors.previous else { return }
Self.moveFocus(next, previous: node)
case .next:
guard let next = neighbors.next else { return }
Self.moveFocus(next, previous: node)
}
} }
/// There is a bug I can't figure out where when changing the split state, the terminal view /// There is a bug I can't figure out where when changing the split state, the terminal view
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal /// figure it out so we're going to do this hacky thing to bring focus back to the terminal
/// that should have it. /// that should have it.
fileprivate static func fixFocus(_ target: SplitNode, previous: SplitNode) { fileprivate static func moveFocus(_ target: SplitNode, previous: SplitNode) {
let view = target.preferredFocus() let view = target.preferredFocus()
DispatchQueue.main.async { DispatchQueue.main.async {
// If the callback runs before the surface is attached to a view // If the callback runs before the surface is attached to a view
// then the window will be nil. We just reschedule in that case. // then the window will be nil. We just reschedule in that case.
guard let window = view.window else { guard let window = view.window else {
self.fixFocus(target, previous: previous) self.moveFocus(target, previous: previous)
return return
} }
@ -204,6 +259,7 @@ extension Ghostty {
/// This represents a split view that is in the horizontal or vertical split state. /// This represents a split view that is in the horizontal or vertical split state.
private struct TerminalSplitContainer: View { private struct TerminalSplitContainer: View {
let direction: SplitViewDirection let direction: SplitViewDirection
let neighbors: SplitNode.Neighbors
@Binding var node: SplitNode @Binding var node: SplitNode
@StateObject var container: SplitNode.Container @StateObject var container: SplitNode.Container
@ -212,23 +268,41 @@ extension Ghostty {
var body: some View { var body: some View {
SplitView(direction, left: { SplitView(direction, left: {
TerminalSplitNested(node: $container.topLeft, requestClose: $closeTopLeft) let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.right : \.bottom
.onChange(of: closeTopLeft) { value in
guard value else { return } TerminalSplitNested(
node: $container.topLeft,
// When closing the topLeft, our parent becomes the bottomRight. neighbors: neighbors.update([
node = container.bottomRight neighborKey: container.bottomRight,
TerminalSplitLeaf.fixFocus(node, previous: container.topLeft) \.next: container.bottomRight,
} ]),
requestClose: $closeTopLeft
)
.onChange(of: closeTopLeft) { value in
guard value else { return }
// When closing the topLeft, our parent becomes the bottomRight.
node = container.bottomRight
TerminalSplitLeaf.moveFocus(node, previous: container.topLeft)
}
}, right: { }, right: {
TerminalSplitNested(node: $container.bottomRight, requestClose: $closeBottomRight) let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.left : \.top
.onChange(of: closeBottomRight) { value in
guard value else { return } TerminalSplitNested(
node: $container.bottomRight,
// When closing the bottomRight, our parent becomes the topLeft. neighbors: neighbors.update([
node = container.topLeft neighborKey: container.topLeft,
TerminalSplitLeaf.fixFocus(node, previous: container.bottomRight) \.previous: container.topLeft,
} ]),
requestClose: $closeBottomRight
)
.onChange(of: closeBottomRight) { value in
guard value else { return }
// When closing the bottomRight, our parent becomes the topLeft.
node = container.topLeft
TerminalSplitLeaf.moveFocus(node, previous: container.bottomRight)
}
}) })
} }
} }
@ -237,18 +311,34 @@ extension Ghostty {
/// requires there be a binding to the parent node. /// requires there be a binding to the parent node.
private struct TerminalSplitNested: View { private struct TerminalSplitNested: View {
@Binding var node: SplitNode @Binding var node: SplitNode
let neighbors: SplitNode.Neighbors
@Binding var requestClose: Bool @Binding var requestClose: Bool
var body: some View { var body: some View {
switch (node) { switch (node) {
case .noSplit(let leaf): case .noSplit(let leaf):
TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $requestClose) TerminalSplitLeaf(
leaf: leaf,
neighbors: neighbors,
node: $node,
requestClose: $requestClose
)
case .horizontal(let container): case .horizontal(let container):
TerminalSplitContainer(direction: .horizontal, node: $node, container: container) TerminalSplitContainer(
direction: .horizontal,
neighbors: neighbors,
node: $node,
container: container
)
case .vertical(let container): case .vertical(let container):
TerminalSplitContainer(direction: .vertical, node: $node, container: container) TerminalSplitContainer(
direction: .vertical,
neighbors: neighbors,
node: $node,
container: container
)
} }
} }
} }