Merge pull request #92 from mitchellh/split-nav

macos: split keyboard navigation up/down/left/right and previous/next
This commit is contained in:
Mitchell Hashimoto
2023-03-11 17:58:32 -08:00
committed by GitHub
11 changed files with 340 additions and 47 deletions

View File

@ -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_new_split_cb)(void *, ghostty_split_direction_e);
typedef void (*ghostty_runtime_close_surface_cb)(void *);
typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e);
typedef struct {
void *userdata;
@ -41,6 +42,7 @@ typedef struct {
ghostty_runtime_write_clipboard_cb write_clipboard_cb;
ghostty_runtime_new_split_cb new_split_cb;
ghostty_runtime_close_surface_cb close_surface_cb;
ghostty_runtime_focus_split_cb focus_split_cb;
} ghostty_runtime_config_s;
typedef struct {
@ -54,6 +56,15 @@ typedef enum {
GHOSTTY_SPLIT_DOWN
} 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 {
GHOSTTY_MOUSE_RELEASE,
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_request_close(ghostty_surface_t);
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
}

View File

@ -58,7 +58,9 @@ extension Ghostty {
read_clipboard_cb: { userdata in AppState.readClipboard(userdata) },
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))) },
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.
@ -92,6 +94,10 @@ extension Ghostty {
ghostty_surface_split(surface, direction)
}
func splitMoveFocus(surface: ghostty_surface_t, direction: SplitFocusDirection) {
ghostty_surface_split_focus(surface, direction.toNative())
}
// MARK: Ghostty Callbacks
static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e) {
@ -106,6 +112,18 @@ extension Ghostty {
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>? {
guard let appState = self.appState(fromSurface: userdata) else { return nil }
guard let str = NSPasteboard.general.string(forType: .string) else { return nil }

View File

@ -73,6 +73,49 @@ extension Ghostty {
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
@ -93,18 +136,33 @@ extension Ghostty {
ZStack {
switch (node) {
case .noSplit(let leaf):
TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $requestClose)
.onChange(of: requestClose) { value in
guard value else { return }
guard let onClose = self.onClose else { return }
onClose()
}
TerminalSplitLeaf(
leaf: leaf,
neighbors: .empty,
node: $node,
requestClose: $requestClose
)
.onChange(of: requestClose) { value in
guard value else { return }
guard let onClose = self.onClose else { return }
onClose()
}
case .horizontal(let container):
TerminalSplitContainer(direction: .horizontal, node: $node, container: container)
TerminalSplitContainer(
direction: .horizontal,
neighbors: .empty,
node: $node,
container: container
)
case .vertical(let container):
TerminalSplitContainer(direction: .vertical, node: $node, container: container)
TerminalSplitContainer(
direction: .vertical,
neighbors: .empty,
node: $node,
container: container
)
}
}
.navigationTitle(surfaceTitle ?? "Ghostty")
@ -116,6 +174,9 @@ extension Ghostty {
/// The leaf to draw the surface for.
let leaf: SplitNode.Leaf
/// The neighbors, used for navigation.
let neighbors: SplitNode.Neighbors
/// The SplitNode that the leaf belongs to.
@Binding var node: SplitNode
@ -123,11 +184,14 @@ extension Ghostty {
@Binding var requestClose: Bool
var body: some View {
let pub = NotificationCenter.default.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface)
let pubClose = NotificationCenter.default.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface)
let center = NotificationCenter.default
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)
.onReceive(pub) { onNewSplit(notification: $0) }
.onReceive(pubClose) { _ in requestClose = true }
.onReceive(pubFocus) { onMoveFocus(notification: $0) }
}
private func onNewSplit(notification: SwiftUI.Notification) {
@ -158,22 +222,31 @@ extension Ghostty {
node = .vertical(container)
}
// See fixFocus comment, we have to run this whenever split changes.
Self.fixFocus(container.bottomRight, previous: node)
// See moveFocus comment, we have to run this whenever split changes.
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
/// 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
/// that should have it.
fileprivate static func fixFocus(_ target: SplitNode, previous: SplitNode) {
fileprivate static func moveFocus(_ target: SplitNode, previous: SplitNode) {
let view = target.preferredFocus()
DispatchQueue.main.async {
// If the callback runs before the surface is attached to a view
// then the window will be nil. We just reschedule in that case.
guard let window = view.window else {
self.fixFocus(target, previous: previous)
self.moveFocus(target, previous: previous)
return
}
@ -194,6 +267,7 @@ extension Ghostty {
/// This represents a split view that is in the horizontal or vertical split state.
private struct TerminalSplitContainer: View {
let direction: SplitViewDirection
let neighbors: SplitNode.Neighbors
@Binding var node: SplitNode
@StateObject var container: SplitNode.Container
@ -202,23 +276,41 @@ extension Ghostty {
var body: some View {
SplitView(direction, left: {
TerminalSplitNested(node: $container.topLeft, requestClose: $closeTopLeft)
.onChange(of: closeTopLeft) { value in
guard value else { return }
// When closing the topLeft, our parent becomes the bottomRight.
node = container.bottomRight
TerminalSplitLeaf.fixFocus(node, previous: container.topLeft)
}
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.right : \.bottom
TerminalSplitNested(
node: $container.topLeft,
neighbors: neighbors.update([
neighborKey: container.bottomRight,
\.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: {
TerminalSplitNested(node: $container.bottomRight, requestClose: $closeBottomRight)
.onChange(of: closeBottomRight) { value in
guard value else { return }
// When closing the bottomRight, our parent becomes the topLeft.
node = container.topLeft
TerminalSplitLeaf.fixFocus(node, previous: container.bottomRight)
}
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.left : \.top
TerminalSplitNested(
node: $container.bottomRight,
neighbors: neighbors.update([
neighborKey: container.topLeft,
\.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.
private struct TerminalSplitNested: View {
@Binding var node: SplitNode
let neighbors: SplitNode.Neighbors
@Binding var requestClose: Bool
var body: some View {
switch (node) {
case .noSplit(let leaf):
TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $requestClose)
TerminalSplitLeaf(
leaf: leaf,
neighbors: neighbors,
node: $node,
requestClose: $requestClose
)
case .horizontal(let container):
TerminalSplitContainer(direction: .horizontal, node: $node, container: container)
TerminalSplitContainer(
direction: .horizontal,
neighbors: neighbors,
node: $node,
container: container
)
case .vertical(let container):
TerminalSplitContainer(direction: .vertical, node: $node, container: container)
TerminalSplitContainer(
direction: .vertical,
neighbors: neighbors,
node: $node,
container: container
)
}
}
}

View File

@ -1,4 +1,77 @@
import SwiftUI
import GhosttyKit
struct Ghostty {
// All the notifications that will be emitted will be put here.
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
}

View File

@ -509,18 +509,6 @@ extension Ghostty {
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

View File

@ -37,6 +37,26 @@ struct GhosttyApp: App {
Button("Close", action: close).keyboardShortcut("w", modifiers: [.command])
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 {
@ -76,6 +96,12 @@ struct GhosttyApp: App {
guard let surface = surfaceView.surface else { return }
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 {

View File

@ -947,6 +947,12 @@ pub fn keyCallback(
} 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 => {
if (@hasDecl(apprt.Surface, "closeSurface")) {
try self.rt_surface.closeSurface();

View File

@ -51,6 +51,9 @@ pub const App = struct {
/// Close the current surface given by this function.
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,
@ -166,13 +169,22 @@ pub const Surface = struct {
pub fn closeSurface(self: *const Surface) !void {
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;
};
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 {
return self.content_scale;
}
@ -481,4 +493,9 @@ pub const CAPI = struct {
export fn ghostty_surface_split(ptr: *Surface, direction: input.SplitDirection) void {
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);
}
};

View File

@ -311,6 +311,36 @@ pub const Config = struct {
.{ .key = .d, .mods = .{ .super = true, .shift = true } },
.{ .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
const start = @enumToInt(inputpkg.Key.one);

View File

@ -4,6 +4,7 @@ pub usingnamespace @import("input/mouse.zig");
pub usingnamespace @import("input/key.zig");
pub const Binding = @import("input/Binding.zig");
pub const SplitDirection = Binding.Action.SplitDirection;
pub const SplitFocusDirection = Binding.Action.SplitFocusDirection;
test {
std.testing.refAllDecls(@This());

View File

@ -184,6 +184,9 @@ pub const Action = union(enum) {
/// in the direction given.
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,
/// etc. This only closes ONE surface.
close_surface: void,
@ -207,6 +210,17 @@ pub const Action = union(enum) {
// 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.