mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
Merge pull request #822 from gpanders/split-resizing
macos: implement split resizing
This commit is contained in:
@ -49,6 +49,13 @@ typedef enum {
|
||||
GHOSTTY_SPLIT_FOCUS_RIGHT,
|
||||
} ghostty_split_focus_direction_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_SPLIT_RESIZE_UP,
|
||||
GHOSTTY_SPLIT_RESIZE_DOWN,
|
||||
GHOSTTY_SPLIT_RESIZE_LEFT,
|
||||
GHOSTTY_SPLIT_RESIZE_RIGHT,
|
||||
} ghostty_split_resize_direction_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_INSPECTOR_TOGGLE,
|
||||
GHOSTTY_INSPECTOR_SHOW,
|
||||
@ -333,6 +340,7 @@ typedef void (*ghostty_runtime_new_window_cb)(void *, ghostty_surface_config_s);
|
||||
typedef void (*ghostty_runtime_control_inspector_cb)(void *, ghostty_inspector_mode_e);
|
||||
typedef void (*ghostty_runtime_close_surface_cb)(void *, bool);
|
||||
typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e);
|
||||
typedef void (*ghostty_runtime_resize_split_cb)(void *, ghostty_split_resize_direction_e, uint16_t);
|
||||
typedef void (*ghostty_runtime_toggle_split_zoom_cb)(void *);
|
||||
typedef void (*ghostty_runtime_goto_tab_cb)(void *, int32_t);
|
||||
typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *, ghostty_non_native_fullscreen_e);
|
||||
@ -357,6 +365,7 @@ typedef struct {
|
||||
ghostty_runtime_control_inspector_cb control_inspector_cb;
|
||||
ghostty_runtime_close_surface_cb close_surface_cb;
|
||||
ghostty_runtime_focus_split_cb focus_split_cb;
|
||||
ghostty_runtime_resize_split_cb resize_split_cb;
|
||||
ghostty_runtime_toggle_split_zoom_cb toggle_split_zoom_cb;
|
||||
ghostty_runtime_goto_tab_cb goto_tab_cb;
|
||||
ghostty_runtime_toggle_fullscreen_cb toggle_fullscreen_cb;
|
||||
@ -412,6 +421,7 @@ 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);
|
||||
void ghostty_surface_split_resize(ghostty_surface_t, ghostty_split_resize_direction_e, uint16_t);
|
||||
bool ghostty_surface_binding_action(ghostty_surface_t, const char *, uintptr_t);
|
||||
void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char *, void *, bool);
|
||||
|
||||
|
@ -38,6 +38,11 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
|
||||
@IBOutlet private var menuResetFontSize: NSMenuItem?
|
||||
@IBOutlet private var menuTerminalInspector: NSMenuItem?
|
||||
|
||||
@IBOutlet private var menuMoveSplitDividerUp: NSMenuItem?
|
||||
@IBOutlet private var menuMoveSplitDividerDown: NSMenuItem?
|
||||
@IBOutlet private var menuMoveSplitDividerLeft: NSMenuItem?
|
||||
@IBOutlet private var menuMoveSplitDividerRight: NSMenuItem?
|
||||
|
||||
/// The dock menu
|
||||
private var dockMenu: NSMenu = NSMenu()
|
||||
|
||||
@ -206,6 +211,10 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
|
||||
syncMenuShortcut(action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow)
|
||||
syncMenuShortcut(action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
|
||||
syncMenuShortcut(action: "goto_split:right", menuItem: self.menuSelectSplitRight)
|
||||
syncMenuShortcut(action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
|
||||
syncMenuShortcut(action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown)
|
||||
syncMenuShortcut(action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight)
|
||||
syncMenuShortcut(action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft)
|
||||
|
||||
syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
|
||||
syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
|
||||
|
@ -40,7 +40,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
|
||||
// Initialize our initial surface.
|
||||
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
|
||||
self.surfaceTree = .noSplit(.init(ghostty_app, base))
|
||||
self.surfaceTree = .leaf(.init(ghostty_app, base))
|
||||
|
||||
// Setup our notifications for behaviors
|
||||
let center = NotificationCenter.default
|
||||
@ -270,7 +270,27 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
@IBAction func splitMoveFocusRight(_ sender: Any) {
|
||||
splitMoveFocus(direction: .right)
|
||||
}
|
||||
|
||||
|
||||
@IBAction func moveSplitDividerUp(_ sender: Any) {
|
||||
guard let surface = focusedSurface?.surface else { return }
|
||||
ghostty.splitResize(surface: surface, direction: .up, amount: 10)
|
||||
}
|
||||
|
||||
@IBAction func moveSplitDividerDown(_ sender: Any) {
|
||||
guard let surface = focusedSurface?.surface else { return }
|
||||
ghostty.splitResize(surface: surface, direction: .down, amount: 10)
|
||||
}
|
||||
|
||||
@IBAction func moveSplitDividerLeft(_ sender: Any) {
|
||||
guard let surface = focusedSurface?.surface else { return }
|
||||
ghostty.splitResize(surface: surface, direction: .left, amount: 10)
|
||||
}
|
||||
|
||||
@IBAction func moveSplitDividerRight(_ sender: Any) {
|
||||
guard let surface = focusedSurface?.surface else { return }
|
||||
ghostty.splitResize(surface: surface, direction: .right, amount: 10)
|
||||
}
|
||||
|
||||
private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
|
||||
guard let surface = focusedSurface?.surface else { return }
|
||||
ghostty.splitMoveFocus(surface: surface, direction: direction)
|
||||
|
@ -84,8 +84,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
||||
}
|
||||
|
||||
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
|
||||
.ghosttyApp(ghostty.app!)
|
||||
.ghosttyConfig(ghostty.config!)
|
||||
.environmentObject(ghostty)
|
||||
.focused($focused)
|
||||
.onAppear { self.focused = true }
|
||||
.onChange(of: focusedSurface) { newValue in
|
||||
|
@ -158,6 +158,8 @@ extension Ghostty {
|
||||
control_inspector_cb: { userdata, mode in AppState.controlInspector(userdata, mode: mode) },
|
||||
close_surface_cb: { userdata, processAlive in AppState.closeSurface(userdata, processAlive: processAlive) },
|
||||
focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) },
|
||||
resize_split_cb: { userdata, direction, amount in
|
||||
AppState.resizeSplit(userdata, direction: direction, amount: amount) },
|
||||
toggle_split_zoom_cb: { userdata in AppState.toggleSplitZoom(userdata) },
|
||||
goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) },
|
||||
toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) },
|
||||
@ -292,6 +294,10 @@ extension Ghostty {
|
||||
ghostty_surface_split_focus(surface, direction.toNative())
|
||||
}
|
||||
|
||||
func splitResize(surface: ghostty_surface_t, direction: SplitResizeDirection, amount: UInt16) {
|
||||
ghostty_surface_split_resize(surface, direction.toNative(), amount)
|
||||
}
|
||||
|
||||
func splitToggleZoom(surface: ghostty_surface_t) {
|
||||
let action = "toggle_split_zoom"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
@ -364,6 +370,19 @@ extension Ghostty {
|
||||
)
|
||||
}
|
||||
|
||||
static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) {
|
||||
guard let surface = self.surfaceUserdata(from: userdata) else { return }
|
||||
guard let resizeDirection = SplitResizeDirection.from(direction: direction) else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didResizeSplit,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.ResizeSplitDirectionKey: resizeDirection,
|
||||
Notification.ResizeSplitAmountKey: amount,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) {
|
||||
guard let surface = self.surfaceUserdata(from: userdata) else { return }
|
||||
|
||||
@ -572,35 +591,3 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: AppState Environment Keys
|
||||
|
||||
private struct GhosttyAppKey: EnvironmentKey {
|
||||
static let defaultValue: ghostty_app_t? = nil
|
||||
}
|
||||
|
||||
private struct GhosttyConfigKey: EnvironmentKey {
|
||||
static let defaultValue: ghostty_config_t? = nil
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var ghosttyApp: ghostty_app_t? {
|
||||
get { self[GhosttyAppKey.self] }
|
||||
set { self[GhosttyAppKey.self] = newValue }
|
||||
}
|
||||
|
||||
var ghosttyConfig: ghostty_config_t? {
|
||||
get { self[GhosttyConfigKey.self] }
|
||||
set { self[GhosttyConfigKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func ghosttyApp(_ app: ghostty_app_t?) -> some View {
|
||||
environment(\.ghosttyApp, app)
|
||||
}
|
||||
|
||||
func ghosttyConfig(_ config: ghostty_config_t?) -> some View {
|
||||
environment(\.ghosttyConfig, config)
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import GhosttyKit
|
||||
|
||||
extension Ghostty {
|
||||
@ -11,9 +12,8 @@ extension Ghostty {
|
||||
/// values can further be split infinitely.
|
||||
///
|
||||
enum SplitNode: Equatable, Hashable {
|
||||
case noSplit(Leaf)
|
||||
case horizontal(Container)
|
||||
case vertical(Container)
|
||||
case leaf(Leaf)
|
||||
case split(Container)
|
||||
|
||||
/// Returns the view that would prefer receiving focus in this tree. This is always the
|
||||
/// top-left-most view. This is used when creating a split or closing a split to find the
|
||||
@ -21,14 +21,11 @@ extension Ghostty {
|
||||
func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView {
|
||||
let container: Container
|
||||
switch (self) {
|
||||
case .noSplit(let leaf):
|
||||
case .leaf(let leaf):
|
||||
// noSplit is easy because there is only one thing to focus
|
||||
return leaf.surface
|
||||
|
||||
case .horizontal(let c):
|
||||
container = c
|
||||
|
||||
case .vertical(let c):
|
||||
case .split(let c):
|
||||
container = c
|
||||
}
|
||||
|
||||
@ -48,14 +45,10 @@ extension Ghostty {
|
||||
/// surface. At this point, the surface view in this node tree can never be used again.
|
||||
func close() {
|
||||
switch (self) {
|
||||
case .noSplit(let leaf):
|
||||
case .leaf(let leaf):
|
||||
leaf.surface.close()
|
||||
|
||||
case .horizontal(let container):
|
||||
container.topLeft.close()
|
||||
container.bottomRight.close()
|
||||
|
||||
case .vertical(let container):
|
||||
case .split(let container):
|
||||
container.topLeft.close()
|
||||
container.bottomRight.close()
|
||||
}
|
||||
@ -64,14 +57,10 @@ extension Ghostty {
|
||||
/// Returns true if any surface in the split stack requires quit confirmation.
|
||||
func needsConfirmQuit() -> Bool {
|
||||
switch (self) {
|
||||
case .noSplit(let leaf):
|
||||
case .leaf(let leaf):
|
||||
return leaf.surface.needsConfirmQuit
|
||||
|
||||
case .horizontal(let container):
|
||||
return container.topLeft.needsConfirmQuit() ||
|
||||
container.bottomRight.needsConfirmQuit()
|
||||
|
||||
case .vertical(let container):
|
||||
case .split(let container):
|
||||
return container.topLeft.needsConfirmQuit() ||
|
||||
container.bottomRight.needsConfirmQuit()
|
||||
}
|
||||
@ -80,14 +69,10 @@ extension Ghostty {
|
||||
/// Returns true if the split tree contains the given view.
|
||||
func contains(view: SurfaceView) -> Bool {
|
||||
switch (self) {
|
||||
case .noSplit(let leaf):
|
||||
case .leaf(let leaf):
|
||||
return leaf.surface == view
|
||||
|
||||
case .horizontal(let container):
|
||||
return container.topLeft.contains(view: view) ||
|
||||
container.bottomRight.contains(view: view)
|
||||
|
||||
case .vertical(let container):
|
||||
case .split(let container):
|
||||
return container.topLeft.contains(view: view) ||
|
||||
container.bottomRight.contains(view: view)
|
||||
}
|
||||
@ -97,11 +82,9 @@ extension Ghostty {
|
||||
|
||||
static func == (lhs: SplitNode, rhs: SplitNode) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.noSplit(let lhs_v), .noSplit(let rhs_v)):
|
||||
case (.leaf(let lhs_v), .leaf(let rhs_v)):
|
||||
return lhs_v === rhs_v
|
||||
case (.horizontal(let lhs_v), .horizontal(let rhs_v)):
|
||||
return lhs_v === rhs_v
|
||||
case (.vertical(let lhs_v), .vertical(let rhs_v)):
|
||||
case (.split(let lhs_v), .split(let rhs_v)):
|
||||
return lhs_v === rhs_v
|
||||
default:
|
||||
return false
|
||||
@ -112,6 +95,8 @@ extension Ghostty {
|
||||
let app: ghostty_app_t
|
||||
@Published var surface: SurfaceView
|
||||
|
||||
weak var parent: SplitNode.Container?
|
||||
|
||||
/// Initialize a new leaf which creates a new terminal surface.
|
||||
init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) {
|
||||
self.app = app
|
||||
@ -134,25 +119,63 @@ extension Ghostty {
|
||||
|
||||
class Container: ObservableObject, Equatable, Hashable {
|
||||
let app: ghostty_app_t
|
||||
let direction: SplitViewDirection
|
||||
|
||||
@Published var topLeft: SplitNode
|
||||
@Published var bottomRight: SplitNode
|
||||
|
||||
var resizeEvent: PassthroughSubject<Double, Never> = .init()
|
||||
|
||||
weak var parent: SplitNode.Container?
|
||||
|
||||
/// A container is always initialized from some prior leaf because a split has to originate
|
||||
/// from a non-split value. When initializing, we inherit the leaf's surface and then
|
||||
/// initialize a new surface for the new pane.
|
||||
init(from: Leaf, baseConfig: SurfaceConfiguration? = nil) {
|
||||
init(from: Leaf, direction: SplitViewDirection, baseConfig: SurfaceConfiguration? = nil) {
|
||||
self.app = from.app
|
||||
self.direction = direction
|
||||
self.parent = from.parent
|
||||
|
||||
// Initially, both topLeft and bottomRight are in the "nosplit"
|
||||
// state since this is a new split.
|
||||
self.topLeft = .noSplit(from)
|
||||
self.bottomRight = .noSplit(.init(app, baseConfig))
|
||||
self.topLeft = .leaf(from)
|
||||
|
||||
let bottomRight: Leaf = .init(app, baseConfig)
|
||||
self.bottomRight = .leaf(bottomRight)
|
||||
|
||||
from.parent = self
|
||||
bottomRight.parent = self
|
||||
}
|
||||
|
||||
/// Resize the split by moving the split divider in the given
|
||||
/// direction by the given amount. If this container is not split
|
||||
/// in the given direction, navigate up the tree until we find a
|
||||
/// container that is
|
||||
func resize(direction: SplitResizeDirection, amount: UInt16) {
|
||||
// We send a resize event to our publisher which will be
|
||||
// received by the SplitView.
|
||||
switch (self.direction) {
|
||||
case .horizontal:
|
||||
switch (direction) {
|
||||
case .left: resizeEvent.send(-Double(amount))
|
||||
case .right: resizeEvent.send(Double(amount))
|
||||
default: parent?.resize(direction: direction, amount: amount)
|
||||
}
|
||||
case .vertical:
|
||||
switch (direction) {
|
||||
case .up: resizeEvent.send(-Double(amount))
|
||||
case .down: resizeEvent.send(Double(amount))
|
||||
default: parent?.resize(direction: direction, amount: amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(app)
|
||||
hasher.combine(direction)
|
||||
hasher.combine(topLeft)
|
||||
hasher.combine(bottomRight)
|
||||
}
|
||||
@ -161,6 +184,7 @@ extension Ghostty {
|
||||
|
||||
static func == (lhs: Container, rhs: Container) -> Bool {
|
||||
return lhs.app == rhs.app &&
|
||||
lhs.direction == rhs.direction &&
|
||||
lhs.topLeft == rhs.topLeft &&
|
||||
lhs.bottomRight == rhs.bottomRight
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ extension Ghostty {
|
||||
.focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever
|
||||
/// one of these in a split tree.
|
||||
private struct TerminalSplitRoot: View {
|
||||
@ -61,25 +61,15 @@ extension Ghostty {
|
||||
case nil:
|
||||
Color(.clear)
|
||||
|
||||
case .noSplit(let leaf):
|
||||
case .leaf(let leaf):
|
||||
TerminalSplitLeaf(
|
||||
leaf: leaf,
|
||||
neighbors: .empty,
|
||||
node: $node
|
||||
)
|
||||
|
||||
case .horizontal(let container):
|
||||
case .split(let container):
|
||||
TerminalSplitContainer(
|
||||
direction: .horizontal,
|
||||
neighbors: .empty,
|
||||
node: $node,
|
||||
container: container
|
||||
)
|
||||
.onReceive(pubZoom) { onZoom(notification: $0) }
|
||||
|
||||
case .vertical(let container):
|
||||
TerminalSplitContainer(
|
||||
direction: .vertical,
|
||||
neighbors: .empty,
|
||||
node: $node,
|
||||
container: container
|
||||
@ -105,7 +95,7 @@ extension Ghostty {
|
||||
|
||||
func onZoom(notification: SwiftUI.Notification) {
|
||||
// Our node must be split to receive zooms. You can't zoom an unsplit terminal.
|
||||
if case .noSplit = node {
|
||||
if case .leaf = node {
|
||||
preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist")
|
||||
}
|
||||
|
||||
@ -163,11 +153,13 @@ extension Ghostty {
|
||||
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)
|
||||
let pubResize = center.publisher(for: Notification.didResizeSplit, object: leaf.surface)
|
||||
|
||||
InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty())
|
||||
.onReceive(pub) { onNewSplit(notification: $0) }
|
||||
.onReceive(pubClose) { onClose(notification: $0) }
|
||||
.onReceive(pubFocus) { onMoveFocus(notification: $0) }
|
||||
.onReceive(pubResize) { onResize(notification: $0) }
|
||||
}
|
||||
|
||||
private func onClose(notification: SwiftUI.Notification) {
|
||||
@ -234,16 +226,10 @@ extension Ghostty {
|
||||
}
|
||||
|
||||
// Setup our new container since we are now split
|
||||
let container = SplitNode.Container(from: leaf, baseConfig: config)
|
||||
let container = SplitNode.Container(from: leaf, direction: splitDirection, baseConfig: config)
|
||||
|
||||
// Depending on the direction, change the parent node. This will trigger
|
||||
// the parent to relayout our views.
|
||||
switch (splitDirection) {
|
||||
case .horizontal:
|
||||
node = .horizontal(container)
|
||||
case .vertical:
|
||||
node = .vertical(container)
|
||||
}
|
||||
// Change the parent node. This will trigger the parent to relayout our views.
|
||||
node = .split(container)
|
||||
|
||||
// See moveFocus comment, we have to run this whenever split changes.
|
||||
Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus())
|
||||
@ -259,18 +245,35 @@ extension Ghostty {
|
||||
to: next.preferredFocus(direction)
|
||||
)
|
||||
}
|
||||
|
||||
/// Handle a resize event.
|
||||
private func onResize(notification: SwiftUI.Notification) {
|
||||
// If this leaf is not part of a split then there is nothing to do
|
||||
guard let parent = leaf.parent else { return }
|
||||
|
||||
guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return }
|
||||
guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return }
|
||||
|
||||
guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return }
|
||||
guard let amount = amountAny as? UInt16 else { return }
|
||||
|
||||
parent.resize(direction: direction, amount: amount)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
var body: some View {
|
||||
SplitView(direction, left: {
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.right : \.bottom
|
||||
SplitView(
|
||||
container.direction,
|
||||
resizeIncrements: .init(width: 1, height: 1),
|
||||
resizePublisher: container.resizeEvent,
|
||||
left: {
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.bottom
|
||||
|
||||
TerminalSplitNested(
|
||||
node: closeableTopLeft(),
|
||||
@ -280,8 +283,8 @@ extension Ghostty {
|
||||
])
|
||||
)
|
||||
}, right: {
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.left : \.top
|
||||
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.top
|
||||
|
||||
TerminalSplitNested(
|
||||
node: closeableBottomRight(),
|
||||
neighbors: neighbors.update([
|
||||
@ -304,7 +307,16 @@ extension Ghostty {
|
||||
// Closing
|
||||
container.topLeft.close()
|
||||
node = container.bottomRight
|
||||
|
||||
|
||||
switch (node) {
|
||||
case .leaf(let l):
|
||||
l.parent = container.parent
|
||||
case .split(let c):
|
||||
c.parent = container.parent
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(
|
||||
to: container.bottomRight.preferredFocus(),
|
||||
@ -326,7 +338,16 @@ extension Ghostty {
|
||||
// Closing
|
||||
container.bottomRight.close()
|
||||
node = container.topLeft
|
||||
|
||||
|
||||
switch (node) {
|
||||
case .leaf(let l):
|
||||
l.parent = container.parent
|
||||
case .split(let c):
|
||||
c.parent = container.parent
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(
|
||||
to: container.topLeft.preferredFocus(),
|
||||
@ -350,24 +371,15 @@ extension Ghostty {
|
||||
case nil:
|
||||
Color(.clear)
|
||||
|
||||
case .noSplit(let leaf):
|
||||
case .leaf(let leaf):
|
||||
TerminalSplitLeaf(
|
||||
leaf: leaf,
|
||||
neighbors: neighbors,
|
||||
node: $node
|
||||
)
|
||||
|
||||
case .horizontal(let container):
|
||||
case .split(let container):
|
||||
TerminalSplitContainer(
|
||||
direction: .horizontal,
|
||||
neighbors: neighbors,
|
||||
node: $node,
|
||||
container: container
|
||||
)
|
||||
|
||||
case .vertical(let container):
|
||||
TerminalSplitContainer(
|
||||
direction: .vertical,
|
||||
neighbors: neighbors,
|
||||
node: $node,
|
||||
container: container
|
||||
|
@ -61,6 +61,39 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum used for resizing splits. This is the direction the split divider will move.
|
||||
enum SplitResizeDirection {
|
||||
case up, down, left, right
|
||||
|
||||
static func from(direction: ghostty_split_resize_direction_e) -> Self? {
|
||||
switch (direction) {
|
||||
case GHOSTTY_SPLIT_RESIZE_UP:
|
||||
return .up;
|
||||
case GHOSTTY_SPLIT_RESIZE_DOWN:
|
||||
return .down;
|
||||
case GHOSTTY_SPLIT_RESIZE_LEFT:
|
||||
return .left;
|
||||
case GHOSTTY_SPLIT_RESIZE_RIGHT:
|
||||
return .right;
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func toNative() -> ghostty_split_resize_direction_e {
|
||||
switch (self) {
|
||||
case .up:
|
||||
return GHOSTTY_SPLIT_RESIZE_UP;
|
||||
case .down:
|
||||
return GHOSTTY_SPLIT_RESIZE_DOWN;
|
||||
case .left:
|
||||
return GHOSTTY_SPLIT_RESIZE_LEFT;
|
||||
case .right:
|
||||
return GHOSTTY_SPLIT_RESIZE_RIGHT;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Ghostty.Notification {
|
||||
@ -112,6 +145,11 @@ extension Ghostty.Notification {
|
||||
static let confirmUnsafePaste = Notification.Name("com.mitchellh.ghostty.confirmUnsafePaste")
|
||||
static let UnsafePasteStrKey = confirmUnsafePaste.rawValue + ".str"
|
||||
static let UnsafePasteStateKey = confirmUnsafePaste.rawValue + ".state"
|
||||
|
||||
/// Notification sent to the active split view to resize the split.
|
||||
static let didResizeSplit = Notification.Name("com.mitchellh.ghostty.didResizeSplit")
|
||||
static let ResizeSplitDirectionKey = didResizeSplit.rawValue + ".direction"
|
||||
static let ResizeSplitAmountKey = didResizeSplit.rawValue + ".amount"
|
||||
}
|
||||
|
||||
// Make the input enum hashable.
|
||||
|
@ -4,11 +4,11 @@ import GhosttyKit
|
||||
extension Ghostty {
|
||||
/// Render a terminal for the active app in the environment.
|
||||
struct Terminal: View {
|
||||
@Environment(\.ghosttyApp) private var app
|
||||
@EnvironmentObject private var ghostty: Ghostty.AppState
|
||||
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
|
||||
|
||||
var body: some View {
|
||||
if let app = self.app {
|
||||
if let app = self.ghostty.app {
|
||||
SurfaceForApp(app) { surfaceView in
|
||||
SurfaceWrapper(surfaceView: surfaceView)
|
||||
}
|
||||
@ -48,7 +48,7 @@ extension Ghostty {
|
||||
// Maintain whether our window has focus (is key) or not
|
||||
@State private var windowFocus: Bool = true
|
||||
|
||||
@Environment(\.ghosttyConfig) private var ghostty_config
|
||||
@EnvironmentObject private var ghostty: Ghostty.AppState
|
||||
|
||||
// This is true if the terminal is considered "focused". The terminal is focused if
|
||||
// it is both individually focused and the containing window is key.
|
||||
@ -58,7 +58,7 @@ extension Ghostty {
|
||||
private var unfocusedOpacity: Double {
|
||||
var opacity: Double = 0.85
|
||||
let key = "unfocused-split-opacity"
|
||||
_ = ghostty_config_get(ghostty_config, &opacity, key, UInt(key.count))
|
||||
_ = ghostty_config_get(ghostty.config, &opacity, key, UInt(key.count))
|
||||
return 1 - opacity
|
||||
}
|
||||
|
||||
@ -508,7 +508,7 @@ extension Ghostty {
|
||||
guard let window = self.window else { return }
|
||||
guard let windowControllerRaw = window.windowController else { return }
|
||||
guard let windowController = windowControllerRaw as? TerminalController else { return }
|
||||
guard case .noSplit = windowController.surfaceTree else { return }
|
||||
guard case .leaf = windowController.surfaceTree else { return }
|
||||
|
||||
// If our window is full screen, we do not set the frame
|
||||
guard !window.styleMask.contains(.fullScreen) else { return }
|
||||
|
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing.
|
||||
/// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom".
|
||||
@ -9,13 +10,20 @@ struct SplitView<L: View, R: View>: View {
|
||||
/// Direction of the split
|
||||
let direction: SplitViewDirection
|
||||
|
||||
/// If set, the split view supports programmatic resizing via events sent via the publisher.
|
||||
/// Minimum increment (in points) that this split can be resized by, in
|
||||
/// each direction. Both `height` and `width` should be whole numbers
|
||||
/// greater than or equal to 1.0
|
||||
let resizeIncrements: NSSize
|
||||
let resizePublisher: PassthroughSubject<Double, Never>
|
||||
|
||||
/// The left and right views to render.
|
||||
let left: L
|
||||
let right: R
|
||||
|
||||
/// The current fractional width of the split view. 0.5 means L/R are equally sized, for example.
|
||||
@State var split: CGFloat = 0.5
|
||||
|
||||
|
||||
/// The visible size of the splitter, in points. The invisible size is a transparent hitbox that can still
|
||||
/// be used for getting a resize handle. The total width/height of the splitter is the sum of both.
|
||||
private let splitterVisibleSize: CGFloat = 1
|
||||
@ -38,15 +46,51 @@ struct SplitView<L: View, R: View>: View {
|
||||
.position(splitterPoint)
|
||||
.gesture(dragGesture(geo.size, splitterPoint: splitterPoint))
|
||||
}
|
||||
.onReceive(resizePublisher) { value in
|
||||
resize(for: geo.size, amount: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a split view. This view isn't programmatically resizable; it can only be resized
|
||||
/// by manually dragging the divider.
|
||||
init(_ direction: SplitViewDirection, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) {
|
||||
self.init(
|
||||
direction,
|
||||
resizeIncrements: .init(width: 1, height: 1),
|
||||
resizePublisher: .init(),
|
||||
left: left,
|
||||
right: right
|
||||
)
|
||||
}
|
||||
|
||||
/// Initialize a split view that supports programmatic resizing.
|
||||
init(
|
||||
_ direction: SplitViewDirection,
|
||||
resizeIncrements: NSSize,
|
||||
resizePublisher: PassthroughSubject<Double, Never>,
|
||||
@ViewBuilder left: (() -> L),
|
||||
@ViewBuilder right: (() -> R)
|
||||
) {
|
||||
self.direction = direction
|
||||
self.resizeIncrements = resizeIncrements
|
||||
self.resizePublisher = resizePublisher
|
||||
self.left = left()
|
||||
self.right = right()
|
||||
}
|
||||
|
||||
private func resize(for size: CGSize, amount: Double) {
|
||||
switch (direction) {
|
||||
case .horizontal:
|
||||
split += amount / size.width
|
||||
case .vertical:
|
||||
split += amount / size.height
|
||||
}
|
||||
|
||||
// Ensure split is clamped between 0 and 1
|
||||
split = max(0.0, min(split, 1.0))
|
||||
}
|
||||
|
||||
private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture {
|
||||
return DragGesture()
|
||||
.onChanged { gesture in
|
||||
@ -72,10 +116,12 @@ struct SplitView<L: View, R: View>: View {
|
||||
case .horizontal:
|
||||
result.size.width = result.size.width * split
|
||||
result.size.width -= splitterVisibleSize / 2
|
||||
result.size.width -= result.size.width.truncatingRemainder(dividingBy: self.resizeIncrements.width)
|
||||
|
||||
case .vertical:
|
||||
result.size.height = result.size.height * split
|
||||
result.size.height -= splitterVisibleSize / 2
|
||||
result.size.height -= result.size.height.truncatingRemainder(dividingBy: self.resizeIncrements.height)
|
||||
}
|
||||
|
||||
return result
|
||||
|
@ -19,6 +19,10 @@
|
||||
<outlet property="menuCopy" destination="Jqf-pv-Zcu" id="bKd-1C-oy9"/>
|
||||
<outlet property="menuDecreaseFontSize" destination="kzb-SZ-dOA" id="Y1B-Vh-6Z2"/>
|
||||
<outlet property="menuIncreaseFontSize" destination="CIH-ey-Z6x" id="hkc-9C-80E"/>
|
||||
<outlet property="menuMoveSplitDividerDown" destination="Zj7-2W-fdF" id="997-LL-nlN"/>
|
||||
<outlet property="menuMoveSplitDividerLeft" destination="wSR-ny-j1a" id="HCZ-CI-2ob"/>
|
||||
<outlet property="menuMoveSplitDividerRight" destination="CcX-ql-QU4" id="rIn-PK-fVM"/>
|
||||
<outlet property="menuMoveSplitDividerUp" destination="h9Y-40-3oo" id="dDi-Vq-I3r"/>
|
||||
<outlet property="menuNewTab" destination="uTG-Vz-hJU" id="eMg-R3-SeS"/>
|
||||
<outlet property="menuNewWindow" destination="Was-JA-tGl" id="lK7-3I-CPG"/>
|
||||
<outlet property="menuNextSplit" destination="bD7-ei-wKU" id="LeT-xw-eh4"/>
|
||||
@ -262,6 +266,37 @@
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
<menuItem title="Resize Split" id="BJO-3W-fkO" userLabel="Resize Split">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Resize Split" id="t7T-Ti-0im">
|
||||
<items>
|
||||
<menuItem title="Move Divider Up" id="h9Y-40-3oo">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="moveSplitDividerUp:" target="-1" id="NhD-6U-Eq2"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Move Divider Down" id="Zj7-2W-fdF">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="moveSplitDividerDown:" target="-1" id="jeD-bm-wJX"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Move Divider Left" id="wSR-ny-j1a">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="moveSplitDividerLeft:" target="-1" id="mlg-SJ-ZZO"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Move Divider Right" id="CcX-ql-QU4">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="moveSplitDividerRight:" target="-1" id="h3W-wY-PI7"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
|
@ -2436,6 +2436,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
} else log.warn("runtime doesn't implement gotoSplit", .{});
|
||||
},
|
||||
|
||||
.resize_split => |param| {
|
||||
if (@hasDecl(apprt.Surface, "resizeSplit")) {
|
||||
const direction = param[0];
|
||||
const amount = param[1];
|
||||
self.rt_surface.resizeSplit(direction, amount);
|
||||
} else log.warn("runtime doesn't implement resizeSplit", .{});
|
||||
},
|
||||
|
||||
.toggle_split_zoom => {
|
||||
if (@hasDecl(apprt.Surface, "toggleSplitZoom")) {
|
||||
self.rt_surface.toggleSplitZoom();
|
||||
|
@ -92,6 +92,9 @@ pub const App = struct {
|
||||
/// Focus the previous/next split (if any).
|
||||
focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null,
|
||||
|
||||
/// Resize the current split.
|
||||
resize_split: ?*const fn (SurfaceUD, input.SplitResizeDirection, u16) callconv(.C) void = null,
|
||||
|
||||
/// Zoom the current split.
|
||||
toggle_split_zoom: ?*const fn (SurfaceUD) callconv(.C) void = null,
|
||||
|
||||
@ -384,6 +387,15 @@ pub const Surface = struct {
|
||||
func(self.opts.userdata, direction);
|
||||
}
|
||||
|
||||
pub fn resizeSplit(self: *const Surface, direction: input.SplitResizeDirection, amount: u16) void {
|
||||
const func = self.app.opts.resize_split orelse {
|
||||
log.info("runtime embedder does not support resize split", .{});
|
||||
return;
|
||||
};
|
||||
|
||||
func(self.opts.userdata, direction, amount);
|
||||
}
|
||||
|
||||
pub fn toggleSplitZoom(self: *const Surface) void {
|
||||
const func = self.app.opts.toggle_split_zoom orelse {
|
||||
log.info("runtime embedder does not support split zoom", .{});
|
||||
@ -1374,6 +1386,14 @@ pub const CAPI = struct {
|
||||
ptr.gotoSplit(direction);
|
||||
}
|
||||
|
||||
/// Resize the current split by moving the split divider in the given
|
||||
/// direction. `direction` specifies which direction the split divider will
|
||||
/// move relative to the focused split. `amount` is a fractional value
|
||||
/// between 0 and 1 that specifies by how much the divider will move.
|
||||
export fn ghostty_surface_split_resize(ptr: *Surface, direction: input.SplitResizeDirection, amount: u16) void {
|
||||
ptr.resizeSplit(direction, amount);
|
||||
}
|
||||
|
||||
/// Invoke an action on the surface.
|
||||
export fn ghostty_surface_binding_action(
|
||||
ptr: *Surface,
|
||||
|
@ -980,6 +980,26 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
|
||||
.{ .key = .right, .mods = .{ .super = true, .alt = true } },
|
||||
.{ .goto_split = .right },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .up, .mods = .{ .super = true, .ctrl = true } },
|
||||
.{ .resize_split = .{ .up, 10 } },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .down, .mods = .{ .super = true, .ctrl = true } },
|
||||
.{ .resize_split = .{ .down, 10 } },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .left, .mods = .{ .super = true, .ctrl = true } },
|
||||
.{ .resize_split = .{ .left, 10 } },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .right, .mods = .{ .super = true, .ctrl = true } },
|
||||
.{ .resize_split = .{ .right, 10 } },
|
||||
);
|
||||
|
||||
// Inspector, matching Chromium
|
||||
try result.keybind.set.put(
|
||||
|
@ -11,6 +11,7 @@ pub const KeyEncoder = @import("input/KeyEncoder.zig");
|
||||
pub const InspectorMode = Binding.Action.InspectorMode;
|
||||
pub const SplitDirection = Binding.Action.SplitDirection;
|
||||
pub const SplitFocusDirection = Binding.Action.SplitFocusDirection;
|
||||
pub const SplitResizeDirection = Binding.Action.SplitResizeDirection;
|
||||
|
||||
// Keymap is only available on macOS right now. We could implement it
|
||||
// in theory for XKB too on Linux but we don't need it right now.
|
||||
|
@ -179,6 +179,10 @@ pub const Action = union(enum) {
|
||||
/// zoom/unzoom the current split.
|
||||
toggle_split_zoom: void,
|
||||
|
||||
/// Resize the current split by moving the split divider in the given
|
||||
/// direction
|
||||
resize_split: SplitResizeParameter,
|
||||
|
||||
/// Show, hide, or toggle the terminal inspector for the currently
|
||||
/// focused terminal.
|
||||
inspector: InspectorMode,
|
||||
@ -227,6 +231,19 @@ pub const Action = union(enum) {
|
||||
right,
|
||||
};
|
||||
|
||||
// Extern because it is used in the embedded runtime ABI.
|
||||
pub const SplitResizeDirection = enum(c_int) {
|
||||
up,
|
||||
down,
|
||||
left,
|
||||
right,
|
||||
};
|
||||
|
||||
pub const SplitResizeParameter = struct {
|
||||
SplitResizeDirection,
|
||||
u16,
|
||||
};
|
||||
|
||||
// Extern because it is used in the embedded runtime ABI.
|
||||
pub const InspectorMode = enum(c_int) {
|
||||
toggle,
|
||||
@ -234,6 +251,53 @@ pub const Action = union(enum) {
|
||||
hide,
|
||||
};
|
||||
|
||||
fn parseEnum(comptime T: type, value: []const u8) !T {
|
||||
return std.meta.stringToEnum(T, value) orelse return Error.InvalidFormat;
|
||||
}
|
||||
|
||||
fn parseInt(comptime T: type, value: []const u8) !T {
|
||||
return std.fmt.parseInt(T, value, 10) catch return Error.InvalidFormat;
|
||||
}
|
||||
|
||||
fn parseFloat(comptime T: type, value: []const u8) !T {
|
||||
return std.fmt.parseFloat(T, value) catch return Error.InvalidFormat;
|
||||
}
|
||||
|
||||
fn parseParameter(
|
||||
comptime field: std.builtin.Type.UnionField,
|
||||
param: []const u8,
|
||||
) !field.type {
|
||||
return switch (@typeInfo(field.type)) {
|
||||
.Enum => try parseEnum(field.type, param),
|
||||
.Int => try parseInt(field.type, param),
|
||||
.Float => try parseFloat(field.type, param),
|
||||
.Struct => |info| blk: {
|
||||
// Only tuples are supported to avoid ambiguity with field
|
||||
// ordering
|
||||
comptime assert(info.is_tuple);
|
||||
|
||||
var it = std.mem.split(u8, param, ",");
|
||||
var value: field.type = undefined;
|
||||
inline for (info.fields) |field_| {
|
||||
const next = it.next() orelse return Error.InvalidFormat;
|
||||
@field(value, field_.name) = switch (@typeInfo(field_.type)) {
|
||||
.Enum => try parseEnum(field_.type, next),
|
||||
.Int => try parseInt(field_.type, next),
|
||||
.Float => try parseFloat(field_.type, next),
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
// If we have extra parameters it is an error
|
||||
if (it.next() != null) return Error.InvalidFormat;
|
||||
|
||||
break :blk value;
|
||||
},
|
||||
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse an action in the format of "key=value" where key is the
|
||||
/// action name and value is the action parameter. The parameter
|
||||
/// is optional depending on the action.
|
||||
@ -266,35 +330,14 @@ pub const Action = union(enum) {
|
||||
// Cursor keys can't be set currently
|
||||
Action.CursorKey => return Error.InvalidAction,
|
||||
|
||||
else => switch (@typeInfo(field.type)) {
|
||||
.Enum => {
|
||||
const idx = colonIdx orelse return Error.InvalidFormat;
|
||||
const param = input[idx + 1 ..];
|
||||
const value = std.meta.stringToEnum(
|
||||
field.type,
|
||||
param,
|
||||
) orelse return Error.InvalidFormat;
|
||||
|
||||
return @unionInit(Action, field.name, value);
|
||||
},
|
||||
|
||||
.Int => {
|
||||
const idx = colonIdx orelse return Error.InvalidFormat;
|
||||
const param = input[idx + 1 ..];
|
||||
const value = std.fmt.parseInt(field.type, param, 10) catch
|
||||
return Error.InvalidFormat;
|
||||
return @unionInit(Action, field.name, value);
|
||||
},
|
||||
|
||||
.Float => {
|
||||
const idx = colonIdx orelse return Error.InvalidFormat;
|
||||
const param = input[idx + 1 ..];
|
||||
const value = std.fmt.parseFloat(field.type, param) catch
|
||||
return Error.InvalidFormat;
|
||||
return @unionInit(Action, field.name, value);
|
||||
},
|
||||
|
||||
else => unreachable,
|
||||
else => {
|
||||
const idx = colonIdx orelse return Error.InvalidFormat;
|
||||
const param = input[idx + 1 ..];
|
||||
return @unionInit(
|
||||
Action,
|
||||
field.name,
|
||||
try parseParameter(field, param),
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -316,24 +359,38 @@ pub const Action = union(enum) {
|
||||
|
||||
switch (self) {
|
||||
inline else => |value| {
|
||||
const Value = @TypeOf(value);
|
||||
const value_info = @typeInfo(Value);
|
||||
|
||||
// All actions start with the tag.
|
||||
try writer.print("{s}", .{@tagName(self)});
|
||||
|
||||
// Write the value depending on the type
|
||||
switch (Value) {
|
||||
void => {},
|
||||
[]const u8 => try writer.print(":{s}", .{value}),
|
||||
else => switch (value_info) {
|
||||
.Enum => try writer.print(":{s}", .{@tagName(value)}),
|
||||
.Float => try writer.print(":{d}", .{value}),
|
||||
.Int => try writer.print(":{d}", .{value}),
|
||||
.Struct => try writer.print("{} (not configurable)", .{value}),
|
||||
else => @compileError("unhandled type: " ++ @typeName(Value)),
|
||||
},
|
||||
}
|
||||
try writer.writeAll(":");
|
||||
try formatValue(writer, value);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn formatValue(
|
||||
writer: anytype,
|
||||
value: anytype,
|
||||
) !void {
|
||||
const Value = @TypeOf(value);
|
||||
const value_info = @typeInfo(Value);
|
||||
switch (Value) {
|
||||
void => {},
|
||||
[]const u8 => try writer.print("{s}", .{value}),
|
||||
else => switch (value_info) {
|
||||
.Enum => try writer.print("{s}", .{@tagName(value)}),
|
||||
.Float => try writer.print("{d}", .{value}),
|
||||
.Int => try writer.print("{d}", .{value}),
|
||||
.Struct => |info| if (!info.is_tuple) {
|
||||
try writer.print("{} (not configurable)", .{value});
|
||||
} else {
|
||||
inline for (info.fields, 0..) |field, i| {
|
||||
try formatValue(writer, @field(value, field.name));
|
||||
if (i + 1 < info.fields.len) try writer.writeAll(",");
|
||||
}
|
||||
},
|
||||
else => @compileError("unhandled type: " ++ @typeName(Value)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -775,6 +832,27 @@ test "parse: action with float" {
|
||||
}
|
||||
}
|
||||
|
||||
test "parse: action with a tuple" {
|
||||
const testing = std.testing;
|
||||
|
||||
// parameter
|
||||
{
|
||||
const binding = try parse("a=resize_split:up,10");
|
||||
try testing.expect(binding.action == .resize_split);
|
||||
try testing.expectEqual(Action.SplitResizeDirection.up, binding.action.resize_split[0]);
|
||||
try testing.expectEqual(@as(u16, 10), binding.action.resize_split[1]);
|
||||
}
|
||||
|
||||
// missing parameter
|
||||
try testing.expectError(Error.InvalidFormat, parse("a=resize_split:up"));
|
||||
|
||||
// too many
|
||||
try testing.expectError(Error.InvalidFormat, parse("a=resize_split:up,10,12"));
|
||||
|
||||
// invalid type
|
||||
try testing.expectError(Error.InvalidFormat, parse("a=resize_split:up,four"));
|
||||
}
|
||||
|
||||
test "set: maintains reverse mapping" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
Reference in New Issue
Block a user