mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 13:30:29 +00:00
macOS: Only trigger main menu items if not performable or all targeted (#10240)
Fixes #10239 The main menu uses first responder which will hit a surface. If a binding would target `all:` we need to avoid it. To achieve this, our `is_key_binding` API now returns information about the binding (if any). I've cleaned up the Swift to implement this. In doing this I realized we have to do the same for `performable` since main menus will effectively always consume.
This commit is contained in:
@@ -102,6 +102,13 @@ typedef enum {
|
||||
GHOSTTY_MODS_SUPER_RIGHT = 1 << 9,
|
||||
} ghostty_input_mods_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0,
|
||||
GHOSTTY_BINDING_FLAGS_ALL = 1 << 1,
|
||||
GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2,
|
||||
GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3,
|
||||
} ghostty_binding_flags_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_ACTION_RELEASE,
|
||||
GHOSTTY_ACTION_PRESS,
|
||||
@@ -1058,7 +1065,9 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t,
|
||||
ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
|
||||
ghostty_input_mods_e);
|
||||
bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
|
||||
bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s);
|
||||
bool ghostty_surface_key_is_binding(ghostty_surface_t,
|
||||
ghostty_input_key_s,
|
||||
ghostty_binding_flags_e*);
|
||||
void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
|
||||
void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);
|
||||
bool ghostty_surface_mouse_captured(ghostty_surface_t);
|
||||
|
||||
@@ -100,6 +100,32 @@ extension Ghostty {
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: Ghostty.Input.BindingFlags
|
||||
|
||||
extension Ghostty.Input {
|
||||
/// `ghostty_binding_flags_e`
|
||||
struct BindingFlags: OptionSet, Sendable {
|
||||
let rawValue: UInt32
|
||||
|
||||
static let consumed = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue)
|
||||
static let all = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_ALL.rawValue)
|
||||
static let global = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_GLOBAL.rawValue)
|
||||
static let performable = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue)
|
||||
|
||||
init(rawValue: UInt32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
init(cFlags: ghostty_binding_flags_e) {
|
||||
self.rawValue = cFlags.rawValue
|
||||
}
|
||||
|
||||
var cFlags: ghostty_binding_flags_e {
|
||||
ghostty_binding_flags_e(rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Ghostty.Input.KeyEvent
|
||||
|
||||
extension Ghostty.Input {
|
||||
|
||||
@@ -62,6 +62,26 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a key event matches a keybinding.
|
||||
///
|
||||
/// This checks whether the given key event would trigger a keybinding in the terminal.
|
||||
/// If it matches, returns the binding flags indicating properties of the matched binding.
|
||||
///
|
||||
/// - Parameter event: The key event to check
|
||||
/// - Returns: The binding flags if a binding matches, or nil if no binding matches
|
||||
@MainActor
|
||||
func keyIsBinding(_ event: ghostty_input_key_s) -> Input.BindingFlags? {
|
||||
var flags = ghostty_binding_flags_e(0)
|
||||
guard ghostty_surface_key_is_binding(surface, event, &flags) else { return nil }
|
||||
return Input.BindingFlags(cFlags: flags)
|
||||
}
|
||||
|
||||
/// See `keyIsBinding(_ event: ghostty_input_key_s)`.
|
||||
@MainActor
|
||||
func keyIsBinding(_ event: Input.KeyEvent) -> Input.BindingFlags? {
|
||||
event.withCValue { keyIsBinding($0) }
|
||||
}
|
||||
|
||||
/// Whether the terminal has captured mouse input.
|
||||
///
|
||||
/// When the mouse is captured, the terminal application is receiving mouse events
|
||||
|
||||
@@ -1184,7 +1184,7 @@ extension Ghostty {
|
||||
// We only care about key down events. It might not even be possible
|
||||
// to receive any other event type here.
|
||||
guard event.type == .keyDown else { return false }
|
||||
|
||||
|
||||
// Only process events if we're focused. Some key events like C-/ macOS
|
||||
// appears to send to the first view in the hierarchy rather than the
|
||||
// the first responder (I don't know why). This prevents us from handling it.
|
||||
@@ -1194,26 +1194,35 @@ extension Ghostty {
|
||||
if (!focused) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Let the menu system handle this event if we're not in a key sequence or key table.
|
||||
// This allows the menu bar to flash for shortcuts like Command+V.
|
||||
if keySequence.isEmpty && keyTables.isEmpty {
|
||||
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
||||
return true
|
||||
|
||||
// Get information about if this is a binding.
|
||||
let bindingFlags = surfaceModel.flatMap { surface in
|
||||
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
|
||||
return (event.characters ?? "").withCString { ptr in
|
||||
ghosttyEvent.text = ptr
|
||||
return surface.keyIsBinding(ghosttyEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// If the menu didn't handle it, check Ghostty bindings for custom shortcuts.
|
||||
if let surface {
|
||||
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
|
||||
let match = (event.characters ?? "").withCString { ptr in
|
||||
ghosttyEvent.text = ptr
|
||||
return ghostty_surface_key_is_binding(surface, ghosttyEvent)
|
||||
}
|
||||
if match {
|
||||
self.keyDown(with: event)
|
||||
return true
|
||||
|
||||
// If this is a binding then we want to perform it.
|
||||
if let bindingFlags {
|
||||
// Attempt to trigger a menu item for this key binding. We only do this if:
|
||||
// - We're not in a key sequence or table (those are separate bindings)
|
||||
// - The binding is NOT `all` (menu uses FirstResponder chain)
|
||||
// - The binding is NOT `performable` (menu will always consume)
|
||||
// - The binding is `consumed` (unconsumed bindings should pass through
|
||||
// to the terminal, so we must not intercept them for the menu)
|
||||
if keySequence.isEmpty,
|
||||
keyTables.isEmpty,
|
||||
bindingFlags.isDisjoint(with: [.all, .performable]),
|
||||
bindingFlags.contains(.consumed) {
|
||||
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
self.keyDown(with: event)
|
||||
return true
|
||||
}
|
||||
|
||||
let equivalent: String
|
||||
|
||||
@@ -2579,7 +2579,7 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void {
|
||||
pub fn keyEventIsBinding(
|
||||
self: *Surface,
|
||||
event_orig: input.KeyEvent,
|
||||
) bool {
|
||||
) ?input.Binding.Flags {
|
||||
// Apply key remappings for consistency with keyCallback
|
||||
var event = event_orig;
|
||||
if (self.config.key_remaps.isRemapped(event_orig.mods)) {
|
||||
@@ -2587,26 +2587,35 @@ pub fn keyEventIsBinding(
|
||||
}
|
||||
|
||||
switch (event.action) {
|
||||
.release => return false,
|
||||
.release => return null,
|
||||
.press, .repeat => {},
|
||||
}
|
||||
|
||||
// If we're in a sequence, check the sequence set
|
||||
if (self.keyboard.sequence_set) |set| {
|
||||
return set.getEvent(event) != null;
|
||||
}
|
||||
|
||||
// Check active key tables (inner-most to outer-most)
|
||||
const table_items = self.keyboard.table_stack.items;
|
||||
for (0..table_items.len) |i| {
|
||||
const rev_i: usize = table_items.len - 1 - i;
|
||||
if (table_items[rev_i].set.getEvent(event) != null) {
|
||||
return true;
|
||||
// Look up our entry
|
||||
const entry: input.Binding.Set.Entry = entry: {
|
||||
// If we're in a sequence, check the sequence set
|
||||
if (self.keyboard.sequence_set) |set| {
|
||||
break :entry set.getEvent(event) orelse return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the root set
|
||||
return self.config.keybind.set.getEvent(event) != null;
|
||||
// Check active key tables (inner-most to outer-most)
|
||||
const table_items = self.keyboard.table_stack.items;
|
||||
for (0..table_items.len) |i| {
|
||||
const rev_i: usize = table_items.len - 1 - i;
|
||||
if (table_items[rev_i].set.getEvent(event)) |entry| {
|
||||
break :entry entry;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the root set
|
||||
break :entry self.config.keybind.set.getEvent(event) orelse return null;
|
||||
};
|
||||
|
||||
// Return flags based on the
|
||||
return switch (entry.value_ptr.*) {
|
||||
.leader => .{},
|
||||
inline .leaf, .leaf_chained => |v| v.flags,
|
||||
};
|
||||
}
|
||||
|
||||
/// Called for any key events. This handles keybindings, encoding and
|
||||
|
||||
@@ -1751,13 +1751,18 @@ pub const CAPI = struct {
|
||||
export fn ghostty_surface_key_is_binding(
|
||||
surface: *Surface,
|
||||
event: KeyEvent,
|
||||
c_flags: ?*input.Binding.Flags.C,
|
||||
) bool {
|
||||
const core_event = event.keyEvent().core() orelse {
|
||||
log.warn("error processing key event", .{});
|
||||
return false;
|
||||
};
|
||||
|
||||
return surface.core_surface.keyEventIsBinding(core_event);
|
||||
const flags = surface.core_surface.keyEventIsBinding(
|
||||
core_event,
|
||||
) orelse return false;
|
||||
if (c_flags) |ptr| ptr.* = flags.cval();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Send raw text to the terminal. This is treated like a paste
|
||||
|
||||
@@ -45,6 +45,27 @@ pub const Flags = packed struct {
|
||||
/// performed. If the action can't be performed then the binding acts as
|
||||
/// if it doesn't exist.
|
||||
performable: bool = false,
|
||||
|
||||
/// C type
|
||||
pub const C = u8;
|
||||
|
||||
/// Converts this to a C-compatible value.
|
||||
///
|
||||
/// Sync with ghostty.h for enums.
|
||||
pub fn cval(self: Flags) C {
|
||||
const Backing = @typeInfo(Flags).@"struct".backing_integer.?;
|
||||
return @as(Backing, @bitCast(self));
|
||||
}
|
||||
|
||||
test "cval" {
|
||||
const testing = std.testing;
|
||||
try testing.expectEqual(@as(u8, 0b0001), (Flags{}).cval());
|
||||
try testing.expectEqual(@as(u8, 0b0000), (Flags{ .consumed = false }).cval());
|
||||
try testing.expectEqual(@as(u8, 0b0011), (Flags{ .all = true }).cval());
|
||||
try testing.expectEqual(@as(u8, 0b0101), (Flags{ .global = true }).cval());
|
||||
try testing.expectEqual(@as(u8, 0b1001), (Flags{ .performable = true }).cval());
|
||||
try testing.expectEqual(@as(u8, 0b1111), (Flags{ .consumed = true, .all = true, .global = true, .performable = true }).cval());
|
||||
}
|
||||
};
|
||||
|
||||
/// Full binding parser. The binding parser is implemented as an iterator
|
||||
|
||||
Reference in New Issue
Block a user