Key tables apprt action plus macOS UI (#9990)

Fixes #9963 (we'll open new issues to track GTK and other stuff)

This adds the apprt actions necessary for key tables to be shown
visually, and adapts the macOS UI to show them.

## Demo

```
keybind = example/
keybind = example/ctrl+a=text:hello
keybind = example/ctrl+b>x=text:wow
keybind = example/ctrl+c=activate_key_table:another
keybind = example/escape=deactivate_key_table
keybind = ctrl+a=activate_key_table:example

keybind = another/
keybind = another/catch_all=deactivate_key_table
```


https://github.com/user-attachments/assets/75e94ec9-b52e-439d-b0ca-229ce533c656

**AI disclosure:** The SwiftUI view was written by AI, everything else
was manual.
This commit is contained in:
Mitchell Hashimoto
2025-12-21 08:11:36 -08:00
committed by GitHub
10 changed files with 433 additions and 42 deletions

View File

@@ -691,6 +691,27 @@ typedef struct {
ghostty_input_trigger_s trigger;
} ghostty_action_key_sequence_s;
// apprt.action.KeyTable.Tag
typedef enum {
GHOSTTY_KEY_TABLE_ACTIVATE,
GHOSTTY_KEY_TABLE_DEACTIVATE,
GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
} ghostty_action_key_table_tag_e;
// apprt.action.KeyTable.CValue
typedef union {
struct {
const char *name;
size_t len;
} activate;
} ghostty_action_key_table_u;
// apprt.action.KeyTable.C
typedef struct {
ghostty_action_key_table_tag_e tag;
ghostty_action_key_table_u value;
} ghostty_action_key_table_s;
// apprt.action.ColorKind
typedef enum {
GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,
@@ -836,6 +857,7 @@ typedef enum {
GHOSTTY_ACTION_FLOAT_WINDOW,
GHOSTTY_ACTION_SECURE_INPUT,
GHOSTTY_ACTION_KEY_SEQUENCE,
GHOSTTY_ACTION_KEY_TABLE,
GHOSTTY_ACTION_COLOR_CHANGE,
GHOSTTY_ACTION_RELOAD_CONFIG,
GHOSTTY_ACTION_CONFIG_CHANGE,
@@ -881,6 +903,7 @@ typedef union {
ghostty_action_float_window_e float_window;
ghostty_action_secure_input_e secure_input;
ghostty_action_key_sequence_s key_sequence;
ghostty_action_key_table_s key_table;
ghostty_action_color_change_s color_change;
ghostty_action_reload_config_s reload_config;
ghostty_action_config_change_s config_change;

View File

@@ -141,6 +141,27 @@ extension Ghostty.Action {
}
}
}
enum KeyTable {
case activate(name: String)
case deactivate
case deactivateAll
init?(c: ghostty_action_key_table_s) {
switch c.tag {
case GHOSTTY_KEY_TABLE_ACTIVATE:
let data = Data(bytes: c.value.activate.name, count: c.value.activate.len)
let name = String(data: data, encoding: .utf8) ?? ""
self = .activate(name: name)
case GHOSTTY_KEY_TABLE_DEACTIVATE:
self = .deactivate
case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL:
self = .deactivateAll
default:
return nil
}
}
}
}
// Putting the initializer in an extension preserves the automatic one.

View File

@@ -578,7 +578,10 @@ extension Ghostty {
case GHOSTTY_ACTION_KEY_SEQUENCE:
keySequence(app, target: target, v: action.action.key_sequence)
case GHOSTTY_ACTION_KEY_TABLE:
keyTable(app, target: target, v: action.action.key_table)
case GHOSTTY_ACTION_PROGRESS_REPORT:
progressReport(app, target: target, v: action.action.progress_report)
@@ -1771,7 +1774,32 @@ extension Ghostty {
assertionFailure()
}
}
private static func keyTable(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_key_table_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("key table does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let action = Ghostty.Action.KeyTable(c: v) else { return }
NotificationCenter.default.post(
name: Notification.didChangeKeyTable,
object: surfaceView,
userInfo: [Notification.KeyTableKey: action]
)
default:
assertionFailure()
}
}
private static func progressReport(
_ app: ghostty_app_t,
target: ghostty_target_s,

View File

@@ -475,6 +475,10 @@ extension Ghostty.Notification {
static let didContinueKeySequence = Notification.Name("com.mitchellh.ghostty.didContinueKeySequence")
static let didEndKeySequence = Notification.Name("com.mitchellh.ghostty.didEndKeySequence")
static let KeySequenceKey = didContinueKeySequence.rawValue + ".key"
/// Notifications related to key tables
static let didChangeKeyTable = Notification.Name("com.mitchellh.ghostty.didChangeKeyTable")
static let KeyTableKey = didChangeKeyTable.rawValue + ".action"
}
// Make the input enum hashable.

View File

@@ -123,31 +123,11 @@ extension Ghostty {
}
}
// If we are in the middle of a key sequence, then we show a visual element. We only
// support this on macOS currently although in theory we can support mobile with keyboards!
if !surfaceView.keySequence.isEmpty {
let padding: CGFloat = 5
VStack {
Spacer()
HStack {
Text(verbatim: "Pending Key Sequence:")
ForEach(0..<surfaceView.keySequence.count, id: \.description) { index in
let key = surfaceView.keySequence[index]
Text(verbatim: key.description)
.font(.system(.body, design: .monospaced))
.padding(3)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(Color(NSColor.selectedTextBackgroundColor))
)
}
}
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.frame(maxWidth: .infinity)
.background(.background)
}
}
// Show key state indicator for active key tables and/or pending key sequences
KeyStateIndicator(
keyTables: surfaceView.keyTables,
keySequence: surfaceView.keySequence
)
#endif
// If we have a URL from hovering a link, we show that.
@@ -752,6 +732,225 @@ extension Ghostty {
}
}
#if canImport(AppKit)
/// Floating indicator that shows active key tables and pending key sequences.
/// Displayed as a compact draggable pill that can be positioned at the top or bottom.
struct KeyStateIndicator: View {
let keyTables: [String]
let keySequence: [KeyboardShortcut]
@State private var isShowingPopover = false
@State private var position: Position = .bottom
@State private var dragOffset: CGSize = .zero
@State private var isDragging = false
private let padding: CGFloat = 8
enum Position {
case top, bottom
var alignment: Alignment {
switch self {
case .top: return .top
case .bottom: return .bottom
}
}
var popoverEdge: Edge {
switch self {
case .top: return .top
case .bottom: return .bottom
}
}
var transitionEdge: Edge {
popoverEdge
}
}
var body: some View {
Group {
if !keyTables.isEmpty {
content
// Reset pointer style incase the mouse didn't move away
.backport.pointerStyle(keyTables.isEmpty ? nil : .link)
}
}
.transition(.move(edge: position.transitionEdge).combined(with: .opacity))
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyTables)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keySequence.count)
}
var content: some View {
indicatorContent
.offset(dragOffset)
.padding(padding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.alignment)
.highPriorityGesture(
DragGesture(coordinateSpace: .local)
.onChanged { value in
isDragging = true
dragOffset = CGSize(width: 0, height: value.translation.height)
}
.onEnded { value in
isDragging = false
let dragThreshold: CGFloat = 50
withAnimation(.easeOut(duration: 0.2)) {
if position == .bottom && value.translation.height < -dragThreshold {
position = .top
} else if position == .top && value.translation.height > dragThreshold {
position = .bottom
}
dragOffset = .zero
}
}
)
}
@ViewBuilder
private var indicatorContent: some View {
HStack(alignment: .center, spacing: 8) {
// Key table indicator
HStack(spacing: 5) {
Image(systemName: "keyboard.badge.ellipsis")
.font(.system(size: 13))
.foregroundStyle(.secondary)
// Show table stack with arrows between them
ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in
if index > 0 {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.tertiary)
}
Text(verbatim: table)
.font(.system(size: 13, weight: .medium, design: .rounded))
}
}
// Separator when both are active
if !keySequence.isEmpty {
Divider()
.frame(height: 14)
}
// Key sequence indicator
if !keySequence.isEmpty {
HStack(alignment: .center, spacing: 4) {
ForEach(Array(keySequence.enumerated()), id: \.offset) { index, key in
KeyCap(key.description)
}
// Animated ellipsis to indicate waiting for next key
PendingIndicator(paused: isDragging)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background {
Capsule()
.fill(.regularMaterial)
.overlay {
Capsule()
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
}
.shadow(color: .black.opacity(0.2), radius: 8, y: 2)
}
.contentShape(Capsule())
.backport.pointerStyle(.link)
.popover(isPresented: $isShowingPopover, arrowEdge: position.popoverEdge) {
VStack(alignment: .leading, spacing: 8) {
if !keyTables.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Label("Key Table", systemImage: "keyboard.badge.ellipsis")
.font(.headline)
Text("A key table is a named set of keybindings, activated by some other key. Keys are interpreted using this table until it is deactivated.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if !keyTables.isEmpty && !keySequence.isEmpty {
Divider()
}
if !keySequence.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Label("Key Sequence", systemImage: "character.cursor.ibeam")
.font(.headline)
Text("A key sequence is a series of key presses that trigger an action. A pending key sequence is currently active.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.padding()
.frame(maxWidth: 400)
.fixedSize(horizontal: false, vertical: true)
}
.onTapGesture {
isShowingPopover.toggle()
}
}
/// A small keycap-style view for displaying keyboard shortcuts
struct KeyCap: View {
let text: String
init(_ text: String) {
self.text = text
}
var body: some View {
Text(verbatim: text)
.font(.system(size: 12, weight: .medium, design: .rounded))
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color(NSColor.controlBackgroundColor))
.shadow(color: .black.opacity(0.12), radius: 0.5, y: 0.5)
)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 0.5)
)
}
}
/// Animated dots to indicate waiting for the next key
struct PendingIndicator: View {
@State private var animationPhase: Double = 0
let paused: Bool
var body: some View {
TimelineView(.animation(paused: paused)) { context in
HStack(spacing: 2) {
ForEach(0..<3, id: \.self) { index in
Circle()
.fill(Color.secondary)
.frame(width: 4, height: 4)
.opacity(dotOpacity(for: index))
}
}
.onChange(of: context.date.timeIntervalSinceReferenceDate) { newValue in
animationPhase = newValue
}
}
}
private func dotOpacity(for index: Int) -> Double {
let phase = animationPhase
let offset = Double(index) / 3.0
let wave = sin((phase + offset) * .pi * 2)
return 0.3 + 0.7 * ((wave + 1) / 2)
}
}
}
#endif
/// Visual overlay that shows a border around the edges when the bell rings with border feature enabled.
struct BellBorderOverlay: View {
let bell: Bool

View File

@@ -65,6 +65,9 @@ extension Ghostty {
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [KeyboardShortcut] = []
// The currently active key tables. Empty if no tables are active.
@Published var keyTables: [String] = []
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState? = nil {
didSet {
@@ -324,6 +327,11 @@ extension Ghostty {
selector: #selector(ghosttyDidEndKeySequence),
name: Ghostty.Notification.didEndKeySequence,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyDidChangeKeyTable),
name: Ghostty.Notification.didChangeKeyTable,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
@@ -680,6 +688,22 @@ extension Ghostty {
}
}
@objc private func ghosttyDidChangeKeyTable(notification: SwiftUI.Notification) {
guard let action = notification.userInfo?[Ghostty.Notification.KeyTableKey] as? Ghostty.Action.KeyTable else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
switch action {
case .activate(let name):
self.keyTables.append(name)
case .deactivate:
_ = self.keyTables.popLast()
case .deactivateAll:
self.keyTables.removeAll()
}
}
}
@objc private func ghosttyConfigDidChange(_ notification: SwiftUI.Notification) {
// Get our managed configuration object out
guard let config = notification.userInfo?[

View File

@@ -43,7 +43,10 @@ extension Ghostty {
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState? = nil
// The currently active key tables. Empty if no tables are active.
@Published var keyTables: [String] = []
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false

View File

@@ -5631,28 +5631,68 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.once = tag == .activate_key_table_once,
});
// Notify the UI.
_ = self.rt_app.performAction(
.{ .surface = self },
.key_table,
.{ .activate = name },
) catch |err| {
log.warn(
"failed to notify app of key table err={}",
.{err},
);
};
log.debug("key table activated: {s}", .{name});
},
.deactivate_key_table => switch (self.keyboard.table_stack.items.len) {
// No key table active. This does nothing.
0 => return false,
.deactivate_key_table => {
switch (self.keyboard.table_stack.items.len) {
// No key table active. This does nothing.
0 => return false,
// Final key table active, clear our state.
1 => self.keyboard.table_stack.clearAndFree(self.alloc),
// Final key table active, clear our state.
1 => self.keyboard.table_stack.clearAndFree(self.alloc),
// Restore the prior key table. We don't free any memory in
// this case because we assume it will be freed later when
// we finish our key table.
else => _ = self.keyboard.table_stack.pop(),
// Restore the prior key table. We don't free any memory in
// this case because we assume it will be freed later when
// we finish our key table.
else => _ = self.keyboard.table_stack.pop(),
}
// Notify the UI.
_ = self.rt_app.performAction(
.{ .surface = self },
.key_table,
.deactivate,
) catch |err| {
log.warn(
"failed to notify app of key table err={}",
.{err},
);
};
},
.deactivate_all_key_tables => switch (self.keyboard.table_stack.items.len) {
// No key table active. This does nothing.
0 => return false,
.deactivate_all_key_tables => {
switch (self.keyboard.table_stack.items.len) {
// No key table active. This does nothing.
0 => return false,
// Clear the entire table stack.
else => self.keyboard.table_stack.clearAndFree(self.alloc),
// Clear the entire table stack.
else => self.keyboard.table_stack.clearAndFree(self.alloc),
}
// Notify the UI.
_ = self.rt_app.performAction(
.{ .surface = self },
.key_table,
.deactivate_all,
) catch |err| {
log.warn(
"failed to notify app of key table err={}",
.{err},
);
};
},
.crash => |location| switch (location) {

View File

@@ -250,6 +250,9 @@ pub const Action = union(Key) {
/// key mode because other input may be ignored.
key_sequence: KeySequence,
/// A key table has been activated or deactivated.
key_table: KeyTable,
/// A terminal color was changed programmatically through things
/// such as OSC 10/11.
color_change: ColorChange,
@@ -371,6 +374,7 @@ pub const Action = union(Key) {
float_window,
secure_input,
key_sequence,
key_table,
color_change,
reload_config,
config_change,
@@ -711,6 +715,50 @@ pub const KeySequence = union(enum) {
}
};
pub const KeyTable = union(enum) {
activate: []const u8,
deactivate,
deactivate_all,
// Sync with: ghostty_action_key_table_tag_e
pub const Tag = enum(c_int) {
activate,
deactivate,
deactivate_all,
};
// Sync with: ghostty_action_key_table_u
pub const CValue = extern union {
activate: extern struct {
name: [*]const u8,
len: usize,
},
};
// Sync with: ghostty_action_key_table_s
pub const C = extern struct {
tag: Tag,
value: CValue,
};
pub fn cval(self: KeyTable) C {
return switch (self) {
.activate => |name| .{
.tag = .activate,
.value = .{ .activate = .{ .name = name.ptr, .len = name.len } },
},
.deactivate => .{
.tag = .deactivate,
.value = undefined,
},
.deactivate_all => .{
.tag = .deactivate_all,
.value = undefined,
},
};
}
};
pub const ColorChange = extern struct {
kind: ColorKind,
r: u8,

View File

@@ -744,6 +744,7 @@ pub const Application = extern struct {
.toggle_background_opacity,
.cell_size,
.key_sequence,
.key_table,
.render_inspector,
.renderer_health,
.color_change,