mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-19 18:26:13 +03:00
Merge pull request #2299 from ghostty-org/global-keybind
Keybind features: System-global keybindings and target-all-terminal keybindings
This commit is contained in:
@ -527,10 +527,12 @@ ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
|
|||||||
void ghostty_app_free(ghostty_app_t);
|
void ghostty_app_free(ghostty_app_t);
|
||||||
bool ghostty_app_tick(ghostty_app_t);
|
bool ghostty_app_tick(ghostty_app_t);
|
||||||
void* ghostty_app_userdata(ghostty_app_t);
|
void* ghostty_app_userdata(ghostty_app_t);
|
||||||
|
bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);
|
||||||
void ghostty_app_keyboard_changed(ghostty_app_t);
|
void ghostty_app_keyboard_changed(ghostty_app_t);
|
||||||
void ghostty_app_open_config(ghostty_app_t);
|
void ghostty_app_open_config(ghostty_app_t);
|
||||||
void ghostty_app_reload_config(ghostty_app_t);
|
void ghostty_app_reload_config(ghostty_app_t);
|
||||||
bool ghostty_app_needs_confirm_quit(ghostty_app_t);
|
bool ghostty_app_needs_confirm_quit(ghostty_app_t);
|
||||||
|
bool ghostty_app_has_global_keybinds(ghostty_app_t);
|
||||||
|
|
||||||
ghostty_surface_config_s ghostty_surface_config_new();
|
ghostty_surface_config_s ghostty_surface_config_new();
|
||||||
|
|
||||||
|
@ -61,6 +61,7 @@
|
|||||||
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; };
|
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; };
|
||||||
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; };
|
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; };
|
||||||
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; };
|
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; };
|
||||||
|
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; };
|
||||||
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; };
|
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; };
|
||||||
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; };
|
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; };
|
||||||
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; };
|
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; };
|
||||||
@ -131,6 +132,7 @@
|
|||||||
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
|
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
|
||||||
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; };
|
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; };
|
||||||
A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; };
|
A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; };
|
||||||
|
A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = "<group>"; };
|
||||||
A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = "<group>"; };
|
A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = "<group>"; };
|
||||||
A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = "<group>"; };
|
A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = "<group>"; };
|
||||||
A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = "<group>"; };
|
A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = "<group>"; };
|
||||||
@ -199,6 +201,7 @@
|
|||||||
A53426362A7DC53000EBB7A2 /* Features */ = {
|
A53426362A7DC53000EBB7A2 /* Features */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A5CBD0672CA2704E0017A1AE /* Global Keybinds */,
|
||||||
A56D58872ACDE6BE00508D2C /* Services */,
|
A56D58872ACDE6BE00508D2C /* Services */,
|
||||||
A59630982AEE1C4400D64628 /* Terminal */,
|
A59630982AEE1C4400D64628 /* Terminal */,
|
||||||
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
|
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
|
||||||
@ -370,6 +373,14 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */,
|
||||||
|
);
|
||||||
|
path = "Global Keybinds";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A5CEAFDA29B8005900646FDA /* SplitView */ = {
|
A5CEAFDA29B8005900646FDA /* SplitView */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -529,6 +540,7 @@
|
|||||||
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
|
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
|
||||||
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
|
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
|
||||||
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
|
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
|
||||||
|
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
|
||||||
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
|
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
|
||||||
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
|
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
|
||||||
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
|
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
|
||||||
|
@ -63,6 +63,10 @@ class AppDelegate: NSObject,
|
|||||||
/// This is only true before application has become active.
|
/// This is only true before application has become active.
|
||||||
private var applicationHasBecomeActive: Bool = false
|
private var applicationHasBecomeActive: Bool = false
|
||||||
|
|
||||||
|
/// This is set in applicationDidFinishLaunching with the system uptime so we can determine the
|
||||||
|
/// seconds since the process was launched.
|
||||||
|
private var applicationLaunchTime: TimeInterval = 0
|
||||||
|
|
||||||
/// The ghostty global state. Only one per process.
|
/// The ghostty global state. Only one per process.
|
||||||
let ghostty: Ghostty.App = Ghostty.App()
|
let ghostty: Ghostty.App = Ghostty.App()
|
||||||
|
|
||||||
@ -73,6 +77,11 @@ class AppDelegate: NSObject,
|
|||||||
let updaterController: SPUStandardUpdaterController
|
let updaterController: SPUStandardUpdaterController
|
||||||
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
|
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
|
||||||
|
|
||||||
|
/// The elapsed time since the process was started
|
||||||
|
var timeSinceLaunch: TimeInterval {
|
||||||
|
return ProcessInfo.processInfo.systemUptime - applicationLaunchTime
|
||||||
|
}
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
terminalManager = TerminalManager(ghostty)
|
terminalManager = TerminalManager(ghostty)
|
||||||
updaterController = SPUStandardUpdaterController(
|
updaterController = SPUStandardUpdaterController(
|
||||||
@ -106,6 +115,9 @@ class AppDelegate: NSObject,
|
|||||||
"ApplePressAndHoldEnabled": false,
|
"ApplePressAndHoldEnabled": false,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Store our start time
|
||||||
|
applicationLaunchTime = ProcessInfo.processInfo.systemUptime
|
||||||
|
|
||||||
// Check if secure input was enabled when we last quit.
|
// Check if secure input was enabled when we last quit.
|
||||||
if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) {
|
if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) {
|
||||||
toggleSecureInput(self)
|
toggleSecureInput(self)
|
||||||
@ -418,6 +430,25 @@ class AppDelegate: NSObject,
|
|||||||
c.showWindow(self)
|
c.showWindow(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We need to handle our global event tap depending on if there are global
|
||||||
|
// events that we care about in Ghostty.
|
||||||
|
if (ghostty_app_has_global_keybinds(ghostty.app!)) {
|
||||||
|
if (timeSinceLaunch > 5) {
|
||||||
|
// If the process has been running for awhile we enable right away
|
||||||
|
// because no windows are likely to pop up.
|
||||||
|
GlobalEventTap.shared.enable()
|
||||||
|
} else {
|
||||||
|
// If the process just started, we wait a couple seconds to allow
|
||||||
|
// the initial windows and so on to load so our permissions dialog
|
||||||
|
// doesn't get buried.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
|
||||||
|
GlobalEventTap.shared.enable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GlobalEventTap.shared.disable()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync the appearance of our app with the theme specified in the config.
|
/// Sync the appearance of our app with the theme specified in the config.
|
||||||
|
151
macos/Sources/Features/Global Keybinds/GlobalEventTap.swift
Normal file
151
macos/Sources/Features/Global Keybinds/GlobalEventTap.swift
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import Cocoa
|
||||||
|
import CoreGraphics
|
||||||
|
import Carbon
|
||||||
|
import OSLog
|
||||||
|
import GhosttyKit
|
||||||
|
|
||||||
|
// Manages the event tap to monitor global events, currently only used for
|
||||||
|
// global keybindings.
|
||||||
|
class GlobalEventTap {
|
||||||
|
static let shared = GlobalEventTap()
|
||||||
|
|
||||||
|
fileprivate static let logger = Logger(
|
||||||
|
subsystem: Bundle.main.bundleIdentifier!,
|
||||||
|
category: String(describing: GlobalEventTap.self)
|
||||||
|
)
|
||||||
|
|
||||||
|
// The event tap used for global event listening. This is non-nil if it is
|
||||||
|
// created.
|
||||||
|
private var eventTap: CFMachPort? = nil
|
||||||
|
|
||||||
|
// This is the timer used to retry enabling the global event tap if we
|
||||||
|
// don't have permissions.
|
||||||
|
private var enableTimer: Timer? = nil
|
||||||
|
|
||||||
|
// Private init so it can't be constructed outside of our singleton
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable the global event tap. This is safe to call if it is already enabled.
|
||||||
|
// If enabling fails due to permissions, this will start a timer to retry since
|
||||||
|
// accessibility permissions take affect immediately.
|
||||||
|
func enable() {
|
||||||
|
if (eventTap != nil) {
|
||||||
|
// Already enabled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are already trying to enable, then stop the timer and restart it.
|
||||||
|
if let enableTimer {
|
||||||
|
enableTimer.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to enable the event tap immediately. If this succeeds then we're done!
|
||||||
|
if (tryEnable()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed, probably due to permissions. The permissions dialog should've
|
||||||
|
// popped up. We retry on a timer since once the permissions are granted
|
||||||
|
// then they take affect immediately.
|
||||||
|
enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||||
|
_ = self.tryEnable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable the global event tap. This is safe to call if it is already disabled.
|
||||||
|
func disable() {
|
||||||
|
// Stop our enable timer if it is on
|
||||||
|
if let enableTimer {
|
||||||
|
enableTimer.invalidate()
|
||||||
|
self.enableTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop our event tap
|
||||||
|
if let eventTap {
|
||||||
|
Self.logger.debug("invalidating event tap mach port")
|
||||||
|
CFMachPortInvalidate(eventTap)
|
||||||
|
self.eventTap = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to enable the global event type, returns false if it fails.
|
||||||
|
private func tryEnable() -> Bool {
|
||||||
|
// The events we care about
|
||||||
|
let eventMask = [
|
||||||
|
CGEventType.keyDown
|
||||||
|
].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue)})
|
||||||
|
|
||||||
|
// Try to create it
|
||||||
|
guard let eventTap = CGEvent.tapCreate(
|
||||||
|
tap: .cgSessionEventTap,
|
||||||
|
place: .headInsertEventTap,
|
||||||
|
options: .defaultTap,
|
||||||
|
eventsOfInterest: eventMask,
|
||||||
|
callback: cgEventFlagsChangedHandler(proxy:type:cgEvent:userInfo:),
|
||||||
|
userInfo: nil
|
||||||
|
) else {
|
||||||
|
// Return false if creation failed. This is usually because we don't have
|
||||||
|
// Accessibility permissions but can probably be other reasons I don't
|
||||||
|
// know about.
|
||||||
|
Self.logger.debug("creating global event tap failed, missing permissions?")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store our event tap
|
||||||
|
self.eventTap = eventTap
|
||||||
|
|
||||||
|
// If we have an enable timer we always want to disable it
|
||||||
|
if let enableTimer {
|
||||||
|
enableTimer.invalidate()
|
||||||
|
self.enableTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach our event tap to the main run loop. Note if you don't do this then
|
||||||
|
// the event tap will block every
|
||||||
|
CFRunLoopAddSource(
|
||||||
|
CFRunLoopGetMain(),
|
||||||
|
CFMachPortCreateRunLoopSource(nil, eventTap, 0),
|
||||||
|
.commonModes
|
||||||
|
)
|
||||||
|
|
||||||
|
Self.logger.info("global event tap enabled for global keybinds")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func cgEventFlagsChangedHandler(
|
||||||
|
proxy: CGEventTapProxy,
|
||||||
|
type: CGEventType,
|
||||||
|
cgEvent: CGEvent,
|
||||||
|
userInfo: UnsafeMutableRawPointer?
|
||||||
|
) -> Unmanaged<CGEvent>? {
|
||||||
|
let result = Unmanaged.passUnretained(cgEvent)
|
||||||
|
|
||||||
|
// We only care about keydown events
|
||||||
|
guard type == .keyDown else { return result }
|
||||||
|
|
||||||
|
// We need an app delegate to get the Ghostty app instance
|
||||||
|
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return result }
|
||||||
|
guard let ghostty = appDelegate.ghostty.app else { return result }
|
||||||
|
|
||||||
|
// We need an NSEvent for our logic below
|
||||||
|
guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result }
|
||||||
|
|
||||||
|
// Build our event input and call ghostty
|
||||||
|
var key_ev = ghostty_input_key_s()
|
||||||
|
key_ev.action = GHOSTTY_ACTION_PRESS
|
||||||
|
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
|
||||||
|
key_ev.keycode = UInt32(event.keyCode)
|
||||||
|
key_ev.text = nil
|
||||||
|
key_ev.composing = false
|
||||||
|
if (ghostty_app_key(ghostty, key_ev)) {
|
||||||
|
GlobalEventTap.logger.info("global key event handled event=\(event)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
@ -78,6 +78,12 @@ class TerminalManager {
|
|||||||
window.toggleFullScreen(nil)
|
window.toggleFullScreen(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If our app isn't active, we make it active. All new_window actions
|
||||||
|
// force our app to be active.
|
||||||
|
if !NSApp.isActive {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
|
||||||
// We're dispatching this async because otherwise the lastCascadePoint doesn't
|
// We're dispatching this async because otherwise the lastCascadePoint doesn't
|
||||||
// take effect. Our best theory is there is some next-event-loop-tick logic
|
// take effect. Our best theory is there is some next-event-loop-tick logic
|
||||||
// that Cocoa is doing that we need to be after.
|
// that Cocoa is doing that we need to be after.
|
||||||
|
@ -631,7 +631,11 @@ extension Ghostty {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
|
static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
|
||||||
let surface = self.surfaceUserdata(from: userdata)
|
let surface: SurfaceView? = if let userdata {
|
||||||
|
self.surfaceUserdata(from: userdata)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: Notification.ghosttyNewWindow,
|
name: Notification.ghosttyNewWindow,
|
||||||
|
96
src/App.zig
96
src/App.zig
@ -262,6 +262,102 @@ pub fn setQuit(self: *App) !void {
|
|||||||
self.quit = true;
|
self.quit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle a key event at the app-scope. If this key event is used,
|
||||||
|
/// this will return true and the caller shouldn't continue processing
|
||||||
|
/// the event. If the event is not used, this will return false.
|
||||||
|
pub fn keyEvent(
|
||||||
|
self: *App,
|
||||||
|
rt_app: *apprt.App,
|
||||||
|
event: input.KeyEvent,
|
||||||
|
) bool {
|
||||||
|
switch (event.action) {
|
||||||
|
// We don't care about key release events.
|
||||||
|
.release => return false,
|
||||||
|
|
||||||
|
// Continue processing key press events.
|
||||||
|
.press, .repeat => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the keybind entry for this event. We don't support key sequences
|
||||||
|
// so we can look directly in the top-level set.
|
||||||
|
const entry = rt_app.config.keybind.set.getEvent(event) orelse return false;
|
||||||
|
const leaf: input.Binding.Set.Leaf = switch (entry) {
|
||||||
|
// Sequences aren't supported. Our configuration parser verifies
|
||||||
|
// this for global keybinds but we may still get an entry for
|
||||||
|
// a non-global keybind.
|
||||||
|
.leader => return false,
|
||||||
|
|
||||||
|
// Leaf entries are good
|
||||||
|
.leaf => |leaf| leaf,
|
||||||
|
};
|
||||||
|
|
||||||
|
// We only care about global keybinds
|
||||||
|
if (!leaf.flags.global) return false;
|
||||||
|
|
||||||
|
// Perform the action
|
||||||
|
self.performAllAction(rt_app, leaf.action) catch |err| {
|
||||||
|
log.warn("error performing global keybind action action={s} err={}", .{
|
||||||
|
@tagName(leaf.action),
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a binding action. This only accepts actions that are scoped
|
||||||
|
/// to the app. Callers can use performAllAction to perform any action
|
||||||
|
/// and any non-app-scoped actions will be performed on all surfaces.
|
||||||
|
pub fn performAction(
|
||||||
|
self: *App,
|
||||||
|
rt_app: *apprt.App,
|
||||||
|
action: input.Binding.Action.Scoped(.app),
|
||||||
|
) !void {
|
||||||
|
switch (action) {
|
||||||
|
.unbind => unreachable,
|
||||||
|
.ignore => {},
|
||||||
|
.quit => try self.setQuit(),
|
||||||
|
.new_window => try self.newWindow(rt_app, .{ .parent = null }),
|
||||||
|
.open_config => try self.openConfig(rt_app),
|
||||||
|
.reload_config => try self.reloadConfig(rt_app),
|
||||||
|
.close_all_windows => {
|
||||||
|
if (@hasDecl(apprt.App, "closeAllWindows")) {
|
||||||
|
rt_app.closeAllWindows();
|
||||||
|
} else log.warn("runtime doesn't implement closeAllWindows", .{});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform an app-wide binding action. If the action is surface-specific
|
||||||
|
/// then it will be performed on all surfaces. To perform only app-scoped
|
||||||
|
/// actions, use performAction.
|
||||||
|
pub fn performAllAction(
|
||||||
|
self: *App,
|
||||||
|
rt_app: *apprt.App,
|
||||||
|
action: input.Binding.Action,
|
||||||
|
) !void {
|
||||||
|
switch (action.scope()) {
|
||||||
|
// App-scoped actions are handled by the app so that they aren't
|
||||||
|
// repeated for each surface (since each surface forwards
|
||||||
|
// app-scoped actions back up).
|
||||||
|
.app => try self.performAction(
|
||||||
|
rt_app,
|
||||||
|
action.scoped(.app).?, // asserted through the scope match
|
||||||
|
),
|
||||||
|
|
||||||
|
// Surface-scoped actions are performed on all surfaces. Errors
|
||||||
|
// are logged but processing continues.
|
||||||
|
.surface => for (self.surfaces.items) |surface| {
|
||||||
|
_ = surface.core_surface.performBindingAction(action) catch |err| {
|
||||||
|
log.warn("error performing binding action on surface ptr={X} err={}", .{
|
||||||
|
@intFromPtr(surface),
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle a window message
|
/// Handle a window message
|
||||||
fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !void {
|
fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !void {
|
||||||
// We want to ensure our window is still active. Window messages
|
// We want to ensure our window is still active. Window messages
|
||||||
|
@ -1561,19 +1561,8 @@ fn maybeHandleBinding(
|
|||||||
const entry: input.Binding.Set.Entry = entry: {
|
const entry: input.Binding.Set.Entry = entry: {
|
||||||
const set = self.keyboard.bindings orelse &self.config.keybind.set;
|
const set = self.keyboard.bindings orelse &self.config.keybind.set;
|
||||||
|
|
||||||
var trigger: input.Binding.Trigger = .{
|
// Get our entry from the set for the given event.
|
||||||
.mods = event.mods.binding(),
|
if (set.getEvent(event)) |v| break :entry v;
|
||||||
.key = .{ .translated = event.key },
|
|
||||||
};
|
|
||||||
if (set.get(trigger)) |v| break :entry v;
|
|
||||||
|
|
||||||
trigger.key = .{ .physical = event.physical_key };
|
|
||||||
if (set.get(trigger)) |v| break :entry v;
|
|
||||||
|
|
||||||
if (event.unshifted_codepoint > 0) {
|
|
||||||
trigger.key = .{ .unicode = event.unshifted_codepoint };
|
|
||||||
if (set.get(trigger)) |v| break :entry v;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No entry found. If we're not looking at the root set of the
|
// No entry found. If we're not looking at the root set of the
|
||||||
// bindings we need to encode everything up to this point and
|
// bindings we need to encode everything up to this point and
|
||||||
@ -1590,7 +1579,7 @@ fn maybeHandleBinding(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Determine if this entry has an action or if its a leader key.
|
// Determine if this entry has an action or if its a leader key.
|
||||||
const action: input.Binding.Action, const consumed: bool = switch (entry) {
|
const leaf: input.Binding.Set.Leaf = switch (entry) {
|
||||||
.leader => |set| {
|
.leader => |set| {
|
||||||
// Setup the next set we'll look at.
|
// Setup the next set we'll look at.
|
||||||
self.keyboard.bindings = set;
|
self.keyboard.bindings = set;
|
||||||
@ -1605,8 +1594,20 @@ fn maybeHandleBinding(
|
|||||||
return .consumed;
|
return .consumed;
|
||||||
},
|
},
|
||||||
|
|
||||||
.action => |v| .{ v, true },
|
.leaf => |leaf| leaf,
|
||||||
.action_unconsumed => |v| .{ v, false },
|
};
|
||||||
|
const action = leaf.action;
|
||||||
|
|
||||||
|
// consumed determines if the input is consumed or if we continue
|
||||||
|
// encoding the key (if we have a key to encode).
|
||||||
|
const consumed = consumed: {
|
||||||
|
// If the consumed flag is explicitly set, then we are consumed.
|
||||||
|
if (leaf.flags.consumed) break :consumed true;
|
||||||
|
|
||||||
|
// If the global or all flag is set, we always consume.
|
||||||
|
if (leaf.flags.global or leaf.flags.all) break :consumed true;
|
||||||
|
|
||||||
|
break :consumed false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// We have an action, so at this point we're handling SOMETHING so
|
// We have an action, so at this point we're handling SOMETHING so
|
||||||
@ -1618,8 +1619,22 @@ fn maybeHandleBinding(
|
|||||||
self.keyboard.bindings = null;
|
self.keyboard.bindings = null;
|
||||||
|
|
||||||
// Attempt to perform the action
|
// Attempt to perform the action
|
||||||
log.debug("key event binding consumed={} action={}", .{ consumed, action });
|
log.debug("key event binding flags={} action={}", .{
|
||||||
const performed = try self.performBindingAction(action);
|
leaf.flags,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
const performed = performed: {
|
||||||
|
// If this is a global or all action, then we perform it on
|
||||||
|
// the app and it applies to every surface.
|
||||||
|
if (leaf.flags.global or leaf.flags.all) {
|
||||||
|
try self.app.performAllAction(self.rt_app, action);
|
||||||
|
|
||||||
|
// "All" actions are always performed since they are global.
|
||||||
|
break :performed true;
|
||||||
|
}
|
||||||
|
|
||||||
|
break :performed try self.performBindingAction(action);
|
||||||
|
};
|
||||||
|
|
||||||
// If we performed an action and it was a closing action,
|
// If we performed an action and it was a closing action,
|
||||||
// our "self" pointer is not safe to use anymore so we need to
|
// our "self" pointer is not safe to use anymore so we need to
|
||||||
@ -3401,14 +3416,25 @@ fn showMouse(self: *Surface) void {
|
|||||||
/// will ever return false. We can expand this in the future if it becomes
|
/// will ever return false. We can expand this in the future if it becomes
|
||||||
/// useful. We did previous/next tab so we could implement #498.
|
/// useful. We did previous/next tab so we could implement #498.
|
||||||
pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {
|
pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {
|
||||||
switch (action) {
|
// Forward app-scoped actions to the app. Some app-scoped actions are
|
||||||
.unbind => unreachable,
|
// special-cased here because they do some special things when performed
|
||||||
.ignore => {},
|
// from the surface.
|
||||||
|
if (action.scoped(.app)) |app_action| {
|
||||||
|
switch (app_action) {
|
||||||
|
.new_window => try self.app.newWindow(
|
||||||
|
self.rt_app,
|
||||||
|
.{ .parent = self },
|
||||||
|
),
|
||||||
|
|
||||||
.open_config => try self.app.openConfig(self.rt_app),
|
else => try self.app.performAction(
|
||||||
|
self.rt_app,
|
||||||
.reload_config => try self.app.reloadConfig(self.rt_app),
|
action.scoped(.app).?,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action.scoped(.surface).?) {
|
||||||
.csi, .esc => |data| {
|
.csi, .esc => |data| {
|
||||||
// We need to send the CSI/ESC sequence as a single write request.
|
// We need to send the CSI/ESC sequence as a single write request.
|
||||||
// If you split it across two then the shell can interpret it
|
// If you split it across two then the shell can interpret it
|
||||||
@ -3630,8 +3656,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
|||||||
v,
|
v,
|
||||||
),
|
),
|
||||||
|
|
||||||
.new_window => try self.app.newWindow(self.rt_app, .{ .parent = self }),
|
|
||||||
|
|
||||||
.new_tab => {
|
.new_tab => {
|
||||||
if (@hasDecl(apprt.Surface, "newTab")) {
|
if (@hasDecl(apprt.Surface, "newTab")) {
|
||||||
try self.rt_surface.newTab();
|
try self.rt_surface.newTab();
|
||||||
@ -3758,14 +3782,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
|||||||
|
|
||||||
.close_window => try self.app.closeSurface(self),
|
.close_window => try self.app.closeSurface(self),
|
||||||
|
|
||||||
.close_all_windows => {
|
|
||||||
if (@hasDecl(apprt.Surface, "closeAllWindows")) {
|
|
||||||
self.rt_surface.closeAllWindows();
|
|
||||||
} else log.warn("runtime doesn't implement closeAllWindows", .{});
|
|
||||||
},
|
|
||||||
|
|
||||||
.quit => try self.app.setQuit(),
|
|
||||||
|
|
||||||
.crash => |location| switch (location) {
|
.crash => |location| switch (location) {
|
||||||
.main => @panic("crash binding action, crashing intentionally"),
|
.main => @panic("crash binding action, crashing intentionally"),
|
||||||
|
|
||||||
|
@ -86,7 +86,8 @@ pub const App = struct {
|
|||||||
/// New tab with options.
|
/// New tab with options.
|
||||||
new_tab: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null,
|
new_tab: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null,
|
||||||
|
|
||||||
/// New window with options.
|
/// New window with options. The surface may be null if there is no
|
||||||
|
/// target surface.
|
||||||
new_window: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null,
|
new_window: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null,
|
||||||
|
|
||||||
/// Control the inspector visibility
|
/// Control the inspector visibility
|
||||||
@ -143,17 +144,37 @@ pub const App = struct {
|
|||||||
toggle_secure_input: ?*const fn () callconv(.C) void = null,
|
toggle_secure_input: ?*const fn () callconv(.C) void = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// This is the key event sent for ghostty_surface_key and
|
||||||
|
/// ghostty_app_key.
|
||||||
|
pub const KeyEvent = struct {
|
||||||
|
/// The three below are absolutely required.
|
||||||
|
action: input.Action,
|
||||||
|
mods: input.Mods,
|
||||||
|
keycode: u32,
|
||||||
|
|
||||||
|
/// Optionally, the embedder can handle text translation and send
|
||||||
|
/// the text value here. If text is non-nil, it is assumed that the
|
||||||
|
/// embedder also handles dead key states and sets composing as necessary.
|
||||||
|
text: ?[:0]const u8,
|
||||||
|
composing: bool,
|
||||||
|
};
|
||||||
|
|
||||||
core_app: *CoreApp,
|
core_app: *CoreApp,
|
||||||
config: *const Config,
|
config: *const Config,
|
||||||
opts: Options,
|
opts: Options,
|
||||||
keymap: input.Keymap,
|
keymap: input.Keymap,
|
||||||
|
|
||||||
|
/// The keymap state is used for global keybinds only. Each surface
|
||||||
|
/// also has its own keymap state for focused keybinds.
|
||||||
|
keymap_state: input.Keymap.State,
|
||||||
|
|
||||||
pub fn init(core_app: *CoreApp, config: *const Config, opts: Options) !App {
|
pub fn init(core_app: *CoreApp, config: *const Config, opts: Options) !App {
|
||||||
return .{
|
return .{
|
||||||
.core_app = core_app,
|
.core_app = core_app,
|
||||||
.config = config,
|
.config = config,
|
||||||
.opts = opts,
|
.opts = opts,
|
||||||
.keymap = try input.Keymap.init(),
|
.keymap = try input.Keymap.init(),
|
||||||
|
.keymap_state = .{},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,6 +182,251 @@ pub const App = struct {
|
|||||||
self.keymap.deinit();
|
self.keymap.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if there are any global keybinds in the configuration.
|
||||||
|
pub fn hasGlobalKeybinds(self: *const App) bool {
|
||||||
|
var it = self.config.keybind.set.bindings.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
switch (entry.value_ptr.*) {
|
||||||
|
.leader => {},
|
||||||
|
.leaf => |leaf| if (leaf.flags.global) return true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target of a key event. This is used to determine some subtly
|
||||||
|
/// different behavior between app and surface key events.
|
||||||
|
pub const KeyTarget = union(enum) {
|
||||||
|
app,
|
||||||
|
surface: *Surface,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// See CoreApp.keyEvent.
|
||||||
|
pub fn keyEvent(
|
||||||
|
self: *App,
|
||||||
|
target: KeyTarget,
|
||||||
|
event: KeyEvent,
|
||||||
|
) !bool {
|
||||||
|
const action = event.action;
|
||||||
|
const keycode = event.keycode;
|
||||||
|
const mods = event.mods;
|
||||||
|
|
||||||
|
// True if this is a key down event
|
||||||
|
const is_down = action == .press or action == .repeat;
|
||||||
|
|
||||||
|
// If we're on macOS and we have macos-option-as-alt enabled,
|
||||||
|
// then we strip the alt modifier from the mods for translation.
|
||||||
|
const translate_mods = translate_mods: {
|
||||||
|
var translate_mods = mods;
|
||||||
|
if (comptime builtin.target.isDarwin()) {
|
||||||
|
const strip = switch (self.config.@"macos-option-as-alt") {
|
||||||
|
.false => false,
|
||||||
|
.true => mods.alt,
|
||||||
|
.left => mods.sides.alt == .left,
|
||||||
|
.right => mods.sides.alt == .right,
|
||||||
|
};
|
||||||
|
if (strip) translate_mods.alt = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On macOS we strip ctrl because UCKeyTranslate
|
||||||
|
// converts to the masked values (i.e. ctrl+c becomes 3)
|
||||||
|
// and we don't want that behavior.
|
||||||
|
//
|
||||||
|
// We also strip super because its not used for translation
|
||||||
|
// on macos and it results in a bad translation.
|
||||||
|
if (comptime builtin.target.isDarwin()) {
|
||||||
|
translate_mods.ctrl = false;
|
||||||
|
translate_mods.super = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
break :translate_mods translate_mods;
|
||||||
|
};
|
||||||
|
|
||||||
|
const event_text: ?[]const u8 = event_text: {
|
||||||
|
// This logic only applies to macOS.
|
||||||
|
if (comptime builtin.os.tag != .macos) break :event_text event.text;
|
||||||
|
|
||||||
|
// If the modifiers are ONLY "control" then we never process
|
||||||
|
// the event text because we want to do our own translation so
|
||||||
|
// we can handle ctrl+c, ctrl+z, etc.
|
||||||
|
//
|
||||||
|
// This is specifically because on macOS using the
|
||||||
|
// "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as
|
||||||
|
// "/" (the physical key that is z on a qwerty keyboard). But on
|
||||||
|
// other layouts, ctrl+<char> is not translated by AppKit. So,
|
||||||
|
// we just avoid this by never allowing AppKit to translate
|
||||||
|
// ctrl+<char> and instead do it ourselves.
|
||||||
|
const ctrl_only = comptime (input.Mods{ .ctrl = true }).int();
|
||||||
|
break :event_text if (mods.binding().int() == ctrl_only) null else event.text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Translate our key using the keymap for our localized keyboard layout.
|
||||||
|
// We only translate for keydown events. Otherwise, we only care about
|
||||||
|
// the raw keycode.
|
||||||
|
var buf: [128]u8 = undefined;
|
||||||
|
const result: input.Keymap.Translation = if (is_down) translate: {
|
||||||
|
// If the event provided us with text, then we use this as a result
|
||||||
|
// and do not do manual translation.
|
||||||
|
const result: input.Keymap.Translation = if (event_text) |text| .{
|
||||||
|
.text = text,
|
||||||
|
.composing = event.composing,
|
||||||
|
} else try self.keymap.translate(
|
||||||
|
&buf,
|
||||||
|
switch (target) {
|
||||||
|
.app => &self.keymap_state,
|
||||||
|
.surface => |surface| &surface.keymap_state,
|
||||||
|
},
|
||||||
|
@intCast(keycode),
|
||||||
|
translate_mods,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If this is a dead key, then we're composing a character and
|
||||||
|
// we need to set our proper preedit state if we're targeting a
|
||||||
|
// surface.
|
||||||
|
if (result.composing) {
|
||||||
|
switch (target) {
|
||||||
|
.app => {},
|
||||||
|
.surface => |surface| surface.core_surface.preeditCallback(
|
||||||
|
result.text,
|
||||||
|
) catch |err| {
|
||||||
|
log.err("error in preedit callback err={}", .{err});
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (target) {
|
||||||
|
.app => {},
|
||||||
|
.surface => |surface| surface.core_surface.preeditCallback(null) catch |err| {
|
||||||
|
log.err("error in preedit callback err={}", .{err});
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the text is just a single non-printable ASCII character
|
||||||
|
// then we clear the text. We handle non-printables in the
|
||||||
|
// key encoder manual (such as tab, ctrl+c, etc.)
|
||||||
|
if (result.text.len == 1 and result.text[0] < 0x20) {
|
||||||
|
break :translate .{ .composing = false, .text = "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break :translate result;
|
||||||
|
} else .{ .composing = false, .text = "" };
|
||||||
|
|
||||||
|
// UCKeyTranslate always consumes all mods, so if we have any output
|
||||||
|
// then we've consumed our translate mods.
|
||||||
|
const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{};
|
||||||
|
|
||||||
|
// We need to always do a translation with no modifiers at all in
|
||||||
|
// order to get the "unshifted_codepoint" for the key event.
|
||||||
|
const unshifted_codepoint: u21 = unshifted: {
|
||||||
|
var nomod_buf: [128]u8 = undefined;
|
||||||
|
var nomod_state: input.Keymap.State = .{};
|
||||||
|
const nomod = try self.keymap.translate(
|
||||||
|
&nomod_buf,
|
||||||
|
&nomod_state,
|
||||||
|
@intCast(keycode),
|
||||||
|
.{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = std.unicode.Utf8View.init(nomod.text) catch |err| {
|
||||||
|
log.warn("cannot build utf8 view over text: {}", .{err});
|
||||||
|
break :unshifted 0;
|
||||||
|
};
|
||||||
|
var it = view.iterator();
|
||||||
|
break :unshifted it.nextCodepoint() orelse 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{
|
||||||
|
// action,
|
||||||
|
// keycode,
|
||||||
|
// result.composing,
|
||||||
|
// result.text.len,
|
||||||
|
// result.text,
|
||||||
|
// result.text,
|
||||||
|
// mods,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// We want to get the physical unmapped key to process keybinds.
|
||||||
|
const physical_key = keycode: for (input.keycodes.entries) |entry| {
|
||||||
|
if (entry.native == keycode) break :keycode entry.key;
|
||||||
|
} else .invalid;
|
||||||
|
|
||||||
|
// If the resulting text has length 1 then we can take its key
|
||||||
|
// and attempt to translate it to a key enum and call the key callback.
|
||||||
|
// If the length is greater than 1 then we're going to call the
|
||||||
|
// charCallback.
|
||||||
|
//
|
||||||
|
// We also only do key translation if this is not a dead key.
|
||||||
|
const key = if (!result.composing) key: {
|
||||||
|
// If our physical key is a keypad key, we use that.
|
||||||
|
if (physical_key.keypad()) break :key physical_key;
|
||||||
|
|
||||||
|
// A completed key. If the length of the key is one then we can
|
||||||
|
// attempt to translate it to a key enum and call the key
|
||||||
|
// callback. First try plain ASCII.
|
||||||
|
if (result.text.len > 0) {
|
||||||
|
if (input.Key.fromASCII(result.text[0])) |key| {
|
||||||
|
break :key key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the above doesn't work, we use the unmodified value.
|
||||||
|
if (std.math.cast(u8, unshifted_codepoint)) |ascii| {
|
||||||
|
if (input.Key.fromASCII(ascii)) |key| {
|
||||||
|
break :key key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break :key physical_key;
|
||||||
|
} else .invalid;
|
||||||
|
|
||||||
|
// Build our final key event
|
||||||
|
const input_event: input.KeyEvent = .{
|
||||||
|
.action = action,
|
||||||
|
.key = key,
|
||||||
|
.physical_key = physical_key,
|
||||||
|
.mods = mods,
|
||||||
|
.consumed_mods = consumed_mods,
|
||||||
|
.composing = result.composing,
|
||||||
|
.utf8 = result.text,
|
||||||
|
.unshifted_codepoint = unshifted_codepoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Invoke the core Ghostty logic to handle this input.
|
||||||
|
const effect: CoreSurface.InputEffect = switch (target) {
|
||||||
|
.app => if (self.core_app.keyEvent(
|
||||||
|
self,
|
||||||
|
input_event,
|
||||||
|
))
|
||||||
|
.consumed
|
||||||
|
else
|
||||||
|
.ignored,
|
||||||
|
|
||||||
|
.surface => |surface| try surface.core_surface.keyCallback(input_event),
|
||||||
|
};
|
||||||
|
|
||||||
|
return switch (effect) {
|
||||||
|
.closed => true,
|
||||||
|
.ignored => false,
|
||||||
|
.consumed => consumed: {
|
||||||
|
if (is_down) {
|
||||||
|
// If we consume the key then we want to reset the dead
|
||||||
|
// key state.
|
||||||
|
self.keymap_state = .{};
|
||||||
|
|
||||||
|
switch (target) {
|
||||||
|
.app => {},
|
||||||
|
.surface => |surface| surface.core_surface.preeditCallback(null) catch {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break :consumed true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// This should be called whenever the keyboard layout was changed.
|
/// This should be called whenever the keyboard layout was changed.
|
||||||
pub fn reloadKeymap(self: *App) !void {
|
pub fn reloadKeymap(self: *App) !void {
|
||||||
// Reload the keymap
|
// Reload the keymap
|
||||||
@ -227,14 +493,19 @@ pub const App = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn newWindow(self: *App, parent: ?*CoreSurface) !void {
|
pub fn newWindow(self: *App, parent: ?*CoreSurface) !void {
|
||||||
_ = self;
|
// If we have a parent, the surface logic handles it.
|
||||||
|
|
||||||
// Right now we only support creating a new window with a parent
|
|
||||||
// through this code.
|
|
||||||
// The other case is handled by the embedding runtime.
|
|
||||||
if (parent) |surface| {
|
if (parent) |surface| {
|
||||||
try surface.rt_surface.newWindow();
|
try surface.rt_surface.newWindow();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No parent, call the new window callback.
|
||||||
|
const func = self.opts.new_window orelse {
|
||||||
|
log.info("runtime embedder does not support new_window", .{});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
func(null, .{});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -336,20 +607,6 @@ pub const Surface = struct {
|
|||||||
command: [*:0]const u8 = "",
|
command: [*:0]const u8 = "",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// This is the key event sent for ghostty_surface_key.
|
|
||||||
pub const KeyEvent = struct {
|
|
||||||
/// The three below are absolutely required.
|
|
||||||
action: input.Action,
|
|
||||||
mods: input.Mods,
|
|
||||||
keycode: u32,
|
|
||||||
|
|
||||||
/// Optionally, the embedder can handle text translation and send
|
|
||||||
/// the text value here. If text is non-nil, it is assumed that the
|
|
||||||
/// embedder also handles dead key states and sets composing as necessary.
|
|
||||||
text: ?[:0]const u8,
|
|
||||||
composing: bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn init(self: *Surface, app: *App, opts: Options) !void {
|
pub fn init(self: *Surface, app: *App, opts: Options) !void {
|
||||||
self.* = .{
|
self.* = .{
|
||||||
.app = app,
|
.app = app,
|
||||||
@ -787,198 +1044,6 @@ pub const Surface = struct {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn keyCallback(
|
|
||||||
self: *Surface,
|
|
||||||
event: KeyEvent,
|
|
||||||
) !void {
|
|
||||||
const action = event.action;
|
|
||||||
const keycode = event.keycode;
|
|
||||||
const mods = event.mods;
|
|
||||||
|
|
||||||
// True if this is a key down event
|
|
||||||
const is_down = action == .press or action == .repeat;
|
|
||||||
|
|
||||||
// If we're on macOS and we have macos-option-as-alt enabled,
|
|
||||||
// then we strip the alt modifier from the mods for translation.
|
|
||||||
const translate_mods = translate_mods: {
|
|
||||||
var translate_mods = mods;
|
|
||||||
if (comptime builtin.target.isDarwin()) {
|
|
||||||
const strip = switch (self.app.config.@"macos-option-as-alt") {
|
|
||||||
.false => false,
|
|
||||||
.true => mods.alt,
|
|
||||||
.left => mods.sides.alt == .left,
|
|
||||||
.right => mods.sides.alt == .right,
|
|
||||||
};
|
|
||||||
if (strip) translate_mods.alt = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// On macOS we strip ctrl because UCKeyTranslate
|
|
||||||
// converts to the masked values (i.e. ctrl+c becomes 3)
|
|
||||||
// and we don't want that behavior.
|
|
||||||
//
|
|
||||||
// We also strip super because its not used for translation
|
|
||||||
// on macos and it results in a bad translation.
|
|
||||||
if (comptime builtin.target.isDarwin()) {
|
|
||||||
translate_mods.ctrl = false;
|
|
||||||
translate_mods.super = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
break :translate_mods translate_mods;
|
|
||||||
};
|
|
||||||
|
|
||||||
const event_text: ?[]const u8 = event_text: {
|
|
||||||
// This logic only applies to macOS.
|
|
||||||
if (comptime builtin.os.tag != .macos) break :event_text event.text;
|
|
||||||
|
|
||||||
// If the modifiers are ONLY "control" then we never process
|
|
||||||
// the event text because we want to do our own translation so
|
|
||||||
// we can handle ctrl+c, ctrl+z, etc.
|
|
||||||
//
|
|
||||||
// This is specifically because on macOS using the
|
|
||||||
// "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as
|
|
||||||
// "/" (the physical key that is z on a qwerty keyboard). But on
|
|
||||||
// other layouts, ctrl+<char> is not translated by AppKit. So,
|
|
||||||
// we just avoid this by never allowing AppKit to translate
|
|
||||||
// ctrl+<char> and instead do it ourselves.
|
|
||||||
const ctrl_only = comptime (input.Mods{ .ctrl = true }).int();
|
|
||||||
break :event_text if (mods.binding().int() == ctrl_only) null else event.text;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Translate our key using the keymap for our localized keyboard layout.
|
|
||||||
// We only translate for keydown events. Otherwise, we only care about
|
|
||||||
// the raw keycode.
|
|
||||||
var buf: [128]u8 = undefined;
|
|
||||||
const result: input.Keymap.Translation = if (is_down) translate: {
|
|
||||||
// If the event provided us with text, then we use this as a result
|
|
||||||
// and do not do manual translation.
|
|
||||||
const result: input.Keymap.Translation = if (event_text) |text| .{
|
|
||||||
.text = text,
|
|
||||||
.composing = event.composing,
|
|
||||||
} else try self.app.keymap.translate(
|
|
||||||
&buf,
|
|
||||||
&self.keymap_state,
|
|
||||||
@intCast(keycode),
|
|
||||||
translate_mods,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If this is a dead key, then we're composing a character and
|
|
||||||
// we need to set our proper preedit state.
|
|
||||||
if (result.composing) {
|
|
||||||
self.core_surface.preeditCallback(result.text) catch |err| {
|
|
||||||
log.err("error in preedit callback err={}", .{err});
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// If we aren't composing, then we set our preedit to
|
|
||||||
// empty no matter what.
|
|
||||||
self.core_surface.preeditCallback(null) catch {};
|
|
||||||
|
|
||||||
// If the text is just a single non-printable ASCII character
|
|
||||||
// then we clear the text. We handle non-printables in the
|
|
||||||
// key encoder manual (such as tab, ctrl+c, etc.)
|
|
||||||
if (result.text.len == 1 and result.text[0] < 0x20) {
|
|
||||||
break :translate .{ .composing = false, .text = "" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break :translate result;
|
|
||||||
} else .{ .composing = false, .text = "" };
|
|
||||||
|
|
||||||
// UCKeyTranslate always consumes all mods, so if we have any output
|
|
||||||
// then we've consumed our translate mods.
|
|
||||||
const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{};
|
|
||||||
|
|
||||||
// We need to always do a translation with no modifiers at all in
|
|
||||||
// order to get the "unshifted_codepoint" for the key event.
|
|
||||||
const unshifted_codepoint: u21 = unshifted: {
|
|
||||||
var nomod_buf: [128]u8 = undefined;
|
|
||||||
var nomod_state: input.Keymap.State = .{};
|
|
||||||
const nomod = try self.app.keymap.translate(
|
|
||||||
&nomod_buf,
|
|
||||||
&nomod_state,
|
|
||||||
@intCast(keycode),
|
|
||||||
.{},
|
|
||||||
);
|
|
||||||
|
|
||||||
const view = std.unicode.Utf8View.init(nomod.text) catch |err| {
|
|
||||||
log.warn("cannot build utf8 view over text: {}", .{err});
|
|
||||||
break :unshifted 0;
|
|
||||||
};
|
|
||||||
var it = view.iterator();
|
|
||||||
break :unshifted it.nextCodepoint() orelse 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{
|
|
||||||
// action,
|
|
||||||
// keycode,
|
|
||||||
// result.composing,
|
|
||||||
// result.text.len,
|
|
||||||
// result.text,
|
|
||||||
// result.text,
|
|
||||||
// mods,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// We want to get the physical unmapped key to process keybinds.
|
|
||||||
const physical_key = keycode: for (input.keycodes.entries) |entry| {
|
|
||||||
if (entry.native == keycode) break :keycode entry.key;
|
|
||||||
} else .invalid;
|
|
||||||
|
|
||||||
// If the resulting text has length 1 then we can take its key
|
|
||||||
// and attempt to translate it to a key enum and call the key callback.
|
|
||||||
// If the length is greater than 1 then we're going to call the
|
|
||||||
// charCallback.
|
|
||||||
//
|
|
||||||
// We also only do key translation if this is not a dead key.
|
|
||||||
const key = if (!result.composing) key: {
|
|
||||||
// If our physical key is a keypad key, we use that.
|
|
||||||
if (physical_key.keypad()) break :key physical_key;
|
|
||||||
|
|
||||||
// A completed key. If the length of the key is one then we can
|
|
||||||
// attempt to translate it to a key enum and call the key
|
|
||||||
// callback. First try plain ASCII.
|
|
||||||
if (result.text.len > 0) {
|
|
||||||
if (input.Key.fromASCII(result.text[0])) |key| {
|
|
||||||
break :key key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the above doesn't work, we use the unmodified value.
|
|
||||||
if (std.math.cast(u8, unshifted_codepoint)) |ascii| {
|
|
||||||
if (input.Key.fromASCII(ascii)) |key| {
|
|
||||||
break :key key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break :key physical_key;
|
|
||||||
} else .invalid;
|
|
||||||
|
|
||||||
// Invoke the core Ghostty logic to handle this input.
|
|
||||||
const effect = self.core_surface.keyCallback(.{
|
|
||||||
.action = action,
|
|
||||||
.key = key,
|
|
||||||
.physical_key = physical_key,
|
|
||||||
.mods = mods,
|
|
||||||
.consumed_mods = consumed_mods,
|
|
||||||
.composing = result.composing,
|
|
||||||
.utf8 = result.text,
|
|
||||||
.unshifted_codepoint = unshifted_codepoint,
|
|
||||||
}) catch |err| {
|
|
||||||
log.err("error in key callback err={}", .{err});
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (effect) {
|
|
||||||
.closed => return,
|
|
||||||
.ignored => {},
|
|
||||||
.consumed => if (is_down) {
|
|
||||||
// If we consume the key then we want to reset the dead
|
|
||||||
// key state.
|
|
||||||
self.keymap_state = .{};
|
|
||||||
self.core_surface.preeditCallback(null) catch {};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn textCallback(self: *Surface, text: []const u8) void {
|
pub fn textCallback(self: *Surface, text: []const u8) void {
|
||||||
_ = self.core_surface.textCallback(text) catch |err| {
|
_ = self.core_surface.textCallback(text) catch |err| {
|
||||||
log.err("error in key callback err={}", .{err});
|
log.err("error in key callback err={}", .{err});
|
||||||
@ -1398,7 +1463,7 @@ pub const CAPI = struct {
|
|||||||
composing: bool,
|
composing: bool,
|
||||||
|
|
||||||
/// Convert to surface key event.
|
/// Convert to surface key event.
|
||||||
fn keyEvent(self: KeyEvent) Surface.KeyEvent {
|
fn keyEvent(self: KeyEvent) App.KeyEvent {
|
||||||
return .{
|
return .{
|
||||||
.action = self.action,
|
.action = self.action,
|
||||||
.mods = @bitCast(@as(
|
.mods = @bitCast(@as(
|
||||||
@ -1484,6 +1549,19 @@ pub const CAPI = struct {
|
|||||||
core_app.destroy();
|
core_app.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Notify the app of a global keypress capture. This will return
|
||||||
|
/// true if the key was captured by the app, in which case the caller
|
||||||
|
/// should not process the key.
|
||||||
|
export fn ghostty_app_key(
|
||||||
|
app: *App,
|
||||||
|
event: KeyEvent,
|
||||||
|
) bool {
|
||||||
|
return app.keyEvent(.app, event.keyEvent()) catch |err| {
|
||||||
|
log.warn("error processing key event err={}", .{err});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Notify the app that the keyboard was changed. This causes the
|
/// Notify the app that the keyboard was changed. This causes the
|
||||||
/// keyboard layout to be reloaded from the OS.
|
/// keyboard layout to be reloaded from the OS.
|
||||||
export fn ghostty_app_keyboard_changed(v: *App) void {
|
export fn ghostty_app_keyboard_changed(v: *App) void {
|
||||||
@ -1514,6 +1592,11 @@ pub const CAPI = struct {
|
|||||||
return v.core_app.needsConfirmQuit();
|
return v.core_app.needsConfirmQuit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if the app has global keybinds.
|
||||||
|
export fn ghostty_app_has_global_keybinds(v: *App) bool {
|
||||||
|
return v.hasGlobalKeybinds();
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns initial surface options.
|
/// Returns initial surface options.
|
||||||
export fn ghostty_surface_config_new() apprt.Surface.Options {
|
export fn ghostty_surface_config_new() apprt.Surface.Options {
|
||||||
return .{};
|
return .{};
|
||||||
@ -1672,16 +1755,15 @@ pub const CAPI = struct {
|
|||||||
/// Send this for raw keypresses (i.e. the keyDown event on macOS).
|
/// Send this for raw keypresses (i.e. the keyDown event on macOS).
|
||||||
/// This will handle the keymap translation and send the appropriate
|
/// This will handle the keymap translation and send the appropriate
|
||||||
/// key and char events.
|
/// key and char events.
|
||||||
///
|
|
||||||
/// You do NOT need to also send "ghostty_surface_char" unless
|
|
||||||
/// you want to send a unicode character that is not associated
|
|
||||||
/// with a keypress, i.e. IME keyboard.
|
|
||||||
export fn ghostty_surface_key(
|
export fn ghostty_surface_key(
|
||||||
surface: *Surface,
|
surface: *Surface,
|
||||||
event: KeyEvent,
|
event: KeyEvent,
|
||||||
) void {
|
) void {
|
||||||
surface.keyCallback(event.keyEvent()) catch |err| {
|
_ = surface.app.keyEvent(
|
||||||
log.err("error processing key event err={}", .{err});
|
.{ .surface = surface },
|
||||||
|
event.keyEvent(),
|
||||||
|
) catch |err| {
|
||||||
|
log.warn("error processing key event err={}", .{err});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -116,7 +116,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
|
|||||||
while (iter.next()) |bind| {
|
while (iter.next()) |bind| {
|
||||||
const action = switch (bind.value_ptr.*) {
|
const action = switch (bind.value_ptr.*) {
|
||||||
.leader => continue, // TODO: support this
|
.leader => continue, // TODO: support this
|
||||||
.action, .action_unconsumed => |action| action,
|
.leaf => |leaf| leaf.action,
|
||||||
};
|
};
|
||||||
const key = switch (bind.key_ptr.key) {
|
const key = switch (bind.key_ptr.key) {
|
||||||
.translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}),
|
.translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}),
|
||||||
|
@ -651,7 +651,8 @@ class: ?[:0]const u8 = null,
|
|||||||
@"working-directory": ?[]const u8 = null,
|
@"working-directory": ?[]const u8 = null,
|
||||||
|
|
||||||
/// Key bindings. The format is `trigger=action`. Duplicate triggers will
|
/// Key bindings. The format is `trigger=action`. Duplicate triggers will
|
||||||
/// overwrite previously set values.
|
/// overwrite previously set values. The list of actions is available in
|
||||||
|
/// the documentation or using the `ghostty +list-actions` command.
|
||||||
///
|
///
|
||||||
/// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`,
|
/// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`,
|
||||||
/// `ctrl+shift+b`, `up`. Some notes:
|
/// `ctrl+shift+b`, `up`. Some notes:
|
||||||
@ -703,6 +704,9 @@ class: ?[:0]const u8 = null,
|
|||||||
/// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and
|
/// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and
|
||||||
/// `ctrl+a>t` will become unbound.
|
/// `ctrl+a>t` will become unbound.
|
||||||
///
|
///
|
||||||
|
/// * Trigger sequences are not allowed for `global:` or `all:`-prefixed
|
||||||
|
/// triggers. This is a limitation we could remove in the future.
|
||||||
|
///
|
||||||
/// Action is the action to take when the trigger is satisfied. It takes the
|
/// Action is the action to take when the trigger is satisfied. It takes the
|
||||||
/// format `action` or `action:param`. The latter form is only valid if the
|
/// format `action` or `action:param`. The latter form is only valid if the
|
||||||
/// action requires a parameter.
|
/// action requires a parameter.
|
||||||
@ -722,6 +726,9 @@ class: ?[:0]const u8 = null,
|
|||||||
/// * `text:text` - Send a string. Uses Zig string literal syntax.
|
/// * `text:text` - Send a string. Uses Zig string literal syntax.
|
||||||
/// i.e. `text:\x15` sends Ctrl-U.
|
/// i.e. `text:\x15` sends Ctrl-U.
|
||||||
///
|
///
|
||||||
|
/// * All other actions can be found in the documentation or by using the
|
||||||
|
/// `ghostty +list-actions` command.
|
||||||
|
///
|
||||||
/// Some notes for the action:
|
/// Some notes for the action:
|
||||||
///
|
///
|
||||||
/// * The parameter is taken as-is after the `:`. Double quotes or
|
/// * The parameter is taken as-is after the `:`. Double quotes or
|
||||||
@ -736,11 +743,48 @@ class: ?[:0]const u8 = null,
|
|||||||
/// removes ALL keybindings up to this point, including the default
|
/// removes ALL keybindings up to this point, including the default
|
||||||
/// keybindings.
|
/// keybindings.
|
||||||
///
|
///
|
||||||
/// A keybind by default causes the input to be consumed. This means that the
|
/// The keybind trigger can be prefixed with some special values to change
|
||||||
/// associated encoding (if any) will not be sent to the running program
|
/// the behavior of the keybind. These are:
|
||||||
/// in the terminal. If you wish to send the encoded value to the program,
|
///
|
||||||
/// specify the "unconsumed:" prefix before the entire keybind. For example:
|
/// * `all:` - Make the keybind apply to all terminal surfaces. By default,
|
||||||
/// "unconsumed:ctrl+a=reload_config"
|
/// keybinds only apply to the focused terminal surface. If this is true,
|
||||||
|
/// then the keybind will be sent to all terminal surfaces. This only
|
||||||
|
/// applies to actions that are surface-specific. For actions that
|
||||||
|
/// are already global (i.e. `quit`), this prefix has no effect.
|
||||||
|
///
|
||||||
|
/// * `global:` - Make the keybind global. By default, keybinds only work
|
||||||
|
/// within Ghostty and under the right conditions (application focused,
|
||||||
|
/// sometimes terminal focused, etc.). If you want a keybind to work
|
||||||
|
/// globally across your system (i.e. even when Ghostty is not focused),
|
||||||
|
/// specify this prefix. This prefix implies `all:`. Note: this does not
|
||||||
|
/// work in all environments; see the additional notes below for more
|
||||||
|
/// information.
|
||||||
|
///
|
||||||
|
/// * `unconsumed:` - Do not consume the input. By default, a keybind
|
||||||
|
/// will consume the input, meaning that the associated encoding (if
|
||||||
|
/// any) will not be sent to the running program in the terminal. If
|
||||||
|
/// you wish to send the encoded value to the program, specify the
|
||||||
|
/// `unconsumed:` prefix before the entire keybind. For example:
|
||||||
|
/// `unconsumed:ctrl+a=reload_config`. `global:` and `all:`-prefixed
|
||||||
|
/// keybinds will always consume the input regardless of this setting.
|
||||||
|
/// Since they are not associated with a specific terminal surface,
|
||||||
|
/// they're never encoded.
|
||||||
|
///
|
||||||
|
/// Keybind trigger are not unique per prefix combination. For example,
|
||||||
|
/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind
|
||||||
|
/// set later will overwrite the keybind set earlier. In this case, the
|
||||||
|
/// `global:` keybind will be used.
|
||||||
|
///
|
||||||
|
/// Multiple prefixes can be specified. For example,
|
||||||
|
/// `global:unconsumed:ctrl+a=reload_config` will make the keybind global
|
||||||
|
/// and not consume the input to reload the config.
|
||||||
|
///
|
||||||
|
/// A note on `global:`: this feature is only supported on macOS. On macOS,
|
||||||
|
/// this feature requires accessibility permissions to be granted to Ghostty.
|
||||||
|
/// When a `global:` keybind is specified and Ghostty is launched or reloaded,
|
||||||
|
/// Ghostty will attempt to request these permissions. If the permissions are
|
||||||
|
/// not granted, the keybind will not work. On macOS, you can find these
|
||||||
|
/// permissions in System Preferences -> Privacy & Security -> Accessibility.
|
||||||
keybind: Keybinds = .{},
|
keybind: Keybinds = .{},
|
||||||
|
|
||||||
/// Horizontal window padding. This applies padding between the terminal cells
|
/// Horizontal window padding. This applies padding between the terminal cells
|
||||||
@ -3704,11 +3748,16 @@ pub const Keybinds = struct {
|
|||||||
)) return false,
|
)) return false,
|
||||||
|
|
||||||
// Actions are compared by field directly
|
// Actions are compared by field directly
|
||||||
inline .action, .action_unconsumed => |_, tag| if (!equalField(
|
.leaf => {
|
||||||
inputpkg.Binding.Action,
|
const self_leaf = self_entry.value_ptr.*.leaf;
|
||||||
@field(self_entry.value_ptr.*, @tagName(tag)),
|
const other_leaf = other_entry.value_ptr.*.leaf;
|
||||||
@field(other_entry.value_ptr.*, @tagName(tag)),
|
|
||||||
)) return false,
|
if (!equalField(
|
||||||
|
inputpkg.Binding.Set.Leaf,
|
||||||
|
self_leaf,
|
||||||
|
other_leaf,
|
||||||
|
)) return false;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ const std = @import("std");
|
|||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const assert = std.debug.assert;
|
const assert = std.debug.assert;
|
||||||
const key = @import("key.zig");
|
const key = @import("key.zig");
|
||||||
|
const KeyEvent = key.KeyEvent;
|
||||||
|
|
||||||
/// The trigger that needs to be performed to execute the action.
|
/// The trigger that needs to be performed to execute the action.
|
||||||
trigger: Trigger,
|
trigger: Trigger,
|
||||||
@ -13,21 +14,36 @@ trigger: Trigger,
|
|||||||
/// The action to take if this binding matches
|
/// The action to take if this binding matches
|
||||||
action: Action,
|
action: Action,
|
||||||
|
|
||||||
/// True if this binding should consume the input when the
|
/// Boolean flags that can be set per binding.
|
||||||
/// action is triggered.
|
flags: Flags = .{},
|
||||||
consumed: bool = true,
|
|
||||||
|
|
||||||
pub const Error = error{
|
pub const Error = error{
|
||||||
InvalidFormat,
|
InvalidFormat,
|
||||||
InvalidAction,
|
InvalidAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Flags the full binding-scoped flags that can be set per binding.
|
||||||
|
pub const Flags = packed struct {
|
||||||
|
/// True if this binding should consume the input when the
|
||||||
|
/// action is triggered.
|
||||||
|
consumed: bool = true,
|
||||||
|
|
||||||
|
/// True if this binding should be forwarded to all active surfaces
|
||||||
|
/// in the application.
|
||||||
|
all: bool = false,
|
||||||
|
|
||||||
|
/// True if this binding is global. Global bindings should work system-wide
|
||||||
|
/// and not just while Ghostty is focused. This may not work on all platforms.
|
||||||
|
/// See the keybind config documentation for more information.
|
||||||
|
global: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
/// Full binding parser. The binding parser is implemented as an iterator
|
/// Full binding parser. The binding parser is implemented as an iterator
|
||||||
/// which yields elements to support multi-key sequences without allocation.
|
/// which yields elements to support multi-key sequences without allocation.
|
||||||
pub const Parser = struct {
|
pub const Parser = struct {
|
||||||
unconsumed: bool = false,
|
|
||||||
trigger_it: SequenceIterator,
|
trigger_it: SequenceIterator,
|
||||||
action: Action,
|
action: Action,
|
||||||
|
flags: Flags = .{},
|
||||||
|
|
||||||
pub const Elem = union(enum) {
|
pub const Elem = union(enum) {
|
||||||
/// A leader trigger in a sequence.
|
/// A leader trigger in a sequence.
|
||||||
@ -38,11 +54,7 @@ pub const Parser = struct {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub fn init(raw_input: []const u8) Error!Parser {
|
pub fn init(raw_input: []const u8) Error!Parser {
|
||||||
// If our entire input is prefixed with "unconsumed:" then we are
|
const flags, const start_idx = try parseFlags(raw_input);
|
||||||
// not consuming this keybind when the action is triggered.
|
|
||||||
const unconsumed_prefix = "unconsumed:";
|
|
||||||
const unconsumed = std.mem.startsWith(u8, raw_input, unconsumed_prefix);
|
|
||||||
const start_idx = if (unconsumed) unconsumed_prefix.len else 0;
|
|
||||||
const input = raw_input[start_idx..];
|
const input = raw_input[start_idx..];
|
||||||
|
|
||||||
// Find the first = which splits are mapping into the trigger
|
// Find the first = which splits are mapping into the trigger
|
||||||
@ -52,24 +64,63 @@ pub const Parser = struct {
|
|||||||
// Sequence iterator goes up to the equal, action is after. We can
|
// Sequence iterator goes up to the equal, action is after. We can
|
||||||
// parse the action now.
|
// parse the action now.
|
||||||
return .{
|
return .{
|
||||||
.unconsumed = unconsumed,
|
|
||||||
.trigger_it = .{ .input = input[0..eql_idx] },
|
.trigger_it = .{ .input = input[0..eql_idx] },
|
||||||
.action = try Action.parse(input[eql_idx + 1 ..]),
|
.action = try Action.parse(input[eql_idx + 1 ..]),
|
||||||
|
.flags = flags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parseFlags(raw_input: []const u8) Error!struct { Flags, usize } {
|
||||||
|
var flags: Flags = .{};
|
||||||
|
|
||||||
|
var start_idx: usize = 0;
|
||||||
|
var input: []const u8 = raw_input;
|
||||||
|
while (true) {
|
||||||
|
// Find the next prefix
|
||||||
|
const idx = std.mem.indexOf(u8, input, ":") orelse break;
|
||||||
|
const prefix = input[0..idx];
|
||||||
|
|
||||||
|
// If the prefix is one of our flags then set it.
|
||||||
|
if (std.mem.eql(u8, prefix, "all")) {
|
||||||
|
if (flags.all) return Error.InvalidFormat;
|
||||||
|
flags.all = true;
|
||||||
|
} else if (std.mem.eql(u8, prefix, "global")) {
|
||||||
|
if (flags.global) return Error.InvalidFormat;
|
||||||
|
flags.global = true;
|
||||||
|
} else if (std.mem.eql(u8, prefix, "unconsumed")) {
|
||||||
|
if (!flags.consumed) return Error.InvalidFormat;
|
||||||
|
flags.consumed = false;
|
||||||
|
} else {
|
||||||
|
// If we don't recognize the prefix then we're done.
|
||||||
|
// There are trigger-specific prefixes like "physical:" so
|
||||||
|
// this lets us fall into that.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move past the prefix
|
||||||
|
start_idx += idx + 1;
|
||||||
|
input = input[idx + 1 ..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{ flags, start_idx };
|
||||||
|
}
|
||||||
|
|
||||||
pub fn next(self: *Parser) Error!?Elem {
|
pub fn next(self: *Parser) Error!?Elem {
|
||||||
// Get our trigger. If we're out of triggers then we're done.
|
// Get our trigger. If we're out of triggers then we're done.
|
||||||
const trigger = (try self.trigger_it.next()) orelse return null;
|
const trigger = (try self.trigger_it.next()) orelse return null;
|
||||||
|
|
||||||
// If this is our last trigger then it is our final binding.
|
// If this is our last trigger then it is our final binding.
|
||||||
if (!self.trigger_it.done()) return .{ .leader = trigger };
|
if (!self.trigger_it.done()) {
|
||||||
|
// Global/all bindings can't be sequences
|
||||||
|
if (self.flags.global or self.flags.all) return error.InvalidFormat;
|
||||||
|
return .{ .leader = trigger };
|
||||||
|
}
|
||||||
|
|
||||||
// Out of triggers, yield the final action.
|
// Out of triggers, yield the final action.
|
||||||
return .{ .binding = .{
|
return .{ .binding = .{
|
||||||
.trigger = trigger,
|
.trigger = trigger,
|
||||||
.action = self.action,
|
.action = self.action,
|
||||||
.consumed = !self.unconsumed,
|
.flags = self.flags,
|
||||||
} };
|
} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,7 +279,8 @@ pub const Action = union(enum) {
|
|||||||
/// available values.
|
/// available values.
|
||||||
write_selection_file: WriteScreenAction,
|
write_selection_file: WriteScreenAction,
|
||||||
|
|
||||||
/// Open a new window.
|
/// Open a new window. If the application isn't currently focused,
|
||||||
|
/// this will bring it to the front.
|
||||||
new_window: void,
|
new_window: void,
|
||||||
|
|
||||||
/// Open a new tab.
|
/// Open a new tab.
|
||||||
@ -489,6 +541,142 @@ pub const Action = union(enum) {
|
|||||||
return Error.InvalidAction;
|
return Error.InvalidAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The scope of an action. The scope is the context in which an action
|
||||||
|
/// must be executed.
|
||||||
|
pub const Scope = enum {
|
||||||
|
app,
|
||||||
|
surface,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Returns the scope of an action.
|
||||||
|
pub fn scope(self: Action) Scope {
|
||||||
|
return switch (self) {
|
||||||
|
// Doesn't really matter, so we'll see app.
|
||||||
|
.ignore,
|
||||||
|
.unbind,
|
||||||
|
=> .app,
|
||||||
|
|
||||||
|
// Obviously app actions.
|
||||||
|
.open_config,
|
||||||
|
.reload_config,
|
||||||
|
.close_all_windows,
|
||||||
|
.quit,
|
||||||
|
=> .app,
|
||||||
|
|
||||||
|
// These are app but can be special-cased in a surface context.
|
||||||
|
.new_window,
|
||||||
|
=> .app,
|
||||||
|
|
||||||
|
// Obviously surface actions.
|
||||||
|
.csi,
|
||||||
|
.esc,
|
||||||
|
.text,
|
||||||
|
.cursor_key,
|
||||||
|
.reset,
|
||||||
|
.copy_to_clipboard,
|
||||||
|
.paste_from_clipboard,
|
||||||
|
.paste_from_selection,
|
||||||
|
.increase_font_size,
|
||||||
|
.decrease_font_size,
|
||||||
|
.reset_font_size,
|
||||||
|
.clear_screen,
|
||||||
|
.select_all,
|
||||||
|
.scroll_to_top,
|
||||||
|
.scroll_to_bottom,
|
||||||
|
.scroll_page_up,
|
||||||
|
.scroll_page_down,
|
||||||
|
.scroll_page_fractional,
|
||||||
|
.scroll_page_lines,
|
||||||
|
.adjust_selection,
|
||||||
|
.jump_to_prompt,
|
||||||
|
.write_scrollback_file,
|
||||||
|
.write_screen_file,
|
||||||
|
.write_selection_file,
|
||||||
|
.close_surface,
|
||||||
|
.close_window,
|
||||||
|
.toggle_fullscreen,
|
||||||
|
.toggle_window_decorations,
|
||||||
|
.toggle_secure_input,
|
||||||
|
.crash,
|
||||||
|
=> .surface,
|
||||||
|
|
||||||
|
// These are less obvious surface actions. They're surface
|
||||||
|
// actions because they are relevant to the surface they
|
||||||
|
// come from. For example `new_window` needs to be sourced to
|
||||||
|
// a surface so inheritance can be done correctly.
|
||||||
|
.new_tab,
|
||||||
|
.previous_tab,
|
||||||
|
.next_tab,
|
||||||
|
.last_tab,
|
||||||
|
.goto_tab,
|
||||||
|
.new_split,
|
||||||
|
.goto_split,
|
||||||
|
.toggle_split_zoom,
|
||||||
|
.resize_split,
|
||||||
|
.equalize_splits,
|
||||||
|
.inspector,
|
||||||
|
=> .surface,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a union type that only contains actions that are scoped to
|
||||||
|
/// the given scope.
|
||||||
|
pub fn Scoped(comptime s: Scope) type {
|
||||||
|
const all_fields = @typeInfo(Action).Union.fields;
|
||||||
|
|
||||||
|
// Find all fields that are app-scoped
|
||||||
|
var i: usize = 0;
|
||||||
|
var union_fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
|
||||||
|
var enum_fields: [all_fields.len]std.builtin.Type.EnumField = undefined;
|
||||||
|
for (all_fields) |field| {
|
||||||
|
const action = @unionInit(Action, field.name, undefined);
|
||||||
|
if (action.scope() == s) {
|
||||||
|
union_fields[i] = field;
|
||||||
|
enum_fields[i] = .{ .name = field.name, .value = i };
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build our union
|
||||||
|
return @Type(.{ .Union = .{
|
||||||
|
.layout = .auto,
|
||||||
|
.tag_type = @Type(.{ .Enum = .{
|
||||||
|
.tag_type = std.math.IntFittingRange(0, i),
|
||||||
|
.fields = enum_fields[0..i],
|
||||||
|
.decls = &.{},
|
||||||
|
.is_exhaustive = true,
|
||||||
|
} }),
|
||||||
|
.fields = union_fields[0..i],
|
||||||
|
.decls = &.{},
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the scoped version of this action. If the action is not
|
||||||
|
/// scoped to the given scope then this returns null.
|
||||||
|
///
|
||||||
|
/// The benefit of this function is that it allows us to use Zig's
|
||||||
|
/// exhaustive switch safety to ensure we always properly handle certain
|
||||||
|
/// scoped actions.
|
||||||
|
pub fn scoped(self: Action, comptime s: Scope) ?Scoped(s) {
|
||||||
|
switch (self) {
|
||||||
|
inline else => |v, tag| {
|
||||||
|
// Use comptime to prune out non-app actions
|
||||||
|
if (comptime @unionInit(
|
||||||
|
Action,
|
||||||
|
@tagName(tag),
|
||||||
|
undefined,
|
||||||
|
).scope() != s) return null;
|
||||||
|
|
||||||
|
// Initialize our app action
|
||||||
|
return @unionInit(
|
||||||
|
Scoped(s),
|
||||||
|
@tagName(tag),
|
||||||
|
v,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Implements the formatter for the fmt package. This encodes the
|
/// Implements the formatter for the fmt package. This encodes the
|
||||||
/// action back into the format used by parse.
|
/// action back into the format used by parse.
|
||||||
pub fn format(
|
pub fn format(
|
||||||
@ -544,10 +732,15 @@ pub const Action = union(enum) {
|
|||||||
/// action.
|
/// action.
|
||||||
pub fn hash(self: Action) u64 {
|
pub fn hash(self: Action) u64 {
|
||||||
var hasher = std.hash.Wyhash.init(0);
|
var hasher = std.hash.Wyhash.init(0);
|
||||||
|
self.hashIncremental(&hasher);
|
||||||
|
return hasher.final();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash the action into the given hasher.
|
||||||
|
fn hashIncremental(self: Action, hasher: anytype) void {
|
||||||
// Always has the active tag.
|
// Always has the active tag.
|
||||||
const Tag = @typeInfo(Action).Union.tag_type.?;
|
const Tag = @typeInfo(Action).Union.tag_type.?;
|
||||||
std.hash.autoHash(&hasher, @as(Tag, self));
|
std.hash.autoHash(hasher, @as(Tag, self));
|
||||||
|
|
||||||
// Hash the value of the field.
|
// Hash the value of the field.
|
||||||
switch (self) {
|
switch (self) {
|
||||||
@ -562,25 +755,23 @@ pub const Action = union(enum) {
|
|||||||
// signed zeros but these are not cases we expect for
|
// signed zeros but these are not cases we expect for
|
||||||
// our bindings.
|
// our bindings.
|
||||||
f32 => std.hash.autoHash(
|
f32 => std.hash.autoHash(
|
||||||
&hasher,
|
hasher,
|
||||||
@as(u32, @bitCast(field)),
|
@as(u32, @bitCast(field)),
|
||||||
),
|
),
|
||||||
f64 => std.hash.autoHash(
|
f64 => std.hash.autoHash(
|
||||||
&hasher,
|
hasher,
|
||||||
@as(u64, @bitCast(field)),
|
@as(u64, @bitCast(field)),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Everything else automatically handle.
|
// Everything else automatically handle.
|
||||||
else => std.hash.autoHashStrat(
|
else => std.hash.autoHashStrat(
|
||||||
&hasher,
|
hasher,
|
||||||
field,
|
field,
|
||||||
.DeepRecursive,
|
.DeepRecursive,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasher.final();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -737,11 +928,16 @@ pub const Trigger = struct {
|
|||||||
/// Returns a hash code that can be used to uniquely identify this trigger.
|
/// Returns a hash code that can be used to uniquely identify this trigger.
|
||||||
pub fn hash(self: Trigger) u64 {
|
pub fn hash(self: Trigger) u64 {
|
||||||
var hasher = std.hash.Wyhash.init(0);
|
var hasher = std.hash.Wyhash.init(0);
|
||||||
std.hash.autoHash(&hasher, self.key);
|
self.hashIncremental(&hasher);
|
||||||
std.hash.autoHash(&hasher, self.mods.binding());
|
|
||||||
return hasher.final();
|
return hasher.final();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hash the trigger into the given hasher.
|
||||||
|
fn hashIncremental(self: Trigger, hasher: anytype) void {
|
||||||
|
std.hash.autoHash(hasher, self.key);
|
||||||
|
std.hash.autoHash(hasher, self.mods.binding());
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert the trigger to a C API compatible trigger.
|
/// Convert the trigger to a C API compatible trigger.
|
||||||
pub fn cval(self: Trigger) C {
|
pub fn cval(self: Trigger) C {
|
||||||
return .{
|
return .{
|
||||||
@ -818,10 +1014,8 @@ pub const Set = struct {
|
|||||||
leader: *Set,
|
leader: *Set,
|
||||||
|
|
||||||
/// This trigger completes a sequence and the value is the action
|
/// This trigger completes a sequence and the value is the action
|
||||||
/// to take. The "_unconsumed" variant is used for triggers that
|
/// to take along with the flags that may define binding behavior.
|
||||||
/// should not consume the input.
|
leaf: Leaf,
|
||||||
action: Action,
|
|
||||||
action_unconsumed: Action,
|
|
||||||
|
|
||||||
/// Implements the formatter for the fmt package. This encodes the
|
/// Implements the formatter for the fmt package. This encodes the
|
||||||
/// action back into the format used by parse.
|
/// action back into the format used by parse.
|
||||||
@ -846,14 +1040,28 @@ pub const Set = struct {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
.action, .action_unconsumed => |action| {
|
.leaf => |leaf| {
|
||||||
// action implements the format
|
// action implements the format
|
||||||
try writer.print("={s}", .{action});
|
try writer.print("={s}", .{leaf.action});
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Leaf node of a set is an action to trigger. This is a "leaf" compared
|
||||||
|
/// to the inner nodes which are "leaders" for sequences.
|
||||||
|
pub const Leaf = struct {
|
||||||
|
action: Action,
|
||||||
|
flags: Flags,
|
||||||
|
|
||||||
|
pub fn hash(self: Leaf) u64 {
|
||||||
|
var hasher = std.hash.Wyhash.init(0);
|
||||||
|
self.action.hash(&hasher);
|
||||||
|
std.hash.autoHash(&hasher, self.flags);
|
||||||
|
return hasher.final();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
pub fn deinit(self: *Set, alloc: Allocator) void {
|
pub fn deinit(self: *Set, alloc: Allocator) void {
|
||||||
// Clear any leaders if we have them
|
// Clear any leaders if we have them
|
||||||
var it = self.bindings.iterator();
|
var it = self.bindings.iterator();
|
||||||
@ -862,7 +1070,7 @@ pub const Set = struct {
|
|||||||
s.deinit(alloc);
|
s.deinit(alloc);
|
||||||
alloc.destroy(s);
|
alloc.destroy(s);
|
||||||
},
|
},
|
||||||
.action, .action_unconsumed => {},
|
.leaf => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.bindings.deinit(alloc);
|
self.bindings.deinit(alloc);
|
||||||
@ -934,7 +1142,7 @@ pub const Set = struct {
|
|||||||
error.OutOfMemory => return error.OutOfMemory,
|
error.OutOfMemory => return error.OutOfMemory,
|
||||||
},
|
},
|
||||||
|
|
||||||
.action, .action_unconsumed => {
|
.leaf => {
|
||||||
// Remove the existing action. Fallthrough as if
|
// Remove the existing action. Fallthrough as if
|
||||||
// we don't have a leader.
|
// we don't have a leader.
|
||||||
set.remove(alloc, t);
|
set.remove(alloc, t);
|
||||||
@ -958,11 +1166,11 @@ pub const Set = struct {
|
|||||||
set.remove(alloc, t);
|
set.remove(alloc, t);
|
||||||
if (old) |entry| switch (entry) {
|
if (old) |entry| switch (entry) {
|
||||||
.leader => unreachable, // Handled above
|
.leader => unreachable, // Handled above
|
||||||
inline .action, .action_unconsumed => |action, tag| set.put_(
|
.leaf => |leaf| set.putFlags(
|
||||||
alloc,
|
alloc,
|
||||||
t,
|
t,
|
||||||
action,
|
leaf.action,
|
||||||
tag == .action,
|
leaf.flags,
|
||||||
) catch {},
|
) catch {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -977,11 +1185,12 @@ pub const Set = struct {
|
|||||||
return error.SequenceUnbind;
|
return error.SequenceUnbind;
|
||||||
},
|
},
|
||||||
|
|
||||||
else => if (b.consumed) {
|
else => try set.putFlags(
|
||||||
try set.put(alloc, b.trigger, b.action);
|
alloc,
|
||||||
} else {
|
b.trigger,
|
||||||
try set.putUnconsumed(alloc, b.trigger, b.action);
|
b.action,
|
||||||
},
|
b.flags,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -994,29 +1203,16 @@ pub const Set = struct {
|
|||||||
t: Trigger,
|
t: Trigger,
|
||||||
action: Action,
|
action: Action,
|
||||||
) Allocator.Error!void {
|
) Allocator.Error!void {
|
||||||
try self.put_(alloc, t, action, true);
|
try self.putFlags(alloc, t, action, .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as put but marks the trigger as unconsumed. An unconsumed
|
/// Add a binding to the set with explicit flags.
|
||||||
/// trigger will evaluate the action and continue to encode for the
|
pub fn putFlags(
|
||||||
/// terminal.
|
|
||||||
///
|
|
||||||
/// This is a separate function because this case is rare.
|
|
||||||
pub fn putUnconsumed(
|
|
||||||
self: *Set,
|
self: *Set,
|
||||||
alloc: Allocator,
|
alloc: Allocator,
|
||||||
t: Trigger,
|
t: Trigger,
|
||||||
action: Action,
|
action: Action,
|
||||||
) Allocator.Error!void {
|
flags: Flags,
|
||||||
try self.put_(alloc, t, action, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn put_(
|
|
||||||
self: *Set,
|
|
||||||
alloc: Allocator,
|
|
||||||
t: Trigger,
|
|
||||||
action: Action,
|
|
||||||
consumed: bool,
|
|
||||||
) Allocator.Error!void {
|
) Allocator.Error!void {
|
||||||
// unbind should never go into the set, it should be handled prior
|
// unbind should never go into the set, it should be handled prior
|
||||||
assert(action != .unbind);
|
assert(action != .unbind);
|
||||||
@ -1032,7 +1228,7 @@ pub const Set = struct {
|
|||||||
|
|
||||||
// If we have an existing binding for this trigger, we have to
|
// If we have an existing binding for this trigger, we have to
|
||||||
// update the reverse mapping to remove the old action.
|
// update the reverse mapping to remove the old action.
|
||||||
.action, .action_unconsumed => {
|
.leaf => {
|
||||||
const t_hash = t.hash();
|
const t_hash = t.hash();
|
||||||
var it = self.reverse.iterator();
|
var it = self.reverse.iterator();
|
||||||
while (it.next()) |reverse_entry| it: {
|
while (it.next()) |reverse_entry| it: {
|
||||||
@ -1044,11 +1240,10 @@ pub const Set = struct {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
gop.value_ptr.* = if (consumed) .{
|
gop.value_ptr.* = .{ .leaf = .{
|
||||||
.action = action,
|
.action = action,
|
||||||
} else .{
|
.flags = flags,
|
||||||
.action_unconsumed = action,
|
} };
|
||||||
};
|
|
||||||
errdefer _ = self.bindings.remove(t);
|
errdefer _ = self.bindings.remove(t);
|
||||||
try self.reverse.put(alloc, action, t);
|
try self.reverse.put(alloc, action, t);
|
||||||
errdefer _ = self.reverse.remove(action);
|
errdefer _ = self.reverse.remove(action);
|
||||||
@ -1065,6 +1260,31 @@ pub const Set = struct {
|
|||||||
return self.reverse.get(a);
|
return self.reverse.get(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get an entry for the given key event. This will attempt to find
|
||||||
|
/// a binding using multiple parts of the event in the following order:
|
||||||
|
///
|
||||||
|
/// 1. Translated key (event.key)
|
||||||
|
/// 2. Physical key (event.physical_key)
|
||||||
|
/// 3. Unshifted Unicode codepoint (event.unshifted_codepoint)
|
||||||
|
///
|
||||||
|
pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry {
|
||||||
|
var trigger: Trigger = .{
|
||||||
|
.mods = event.mods.binding(),
|
||||||
|
.key = .{ .translated = event.key },
|
||||||
|
};
|
||||||
|
if (self.get(trigger)) |v| return v;
|
||||||
|
|
||||||
|
trigger.key = .{ .physical = event.physical_key };
|
||||||
|
if (self.get(trigger)) |v| return v;
|
||||||
|
|
||||||
|
if (event.unshifted_codepoint > 0) {
|
||||||
|
trigger.key = .{ .unicode = event.unshifted_codepoint };
|
||||||
|
if (self.get(trigger)) |v| return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a binding for a given trigger.
|
/// Remove a binding for a given trigger.
|
||||||
pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void {
|
pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void {
|
||||||
const entry = self.bindings.get(t) orelse return;
|
const entry = self.bindings.get(t) orelse return;
|
||||||
@ -1083,15 +1303,16 @@ pub const Set = struct {
|
|||||||
// Note: we'd LIKE to replace this with the most recent binding but
|
// Note: we'd LIKE to replace this with the most recent binding but
|
||||||
// our hash map obviously has no concept of ordering so we have to
|
// our hash map obviously has no concept of ordering so we have to
|
||||||
// choose whatever. Maybe a switch to an array hash map here.
|
// choose whatever. Maybe a switch to an array hash map here.
|
||||||
.action, .action_unconsumed => |action| {
|
.leaf => |leaf| {
|
||||||
const action_hash = action.hash();
|
const action_hash = leaf.action.hash();
|
||||||
|
|
||||||
var it = self.bindings.iterator();
|
var it = self.bindings.iterator();
|
||||||
while (it.next()) |it_entry| {
|
while (it.next()) |it_entry| {
|
||||||
switch (it_entry.value_ptr.*) {
|
switch (it_entry.value_ptr.*) {
|
||||||
.leader => {},
|
.leader => {},
|
||||||
.action, .action_unconsumed => |action_search| {
|
.leaf => |leaf_search| {
|
||||||
if (action_search.hash() == action_hash) {
|
if (leaf_search.action.hash() == action_hash) {
|
||||||
self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*);
|
self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1099,7 +1320,7 @@ pub const Set = struct {
|
|||||||
} else {
|
} else {
|
||||||
// No over trigger points to this action so we remove
|
// No over trigger points to this action so we remove
|
||||||
// the reverse mapping completely.
|
// the reverse mapping completely.
|
||||||
_ = self.reverse.remove(action);
|
_ = self.reverse.remove(leaf.action);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -1116,7 +1337,7 @@ pub const Set = struct {
|
|||||||
var it = result.bindings.iterator();
|
var it = result.bindings.iterator();
|
||||||
while (it.next()) |entry| switch (entry.value_ptr.*) {
|
while (it.next()) |entry| switch (entry.value_ptr.*) {
|
||||||
// No data to clone
|
// No data to clone
|
||||||
.action, .action_unconsumed => {},
|
.leaf => {},
|
||||||
|
|
||||||
// Must be deep cloned.
|
// Must be deep cloned.
|
||||||
.leader => |*s| {
|
.leader => |*s| {
|
||||||
@ -1218,7 +1439,7 @@ test "parse: triggers" {
|
|||||||
.key = .{ .translated = .a },
|
.key = .{ .translated = .a },
|
||||||
},
|
},
|
||||||
.action = .{ .ignore = {} },
|
.action = .{ .ignore = {} },
|
||||||
.consumed = false,
|
.flags = .{ .consumed = false },
|
||||||
}, try parseSingle("unconsumed:shift+a=ignore"));
|
}, try parseSingle("unconsumed:shift+a=ignore"));
|
||||||
|
|
||||||
// unconsumed physical keys
|
// unconsumed physical keys
|
||||||
@ -1228,7 +1449,7 @@ test "parse: triggers" {
|
|||||||
.key = .{ .physical = .a },
|
.key = .{ .physical = .a },
|
||||||
},
|
},
|
||||||
.action = .{ .ignore = {} },
|
.action = .{ .ignore = {} },
|
||||||
.consumed = false,
|
.flags = .{ .consumed = false },
|
||||||
}, try parseSingle("unconsumed:physical:a+shift=ignore"));
|
}, try parseSingle("unconsumed:physical:a+shift=ignore"));
|
||||||
|
|
||||||
// invalid key
|
// invalid key
|
||||||
@ -1241,6 +1462,92 @@ test "parse: triggers" {
|
|||||||
try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore"));
|
try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "parse: global triggers" {
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
// global keys
|
||||||
|
try testing.expectEqual(Binding{
|
||||||
|
.trigger = .{
|
||||||
|
.mods = .{ .shift = true },
|
||||||
|
.key = .{ .translated = .a },
|
||||||
|
},
|
||||||
|
.action = .{ .ignore = {} },
|
||||||
|
.flags = .{ .global = true },
|
||||||
|
}, try parseSingle("global:shift+a=ignore"));
|
||||||
|
|
||||||
|
// global physical keys
|
||||||
|
try testing.expectEqual(Binding{
|
||||||
|
.trigger = .{
|
||||||
|
.mods = .{ .shift = true },
|
||||||
|
.key = .{ .physical = .a },
|
||||||
|
},
|
||||||
|
.action = .{ .ignore = {} },
|
||||||
|
.flags = .{ .global = true },
|
||||||
|
}, try parseSingle("global:physical:a+shift=ignore"));
|
||||||
|
|
||||||
|
// global unconsumed keys
|
||||||
|
try testing.expectEqual(Binding{
|
||||||
|
.trigger = .{
|
||||||
|
.mods = .{ .shift = true },
|
||||||
|
.key = .{ .translated = .a },
|
||||||
|
},
|
||||||
|
.action = .{ .ignore = {} },
|
||||||
|
.flags = .{
|
||||||
|
.global = true,
|
||||||
|
.consumed = false,
|
||||||
|
},
|
||||||
|
}, try parseSingle("unconsumed:global:a+shift=ignore"));
|
||||||
|
|
||||||
|
// global sequences not allowed
|
||||||
|
{
|
||||||
|
var p = try Parser.init("global:a>b=ignore");
|
||||||
|
try testing.expectError(Error.InvalidFormat, p.next());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse: all triggers" {
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
// all keys
|
||||||
|
try testing.expectEqual(Binding{
|
||||||
|
.trigger = .{
|
||||||
|
.mods = .{ .shift = true },
|
||||||
|
.key = .{ .translated = .a },
|
||||||
|
},
|
||||||
|
.action = .{ .ignore = {} },
|
||||||
|
.flags = .{ .all = true },
|
||||||
|
}, try parseSingle("all:shift+a=ignore"));
|
||||||
|
|
||||||
|
// all physical keys
|
||||||
|
try testing.expectEqual(Binding{
|
||||||
|
.trigger = .{
|
||||||
|
.mods = .{ .shift = true },
|
||||||
|
.key = .{ .physical = .a },
|
||||||
|
},
|
||||||
|
.action = .{ .ignore = {} },
|
||||||
|
.flags = .{ .all = true },
|
||||||
|
}, try parseSingle("all:physical:a+shift=ignore"));
|
||||||
|
|
||||||
|
// all unconsumed keys
|
||||||
|
try testing.expectEqual(Binding{
|
||||||
|
.trigger = .{
|
||||||
|
.mods = .{ .shift = true },
|
||||||
|
.key = .{ .translated = .a },
|
||||||
|
},
|
||||||
|
.action = .{ .ignore = {} },
|
||||||
|
.flags = .{
|
||||||
|
.all = true,
|
||||||
|
.consumed = false,
|
||||||
|
},
|
||||||
|
}, try parseSingle("unconsumed:all:a+shift=ignore"));
|
||||||
|
|
||||||
|
// all sequences not allowed
|
||||||
|
{
|
||||||
|
var p = try Parser.init("all:a>b=ignore");
|
||||||
|
try testing.expectError(Error.InvalidFormat, p.next());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test "parse: modifier aliases" {
|
test "parse: modifier aliases" {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
@ -1466,8 +1773,9 @@ test "set: parseAndPut typical binding" {
|
|||||||
|
|
||||||
// Creates forward mapping
|
// Creates forward mapping
|
||||||
{
|
{
|
||||||
const action = s.get(.{ .key = .{ .translated = .a } }).?.action;
|
const action = s.get(.{ .key = .{ .translated = .a } }).?.leaf;
|
||||||
try testing.expect(action == .new_window);
|
try testing.expect(action.action == .new_window);
|
||||||
|
try testing.expectEqual(Flags{}, action.flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates reverse mapping
|
// Creates reverse mapping
|
||||||
@ -1489,8 +1797,9 @@ test "set: parseAndPut unconsumed binding" {
|
|||||||
// Creates forward mapping
|
// Creates forward mapping
|
||||||
{
|
{
|
||||||
const trigger: Trigger = .{ .key = .{ .translated = .a } };
|
const trigger: Trigger = .{ .key = .{ .translated = .a } };
|
||||||
const action = s.get(trigger).?.action_unconsumed;
|
const action = s.get(trigger).?.leaf;
|
||||||
try testing.expect(action == .new_window);
|
try testing.expect(action.action == .new_window);
|
||||||
|
try testing.expectEqual(Flags{ .consumed = false }, action.flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates reverse mapping
|
// Creates reverse mapping
|
||||||
@ -1536,8 +1845,9 @@ test "set: parseAndPut sequence" {
|
|||||||
{
|
{
|
||||||
const t: Trigger = .{ .key = .{ .translated = .b } };
|
const t: Trigger = .{ .key = .{ .translated = .b } };
|
||||||
const e = current.get(t).?;
|
const e = current.get(t).?;
|
||||||
try testing.expect(e == .action);
|
try testing.expect(e == .leaf);
|
||||||
try testing.expect(e.action == .new_window);
|
try testing.expect(e.leaf.action == .new_window);
|
||||||
|
try testing.expectEqual(Flags{}, e.leaf.flags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1560,14 +1870,16 @@ test "set: parseAndPut sequence with two actions" {
|
|||||||
{
|
{
|
||||||
const t: Trigger = .{ .key = .{ .translated = .b } };
|
const t: Trigger = .{ .key = .{ .translated = .b } };
|
||||||
const e = current.get(t).?;
|
const e = current.get(t).?;
|
||||||
try testing.expect(e == .action);
|
try testing.expect(e == .leaf);
|
||||||
try testing.expect(e.action == .new_window);
|
try testing.expect(e.leaf.action == .new_window);
|
||||||
|
try testing.expectEqual(Flags{}, e.leaf.flags);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const t: Trigger = .{ .key = .{ .translated = .c } };
|
const t: Trigger = .{ .key = .{ .translated = .c } };
|
||||||
const e = current.get(t).?;
|
const e = current.get(t).?;
|
||||||
try testing.expect(e == .action);
|
try testing.expect(e == .leaf);
|
||||||
try testing.expect(e.action == .new_tab);
|
try testing.expect(e.leaf.action == .new_tab);
|
||||||
|
try testing.expectEqual(Flags{}, e.leaf.flags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1590,8 +1902,9 @@ test "set: parseAndPut overwrite sequence" {
|
|||||||
{
|
{
|
||||||
const t: Trigger = .{ .key = .{ .translated = .b } };
|
const t: Trigger = .{ .key = .{ .translated = .b } };
|
||||||
const e = current.get(t).?;
|
const e = current.get(t).?;
|
||||||
try testing.expect(e == .action);
|
try testing.expect(e == .leaf);
|
||||||
try testing.expect(e.action == .new_window);
|
try testing.expect(e.leaf.action == .new_window);
|
||||||
|
try testing.expectEqual(Flags{}, e.leaf.flags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1614,8 +1927,9 @@ test "set: parseAndPut overwrite leader" {
|
|||||||
{
|
{
|
||||||
const t: Trigger = .{ .key = .{ .translated = .b } };
|
const t: Trigger = .{ .key = .{ .translated = .b } };
|
||||||
const e = current.get(t).?;
|
const e = current.get(t).?;
|
||||||
try testing.expect(e == .action);
|
try testing.expect(e == .leaf);
|
||||||
try testing.expect(e.action == .new_window);
|
try testing.expect(e.leaf.action == .new_window);
|
||||||
|
try testing.expectEqual(Flags{}, e.leaf.flags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1744,11 +2058,19 @@ test "set: consumed state" {
|
|||||||
defer s.deinit(alloc);
|
defer s.deinit(alloc);
|
||||||
|
|
||||||
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
|
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
|
||||||
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action);
|
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf);
|
||||||
|
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed);
|
||||||
|
|
||||||
try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
|
try s.putFlags(
|
||||||
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action_unconsumed);
|
alloc,
|
||||||
|
.{ .key = .{ .translated = .a } },
|
||||||
|
.{ .new_window = {} },
|
||||||
|
.{ .consumed = false },
|
||||||
|
);
|
||||||
|
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf);
|
||||||
|
try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed);
|
||||||
|
|
||||||
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
|
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
|
||||||
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action);
|
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf);
|
||||||
|
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user