mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-25 13:16:11 +03:00
Merge pull request #92 from mitchellh/split-nav
macos: split keyboard navigation up/down/left/right and previous/next
This commit is contained in:
@ -32,6 +32,7 @@ typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *);
|
|||||||
typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *);
|
typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *);
|
||||||
typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e);
|
typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e);
|
||||||
typedef void (*ghostty_runtime_close_surface_cb)(void *);
|
typedef void (*ghostty_runtime_close_surface_cb)(void *);
|
||||||
|
typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e);
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
void *userdata;
|
void *userdata;
|
||||||
@ -41,6 +42,7 @@ typedef struct {
|
|||||||
ghostty_runtime_write_clipboard_cb write_clipboard_cb;
|
ghostty_runtime_write_clipboard_cb write_clipboard_cb;
|
||||||
ghostty_runtime_new_split_cb new_split_cb;
|
ghostty_runtime_new_split_cb new_split_cb;
|
||||||
ghostty_runtime_close_surface_cb close_surface_cb;
|
ghostty_runtime_close_surface_cb close_surface_cb;
|
||||||
|
ghostty_runtime_focus_split_cb focus_split_cb;
|
||||||
} ghostty_runtime_config_s;
|
} ghostty_runtime_config_s;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@ -54,6 +56,15 @@ typedef enum {
|
|||||||
GHOSTTY_SPLIT_DOWN
|
GHOSTTY_SPLIT_DOWN
|
||||||
} ghostty_split_direction_e;
|
} ghostty_split_direction_e;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
GHOSTTY_SPLIT_FOCUS_PREVIOUS,
|
||||||
|
GHOSTTY_SPLIT_FOCUS_NEXT,
|
||||||
|
GHOSTTY_SPLIT_FOCUS_TOP,
|
||||||
|
GHOSTTY_SPLIT_FOCUS_LEFT,
|
||||||
|
GHOSTTY_SPLIT_FOCUS_BOTTOM,
|
||||||
|
GHOSTTY_SPLIT_FOCUS_RIGHT,
|
||||||
|
} ghostty_split_focus_direction_e;
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
GHOSTTY_MOUSE_RELEASE,
|
GHOSTTY_MOUSE_RELEASE,
|
||||||
GHOSTTY_MOUSE_PRESS,
|
GHOSTTY_MOUSE_PRESS,
|
||||||
@ -254,6 +265,7 @@ void ghostty_surface_mouse_scroll(ghostty_surface_t, double, double);
|
|||||||
void ghostty_surface_ime_point(ghostty_surface_t, double *, double *);
|
void ghostty_surface_ime_point(ghostty_surface_t, double *, double *);
|
||||||
void ghostty_surface_request_close(ghostty_surface_t);
|
void ghostty_surface_request_close(ghostty_surface_t);
|
||||||
void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e);
|
void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e);
|
||||||
|
void ghostty_surface_split_focus(ghostty_surface_t, ghostty_split_focus_direction_e);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,9 @@ extension Ghostty {
|
|||||||
read_clipboard_cb: { userdata in AppState.readClipboard(userdata) },
|
read_clipboard_cb: { userdata in AppState.readClipboard(userdata) },
|
||||||
write_clipboard_cb: { userdata, str in AppState.writeClipboard(userdata, string: str) },
|
write_clipboard_cb: { userdata, str in AppState.writeClipboard(userdata, string: str) },
|
||||||
new_split_cb: { userdata, direction in AppState.newSplit(userdata, direction: ghostty_split_direction_e(UInt32(direction))) },
|
new_split_cb: { userdata, direction in AppState.newSplit(userdata, direction: ghostty_split_direction_e(UInt32(direction))) },
|
||||||
close_surface_cb: { userdata in AppState.closeSurface(userdata) }
|
close_surface_cb: { userdata in AppState.closeSurface(userdata) },
|
||||||
|
focus_split_cb: { userdata, direction in
|
||||||
|
AppState.focusSplit(userdata, direction: ghostty_split_focus_direction_e(UInt32(direction))) }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create the ghostty app.
|
// Create the ghostty app.
|
||||||
@ -92,6 +94,10 @@ extension Ghostty {
|
|||||||
ghostty_surface_split(surface, direction)
|
ghostty_surface_split(surface, direction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitMoveFocus(surface: ghostty_surface_t, direction: SplitFocusDirection) {
|
||||||
|
ghostty_surface_split_focus(surface, direction.toNative())
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Ghostty Callbacks
|
// MARK: Ghostty Callbacks
|
||||||
|
|
||||||
static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e) {
|
static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e) {
|
||||||
@ -106,6 +112,18 @@ extension Ghostty {
|
|||||||
NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface)
|
NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) {
|
||||||
|
guard let surface = self.surfaceUserdata(from: userdata) else { return }
|
||||||
|
guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return }
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: Notification.ghosttyFocusSplit,
|
||||||
|
object: surface,
|
||||||
|
userInfo: [
|
||||||
|
Notification.SplitDirectionKey: splitDirection,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
static func readClipboard(_ userdata: UnsafeMutableRawPointer?) -> UnsafePointer<CChar>? {
|
static func readClipboard(_ userdata: UnsafeMutableRawPointer?) -> UnsafePointer<CChar>? {
|
||||||
guard let appState = self.appState(fromSurface: userdata) else { return nil }
|
guard let appState = self.appState(fromSurface: userdata) else { return nil }
|
||||||
guard let str = NSPasteboard.general.string(forType: .string) else { return nil }
|
guard let str = NSPasteboard.general.string(forType: .string) else { return nil }
|
||||||
|
@ -73,6 +73,49 @@ 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()
|
||||||
|
|
||||||
|
/// Get the node for a given direction.
|
||||||
|
func get(direction: SplitFocusDirection) -> SplitNode? {
|
||||||
|
let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
|
||||||
|
.previous: \.previous,
|
||||||
|
.next: \.next,
|
||||||
|
.top: \.top,
|
||||||
|
.bottom: \.bottom,
|
||||||
|
.left: \.left,
|
||||||
|
.right: \.right,
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let path = map[direction] else { return nil }
|
||||||
|
return self[keyPath: path]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 +136,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 +174,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
|
||||||
|
|
||||||
@ -123,11 +184,14 @@ extension Ghostty {
|
|||||||
@Binding var requestClose: Bool
|
@Binding var requestClose: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let pub = NotificationCenter.default.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface)
|
let center = NotificationCenter.default
|
||||||
let pubClose = NotificationCenter.default.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface)
|
let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface)
|
||||||
|
let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface)
|
||||||
|
let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface)
|
||||||
SurfaceWrapper(surfaceView: leaf.surface)
|
SurfaceWrapper(surfaceView: leaf.surface)
|
||||||
.onReceive(pub) { onNewSplit(notification: $0) }
|
.onReceive(pub) { onNewSplit(notification: $0) }
|
||||||
.onReceive(pubClose) { _ in requestClose = true }
|
.onReceive(pubClose) { _ in requestClose = true }
|
||||||
|
.onReceive(pubFocus) { onMoveFocus(notification: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func onNewSplit(notification: SwiftUI.Notification) {
|
private func onNewSplit(notification: SwiftUI.Notification) {
|
||||||
@ -158,22 +222,31 @@ 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) {
|
||||||
|
// Determine our desired direction
|
||||||
|
guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return }
|
||||||
|
guard let direction = directionAny as? SplitFocusDirection else { return }
|
||||||
|
guard let next = neighbors.get(direction: direction) 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,6 +267,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
|
||||||
|
|
||||||
@ -202,23 +276,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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -227,18 +319,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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,77 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import GhosttyKit
|
||||||
|
|
||||||
struct Ghostty {
|
struct Ghostty {
|
||||||
// All the notifications that will be emitted will be put here.
|
// All the notifications that will be emitted will be put here.
|
||||||
struct Notification {}
|
struct Notification {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Surface Notifications
|
||||||
|
|
||||||
|
extension Ghostty {
|
||||||
|
/// An enum that is used for the directions that a split focus event can change.
|
||||||
|
enum SplitFocusDirection {
|
||||||
|
case previous, next, top, bottom, left, right
|
||||||
|
|
||||||
|
/// Initialize from a Ghostty API enum.
|
||||||
|
static func from(direction: ghostty_split_focus_direction_e) -> Self? {
|
||||||
|
switch (direction) {
|
||||||
|
case GHOSTTY_SPLIT_FOCUS_PREVIOUS:
|
||||||
|
return .previous
|
||||||
|
|
||||||
|
case GHOSTTY_SPLIT_FOCUS_NEXT:
|
||||||
|
return .next
|
||||||
|
|
||||||
|
case GHOSTTY_SPLIT_FOCUS_TOP:
|
||||||
|
return .top
|
||||||
|
|
||||||
|
case GHOSTTY_SPLIT_FOCUS_BOTTOM:
|
||||||
|
return .bottom
|
||||||
|
|
||||||
|
case GHOSTTY_SPLIT_FOCUS_LEFT:
|
||||||
|
return .left
|
||||||
|
|
||||||
|
case GHOSTTY_SPLIT_FOCUS_RIGHT:
|
||||||
|
return .right
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toNative() -> ghostty_split_focus_direction_e {
|
||||||
|
switch (self) {
|
||||||
|
case .previous:
|
||||||
|
return GHOSTTY_SPLIT_FOCUS_PREVIOUS
|
||||||
|
|
||||||
|
case .next:
|
||||||
|
return GHOSTTY_SPLIT_FOCUS_NEXT
|
||||||
|
|
||||||
|
case .top:
|
||||||
|
return GHOSTTY_SPLIT_FOCUS_TOP
|
||||||
|
|
||||||
|
case .bottom:
|
||||||
|
return GHOSTTY_SPLIT_FOCUS_BOTTOM
|
||||||
|
|
||||||
|
case .left:
|
||||||
|
return GHOSTTY_SPLIT_FOCUS_LEFT
|
||||||
|
|
||||||
|
case .right:
|
||||||
|
return GHOSTTY_SPLIT_FOCUS_RIGHT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Ghostty.Notification {
|
||||||
|
/// Posted when a new split is requested. The sending object will be the surface that had focus. The
|
||||||
|
/// userdata has one key "direction" with the direction to split to.
|
||||||
|
static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit")
|
||||||
|
|
||||||
|
/// Close the calling surface.
|
||||||
|
static let ghosttyCloseSurface = Notification.Name("com.mitchellh.ghostty.closeSurface")
|
||||||
|
|
||||||
|
/// Focus previous/next split. Has a SplitFocusDirection in the userinfo.
|
||||||
|
static let ghosttyFocusSplit = Notification.Name("com.mitchellh.ghostty.focusSplit")
|
||||||
|
static let SplitDirectionKey = ghosttyFocusSplit.rawValue
|
||||||
|
}
|
||||||
|
@ -509,18 +509,6 @@ extension Ghostty {
|
|||||||
0x4E: GHOSTTY_KEY_KP_SUBTRACT,
|
0x4E: GHOSTTY_KEY_KP_SUBTRACT,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Surface Notifications
|
|
||||||
|
|
||||||
extension Ghostty.Notification {
|
|
||||||
/// Posted when a new split is requested. The sending object will be the surface that had focus. The
|
|
||||||
/// userdata has one key "direction" with the direction to split to.
|
|
||||||
static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit")
|
|
||||||
|
|
||||||
/// Close the calling surface.
|
|
||||||
static let ghosttyCloseSurface = Notification.Name("com.mitchellh.ghostty.closeSurface")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Surface Environment Keys
|
// MARK: Surface Environment Keys
|
||||||
|
@ -37,6 +37,26 @@ struct GhosttyApp: App {
|
|||||||
Button("Close", action: close).keyboardShortcut("w", modifiers: [.command])
|
Button("Close", action: close).keyboardShortcut("w", modifiers: [.command])
|
||||||
Button("Close Window", action: Self.closeWindow).keyboardShortcut("w", modifiers: [.command, .shift])
|
Button("Close Window", action: Self.closeWindow).keyboardShortcut("w", modifiers: [.command, .shift])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CommandGroup(before: .windowArrangement) {
|
||||||
|
Divider()
|
||||||
|
Button("Select Previous Split") { splitMoveFocus(direction: .previous) }
|
||||||
|
.keyboardShortcut("[", modifiers: .command)
|
||||||
|
Button("Select Next Split") { splitMoveFocus(direction: .next) }
|
||||||
|
.keyboardShortcut("]", modifiers: .command)
|
||||||
|
Menu("Select Split") {
|
||||||
|
Button("Select Split Above") { splitMoveFocus(direction: .top) }
|
||||||
|
.keyboardShortcut(.upArrow, modifiers: [.command, .option])
|
||||||
|
Button("Select Split Below") { splitMoveFocus(direction: .bottom) }
|
||||||
|
.keyboardShortcut(.downArrow, modifiers: [.command, .option])
|
||||||
|
Button("Select Split Left") { splitMoveFocus(direction: .left) }
|
||||||
|
.keyboardShortcut(.leftArrow, modifiers: [.command, .option])
|
||||||
|
Button("Select Split Right") { splitMoveFocus(direction: .right)}
|
||||||
|
.keyboardShortcut(.rightArrow, modifiers: [.command, .option])
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings {
|
Settings {
|
||||||
@ -76,6 +96,12 @@ struct GhosttyApp: App {
|
|||||||
guard let surface = surfaceView.surface else { return }
|
guard let surface = surfaceView.surface else { return }
|
||||||
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
|
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
|
||||||
|
guard let surfaceView = focusedSurface else { return }
|
||||||
|
guard let surface = surfaceView.surface else { return }
|
||||||
|
ghostty.splitMoveFocus(surface: surface, direction: direction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
@ -947,6 +947,12 @@ pub fn keyCallback(
|
|||||||
} else log.warn("runtime doesn't implement newSplit", .{});
|
} else log.warn("runtime doesn't implement newSplit", .{});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
.goto_split => |direction| {
|
||||||
|
if (@hasDecl(apprt.Surface, "gotoSplit")) {
|
||||||
|
self.rt_surface.gotoSplit(direction);
|
||||||
|
} else log.warn("runtime doesn't implement gotoSplit", .{});
|
||||||
|
},
|
||||||
|
|
||||||
.close_surface => {
|
.close_surface => {
|
||||||
if (@hasDecl(apprt.Surface, "closeSurface")) {
|
if (@hasDecl(apprt.Surface, "closeSurface")) {
|
||||||
try self.rt_surface.closeSurface();
|
try self.rt_surface.closeSurface();
|
||||||
|
@ -51,6 +51,9 @@ pub const App = struct {
|
|||||||
|
|
||||||
/// Close the current surface given by this function.
|
/// Close the current surface given by this function.
|
||||||
close_surface: ?*const fn (SurfaceUD) callconv(.C) void = null,
|
close_surface: ?*const fn (SurfaceUD) callconv(.C) void = null,
|
||||||
|
|
||||||
|
/// Focus the previous/next split (if any).
|
||||||
|
focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
core_app: *CoreApp,
|
core_app: *CoreApp,
|
||||||
@ -166,13 +169,22 @@ pub const Surface = struct {
|
|||||||
|
|
||||||
pub fn closeSurface(self: *const Surface) !void {
|
pub fn closeSurface(self: *const Surface) !void {
|
||||||
const func = self.app.opts.close_surface orelse {
|
const func = self.app.opts.close_surface orelse {
|
||||||
log.info("runtime embedder does not closing a surface", .{});
|
log.info("runtime embedder does not support closing a surface", .{});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
func(self.opts.userdata);
|
func(self.opts.userdata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn gotoSplit(self: *const Surface, direction: input.SplitFocusDirection) void {
|
||||||
|
const func = self.app.opts.focus_split orelse {
|
||||||
|
log.info("runtime embedder does not support focus split", .{});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
func(self.opts.userdata, direction);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
|
pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
|
||||||
return self.content_scale;
|
return self.content_scale;
|
||||||
}
|
}
|
||||||
@ -481,4 +493,9 @@ pub const CAPI = struct {
|
|||||||
export fn ghostty_surface_split(ptr: *Surface, direction: input.SplitDirection) void {
|
export fn ghostty_surface_split(ptr: *Surface, direction: input.SplitDirection) void {
|
||||||
ptr.newSplit(direction) catch {};
|
ptr.newSplit(direction) catch {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Focus on the next split (if any).
|
||||||
|
export fn ghostty_surface_split_focus(ptr: *Surface, direction: input.SplitFocusDirection) void {
|
||||||
|
ptr.gotoSplit(direction);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
@ -311,6 +311,36 @@ pub const Config = struct {
|
|||||||
.{ .key = .d, .mods = .{ .super = true, .shift = true } },
|
.{ .key = .d, .mods = .{ .super = true, .shift = true } },
|
||||||
.{ .new_split = .down },
|
.{ .new_split = .down },
|
||||||
);
|
);
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .left_bracket, .mods = .{ .super = true } },
|
||||||
|
.{ .goto_split = .previous },
|
||||||
|
);
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .right_bracket, .mods = .{ .super = true } },
|
||||||
|
.{ .goto_split = .next },
|
||||||
|
);
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .up, .mods = .{ .super = true, .alt = true } },
|
||||||
|
.{ .goto_split = .top },
|
||||||
|
);
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .down, .mods = .{ .super = true, .alt = true } },
|
||||||
|
.{ .goto_split = .bottom },
|
||||||
|
);
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .left, .mods = .{ .super = true, .alt = true } },
|
||||||
|
.{ .goto_split = .left },
|
||||||
|
);
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .right, .mods = .{ .super = true, .alt = true } },
|
||||||
|
.{ .goto_split = .right },
|
||||||
|
);
|
||||||
{
|
{
|
||||||
// Cmd+N for goto tab N
|
// Cmd+N for goto tab N
|
||||||
const start = @enumToInt(inputpkg.Key.one);
|
const start = @enumToInt(inputpkg.Key.one);
|
||||||
|
@ -4,6 +4,7 @@ pub usingnamespace @import("input/mouse.zig");
|
|||||||
pub usingnamespace @import("input/key.zig");
|
pub usingnamespace @import("input/key.zig");
|
||||||
pub const Binding = @import("input/Binding.zig");
|
pub const Binding = @import("input/Binding.zig");
|
||||||
pub const SplitDirection = Binding.Action.SplitDirection;
|
pub const SplitDirection = Binding.Action.SplitDirection;
|
||||||
|
pub const SplitFocusDirection = Binding.Action.SplitFocusDirection;
|
||||||
|
|
||||||
test {
|
test {
|
||||||
std.testing.refAllDecls(@This());
|
std.testing.refAllDecls(@This());
|
||||||
|
@ -184,6 +184,9 @@ pub const Action = union(enum) {
|
|||||||
/// in the direction given.
|
/// in the direction given.
|
||||||
new_split: SplitDirection,
|
new_split: SplitDirection,
|
||||||
|
|
||||||
|
/// Focus on a split in a given direction.
|
||||||
|
goto_split: SplitFocusDirection,
|
||||||
|
|
||||||
/// Close the current "surface", whether that is a window, tab, split,
|
/// Close the current "surface", whether that is a window, tab, split,
|
||||||
/// etc. This only closes ONE surface.
|
/// etc. This only closes ONE surface.
|
||||||
close_surface: void,
|
close_surface: void,
|
||||||
@ -207,6 +210,17 @@ pub const Action = union(enum) {
|
|||||||
|
|
||||||
// Note: we don't support top or left yet
|
// Note: we don't support top or left yet
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extern because it is used in the embedded runtime ABI.
|
||||||
|
pub const SplitFocusDirection = enum(c_int) {
|
||||||
|
previous,
|
||||||
|
next,
|
||||||
|
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
bottom,
|
||||||
|
right,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Trigger is the associated key state that can trigger an action.
|
/// Trigger is the associated key state that can trigger an action.
|
||||||
|
Reference in New Issue
Block a user