Merge pull request #2299 from ghostty-org/global-keybind

Keybind features: System-global keybindings and target-all-terminal keybindings
This commit is contained in:
Mitchell Hashimoto
2024-09-24 21:01:32 -07:00
committed by GitHub
12 changed files with 1123 additions and 352 deletions

View File

@ -527,10 +527,12 @@ ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
void ghostty_app_free(ghostty_app_t);
bool ghostty_app_tick(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_open_config(ghostty_app_t);
void ghostty_app_reload_config(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();

View File

@ -61,6 +61,7 @@
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; };
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.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 */; };
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; };
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>"; };
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>"; };
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>"; };
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>"; };
@ -199,6 +201,7 @@
A53426362A7DC53000EBB7A2 /* Features */ = {
isa = PBXGroup;
children = (
A5CBD0672CA2704E0017A1AE /* Global Keybinds */,
A56D58872ACDE6BE00508D2C /* Services */,
A59630982AEE1C4400D64628 /* Terminal */,
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
@ -370,6 +373,14 @@
name = Products;
sourceTree = "<group>";
};
A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = {
isa = PBXGroup;
children = (
A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */,
);
path = "Global Keybinds";
sourceTree = "<group>";
};
A5CEAFDA29B8005900646FDA /* SplitView */ = {
isa = PBXGroup;
children = (
@ -529,6 +540,7 @@
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,

View File

@ -63,6 +63,10 @@ class AppDelegate: NSObject,
/// This is only true before application has become active.
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.
let ghostty: Ghostty.App = Ghostty.App()
@ -73,6 +77,11 @@ class AppDelegate: NSObject,
let updaterController: SPUStandardUpdaterController
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
/// The elapsed time since the process was started
var timeSinceLaunch: TimeInterval {
return ProcessInfo.processInfo.systemUptime - applicationLaunchTime
}
override init() {
terminalManager = TerminalManager(ghostty)
updaterController = SPUStandardUpdaterController(
@ -106,6 +115,9 @@ class AppDelegate: NSObject,
"ApplePressAndHoldEnabled": false,
])
// Store our start time
applicationLaunchTime = ProcessInfo.processInfo.systemUptime
// Check if secure input was enabled when we last quit.
if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) {
toggleSecureInput(self)
@ -418,6 +430,25 @@ class AppDelegate: NSObject,
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.

View 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
}

View File

@ -78,6 +78,12 @@ class TerminalManager {
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
// take effect. Our best theory is there is some next-event-loop-tick logic
// that Cocoa is doing that we need to be after.

View File

@ -631,7 +631,11 @@ extension Ghostty {
}
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(
name: Notification.ghosttyNewWindow,

View File

@ -262,6 +262,102 @@ pub fn setQuit(self: *App) !void {
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
fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !void {
// We want to ensure our window is still active. Window messages

View File

@ -1561,19 +1561,8 @@ fn maybeHandleBinding(
const entry: input.Binding.Set.Entry = entry: {
const set = self.keyboard.bindings orelse &self.config.keybind.set;
var trigger: input.Binding.Trigger = .{
.mods = event.mods.binding(),
.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;
}
// Get our entry from the set for the given event.
if (set.getEvent(event)) |v| break :entry v;
// 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
@ -1590,7 +1579,7 @@ fn maybeHandleBinding(
};
// 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| {
// Setup the next set we'll look at.
self.keyboard.bindings = set;
@ -1605,8 +1594,20 @@ fn maybeHandleBinding(
return .consumed;
},
.action => |v| .{ v, true },
.action_unconsumed => |v| .{ v, false },
.leaf => |leaf| leaf,
};
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
@ -1618,8 +1619,22 @@ fn maybeHandleBinding(
self.keyboard.bindings = null;
// Attempt to perform the action
log.debug("key event binding consumed={} action={}", .{ consumed, action });
const performed = try self.performBindingAction(action);
log.debug("key event binding flags={} 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,
// 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
/// useful. We did previous/next tab so we could implement #498.
pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {
switch (action) {
.unbind => unreachable,
.ignore => {},
// Forward app-scoped actions to the app. Some app-scoped actions are
// special-cased here because they do some special things when performed
// 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),
.reload_config => try self.app.reloadConfig(self.rt_app),
else => try self.app.performAction(
self.rt_app,
action.scoped(.app).?,
),
}
return true;
}
switch (action.scoped(.surface).?) {
.csi, .esc => |data| {
// 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
@ -3630,8 +3656,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
v,
),
.new_window => try self.app.newWindow(self.rt_app, .{ .parent = self }),
.new_tab => {
if (@hasDecl(apprt.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_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) {
.main => @panic("crash binding action, crashing intentionally"),

View File

@ -86,7 +86,8 @@ pub const App = struct {
/// New tab with options.
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,
/// Control the inspector visibility
@ -143,17 +144,37 @@ pub const App = struct {
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,
config: *const Config,
opts: Options,
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 {
return .{
.core_app = core_app,
.config = config,
.opts = opts,
.keymap = try input.Keymap.init(),
.keymap_state = .{},
};
}
@ -161,6 +182,251 @@ pub const App = struct {
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.
pub fn reloadKeymap(self: *App) !void {
// Reload the keymap
@ -227,14 +493,19 @@ pub const App = struct {
}
pub fn newWindow(self: *App, parent: ?*CoreSurface) !void {
_ = self;
// 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 we have a parent, the surface logic handles it.
if (parent) |surface| {
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 = "",
};
/// 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 {
self.* = .{
.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 {
_ = self.core_surface.textCallback(text) catch |err| {
log.err("error in key callback err={}", .{err});
@ -1398,7 +1463,7 @@ pub const CAPI = struct {
composing: bool,
/// Convert to surface key event.
fn keyEvent(self: KeyEvent) Surface.KeyEvent {
fn keyEvent(self: KeyEvent) App.KeyEvent {
return .{
.action = self.action,
.mods = @bitCast(@as(
@ -1484,6 +1549,19 @@ pub const CAPI = struct {
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
/// keyboard layout to be reloaded from the OS.
export fn ghostty_app_keyboard_changed(v: *App) void {
@ -1514,6 +1592,11 @@ pub const CAPI = struct {
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.
export fn ghostty_surface_config_new() apprt.Surface.Options {
return .{};
@ -1672,16 +1755,15 @@ pub const CAPI = struct {
/// Send this for raw keypresses (i.e. the keyDown event on macOS).
/// This will handle the keymap translation and send the appropriate
/// 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(
surface: *Surface,
event: KeyEvent,
) void {
surface.keyCallback(event.keyEvent()) catch |err| {
log.err("error processing key event err={}", .{err});
_ = surface.app.keyEvent(
.{ .surface = surface },
event.keyEvent(),
) catch |err| {
log.warn("error processing key event err={}", .{err});
return;
};
}

View File

@ -116,7 +116,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
while (iter.next()) |bind| {
const action = switch (bind.value_ptr.*) {
.leader => continue, // TODO: support this
.action, .action_unconsumed => |action| action,
.leaf => |leaf| leaf.action,
};
const key = switch (bind.key_ptr.key) {
.translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}),

View File

@ -651,7 +651,8 @@ class: ?[:0]const u8 = null,
@"working-directory": ?[]const u8 = null,
/// 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`,
/// `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` 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
/// format `action` or `action:param`. The latter form is only valid if the
/// action requires a parameter.
@ -722,6 +726,9 @@ class: ?[:0]const u8 = null,
/// * `text:text` - Send a string. Uses Zig string literal syntax.
/// 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:
///
/// * 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
/// keybindings.
///
/// A keybind by default causes the input to be consumed. This means 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"
/// The keybind trigger can be prefixed with some special values to change
/// the behavior of the keybind. These are:
///
/// * `all:` - Make the keybind apply to all terminal surfaces. By default,
/// 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 = .{},
/// Horizontal window padding. This applies padding between the terminal cells
@ -3704,11 +3748,16 @@ pub const Keybinds = struct {
)) return false,
// Actions are compared by field directly
inline .action, .action_unconsumed => |_, tag| if (!equalField(
inputpkg.Binding.Action,
@field(self_entry.value_ptr.*, @tagName(tag)),
@field(other_entry.value_ptr.*, @tagName(tag)),
)) return false,
.leaf => {
const self_leaf = self_entry.value_ptr.*.leaf;
const other_leaf = other_entry.value_ptr.*.leaf;
if (!equalField(
inputpkg.Binding.Set.Leaf,
self_leaf,
other_leaf,
)) return false;
},
}
}

View File

@ -6,6 +6,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const key = @import("key.zig");
const KeyEvent = key.KeyEvent;
/// The trigger that needs to be performed to execute the action.
trigger: Trigger,
@ -13,21 +14,36 @@ trigger: Trigger,
/// The action to take if this binding matches
action: Action,
/// True if this binding should consume the input when the
/// action is triggered.
consumed: bool = true,
/// Boolean flags that can be set per binding.
flags: Flags = .{},
pub const Error = error{
InvalidFormat,
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
/// which yields elements to support multi-key sequences without allocation.
pub const Parser = struct {
unconsumed: bool = false,
trigger_it: SequenceIterator,
action: Action,
flags: Flags = .{},
pub const Elem = union(enum) {
/// A leader trigger in a sequence.
@ -38,11 +54,7 @@ pub const Parser = struct {
};
pub fn init(raw_input: []const u8) Error!Parser {
// If our entire input is prefixed with "unconsumed:" then we are
// 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 flags, const start_idx = try parseFlags(raw_input);
const input = raw_input[start_idx..];
// 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
// parse the action now.
return .{
.unconsumed = unconsumed,
.trigger_it = .{ .input = input[0..eql_idx] },
.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 {
// Get our trigger. If we're out of triggers then we're done.
const trigger = (try self.trigger_it.next()) orelse return null;
// 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.
return .{ .binding = .{
.trigger = trigger,
.action = self.action,
.consumed = !self.unconsumed,
.flags = self.flags,
} };
}
@ -228,7 +279,8 @@ pub const Action = union(enum) {
/// available values.
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,
/// Open a new tab.
@ -489,6 +541,142 @@ pub const Action = union(enum) {
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
/// action back into the format used by parse.
pub fn format(
@ -544,10 +732,15 @@ pub const Action = union(enum) {
/// action.
pub fn hash(self: Action) u64 {
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.
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.
switch (self) {
@ -562,25 +755,23 @@ pub const Action = union(enum) {
// signed zeros but these are not cases we expect for
// our bindings.
f32 => std.hash.autoHash(
&hasher,
hasher,
@as(u32, @bitCast(field)),
),
f64 => std.hash.autoHash(
&hasher,
hasher,
@as(u64, @bitCast(field)),
),
// Everything else automatically handle.
else => std.hash.autoHashStrat(
&hasher,
hasher,
field,
.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.
pub fn hash(self: Trigger) u64 {
var hasher = std.hash.Wyhash.init(0);
std.hash.autoHash(&hasher, self.key);
std.hash.autoHash(&hasher, self.mods.binding());
self.hashIncremental(&hasher);
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.
pub fn cval(self: Trigger) C {
return .{
@ -818,10 +1014,8 @@ pub const Set = struct {
leader: *Set,
/// This trigger completes a sequence and the value is the action
/// to take. The "_unconsumed" variant is used for triggers that
/// should not consume the input.
action: Action,
action_unconsumed: Action,
/// to take along with the flags that may define binding behavior.
leaf: Leaf,
/// Implements the formatter for the fmt package. This encodes the
/// 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
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 {
// Clear any leaders if we have them
var it = self.bindings.iterator();
@ -862,7 +1070,7 @@ pub const Set = struct {
s.deinit(alloc);
alloc.destroy(s);
},
.action, .action_unconsumed => {},
.leaf => {},
};
self.bindings.deinit(alloc);
@ -934,7 +1142,7 @@ pub const Set = struct {
error.OutOfMemory => return error.OutOfMemory,
},
.action, .action_unconsumed => {
.leaf => {
// Remove the existing action. Fallthrough as if
// we don't have a leader.
set.remove(alloc, t);
@ -958,11 +1166,11 @@ pub const Set = struct {
set.remove(alloc, t);
if (old) |entry| switch (entry) {
.leader => unreachable, // Handled above
inline .action, .action_unconsumed => |action, tag| set.put_(
.leaf => |leaf| set.putFlags(
alloc,
t,
action,
tag == .action,
leaf.action,
leaf.flags,
) catch {},
};
},
@ -977,11 +1185,12 @@ pub const Set = struct {
return error.SequenceUnbind;
},
else => if (b.consumed) {
try set.put(alloc, b.trigger, b.action);
} else {
try set.putUnconsumed(alloc, b.trigger, b.action);
},
else => try set.putFlags(
alloc,
b.trigger,
b.action,
b.flags,
),
},
}
}
@ -994,29 +1203,16 @@ pub const Set = struct {
t: Trigger,
action: Action,
) 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
/// trigger will evaluate the action and continue to encode for the
/// terminal.
///
/// This is a separate function because this case is rare.
pub fn putUnconsumed(
/// Add a binding to the set with explicit flags.
pub fn putFlags(
self: *Set,
alloc: Allocator,
t: Trigger,
action: Action,
) Allocator.Error!void {
try self.put_(alloc, t, action, false);
}
fn put_(
self: *Set,
alloc: Allocator,
t: Trigger,
action: Action,
consumed: bool,
flags: Flags,
) Allocator.Error!void {
// unbind should never go into the set, it should be handled prior
assert(action != .unbind);
@ -1032,7 +1228,7 @@ pub const Set = struct {
// If we have an existing binding for this trigger, we have to
// update the reverse mapping to remove the old action.
.action, .action_unconsumed => {
.leaf => {
const t_hash = t.hash();
var it = self.reverse.iterator();
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,
} else .{
.action_unconsumed = action,
};
.flags = flags,
} };
errdefer _ = self.bindings.remove(t);
try self.reverse.put(alloc, action, t);
errdefer _ = self.reverse.remove(action);
@ -1065,6 +1260,31 @@ pub const Set = struct {
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.
pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void {
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
// our hash map obviously has no concept of ordering so we have to
// choose whatever. Maybe a switch to an array hash map here.
.action, .action_unconsumed => |action| {
const action_hash = action.hash();
.leaf => |leaf| {
const action_hash = leaf.action.hash();
var it = self.bindings.iterator();
while (it.next()) |it_entry| {
switch (it_entry.value_ptr.*) {
.leader => {},
.action, .action_unconsumed => |action_search| {
if (action_search.hash() == action_hash) {
self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*);
.leaf => |leaf_search| {
if (leaf_search.action.hash() == action_hash) {
self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*);
break;
}
},
@ -1099,7 +1320,7 @@ pub const Set = struct {
} else {
// No over trigger points to this action so we remove
// 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();
while (it.next()) |entry| switch (entry.value_ptr.*) {
// No data to clone
.action, .action_unconsumed => {},
.leaf => {},
// Must be deep cloned.
.leader => |*s| {
@ -1218,7 +1439,7 @@ test "parse: triggers" {
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.consumed = false,
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:shift+a=ignore"));
// unconsumed physical keys
@ -1228,7 +1449,7 @@ test "parse: triggers" {
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.consumed = false,
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:physical:a+shift=ignore"));
// invalid key
@ -1241,6 +1462,92 @@ test "parse: triggers" {
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" {
const testing = std.testing;
@ -1466,8 +1773,9 @@ test "set: parseAndPut typical binding" {
// Creates forward mapping
{
const action = s.get(.{ .key = .{ .translated = .a } }).?.action;
try testing.expect(action == .new_window);
const action = s.get(.{ .key = .{ .translated = .a } }).?.leaf;
try testing.expect(action.action == .new_window);
try testing.expectEqual(Flags{}, action.flags);
}
// Creates reverse mapping
@ -1489,8 +1797,9 @@ test "set: parseAndPut unconsumed binding" {
// Creates forward mapping
{
const trigger: Trigger = .{ .key = .{ .translated = .a } };
const action = s.get(trigger).?.action_unconsumed;
try testing.expect(action == .new_window);
const action = s.get(trigger).?.leaf;
try testing.expect(action.action == .new_window);
try testing.expectEqual(Flags{ .consumed = false }, action.flags);
}
// Creates reverse mapping
@ -1536,8 +1845,9 @@ test "set: parseAndPut sequence" {
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
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 e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
{
const t: Trigger = .{ .key = .{ .translated = .c } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_tab);
try testing.expect(e == .leaf);
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 e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
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 e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
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);
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 testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action_unconsumed);
try s.putFlags(
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 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);
}