Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Key bindings: keys harrier acts on itself rather than sending on.//!//! A binding is written `ctrl+shift+c`: modifiers, then a key. The key is a//! character, matched against what the key types with no modifiers — so//! `ctrl+shift+c` is the C key on any layout that has one — or the name of a//! key that types nothing (`page_up`, `insert`, `f5`), matched against the//! physical key.
const std = @import("std");const vt = @import("ghostty-vt");
const Config = @import("../config/Config.zig");const ev = @import("aerie").platform.event;const keys = @import("keys.zig");const keysyms = @import("keysym.zig");
pub const Action = Config.Action;
pub const Trigger = struct { shift: bool = false, ctrl: bool = false, alt: bool = false, super: bool = false, key: union(enum) { char: u21, physical: vt.input.Key, },
pub fn parse(s: []const u8) error{InvalidBinding}!Trigger { var t: Trigger = .{ .key = .{ .char = 0 } }; var it = std.mem.splitScalar(u8, s, '+'); var last: ?[]const u8 = null; while (it.next()) |part| { if (last) |mod| { if (std.ascii.eqlIgnoreCase(mod, "shift")) { t.shift = true; } else if (std.ascii.eqlIgnoreCase(mod, "ctrl") or std.ascii.eqlIgnoreCase(mod, "control")) { t.ctrl = true; } else if (std.ascii.eqlIgnoreCase(mod, "alt")) { t.alt = true; } else if (std.ascii.eqlIgnoreCase(mod, "super")) { t.super = true; } else return error.InvalidBinding; } last = part; } const name = last orelse return error.InvalidBinding; if (name.len == 0) { // Nothing after the last plus; the plus key is written "plus". return error.InvalidBinding; } else if (std.unicode.utf8CountCodepoints(name) catch 0 == 1) { const cp = std.unicode.utf8Decode(name) catch return error.InvalidBinding; t.key = .{ .char = if (cp < 0x80) std.ascii.toLower(@intCast(cp)) else cp }; } else if (std.meta.stringToEnum(vt.input.Key, name)) |k| { t.key = .{ .physical = k }; } else if (std.ascii.eqlIgnoreCase(name, "plus")) { t.key = .{ .char = '+' }; } else return error.InvalidBinding; return t; }
pub fn matches(t: Trigger, k: ev.Key) bool { if (t.shift != k.mods.shift or t.ctrl != k.mods.ctrl or t.alt != k.mods.alt or t.super != k.mods.super) return false; return switch (t.key) { .char => |c| c == k.unshifted or (k.text.len > 0 and c == (std.unicode.utf8Decode(k.text) catch 0)), .physical => |p| p == keys.physical(k), }; }
/// The keysym the trigger's key is, unshifted, for a global hotkey: a /// character's own, or a named key's. Null for a key with none. pub fn keysym(t: Trigger) ?u32 { return switch (t.key) { .char => |c| if (c == 0) null else keysyms.fromUtf32(c), .physical => |p| keys.toKeysym(p), }; }
/// The trigger as the XDG shortcuts specification writes one, for the /// desktop portal: `CTRL+ALT+Return`. Null for a key with no keysym. pub fn xdgTrigger(t: Trigger, buf: []u8) ?[]const u8 { const sym = t.keysym() orelse return null; var w: std.Io.Writer = .fixed(buf); if (t.ctrl) w.writeAll("CTRL+") catch return null; if (t.alt) w.writeAll("ALT+") catch return null; if (t.shift) w.writeAll("SHIFT+") catch return null; if (t.super) w.writeAll("LOGO+") catch return null; var name_buf: [64]u8 = undefined; w.writeAll(keysyms.name(sym, &name_buf)) catch return null; return w.buffered(); }};
pub const Binding = struct { trigger: Trigger, action: Action };
/// A binding that works wherever the focus is: a global hotkey, through/// the compositor or the desktop portal.pub const Global = struct { trigger: Trigger, action: Action, /// As the configuration wrote it. keys: []const u8,};
/// The configuration's global bindings, in its order. `keys` points into/// `configured`.pub fn globals(gpa: std.mem.Allocator, configured: []const Config.Keybind) std.mem.Allocator.Error![]Global { var out: std.ArrayList(Global) = .empty; errdefer out.deinit(gpa); for (configured) |k| { if (!k.global or k.action == .unbind) continue; const t = Trigger.parse(k.keys) catch { std.log.warn("global keybind \"{s}\": not a key this can read", .{k.keys}); continue; }; if (t.keysym() == null) { std.log.warn("global keybind \"{s}\": a key no hotkey can name", .{k.keys}); continue; } try out.append(gpa, .{ .trigger = t, .action = k.action, .keys = k.keys }); } return out.toOwnedSlice(gpa);}
pub const defaults = [_]struct { []const u8, Action }{ .{ "ctrl+shift+c", .copy }, .{ "ctrl+shift+v", .paste }, .{ "shift+insert", .paste_selection }, .{ "shift+page_up", .scroll_page_up }, .{ "shift+page_down", .scroll_page_down }, .{ "ctrl+shift+home", .scroll_top }, .{ "ctrl+shift+end", .scroll_bottom }, .{ "ctrl+shift+arrow_up", .scroll_line_up }, .{ "ctrl+shift+arrow_down", .scroll_line_down }, // Between shell prompts, where the shell marks them (OSC 133), as // kitty has it. .{ "ctrl+shift+z", .scroll_to_prompt_previous }, .{ "ctrl+shift+x", .scroll_to_prompt_next }, .{ "ctrl+equal", .font_size_increase }, .{ "ctrl+=", .font_size_increase }, .{ "ctrl+-", .font_size_decrease }, .{ "ctrl+0", .font_size_reset }, .{ "ctrl+shift+n", .new_window }, // As Ghostty has it. .{ "ctrl+shift+comma", .reload_config }, .{ "ctrl+shift+q", .close_window }, // Tabs, as GNOME Terminal has them. .{ "ctrl+shift+t", .new_tab }, // Closes the focused pane, and the tab with its last. .{ "ctrl+shift+w", .close_pane }, .{ "ctrl+page_down", .next_tab }, .{ "ctrl+page_up", .previous_tab }, .{ "ctrl+tab", .next_tab }, .{ "ctrl+shift+tab", .previous_tab }, .{ "ctrl+shift+page_up", .move_tab_left }, .{ "ctrl+shift+page_down", .move_tab_right }, .{ "alt+1", .goto_tab_1 }, .{ "alt+2", .goto_tab_2 }, .{ "alt+3", .goto_tab_3 }, .{ "alt+4", .goto_tab_4 }, .{ "alt+5", .goto_tab_5 }, .{ "alt+6", .goto_tab_6 }, .{ "alt+7", .goto_tab_7 }, .{ "alt+8", .goto_tab_8 }, .{ "alt+9", .last_tab }, // Splits, as Ghostty has them. .{ "ctrl+shift+o", .new_split_right }, .{ "ctrl+shift+e", .new_split_down }, .{ "ctrl+alt+arrow_left", .goto_pane_left }, .{ "ctrl+alt+arrow_right", .goto_pane_right }, .{ "ctrl+alt+arrow_up", .goto_pane_up }, .{ "ctrl+alt+arrow_down", .goto_pane_down }, .{ "ctrl+super+]", .goto_pane_next }, .{ "ctrl+super+[", .goto_pane_previous }, .{ "ctrl+super+shift+arrow_left", .resize_split_left }, .{ "ctrl+super+shift+arrow_right", .resize_split_right }, .{ "ctrl+super+shift+arrow_up", .resize_split_up }, .{ "ctrl+super+shift+arrow_down", .resize_split_down }, .{ "ctrl+super+shift+equal", .equalize_splits }, .{ "ctrl+shift+enter", .toggle_pane_zoom }, .{ "ctrl+shift+s", .toggle_status }, .{ "ctrl+shift+f", .start_search }, .{ "ctrl+shift+g", .search_next }, .{ "ctrl+shift+h", .search_previous }, .{ "ctrl+shift+p", .toggle_profile_selector },};
/// The defaults with the configuration's bindings applied over them.pub fn load(gpa: std.mem.Allocator, configured: []const Config.Keybind) std.mem.Allocator.Error![]Binding { var list: std.ArrayList(Binding) = .empty; errdefer list.deinit(gpa); for (defaults) |d| try list.append(gpa, .{ .trigger = Trigger.parse(d[0]) catch unreachable, .action = d[1] }); for (configured) |c| { // Global ones are the compositor's or the portal's to catch. if (c.global) continue; const trigger = Trigger.parse(c.keys) catch { std.log.warn("keybind \"{s}\": not a key this can read", .{c.keys}); continue; }; var i: usize = 0; while (i < list.items.len) { if (std.meta.eql(list.items[i].trigger, trigger)) _ = list.orderedRemove(i) else i += 1; } if (c.action != .unbind) try list.append(gpa, .{ .trigger = trigger, .action = c.action }); } return list.toOwnedSlice(gpa);}
pub fn find(bindings: []const Binding, k: ev.Key) ?Action { if (k.action == .release) return null; for (bindings) |b| if (b.trigger.matches(k)) return b.action; return null;}
const testing = std.testing;
fn key(code: u32, unshifted: u21, mods: ev.Mods) ev.Key { return .{ .action = .press, .code = code, .keysym = 0, .text = "", .mods = mods, .consumed = .{}, .unshifted = unshifted, .composing = false };}
test "parsing and matching" { const t = try Trigger.parse("ctrl+shift+c"); try testing.expect(t.matches(key(46, 'c', .{ .ctrl = true, .shift = true }))); try testing.expect(!t.matches(key(46, 'c', .{ .ctrl = true }))); const pu = try Trigger.parse("shift+page_up"); try testing.expect(pu.matches(key(104, 0, .{ .shift = true }))); try testing.expectError(error.InvalidBinding, Trigger.parse("hyper+x"));}
test "configuration replaces and unbinds" { const list = try load(testing.allocator, &.{ .{ .keys = "ctrl+shift+c", .action = .unbind }, .{ .keys = "super+c", .action = .copy }, }); defer testing.allocator.free(list); try testing.expect(find(list, key(46, 'c', .{ .ctrl = true, .shift = true })) == null); try testing.expectEqual(Action.copy, find(list, key(46, 'c', .{ .super = true })).?);}
test "a global hotkey's keysym, and its trigger as the portal writes it" { var buf: [96]u8 = undefined; const a = try Trigger.parse("ctrl+alt+t"); try std.testing.expectEqual(@as(?u32, 't'), a.keysym()); const b = try Trigger.parse("super+shift+enter"); try std.testing.expectEqual(@as(?u32, 0xff0d), b.keysym()); const c = try Trigger.parse("super+`"); try std.testing.expectEqual(@as(?u32, '`'), c.keysym()); // The portal, and the names of keysyms it wants, are Linux's. if (@import("builtin").os.tag != .linux) return; try std.testing.expectEqualStrings("CTRL+ALT+t", a.xdgTrigger(&buf).?); try std.testing.expectEqualStrings("SHIFT+LOGO+Return", b.xdgTrigger(&buf).?); try std.testing.expectEqualStrings("LOGO+grave", c.xdgTrigger(&buf).?); const d = try Trigger.parse("f12"); try std.testing.expectEqualStrings("F12", d.xdgTrigger(&buf).?);}
test "global bindings are kept apart from the window's own" { const configured = [_]Config.Keybind{ .{ .keys = "super+backquote", .action = .present_window, .global = true }, .{ .keys = "ctrl+shift+n", .action = .copy }, .{ .keys = "ctrl+alt+t", .action = .new_window, .global = true }, }; const g = try globals(std.testing.allocator, &configured); defer std.testing.allocator.free(g); try std.testing.expectEqual(@as(usize, 2), g.len); try std.testing.expectEqual(Action.present_window, g[0].action); try std.testing.expectEqualStrings("ctrl+alt+t", g[1].keys); const local = try load(std.testing.allocator, &configured); defer std.testing.allocator.free(local); const t = try Trigger.parse("ctrl+alt+t"); for (local) |l| { try std.testing.expect(l.action != .present_window); try std.testing.expect(!std.meta.eql(l.trigger, t)); }}