Merge remote-tracking branch 'origin/main' into harfbuzz-positions

This commit is contained in:
Jacob Sandlund
2025-12-28 17:24:58 -06:00
83 changed files with 5139 additions and 563 deletions

View File

@@ -143,7 +143,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version

View File

@@ -232,7 +232,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -466,7 +466,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -650,7 +650,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version

View File

@@ -456,7 +456,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Xcode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -499,7 +499,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Xcode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -764,7 +764,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Xcode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version

View File

@@ -116,8 +116,8 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
.hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz",
.hash = "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU",
.lazy = true,
},
},

6
build.zig.zon.json generated
View File

@@ -49,10 +49,10 @@
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
},
"N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": {
"N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU": {
"name": "iterm2_themes",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
"hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz",
"hash": "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",

6
build.zig.zon.nix generated
View File

@@ -163,11 +163,11 @@ in
};
}
{
name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-";
name = "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz";
hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz";
hash = "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg=";
};
}
{

2
build.zig.zon.txt generated
View File

@@ -5,6 +5,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
https://deps.files.ghostty.org/gettext-0.24.tar.gz
https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
@@ -32,4 +33,3 @@ https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae
https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz
https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz

View File

@@ -61,9 +61,9 @@
},
{
"type": "archive",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
"dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
"sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149"
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz",
"dest": "vendor/p/N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU",
"sha256": "70c2040d91587629426af84be69590ea345eac65054992232f06f1368b2c49e8"
},
{
"type": "archive",

View File

@@ -317,12 +317,14 @@ typedef struct {
typedef enum {
GHOSTTY_TRIGGER_PHYSICAL,
GHOSTTY_TRIGGER_UNICODE,
GHOSTTY_TRIGGER_CATCH_ALL,
} ghostty_input_trigger_tag_e;
typedef union {
ghostty_input_key_e translated;
ghostty_input_key_e physical;
uint32_t unicode;
// catch_all has no payload
} ghostty_input_trigger_key_u;
typedef struct {
@@ -452,6 +454,12 @@ typedef struct {
size_t len;
} ghostty_config_color_list_s;
// config.RepeatableCommand
typedef struct {
const ghostty_command_s* commands;
size_t len;
} ghostty_config_command_list_s;
// config.Palette
typedef struct {
ghostty_config_color_s colors[256];
@@ -689,6 +697,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,
@@ -834,6 +863,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,
@@ -879,6 +909,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;
@@ -1019,7 +1050,6 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t,
ghostty_color_scheme_e);
ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
ghostty_input_mods_e);
void ghostty_surface_commands(ghostty_surface_t, ghostty_command_s**, size_t*);
bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s);
void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);

View File

@@ -137,7 +137,6 @@
Features/Update/UpdateSimulator.swift,
Features/Update/UpdateViewModel.swift,
"Ghostty/FullscreenMode+Extension.swift",
Ghostty/Ghostty.Command.swift,
Ghostty/Ghostty.Error.swift,
Ghostty/Ghostty.Event.swift,
Ghostty/Ghostty.Input.swift,
@@ -157,6 +156,7 @@
"Helpers/Extensions/KeyboardShortcut+Extension.swift",
"Helpers/Extensions/NSAppearance+Extension.swift",
"Helpers/Extensions/NSApplication+Extension.swift",
"Helpers/Extensions/NSColor+Extension.swift",
"Helpers/Extensions/NSImage+Extension.swift",
"Helpers/Extensions/NSMenu+Extension.swift",
"Helpers/Extensions/NSMenuItem+Extension.swift",

View File

@@ -1,8 +1,16 @@
import SwiftUI
import GhosttyKit
@main
struct Ghostty_iOSApp: App {
@StateObject private var ghostty_app = Ghostty.App()
@StateObject private var ghostty_app: Ghostty.App
init() {
if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS {
preconditionFailure("Initialize ghostty backend failed")
}
_ghostty_app = StateObject(wrappedValue: Ghostty.App())
}
var body: some Scene {
WindowGroup {

View File

@@ -685,6 +685,18 @@ class AppDelegate: NSObject,
}
private func localEventKeyDown(_ event: NSEvent) -> NSEvent? {
// If the tab overview is visible and escape is pressed, close it.
// This can't POSSIBLY be right and is probably a FirstResponder problem
// that we should handle elsewhere in our program. But this works and it
// is guarded by the tab overview currently showing.
if event.keyCode == 0x35, // Escape key
let window = NSApp.keyWindow,
let tabGroup = window.tabGroup,
tabGroup.isOverviewVisible {
window.toggleTabOverview(nil)
return nil
}
// If we have a main window then we don't process any of the keys
// because we let it capture and propagate.
guard NSApp.mainWindow == nil else { return event }
@@ -982,9 +994,15 @@ class AppDelegate: NSObject,
appIconName = (colorStrings + [config.macosIconFrame.rawValue])
.joined(separator: "_")
}
// Only change the icon if it has actually changed
// from the current one
guard UserDefaults.standard.string(forKey: "CustomGhosttyIcon") != appIconName else {
// Only change the icon if it has actually changed from the current one,
// or if the app build has changed (e.g. after an update that reset the icon)
let cachedIconName = UserDefaults.standard.string(forKey: "CustomGhosttyIcon")
let cachedIconBuild = UserDefaults.standard.string(forKey: "CustomGhosttyIconBuild")
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
let buildChanged = cachedIconBuild != currentBuild
guard cachedIconName != appIconName || buildChanged else {
#if DEBUG
if appIcon == nil {
await MainActor.run {
@@ -1001,14 +1019,16 @@ class AppDelegate: NSObject,
let newIcon = appIcon
let appPath = Bundle.main.bundlePath
NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: [])
guard NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) else { return }
NSWorkspace.shared.noteFileSystemChanged(appPath)
await MainActor.run {
self.appIcon = newIcon
NSApplication.shared.applicationIconImage = newIcon
}
UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon")
UserDefaults.standard.set(currentBuild, forKey: "CustomGhosttyIconBuild")
}
//MARK: - Restorable State

View File

@@ -1,4 +1,5 @@
import AppIntents
import Cocoa
// MARK: AppEntity
@@ -94,23 +95,23 @@ struct CommandQuery: EntityQuery {
@MainActor
func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] {
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
let commands = appDelegate.ghostty.config.commandPaletteEntries
// Extract unique terminal IDs to avoid fetching duplicates
let terminalIds = Set(identifiers.map(\.terminalId))
let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds))
// Build a cache of terminals and their available commands
// This avoids repeated command fetching for the same terminal
typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command])
let commandMap: [TerminalEntity.ID: Tuple] =
// Build a lookup from terminal ID to terminal entity
let terminalMap: [TerminalEntity.ID: TerminalEntity] =
terminals.reduce(into: [:]) { result, terminal in
guard let commands = try? terminal.surfaceModel?.commands() else { return }
result[terminal.id] = (terminal: terminal, commands: commands)
result[terminal.id] = terminal
}
// Map each identifier to its corresponding CommandEntity. If a command doesn't
// exist it maps to nil and is removed via compactMap.
return identifiers.compactMap { id in
guard let (terminal, commands) = commandMap[id.terminalId],
guard let terminal = terminalMap[id.terminalId],
let command = commands.first(where: { $0.actionKey == id.actionKey }) else {
return nil
}
@@ -121,8 +122,8 @@ struct CommandQuery: EntityQuery {
@MainActor
func suggestedEntities() async throws -> [CommandEntity] {
guard let terminal = commandPaletteIntent?.terminal,
let surface = terminal.surfaceModel else { return [] }
return try surface.commands().map { CommandEntity($0, for: terminal) }
guard let appDelegate = NSApp.delegate as? AppDelegate,
let terminal = commandPaletteIntent?.terminal else { return [] }
return appDelegate.ghostty.config.commandPaletteEntries.map { CommandEntity($0, for: terminal) }
}
}

View File

@@ -1,30 +1,50 @@
import SwiftUI
struct CommandOption: Identifiable, Hashable {
/// Unique identifier for this option.
let id = UUID()
/// The primary text displayed for this command.
let title: String
/// Secondary text displayed below the title.
let subtitle: String?
/// Tooltip text shown on hover.
let description: String?
/// Keyboard shortcut symbols to display.
let symbols: [String]?
/// SF Symbol name for the leading icon.
let leadingIcon: String?
/// Color for the leading indicator circle.
let leadingColor: Color?
/// Badge text displayed as a pill.
let badge: String?
/// Whether to visually emphasize this option.
let emphasis: Bool
/// Sort key for stable ordering when titles are equal.
let sortKey: AnySortKey?
/// The action to perform when this option is selected.
let action: () -> Void
init(
title: String,
subtitle: String? = nil,
description: String? = nil,
symbols: [String]? = nil,
leadingIcon: String? = nil,
leadingColor: Color? = nil,
badge: String? = nil,
emphasis: Bool = false,
sortKey: AnySortKey? = nil,
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.description = description
self.symbols = symbols
self.leadingIcon = leadingIcon
self.leadingColor = leadingColor
self.badge = badge
self.emphasis = emphasis
self.sortKey = sortKey
self.action = action
}
@@ -47,12 +67,24 @@ struct CommandPaletteView: View {
@FocusState private var isTextFieldFocused: Bool
// The options that we should show, taking into account any filtering from
// the query.
// the query. Options with matching leadingColor are ranked higher.
var filteredOptions: [CommandOption] {
if query.isEmpty {
return options
} else {
return options.filter { $0.title.localizedCaseInsensitiveContains(query) }
// Filter by title/subtitle match OR color match
let filtered = options.filter {
$0.title.localizedCaseInsensitiveContains(query) ||
($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) ||
colorMatchScore(for: $0.leadingColor, query: query) > 0
}
// Sort by color match score (higher scores first), then maintain original order
return filtered.sorted { a, b in
let scoreA = colorMatchScore(for: a.leadingColor, query: query)
let scoreB = colorMatchScore(for: b.leadingColor, query: query)
return scoreA > scoreB
}
}
}
@@ -168,6 +200,32 @@ struct CommandPaletteView: View {
isTextFieldFocused = isPresented
}
}
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
/// Returns 0 if no color name in the query matches, or if the color is nil.
private func colorMatchScore(for color: Color?, query: String) -> Double {
guard let color = color else { return 0 }
let queryLower = query.lowercased()
let nsColor = NSColor(color)
var bestScore: Double = 0
for name in NSColor.colorNames {
guard queryLower.contains(name),
let systemColor = NSColor(named: name) else { continue }
let distance = nsColor.distance(to: systemColor)
// Max distance in weighted RGB space is ~3.0, so normalize and invert
// Use a threshold to determine "close enough" matches
let maxDistance: Double = 1.5
if distance < maxDistance {
let score = 1.0 - (distance / maxDistance)
bestScore = max(bestScore, score)
}
}
return bestScore
}
}
/// The text field for building the query for the command palette.
@@ -283,14 +341,28 @@ fileprivate struct CommandRow: View {
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if let color = option.leadingColor {
Circle()
.fill(color)
.frame(width: 8, height: 8)
}
if let icon = option.leadingIcon {
Image(systemName: icon)
.foregroundStyle(option.emphasis ? Color.accentColor : .secondary)
.font(.system(size: 14, weight: .medium))
}
Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
VStack(alignment: .leading, spacing: 2) {
Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
if let subtitle = option.subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()

View File

@@ -18,63 +18,6 @@ struct TerminalCommandPaletteView: View {
/// The callback when an action is submitted.
var onAction: ((String) -> Void)
// The commands available to the command palette.
private var commandOptions: [CommandOption] {
var options: [CommandOption] = []
// Add update command if an update is installable. This must always be the first so
// it is at the top.
if let updateViewModel, updateViewModel.state.isInstallable {
// We override the update available one only because we want to properly
// convey it'll go all the way through.
let title: String
if case .updateAvailable = updateViewModel.state {
title = "Update Ghostty and Restart"
} else {
title = updateViewModel.text
}
options.append(CommandOption(
title: title,
description: updateViewModel.description,
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
badge: updateViewModel.badge,
emphasis: true
) {
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
})
}
// Add cancel/skip update command if the update is installable
if let updateViewModel, updateViewModel.state.isInstallable {
options.append(CommandOption(
title: "Cancel or Skip Update",
description: "Dismiss the current update process"
) {
updateViewModel.state.cancel()
})
}
// Add terminal commands
guard let surface = surfaceView.surfaceModel else { return options }
do {
let terminalCommands = try surface.commands().map { c in
return CommandOption(
title: c.title,
description: c.description,
symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList
) {
onAction(c.action)
}
}
options.append(contentsOf: terminalCommands)
} catch {
return options
}
return options
}
var body: some View {
ZStack {
if isPresented {
@@ -96,13 +39,8 @@ struct TerminalCommandPaletteView: View {
}
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
}
.transition(
.move(edge: .top)
.combined(with: .opacity)
)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented)
.onChange(of: isPresented) { newValue in
// When the command palette disappears we need to send focus back to the
// surface view we were overlaid on top of. There's probably a better way
@@ -116,6 +54,116 @@ struct TerminalCommandPaletteView: View {
}
}
}
/// All commands available in the command palette, combining update and terminal options.
private var commandOptions: [CommandOption] {
var options: [CommandOption] = []
// Updates always appear first
options.append(contentsOf: updateOptions)
// Sort the rest. We replace ":" with a character that sorts before space
// so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker
// for stable ordering when titles are equal.
options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in
let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t")
let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t")
let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized)
if comparison != .orderedSame {
return comparison == .orderedAscending
}
// Tie-breaker: use sortKey if both have one
if let aSortKey = a.sortKey, let bSortKey = b.sortKey {
return aSortKey < bSortKey
}
return false
})
return options
}
/// Commands for installing or canceling available updates.
private var updateOptions: [CommandOption] {
var options: [CommandOption] = []
guard let updateViewModel, updateViewModel.state.isInstallable else {
return options
}
// We override the update available one only because we want to properly
// convey it'll go all the way through.
let title: String
if case .updateAvailable = updateViewModel.state {
title = "Update Ghostty and Restart"
} else {
title = updateViewModel.text
}
options.append(CommandOption(
title: title,
description: updateViewModel.description,
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
badge: updateViewModel.badge,
emphasis: true
) {
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
})
options.append(CommandOption(
title: "Cancel or Skip Update",
description: "Dismiss the current update process"
) {
updateViewModel.state.cancel()
})
return options
}
/// Custom commands from the command-palette-entry configuration.
private var terminalOptions: [CommandOption] {
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
return appDelegate.ghostty.config.commandPaletteEntries.map { c in
CommandOption(
title: c.title,
description: c.description
) {
onAction(c.action)
}
}
}
/// Commands for jumping to other terminal surfaces.
private var jumpOptions: [CommandOption] {
TerminalController.all.flatMap { controller -> [CommandOption] in
guard let window = controller.window else { return [] }
let color = (window as? TerminalWindow)?.tabColor
let displayColor = color != TerminalTabColor.none ? color : nil
return controller.surfaceTree.map { surface in
let title = surface.title.isEmpty ? window.title : surface.title
let displayTitle = title.isEmpty ? "Untitled" : title
let pwd = surface.pwd?.abbreviatedPath
let subtitle: String? = if let pwd, !displayTitle.contains(pwd) {
pwd
} else {
nil
}
return CommandOption(
title: "Focus: \(displayTitle)",
subtitle: subtitle,
leadingIcon: "rectangle.on.rectangle",
leadingColor: displayColor?.displayColor.map { Color($0) },
sortKey: AnySortKey(ObjectIdentifier(surface))
) {
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyPresentTerminal,
object: surface
)
}
}
}
}
}
/// This is done to ensure that the given view is in the responder chain.

View File

@@ -195,6 +195,11 @@ class BaseTerminalController: NSWindowController,
selector: #selector(ghosttyDidResizeSplit(_:)),
name: Ghostty.Notification.didResizeSplit,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidPresentTerminal(_:)),
name: Ghostty.Notification.ghosttyPresentTerminal,
object: nil)
// Listen for local events that we need to know of outside of
// single surface handlers.
@@ -700,6 +705,22 @@ class BaseTerminalController: NSWindowController,
}
}
@objc private func ghosttyDidPresentTerminal(_ notification: Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(target) else { return }
// Bring the window to front and focus the surface.
window?.makeKeyAndOrderFront(nil)
// We use a small delay to ensure this runs after any UI cleanup
// (e.g., command palette restoring focus to its original surface).
Ghostty.moveFocus(to: target)
Ghostty.moveFocus(to: target, delay: 0.1)
// Show a brief highlight to help the user locate the presented terminal.
target.highlight()
}
// MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? {

View File

@@ -952,9 +952,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
case .contentIntrinsicSize:
// Content intrinsic size requires a short delay so that AppKit
// can layout our SwiftUI views.
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak window] in
guard let window else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak self, weak window] in
guard let self, let window else { return }
defaultSize.apply(to: window)
if let screen = window.screen ?? NSScreen.main {
let frame = self.adjustForWindowPosition(frame: window.frame, on: screen)
window.setFrameOrigin(frame.origin)
}
}
}
}

View File

@@ -5,7 +5,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
/// Titlebar tabs can't support the update accessory because of the way we layout
/// the native tabs back into the menu bar.
override var supportsUpdateAccessory: Bool { false }
/// This is used to determine if certain elements should be drawn light or dark and should
/// be updated whenever the window background color or surrounding elements changes.
fileprivate var isLightTheme: Bool = false
@@ -395,7 +395,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
// Hide the window drag handle.
windowDragHandle?.isHidden = true
// Reenable the main toolbar title
// Re-enable the main toolbar title
if let toolbar = toolbar as? TerminalToolbar {
toolbar.titleIsHidden = false
}

View File

@@ -7,16 +7,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
/// This is necessary because various macOS operations (tab switching, tab bar
/// visibility changes) can reset the titlebar appearance.
private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig?
/// KVO observation for tab group window changes.
private var tabGroupWindowsObservation: NSKeyValueObservation?
private var tabBarVisibleObservation: NSKeyValueObservation?
deinit {
tabGroupWindowsObservation?.invalidate()
tabBarVisibleObservation?.invalidate()
}
// MARK: NSWindow
override func awakeFromNib() {
@@ -29,7 +29,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
override func becomeMain() {
super.becomeMain()
guard let lastSurfaceConfig else { return }
syncAppearance(lastSurfaceConfig)
@@ -42,7 +42,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
}
}
}
override func update() {
super.update()
@@ -67,7 +67,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// Save our config in case we need to reapply
lastSurfaceConfig = surfaceConfig
// Everytime we change appearance, set KVO up again in case any of our
// Every time we change appearance, set KVO up again in case any of our
// references changed (e.g. tabGroup is new).
setupKVO()
@@ -99,7 +99,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
? NSColor.clear.cgColor
: preferredBackgroundColor?.cgColor
}
// In all cases, we have to hide the background view since this has multiple subviews
// that force a background color.
titlebarBackgroundView?.isHidden = true
@@ -108,14 +108,14 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
@available(macOS 13.0, *)
private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
guard let titlebarContainer else { return }
// Setup the titlebar background color to match ours
titlebarContainer.wantsLayer = true
titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor
// See the docs for the function that sets this to true on why
effectViewIsHidden = false
// Necessary to not draw the border around the title
titlebarAppearsTransparent = true
}
@@ -141,7 +141,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// Remove existing observation if any
tabGroupWindowsObservation?.invalidate()
tabGroupWindowsObservation = nil
// Check if tabGroup is available
guard let tabGroup else { return }
@@ -170,7 +170,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// Remove existing observation if any
tabBarVisibleObservation?.invalidate()
tabBarVisibleObservation = nil
// Set up KVO observation for isTabBarVisible
tabBarVisibleObservation = tabGroup?.observe(
\.isTabBarVisible,
@@ -181,18 +181,18 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
self.syncAppearance(lastSurfaceConfig)
}
}
// MARK: macOS 13 to 15
// We only need to set this once, but need to do it after the window has been created in order
// to determine if the theme is using a very dark background, in which case we don't want to
// remove the effect view if the default tab bar is being used since the effect created in
// `updateTabsForVeryDarkBackgrounds` creates a confusing visual design.
private var effectViewIsHidden = false
private func hideEffectView() {
guard !effectViewIsHidden else { return }
// By hiding the visual effect view, we allow the window's (or titlebar's in this case)
// background color to show through. If we were to set `titlebarAppearsTransparent` to true
// the selected tab would look fine, but the unselected ones and new tab button backgrounds

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)
@@ -627,12 +630,13 @@ extension Ghostty {
case GHOSTTY_ACTION_SEARCH_SELECTED:
searchSelected(app, target: target, v: action.action.search_selected)
case GHOSTTY_ACTION_PRESENT_TERMINAL:
return presentTerminal(app, target: target)
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
fallthrough
case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS:
fallthrough
case GHOSTTY_ACTION_PRESENT_TERMINAL:
fallthrough
case GHOSTTY_ACTION_SIZE_LIMIT:
fallthrough
case GHOSTTY_ACTION_QUIT_TIMER:
@@ -845,6 +849,30 @@ extension Ghostty {
}
}
private static func presentTerminal(
_ app: ghostty_app_t,
target: ghostty_target_s
) -> Bool {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
return false
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
NotificationCenter.default.post(
name: Notification.ghosttyPresentTerminal,
object: surfaceView
)
return true
default:
assertionFailure()
return false
}
}
private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_close_tab_mode_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
@@ -1746,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

@@ -622,6 +622,16 @@ extension Ghostty {
let str = String(cString: ptr)
return Scrollbar(rawValue: str) ?? defaultValue
}
var commandPaletteEntries: [Ghostty.Command] {
guard let config = self.config else { return [] }
var v: ghostty_config_command_list_s = .init()
let key = "command-palette-entry"
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return [] }
guard v.len > 0 else { return [] }
let buffer = UnsafeBufferPointer(start: v.commands, count: v.len)
return buffer.map { Ghostty.Command(cValue: $0) }
}
}
}

View File

@@ -32,6 +32,10 @@ extension Ghostty {
guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }
key = KeyEquivalent(Character(scalar))
case GHOSTTY_TRIGGER_CATCH_ALL:
// catch_all matches any key, so it can't be represented as a KeyboardShortcut
return nil
default:
return nil
}
@@ -64,7 +68,7 @@ extension Ghostty {
if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }
// Handle sided input. We can't tell that both are pressed in the
// Ghostty structure but thats okay -- we don't use that information.
// Ghostty structure but that's okay -- we don't use that information.
let rawFlags = flags.rawValue
if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }
@@ -135,7 +139,7 @@ extension Ghostty.Input {
case GHOSTTY_ACTION_REPEAT: self.action = .repeat
default: self.action = .press
}
// Convert key from keycode
guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil }
self.key = key
@@ -146,18 +150,18 @@ extension Ghostty.Input {
} else {
self.text = nil
}
// Set composing state
self.composing = cValue.composing
// Convert modifiers
self.mods = Mods(cMods: cValue.mods)
self.consumedMods = Mods(cMods: cValue.consumed_mods)
// Set unshifted codepoint
self.unshiftedCodepoint = cValue.unshifted_codepoint
}
/// Executes a closure with a temporary C representation of this KeyEvent.
///
/// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct
@@ -176,7 +180,7 @@ extension Ghostty.Input {
keyEvent.mods = mods.cMods
keyEvent.consumed_mods = consumedMods.cMods
keyEvent.unshifted_codepoint = unshiftedCodepoint
// Handle text with proper memory management
if let text = text {
return text.withCString { textPtr in
@@ -199,7 +203,7 @@ extension Ghostty.Input {
case release
case press
case `repeat`
var cAction: ghostty_input_action_e {
switch self {
case .release: GHOSTTY_ACTION_RELEASE
@@ -228,7 +232,7 @@ extension Ghostty.Input {
let action: MouseState
let button: MouseButton
let mods: Mods
init(
action: MouseState,
button: MouseButton,
@@ -238,7 +242,7 @@ extension Ghostty.Input {
self.button = button
self.mods = mods
}
/// Creates a MouseEvent from C enum values.
///
/// This initializer converts C-style mouse input enums to Swift types.
@@ -255,7 +259,7 @@ extension Ghostty.Input {
case GHOSTTY_MOUSE_PRESS: self.action = .press
default: return nil
}
// Convert button
switch button {
case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown
@@ -264,7 +268,7 @@ extension Ghostty.Input {
case GHOSTTY_MOUSE_MIDDLE: self.button = .middle
default: return nil
}
// Convert modifiers
self.mods = Mods(cMods: mods)
}
@@ -275,7 +279,7 @@ extension Ghostty.Input {
let x: Double
let y: Double
let mods: Mods
init(
x: Double,
y: Double,
@@ -312,7 +316,7 @@ extension Ghostty.Input {
enum MouseState: String, CaseIterable {
case release
case press
var cMouseState: ghostty_input_mouse_state_e {
switch self {
case .release: GHOSTTY_MOUSE_RELEASE
@@ -340,7 +344,7 @@ extension Ghostty.Input {
case left
case right
case middle
var cMouseButton: ghostty_input_mouse_button_e {
switch self {
case .unknown: GHOSTTY_MOUSE_UNKNOWN
@@ -378,18 +382,18 @@ extension Ghostty.Input {
/// for scroll events, matching the Zig `ScrollMods` packed struct.
struct ScrollMods {
let rawValue: Int32
/// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse)
var precision: Bool {
rawValue & 0b0000_0001 != 0
}
/// The momentum phase of the scroll event for inertial scrolling
var momentum: Momentum {
let momentumBits = (rawValue >> 1) & 0b0000_0111
return Momentum(rawValue: UInt8(momentumBits)) ?? .none
}
init(precision: Bool = false, momentum: Momentum = .none) {
var value: Int32 = 0
if precision {
@@ -398,11 +402,11 @@ extension Ghostty.Input {
value |= Int32(momentum.rawValue) << 1
self.rawValue = value
}
init(rawValue: Int32) {
self.rawValue = rawValue
}
var cScrollMods: ghostty_input_scroll_mods_t {
rawValue
}
@@ -421,7 +425,7 @@ extension Ghostty.Input {
case ended = 4
case cancelled = 5
case mayBegin = 6
var cMomentum: ghostty_input_mouse_momentum_e {
switch self {
case .none: GHOSTTY_MOUSE_MOMENTUM_NONE
@@ -438,7 +442,7 @@ extension Ghostty.Input {
extension Ghostty.Input.Momentum: AppEnum {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum")
static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [
.none: "None",
.began: "Began",
@@ -475,7 +479,7 @@ extension Ghostty.Input {
/// `ghostty_input_mods_e`
struct Mods: OptionSet {
let rawValue: UInt32
static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue)
static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue)
static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue)
@@ -486,23 +490,23 @@ extension Ghostty.Input {
static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue)
static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue)
static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue)
var cMods: ghostty_input_mods_e {
ghostty_input_mods_e(rawValue)
}
init(rawValue: UInt32) {
self.rawValue = rawValue
}
init(cMods: ghostty_input_mods_e) {
self.rawValue = cMods.rawValue
}
init(nsFlags: NSEvent.ModifierFlags) {
self.init(cMods: Ghostty.ghosttyMods(nsFlags))
}
var nsFlags: NSEvent.ModifierFlags {
Ghostty.eventModifierFlags(mods: cMods)
}
@@ -1116,43 +1120,43 @@ extension Ghostty.Input.Key: AppEnum {
return [
// Letters (A-Z)
.a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z,
// Numbers (0-9)
.digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9,
// Common Control Keys
.space, .enter, .tab, .backspace, .escape, .delete,
// Arrow Keys
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
// Navigation Keys
.home, .end, .pageUp, .pageDown, .insert,
// Function Keys (F1-F20)
.f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12,
.f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20,
// Modifier Keys
.shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight,
.metaLeft, .metaRight, .capsLock,
// Punctuation & Symbols
.minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash,
.semicolon, .quote, .comma, .period, .slash,
// Numpad
.numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5,
.numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract,
.numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual,
.numpadEnter, .numpadComma,
// Media Keys
.audioVolumeUp, .audioVolumeDown, .audioVolumeMute,
// International Keys
.intlBackslash, .intlRo, .intlYen,
// Other
.contextMenu
]
@@ -1163,11 +1167,11 @@ extension Ghostty.Input.Key: AppEnum {
.a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J",
.k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T",
.u: "U", .v: "V", .w: "W", .x: "X", .y: "Y", .z: "Z",
// Numbers (0-9)
.digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4",
.digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9",
// Common Control Keys
.space: "Space",
.enter: "Enter",
@@ -1175,26 +1179,26 @@ extension Ghostty.Input.Key: AppEnum {
.backspace: "Backspace",
.escape: "Escape",
.delete: "Delete",
// Arrow Keys
.arrowUp: "Up Arrow",
.arrowDown: "Down Arrow",
.arrowLeft: "Left Arrow",
.arrowRight: "Right Arrow",
// Navigation Keys
.home: "Home",
.end: "End",
.pageUp: "Page Up",
.pageDown: "Page Down",
.insert: "Insert",
// Function Keys (F1-F20)
.f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6",
.f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12",
.f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17",
.f18: "F18", .f19: "F19", .f20: "F20",
// Modifier Keys
.shiftLeft: "Left Shift",
.shiftRight: "Right Shift",
@@ -1205,7 +1209,7 @@ extension Ghostty.Input.Key: AppEnum {
.metaLeft: "Left Command",
.metaRight: "Right Command",
.capsLock: "Caps Lock",
// Punctuation & Symbols
.minus: "Minus (-)",
.equal: "Equal (=)",
@@ -1218,7 +1222,7 @@ extension Ghostty.Input.Key: AppEnum {
.comma: "Comma (,)",
.period: "Period (.)",
.slash: "Slash (/)",
// Numpad
.numLock: "Num Lock",
.numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2",
@@ -1232,17 +1236,17 @@ extension Ghostty.Input.Key: AppEnum {
.numpadEqual: "Numpad Equal",
.numpadEnter: "Numpad Enter",
.numpadComma: "Numpad Comma",
// Media Keys
.audioVolumeUp: "Volume Up",
.audioVolumeDown: "Volume Down",
.audioVolumeMute: "Volume Mute",
// International Keys
.intlBackslash: "International Backslash",
.intlRo: "International Ro",
.intlYen: "International Yen",
// Other
.contextMenu: "Context Menu"
]

View File

@@ -134,16 +134,5 @@ extension Ghostty {
ghostty_surface_binding_action(surface, cString, UInt(len - 1))
}
}
/// Command options for this surface.
@MainActor
func commands() throws -> [Command] {
var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil
var count: Int = 0
ghostty_surface_commands(surface, &ptr, &count)
guard let ptr else { throw Error.apiFailed }
let buffer = UnsafeBufferPointer(start: ptr, count: count)
return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported }
}
}
}

View File

@@ -435,6 +435,9 @@ extension Ghostty.Notification {
/// New window. Has base surface config requested in userinfo.
static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow")
/// Present terminal. Bring the surface's window to focus without activating the app.
static let ghosttyPresentTerminal = Notification.Name("com.mitchellh.ghostty.presentTerminal")
/// Toggle fullscreen of current window
static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen")
static let FullscreenModeKey = ghosttyToggleFullscreen.rawValue
@@ -472,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

@@ -49,7 +49,7 @@ extension Ghostty {
// True if we're hovering over the left URL view, so we can show it on the right.
@State private var isHoveringURLLeft: Bool = false
#if canImport(AppKit)
// Observe SecureInput to detect when its enabled
@ObservedObject private var secureInput = SecureInput.shared
@@ -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.
@@ -219,6 +199,9 @@ extension Ghostty {
BellBorderOverlay(bell: surfaceView.bell)
}
// Show a highlight effect when this surface needs attention
HighlightOverlay(highlighted: surfaceView.highlighted)
// If our surface is not healthy, then we render an error view over it.
if (!surfaceView.healthy) {
Rectangle().fill(ghostty.config.backgroundColor)
@@ -242,6 +225,7 @@ extension Ghostty {
}
}
}
}
}
@@ -748,6 +732,226 @@ 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 || !keySequence.isEmpty {
content
.backport.pointerStyle(!keyTables.isEmpty ? .link : nil)
}
}
.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
if !keyTables.isEmpty {
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 !keyTables.isEmpty && !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
@@ -764,6 +968,62 @@ extension Ghostty {
}
}
/// Visual overlay that briefly highlights a surface to draw attention to it.
/// Uses a soft, soothing highlight with a pulsing border effect.
struct HighlightOverlay: View {
let highlighted: Bool
@State private var borderPulse: Bool = false
var body: some View {
ZStack {
Rectangle()
.fill(
RadialGradient(
gradient: Gradient(colors: [
Color.accentColor.opacity(0.12),
Color.accentColor.opacity(0.03),
Color.clear
]),
center: .center,
startRadius: 0,
endRadius: 2000
)
)
Rectangle()
.strokeBorder(
LinearGradient(
gradient: Gradient(colors: [
Color.accentColor.opacity(0.8),
Color.accentColor.opacity(0.5),
Color.accentColor.opacity(0.8)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: borderPulse ? 4 : 2
)
.shadow(color: Color.accentColor.opacity(borderPulse ? 0.8 : 0.6), radius: borderPulse ? 12 : 8, x: 0, y: 0)
.shadow(color: Color.accentColor.opacity(borderPulse ? 0.5 : 0.3), radius: borderPulse ? 24 : 16, x: 0, y: 0)
}
.allowsHitTesting(false)
.opacity(highlighted ? 1.0 : 0.0)
.animation(.easeOut(duration: 0.4), value: highlighted)
.onChange(of: highlighted) { newValue in
if newValue {
withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) {
borderPulse = true
}
} else {
withAnimation(.easeOut(duration: 0.4)) {
borderPulse = false
}
}
}
}
}
// MARK: Readonly Badge
/// A badge overlay that indicates a surface is in readonly mode.

View File

@@ -9,7 +9,7 @@ extension Ghostty {
/// The NSView implementation for a terminal surface.
class SurfaceView: OSView, ObservableObject, Codable, Identifiable {
typealias ID = UUID
/// Unique ID per surface
let id: UUID
@@ -44,14 +44,14 @@ extension Ghostty {
// The hovered URL string
@Published var hoverUrl: String? = nil
// The progress report (if any)
@Published var progressReport: Action.ProgressReport? = nil {
didSet {
// Cancel any existing timer
progressReportTimer?.invalidate()
progressReportTimer = nil
// If we have a new progress report, start a timer to remove it after 15 seconds
if progressReport != nil {
progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
@@ -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 {
@@ -98,7 +101,7 @@ extension Ghostty {
}
}
}
// Cancellable for search state needle changes
private var searchNeedleCancellable: AnyCancellable?
@@ -126,6 +129,9 @@ extension Ghostty {
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false
// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
var initialSize: NSSize? = nil
@@ -213,7 +219,7 @@ extension Ghostty {
// A timer to fallback to ghost emoji if no title is set within the grace period
private var titleFallbackTimer: Timer?
// Timer to remove progress report after 15 seconds
private var progressReportTimer: Timer?
@@ -321,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(_:)),
@@ -407,7 +418,7 @@ extension Ghostty {
// Remove any notifications associated with this surface
let identifiers = Array(self.notificationIdentifiers)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
// Cancel progress report timer
progressReportTimer?.invalidate()
}
@@ -544,16 +555,16 @@ extension Ghostty {
// Add buttons
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
// Make the text field the first responder so it gets focus
alert.window.initialFirstResponder = textField
let completionHandler: (NSApplication.ModalResponse) -> Void = { [weak self] response in
guard let self else { return }
// Check if the user clicked "OK"
guard response == .alertFirstButtonReturn else { return }
// Get the input text
let newTitle = textField.stringValue
if newTitle.isEmpty {
@@ -677,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?[
@@ -961,7 +988,7 @@ extension Ghostty {
var x = event.scrollingDeltaX
var y = event.scrollingDeltaY
let precision = event.hasPreciseScrollingDeltas
if precision {
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
x *= 2;
@@ -1323,7 +1350,7 @@ extension Ghostty {
var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags)
key_ev.composing = composing
// For text, we only encode UTF8 if we don't have a single control
// character. Control characters are encoded by Ghostty itself.
// Without this, `ctrl+enter` does the wrong thing.
@@ -1482,7 +1509,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func find(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "start_search"
@@ -1490,7 +1517,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func findNext(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "search:next"
@@ -1506,7 +1533,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func findHide(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "end_search"
@@ -1523,6 +1550,14 @@ extension Ghostty {
}
}
/// Triggers a brief highlight animation on this surface.
func highlight() {
highlighted = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.highlighted = false
}
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = self.surface else { return }
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT)
@@ -1558,7 +1593,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func changeTitle(_ sender: Any) {
promptTitle()
}
@@ -1668,7 +1703,7 @@ extension Ghostty {
let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false
self.init(app, baseConfig: config, uuid: uuid)
// Restore the saved title after initialization
if let title = savedTitle {
self.title = title
@@ -1885,6 +1920,17 @@ extension Ghostty.SurfaceView: NSTextInputClient {
return
}
guard let surfaceModel else { return }
// Process MacOS native scroll events
switch selector {
case #selector(moveToBeginningOfDocument(_:)):
_ = surfaceModel.perform(action: "scroll_to_top")
case #selector(moveToEndOfDocument(_:)):
_ = surfaceModel.perform(action: "scroll_to_bottom")
default:
break
}
print("SEL: \(selector)")
}
@@ -1925,14 +1971,14 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
// The "COMBINATION" bit is key: we might get sent a string (we can handle that)
// but get requested an image (we can't handle that at the time of writing this),
// so we must bubble up.
// Types we can receive
let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
// Types that we can send. Currently the same as receivable but I'm separating
// this out so we can modify this in the future.
let sendable: [NSPasteboard.PasteboardType] = receivable
// The sendable types that require a selection (currently all)
let sendableRequiresSelection = sendable
@@ -1949,7 +1995,7 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
}
return self
}
@@ -1995,7 +2041,7 @@ extension Ghostty.SurfaceView: NSMenuItemValidation {
let pb = NSPasteboard.ghosttySelection
guard let str = pb.getOpinionatedStringContents() else { return false }
return !str.isEmpty
case #selector(findHide):
return searchState != nil
@@ -2100,7 +2146,7 @@ extension Ghostty.SurfaceView {
override func accessibilitySelectedTextRange() -> NSRange {
return selectedRange()
}
/// Returns the currently selected text as a string.
/// This allows assistive technologies to read the selected content.
override func accessibilitySelectedText() -> String? {
@@ -2114,21 +2160,21 @@ extension Ghostty.SurfaceView {
let str = String(cString: text.text)
return str.isEmpty ? nil : str
}
/// Returns the number of characters in the terminal content.
/// This helps assistive technologies understand the size of the content.
override func accessibilityNumberOfCharacters() -> Int {
let content = cachedScreenContents.get()
return content.count
}
/// Returns the visible character range for the terminal.
/// For terminals, we typically show all content as visible.
override func accessibilityVisibleCharacterRange() -> NSRange {
let content = cachedScreenContents.get()
return NSRange(location: 0, length: content.count)
}
/// Returns the line number for a given character index.
/// This helps assistive technologies navigate by line.
override func accessibilityLine(for index: Int) -> Int {
@@ -2136,7 +2182,7 @@ extension Ghostty.SurfaceView {
let substring = String(content.prefix(index))
return substring.components(separatedBy: .newlines).count - 1
}
/// Returns a substring for the given range.
/// This allows assistive technologies to read specific portions of the content.
override func accessibilityString(for range: NSRange) -> String? {
@@ -2144,7 +2190,7 @@ extension Ghostty.SurfaceView {
guard let swiftRange = Range(range, in: content) else { return nil }
return String(content[swiftRange])
}
/// Returns an attributed string for the given range.
///
/// Note: right now this only applies font information. One day it'd be nice to extend
@@ -2155,9 +2201,9 @@ extension Ghostty.SurfaceView {
override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? {
guard let surface = self.surface else { return nil }
guard let plainString = accessibilityString(for: range) else { return nil }
var attributes: [NSAttributedString.Key: Any] = [:]
// Try to get the font from the surface
if let fontRaw = ghostty_surface_quicklook_font(surface) {
let font = Unmanaged<CTFont>.fromOpaque(fontRaw)

View File

@@ -43,9 +43,15 @@ 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
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.

View File

@@ -0,0 +1,25 @@
import Foundation
/// Type-erased wrapper for any Comparable type to use as a sort key.
struct AnySortKey: Comparable {
private let value: Any
private let comparator: (Any, Any) -> ComparisonResult
init<T: Comparable>(_ value: T) {
self.value = value
self.comparator = { lhs, rhs in
guard let l = lhs as? T, let r = rhs as? T else { return .orderedSame }
if l < r { return .orderedAscending }
if l > r { return .orderedDescending }
return .orderedSame
}
}
static func < (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
lhs.comparator(lhs.value, rhs.value) == .orderedAscending
}
static func == (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
lhs.comparator(lhs.value, rhs.value) == .orderedSame
}
}

View File

@@ -0,0 +1,39 @@
import AppKit
extension NSColor {
/// Using a color list let's us get localized names.
private static let appleColorList: NSColorList? = NSColorList(named: "Apple")
convenience init?(named name: String) {
guard let colorList = Self.appleColorList,
let color = colorList.color(withKey: name.capitalized) else {
return nil
}
guard let components = color.usingColorSpace(.sRGB) else {
return nil
}
self.init(
red: components.redComponent,
green: components.greenComponent,
blue: components.blueComponent,
alpha: components.alphaComponent
)
}
static var colorNames: [String] {
appleColorList?.allKeys.map { $0.lowercased() } ?? []
}
/// Calculates the perceptual distance to another color in RGB space.
func distance(to other: NSColor) -> Double {
guard let a = self.usingColorSpace(.sRGB),
let b = other.usingColorSpace(.sRGB) else { return .infinity }
let dr = a.redComponent - b.redComponent
let dg = a.greenComponent - b.greenComponent
let db = a.blueComponent - b.blueComponent
// Weighted Euclidean distance (human eye is more sensitive to green)
return sqrt(2 * dr * dr + 4 * dg * dg + 3 * db * db)
}
}

View File

@@ -16,19 +16,23 @@ extension NSWindow {
return firstWindow === self
}
/// Adjusts the window origin if necessary to ensure the window remains visible on screen.
/// Adjusts the window frame if necessary to ensure the window remains visible on screen.
/// This constrains both the size (to not exceed the screen) and the origin (to keep the window on screen).
func constrainToScreen() {
guard let screen = screen ?? NSScreen.main else { return }
let visibleFrame = screen.visibleFrame
var windowFrame = frame
windowFrame.size.width = min(windowFrame.size.width, visibleFrame.size.width)
windowFrame.size.height = min(windowFrame.size.height, visibleFrame.size.height)
windowFrame.origin.x = max(visibleFrame.minX,
min(windowFrame.origin.x, visibleFrame.maxX - windowFrame.width))
windowFrame.origin.y = max(visibleFrame.minY,
min(windowFrame.origin.y, visibleFrame.maxY - windowFrame.height))
if windowFrame.origin != frame.origin {
setFrameOrigin(windowFrame.origin)
if windowFrame != frame {
setFrame(windowFrame, display: true)
}
}
}

View File

@@ -7,7 +7,7 @@ extension String {
return self.prefix(maxLength) + trailing
}
#if canImport(AppKit)
#if canImport(AppKit)
func temporaryFile(_ filename: String = "temp") -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(filename)
@@ -16,5 +16,14 @@ extension String {
try? string.write(to: url, atomically: true, encoding: .utf8)
return url
}
#endif
/// Returns the path with the home directory abbreviated as ~.
var abbreviatedPath: String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
if hasPrefix(home) {
return "~" + dropFirst(home.count)
}
return self
}
#endif
}

View File

@@ -130,7 +130,7 @@ class NativeFullscreen: FullscreenBase, FullscreenStyle {
class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
var fullscreenMode: FullscreenMode { .nonNative }
// Non-native fullscreen never supports tabs because tabs require
// the "titled" style and we don't have it for non-native fullscreen.
var supportsTabs: Bool { false }
@@ -223,7 +223,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
// Being untitled let's our content take up the full frame.
window.styleMask.remove(.titled)
// We dont' want the non-native fullscreen window to be resizable
// We don't want the non-native fullscreen window to be resizable
// from the edges.
window.styleMask.remove(.resizable)
@@ -277,7 +277,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
if let window = window as? TerminalWindow, window.isTabBar(c) {
continue
}
if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil {
window.addTitlebarAccessoryViewController(c)
}
@@ -286,7 +286,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
// Removing "titled" also clears our toolbar
window.toolbar = savedState.toolbar
window.toolbarStyle = savedState.toolbarStyle
// If the window was previously in a tab group that isn't empty now,
// we re-add it. We have to do this because our process of doing non-native
// fullscreen removes the window from the tab group.
@@ -412,7 +412,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
self.toolbar = window.toolbar
self.toolbarStyle = window.toolbarStyle
self.dock = window.screen?.hasDock ?? false
self.titlebarAccessoryViewControllers = if (window.hasTitleBar) {
// Accessing titlebarAccessoryViewControllers without a titlebar triggers a crash.
window.titlebarAccessoryViewControllers

View File

@@ -72,6 +72,10 @@ pub fn build(b: *std.Build) !void {
});
}
if (target.result.os.tag == .freebsd) {
try flags.append(b.allocator, "-fPIC");
}
if (imgui_) |imgui| {
lib.addCSourceFile(.{ .file = b.path("vendor/cimgui.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui.cpp"), .flags = flags.items });

View File

@@ -90,6 +90,10 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
"-fno-sanitize=undefined",
});
if (target.result.os.tag == .freebsd) {
try flags.append(b.allocator, "-fPIC");
}
const dynamic_link_opts = options.dynamic_link_opts;
// Zlib

View File

@@ -66,6 +66,10 @@ fn buildGlslang(
"-fno-sanitize-trap=undefined",
});
if (target.result.os.tag == .freebsd) {
try flags.append(b.allocator, "-fPIC");
}
if (upstream_) |upstream| {
lib.addCSourceFiles(.{
.root = upstream.path(""),

View File

@@ -72,6 +72,11 @@ pub fn build(b: *std.Build) !void {
"-fno-sanitize=undefined",
"-fno-sanitize-trap=undefined",
});
if (target.result.os.tag == .freebsd) {
try flags.append(b.allocator, "-fPIC");
}
if (target.result.os.tag != .windows) {
try flags.appendSlice(b.allocator, &.{
"-fmath-errno",

View File

@@ -32,6 +32,10 @@ pub fn build(b: *std.Build) !void {
"-fno-sanitize-trap=undefined",
});
if (target.result.os.tag == .freebsd) {
try flags.append(b.allocator, "-fPIC");
}
lib.addCSourceFiles(.{
.flags = flags.items,
.files = &.{

View File

@@ -74,6 +74,10 @@ fn buildSpirvCross(
"-fno-sanitize-trap=undefined",
});
if (target.result.os.tag == .freebsd) {
try flags.append(b.allocator, "-fPIC");
}
if (b.lazyDependency("spirv_cross", .{})) |upstream| {
lib.addIncludePath(upstream.path(""));
module.addIncludePath(upstream.path(""));

View File

@@ -357,15 +357,17 @@ pub fn keyEvent(
// 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.value_ptr.*) {
const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
// 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,
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
};
const actions: []const input.Binding.Action = leaf.actionsSlice();
assert(actions.len > 0);
// If we aren't focused, then we only process global keybinds.
if (!self.focused and !leaf.flags.global) return false;
@@ -373,13 +375,7 @@ pub fn keyEvent(
// Global keybinds are done using performAll so that they
// can target all surfaces too.
if (leaf.flags.global) {
self.performAllAction(rt_app, leaf.action) catch |err| {
log.warn("error performing global keybind action action={s} err={}", .{
@tagName(leaf.action),
err,
});
};
self.performAllChainedAction(rt_app, actions);
return true;
}
@@ -389,14 +385,20 @@ pub fn keyEvent(
// If we are focused, then we process keybinds only if they are
// app-scoped. Otherwise, we do nothing. Surface-scoped should
// be processed by Surface.keyEvent.
const app_action = leaf.action.scoped(.app) orelse return false;
self.performAction(rt_app, app_action) catch |err| {
log.warn("error performing app keybind action action={s} err={}", .{
@tagName(app_action),
err,
});
};
// be processed by Surface.keyEvent. For chained actions, all
// actions must be app-scoped.
for (actions) |action| if (action.scoped(.app) == null) return false;
for (actions) |action| {
self.performAction(
rt_app,
action.scoped(.app).?,
) catch |err| {
log.warn("error performing app keybind action action={s} err={}", .{
@tagName(action),
err,
});
};
}
return true;
}
@@ -454,6 +456,23 @@ pub fn performAction(
}
}
/// Performs a chained action. We will continue executing each action
/// even if there is a failure in a prior action.
pub fn performAllChainedAction(
self: *App,
rt_app: *apprt.App,
actions: []const input.Binding.Action,
) void {
for (actions) |action| {
self.performAllAction(rt_app, action) catch |err| {
log.warn("error performing chained action action={s} err={}", .{
@tagName(action),
err,
});
};
}
}
/// 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.

View File

@@ -49,6 +49,10 @@ const Renderer = rendererpkg.Renderer;
const min_window_width_cells: u32 = 10;
const min_window_height_cells: u32 = 4;
/// The maximum number of key tables that can be active at any
/// given time. `activate_key_table` calls after this are ignored.
const max_active_key_tables = 8;
/// Allocator
alloc: Allocator,
@@ -253,18 +257,9 @@ const Mouse = struct {
/// Keyboard state for the surface.
pub const Keyboard = struct {
/// The currently active keybindings for the surface. This is used to
/// implement sequences: as leader keys are pressed, the active bindings
/// set is updated to reflect the current leader key sequence. If this is
/// null then the root bindings are used.
bindings: ?*const input.Binding.Set = null,
/// The last handled binding. This is used to prevent encoding release
/// events for handled bindings. We only need to keep track of one because
/// at least at the time of writing this, its impossible for two keys of
/// a combination to be handled by different bindings before the release
/// of the prior (namely since you can't bind modifier-only).
last_trigger: ?u64 = null,
/// The currently active key sequence for the surface. If this is null
/// then we're not currently in a key sequence.
sequence_set: ?*const input.Binding.Set = null,
/// The queued keys when we're in the middle of a sequenced binding.
/// These are flushed when the sequence is completed and unconsumed or
@@ -272,7 +267,23 @@ pub const Keyboard = struct {
///
/// This is naturally bounded due to the configuration maximum
/// length of a sequence.
queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .{},
sequence_queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .empty,
/// The stack of tables that is currently active. The first value
/// in this is the first activated table (NOT the default keybinding set).
///
/// This is bounded by `max_active_key_tables`.
table_stack: std.ArrayListUnmanaged(struct {
set: *const input.Binding.Set,
once: bool,
}) = .empty,
/// The last handled binding. This is used to prevent encoding release
/// events for handled bindings. We only need to keep track of one because
/// at least at the time of writing this, its impossible for two keys of
/// a combination to be handled by different bindings before the release
/// of the prior (namely since you can't bind modifier-only).
last_trigger: ?u64 = null,
};
/// The configuration that a surface has, this is copied from the main
@@ -793,8 +804,9 @@ pub fn deinit(self: *Surface) void {
}
// Clean up our keyboard state
for (self.keyboard.queued.items) |req| req.deinit();
self.keyboard.queued.deinit(self.alloc);
for (self.keyboard.sequence_queued.items) |req| req.deinit();
self.keyboard.sequence_queued.deinit(self.alloc);
self.keyboard.table_stack.deinit(self.alloc);
// Clean up our font grid
self.app.font_grid_set.deref(self.font_grid_key);
@@ -1210,7 +1222,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void {
break :gui false;
}) return;
// If a native GUI notification was not showm. update our terminal to
// If a native GUI notification was not shown, update our terminal to
// note the abnormal exit.
self.childExitedAbnormally(info) catch |err| {
log.err("error handling abnormal child exit err={}", .{err});
@@ -1220,7 +1232,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void {
return;
}
// We output a message so that the user knows whats going on and
// We output a message so that the user knows what's going on and
// doesn't think their terminal just froze. We show this unconditionally
// on close even if `wait_after_command` is false and the surface closes
// immediately because if a user does an `undo` to restore a closed
@@ -1731,6 +1743,14 @@ pub fn updateConfig(
// If we are in the middle of a key sequence, clear it.
self.endKeySequence(.drop, .free);
// Deactivate all key tables since they may have changed. Importantly,
// we store pointers into the config as part of our table stack so
// we can't keep them active across config changes. But this behavior
// also matches key sequences.
_ = self.deactivateAllKeyTables() catch |err| {
log.warn("failed to deactivate key tables err={}", .{err});
};
// Before sending any other config changes, we give the renderer a new font
// grid. We could check to see if there was an actual change to the font,
// but this is easier and pretty rare so it's not a performance concern.
@@ -2563,14 +2583,22 @@ pub fn keyEventIsBinding(
.press, .repeat => {},
}
// Our keybinding set is either our current nested set (for
// sequences) or the root set.
const set = self.keyboard.bindings orelse &self.config.keybind.set;
// If we're in a sequence, check the sequence set
if (self.keyboard.sequence_set) |set| {
return set.getEvent(event) != null;
}
// log.warn("text keyEventIsBinding event={} match={}", .{ event, 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;
}
}
// If we have a keybinding for this event then we return true.
return set.getEvent(event) != null;
// Check the root set
return self.config.keybind.set.getEvent(event) != null;
}
/// Called for any key events. This handles keybindings, encoding and
@@ -2791,38 +2819,70 @@ fn maybeHandleBinding(
// Find an entry in the keybind set that matches our event.
const entry: input.Binding.Set.Entry = entry: {
const set = self.keyboard.bindings orelse &self.config.keybind.set;
// Handle key sequences first.
if (self.keyboard.sequence_set) |set| {
// Get our entry from the set for the given event.
if (set.getEvent(event)) |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. We need to encode everything up to this
// point and send to the pty since we're in a sequence.
// We ignore modifiers so that nested sequences such as
// ctrl+a>ctrl+b>c work.
if (event.key.modifier()) return null;
// If we have a catch-all of ignore, then we special case our
// invalid sequence handling to ignore it.
if (self.catchAllIsIgnore()) {
self.endKeySequence(.drop, .retain);
return .ignored;
}
// 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
// send to the pty.
//
// We also ignore modifiers so that nested sequences such as
// ctrl+a>ctrl+b>c work.
if (self.keyboard.bindings != null and
!event.key.modifier())
{
// Encode everything up to this point
self.endKeySequence(.flush, .retain);
return null;
}
return null;
// No currently active sequence, move on to tables. For tables,
// we search inner-most table to outer-most. The table stack does
// NOT include the root set.
const table_items = self.keyboard.table_stack.items;
if (table_items.len > 0) {
for (0..table_items.len) |i| {
const rev_i: usize = table_items.len - 1 - i;
const table = table_items[rev_i];
if (table.set.getEvent(event)) |v| {
// If this is a one-shot activation AND its the currently
// active table, then we deactivate it after this.
// Note: we may want to change the semantics here to
// remove this table no matter where it is in the stack,
// maybe.
if (table.once and i == 0) _ = try self.performBindingAction(
.deactivate_key_table,
);
break :entry v;
}
}
}
// No table, use our default set
break :entry self.config.keybind.set.getEvent(event) orelse
return null;
};
// Determine if this entry has an action or if its a leader key.
const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) {
const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
.leader => |set| {
// Setup the next set we'll look at.
self.keyboard.bindings = set;
self.keyboard.sequence_set = set;
// Store this event so that we can drain and encode on invalid.
// We don't need to cap this because it is naturally capped by
// the config validation.
if (try self.encodeKey(event, insp_ev)) |req| {
try self.keyboard.queued.append(self.alloc, req);
try self.keyboard.sequence_queued.append(self.alloc, req);
}
// Start or continue our key sequence
@@ -2840,9 +2900,8 @@ fn maybeHandleBinding(
return .consumed;
},
.leaf => |leaf| leaf,
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
};
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).
@@ -2861,39 +2920,61 @@ fn maybeHandleBinding(
// perform an action (below)
self.keyboard.last_trigger = null;
// An action also always resets the binding set.
self.keyboard.bindings = null;
// An action also always resets the sequence set.
self.keyboard.sequence_set = null;
// Setup our actions
const actions = leaf.actionsSlice();
// Attempt to perform the action
log.debug("key event binding flags={} action={f}", .{
log.debug("key event binding flags={} action={any}", .{
leaf.flags,
action,
actions,
});
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);
self.app.performAllChainedAction(
self.rt_app,
actions,
);
// "All" actions are always performed since they are global.
break :performed true;
}
break :performed try self.performBindingAction(action);
// Perform each action. We are performed if ANY of the chained
// actions perform.
var performed: bool = false;
for (actions) |action| {
if (self.performBindingAction(action)) |v| {
performed = performed or v;
} else |err| {
log.info(
"key binding action failed action={t} err={}",
.{ action, err },
);
}
}
break :performed performed;
};
if (performed) {
// If we performed an action and it was a closing action,
// our "self" pointer is not safe to use anymore so we need to
// just exit immediately.
if (closingAction(action)) {
for (actions) |action| if (closingAction(action)) {
log.debug("key binding is a closing binding, halting key event processing", .{});
return .closed;
}
};
// If our action was "ignore" then we return the special input
// effect of "ignored".
if (action == .ignore) return .ignored;
for (actions) |action| if (action == .ignore) {
return .ignored;
};
}
// If we have the performable flag and the action was not performed,
@@ -2917,7 +2998,18 @@ fn maybeHandleBinding(
// Store our last trigger so we don't encode the release event
self.keyboard.last_trigger = event.bindingHash();
if (insp_ev) |ev| ev.binding = action;
if (insp_ev) |ev| {
ev.binding = self.alloc.dupe(
input.Binding.Action,
actions,
) catch |err| binding: {
log.warn(
"error allocating binding action for inspector err={}",
.{err},
);
break :binding &.{};
};
}
return .consumed;
}
@@ -2928,6 +3020,58 @@ fn maybeHandleBinding(
return null;
}
fn deactivateAllKeyTables(self: *Surface) !bool {
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),
}
// 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},
);
};
return true;
}
/// This checks if the current keybinding sets have a catch_all binding
/// with `ignore`. This is used to determine some special input cases.
fn catchAllIsIgnore(self: *Surface) bool {
// Get our catch all
const entry: input.Binding.Set.Entry = entry: {
const trigger: input.Binding.Trigger = .{ .key = .catch_all };
const table_items = self.keyboard.table_stack.items;
for (0..table_items.len) |i| {
const rev_i: usize = table_items.len - 1 - i;
const entry = table_items[rev_i].set.get(trigger) orelse continue;
break :entry entry;
}
break :entry self.config.keybind.set.get(trigger) orelse
return false;
};
// We have a catch-all entry, see if its an ignore
return switch (entry.value_ptr.*) {
.leader => false,
.leaf => |leaf| leaf.action == .ignore,
.leaf_chained => |leaf| chained: for (leaf.actions.items) |action| {
if (action == .ignore) break :chained true;
} else false,
};
}
const KeySequenceQueued = enum { flush, drop };
const KeySequenceMemory = enum { retain, free };
@@ -2952,27 +3096,30 @@ fn endKeySequence(
);
};
// No matter what we clear our current binding set. This restores
// No matter what we clear our current sequence set. This restores
// the set we look at to the root set.
self.keyboard.bindings = null;
self.keyboard.sequence_set = null;
if (self.keyboard.queued.items.len > 0) {
switch (action) {
.flush => for (self.keyboard.queued.items) |write_req| {
self.queueIo(switch (write_req) {
.small => |v| .{ .write_small = v },
.stable => |v| .{ .write_stable = v },
.alloc => |v| .{ .write_alloc = v },
}, .unlocked);
},
// If we have no queued data, there is nothing else to do.
if (self.keyboard.sequence_queued.items.len == 0) return;
.drop => for (self.keyboard.queued.items) |req| req.deinit(),
}
// Run the proper action first
switch (action) {
.flush => for (self.keyboard.sequence_queued.items) |write_req| {
self.queueIo(switch (write_req) {
.small => |v| .{ .write_small = v },
.stable => |v| .{ .write_stable = v },
.alloc => |v| .{ .write_alloc = v },
}, .unlocked);
},
switch (mem) {
.free => self.keyboard.queued.clearAndFree(self.alloc),
.retain => self.keyboard.queued.clearRetainingCapacity(),
}
.drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(),
}
// Memory handling of the sequence after the action
switch (mem) {
.free => self.keyboard.sequence_queued.clearAndFree(self.alloc),
.retain => self.keyboard.sequence_queued.clearRetainingCapacity(),
}
}
@@ -5566,6 +5713,87 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
inline .activate_key_table,
.activate_key_table_once,
=> |name, tag| {
// Look up the table in our config
const set = self.config.keybind.tables.getPtr(name) orelse {
log.debug("key table not found: {s}", .{name});
return false;
};
// If this is the same table as is currently active, then
// do nothing.
if (self.keyboard.table_stack.items.len > 0) {
const items = self.keyboard.table_stack.items;
const active = items[items.len - 1].set;
if (active == set) {
log.debug("ignoring duplicate activate table: {s}", .{name});
return false;
}
}
// If we're already at the max, ignore it.
if (self.keyboard.table_stack.items.len >= max_active_key_tables) {
log.info(
"ignoring activate table, max depth reached: {s}",
.{name},
);
return false;
}
// Add the table to the stack.
try self.keyboard.table_stack.append(self.alloc, .{
.set = set,
.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,
// 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(),
}
// 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 => {
return try self.deactivateAllKeyTables();
},
.crash => |location| switch (location) {
.main => @panic("crash binding action, crashing intentionally"),

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

@@ -155,7 +155,7 @@ pub const App = struct {
while (it.next()) |entry| {
switch (entry.value_ptr.*) {
.leader => {},
.leaf => |leaf| if (leaf.flags.global) return true,
inline .leaf, .leaf_chained => |leaf| if (leaf.flags.global) return true,
}
}
@@ -1700,23 +1700,6 @@ pub const CAPI = struct {
return @intCast(@as(input.Mods.Backing, @bitCast(result)));
}
/// Returns the current possible commands for a surface
/// in the output parameter. The memory is owned by libghostty
/// and doesn't need to be freed.
export fn ghostty_surface_commands(
surface: *Surface,
out: *[*]const input.Command.C,
len: *usize,
) void {
// In the future we may use this information to filter
// some commands.
_ = surface;
const commands = input.command.defaultsC;
out.* = commands.ptr;
len.* = commands.len;
}
/// 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.

View File

@@ -10,4 +10,5 @@ pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef;
test {
@import("std").testing.refAllDecls(@This());
_ = @import("gtk/ext.zig");
_ = @import("gtk/key.zig");
}

View File

@@ -44,6 +44,7 @@ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 5, .name = "inspector-window" },
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
.{ .major = 1, .minor = 2, .name = "search-overlay" },
.{ .major = 1, .minor = 2, .name = "key-state-overlay" },
.{ .major = 1, .minor = 5, .name = "split-tree" },
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
.{ .major = 1, .minor = 2, .name = "surface" },

View File

@@ -669,6 +669,9 @@ pub const Application = extern struct {
.inspector => return Action.controlInspector(target, value),
.key_sequence => return Action.keySequence(target, value),
.key_table => return Action.keyTable(target, value),
.mouse_over_link => Action.mouseOverLink(target, value),
.mouse_shape => Action.mouseShape(target, value),
.mouse_visibility => Action.mouseVisibility(target, value),
@@ -743,7 +746,6 @@ pub const Application = extern struct {
.toggle_visibility,
.toggle_background_opacity,
.cell_size,
.key_sequence,
.render_inspector,
.renderer_health,
.color_change,
@@ -2659,6 +2661,36 @@ const Action = struct {
},
}
}
pub fn keySequence(target: apprt.Target, value: apprt.Action.Value(.key_sequence)) bool {
switch (target) {
.app => {
log.warn("key_sequence action to app is unexpected", .{});
return false;
},
.surface => |core| {
core.rt_surface.gobj().keySequenceAction(value) catch |err| {
log.warn("error handling key_sequence action: {}", .{err});
};
return true;
},
}
}
pub fn keyTable(target: apprt.Target, value: apprt.Action.Value(.key_table)) bool {
switch (target) {
.app => {
log.warn("key_table action to app is unexpected", .{});
return false;
},
.surface => |core| {
core.rt_surface.gobj().keyTableAction(value) catch |err| {
log.warn("error handling key_table action: {}", .{err});
};
return true;
},
}
}
};
/// This sets various GTK-related environment variables as necessary

View File

@@ -169,13 +169,17 @@ pub const GlobalShortcuts = extern struct {
var trigger_buf: [1024]u8 = undefined;
var it = config.keybind.set.bindings.iterator();
while (it.next()) |entry| {
const leaf = switch (entry.value_ptr.*) {
// Global shortcuts can't have leaders
const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
.leader => continue,
.leaf => |leaf| leaf,
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
};
if (!leaf.flags.global) continue;
// We only allow global keybinds that map to exactly a single
// action for now. TODO: remove this restriction
const actions = leaf.actionsSlice();
if (actions.len != 1) continue;
const trigger = if (key.xdgShortcutFromTrigger(
&trigger_buf,
entry.key_ptr.*,
@@ -197,7 +201,7 @@ pub const GlobalShortcuts = extern struct {
try priv.map.put(
alloc,
try alloc.dupeZ(u8, trigger),
leaf.action,
actions[0],
);
}

View File

@@ -0,0 +1,342 @@
const std = @import("std");
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const ext = @import("../ext.zig");
const gresource = @import("../build/gresource.zig");
const Application = @import("application.zig").Application;
const Common = @import("../class.zig").Common;
const log = std.log.scoped(.gtk_ghostty_key_state_overlay);
/// An overlay that displays the current key table stack and pending key sequence.
/// This helps users understand what key bindings are active and what keys they've
/// pressed in a multi-key sequence.
pub const KeyStateOverlay = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttyKeyStateOverlay",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const tables = struct {
pub const name = "tables";
const impl = gobject.ext.defineProperty(
name,
Self,
?*ext.StringList,
.{
.accessor = gobject.ext.typedAccessor(
Self,
?*ext.StringList,
.{
.getter = getTables,
.getter_transfer = .none,
.setter = setTables,
.setter_transfer = .full,
},
),
},
);
};
pub const @"has-tables" = struct {
pub const name = "has-tables";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{ .getter = getHasTables },
),
},
);
};
pub const sequence = struct {
pub const name = "sequence";
const impl = gobject.ext.defineProperty(
name,
Self,
?*ext.StringList,
.{
.accessor = gobject.ext.typedAccessor(
Self,
?*ext.StringList,
.{
.getter = getSequence,
.getter_transfer = .none,
.setter = setSequence,
.setter_transfer = .full,
},
),
},
);
};
pub const @"has-sequence" = struct {
pub const name = "has-sequence";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{ .getter = getHasSequence },
),
},
);
};
pub const @"valign-target" = struct {
pub const name = "valign-target";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.Align,
.{
.default = .end,
.accessor = C.privateShallowFieldAccessor("valign_target"),
},
);
};
};
const Private = struct {
/// The key table stack.
tables: ?*ext.StringList = null,
/// The key sequence.
sequence: ?*ext.StringList = null,
/// Target vertical alignment for the overlay.
valign_target: gtk.Align = .end,
pub var offset: c_int = 0;
};
fn init(self: *Self, _: *Class) callconv(.c) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
fn getTables(self: *Self) ?*ext.StringList {
return self.private().tables;
}
fn getSequence(self: *Self) ?*ext.StringList {
return self.private().sequence;
}
fn setTables(self: *Self, value: ?*ext.StringList) void {
const priv = self.private();
if (priv.tables) |old| {
old.destroy();
priv.tables = null;
}
if (value) |v| {
priv.tables = v;
}
self.as(gobject.Object).notifyByPspec(properties.tables.impl.param_spec);
self.as(gobject.Object).notifyByPspec(properties.@"has-tables".impl.param_spec);
}
fn setSequence(self: *Self, value: ?*ext.StringList) void {
const priv = self.private();
if (priv.sequence) |old| {
old.destroy();
priv.sequence = null;
}
if (value) |v| {
priv.sequence = v;
}
self.as(gobject.Object).notifyByPspec(properties.sequence.impl.param_spec);
self.as(gobject.Object).notifyByPspec(properties.@"has-sequence".impl.param_spec);
}
fn getHasTables(self: *Self) bool {
const v = self.private().tables orelse return false;
return v.strings.len > 0;
}
fn getHasSequence(self: *Self) bool {
const v = self.private().sequence orelse return false;
return v.strings.len > 0;
}
fn closureShowChevron(
_: *Self,
has_tables: bool,
has_sequence: bool,
) callconv(.c) c_int {
return if (has_tables and has_sequence) 1 else 0;
}
fn closureHasState(
_: *Self,
has_tables: bool,
has_sequence: bool,
) callconv(.c) c_int {
return if (has_tables or has_sequence) 1 else 0;
}
fn closureTablesText(
_: *Self,
tables: ?*ext.StringList,
) callconv(.c) ?[*:0]const u8 {
const list = tables orelse return null;
if (list.strings.len == 0) return null;
var buf: std.Io.Writer.Allocating = .init(Application.default().allocator());
defer buf.deinit();
for (list.strings, 0..) |s, i| {
if (i > 0) buf.writer.writeAll(" > ") catch return null;
buf.writer.writeAll(s) catch return null;
}
return glib.ext.dupeZ(u8, buf.written());
}
fn closureSequenceText(
_: *Self,
sequence: ?*ext.StringList,
) callconv(.c) ?[*:0]const u8 {
const list = sequence orelse return null;
if (list.strings.len == 0) return null;
var buf: std.Io.Writer.Allocating = .init(Application.default().allocator());
defer buf.deinit();
for (list.strings, 0..) |s, i| {
if (i > 0) buf.writer.writeAll(" ") catch return null;
buf.writer.writeAll(s) catch return null;
}
return glib.ext.dupeZ(u8, buf.written());
}
//---------------------------------------------------------------
// Template callbacks
fn onDragEnd(
_: *gtk.GestureDrag,
_: f64,
offset_y: f64,
self: *Self,
) callconv(.c) void {
// Key state overlay only moves between top-center and bottom-center.
// Horizontal alignment is always center.
const priv = self.private();
const widget = self.as(gtk.Widget);
const parent = widget.getParent() orelse return;
const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight());
const self_height: f64 = @floatFromInt(widget.getAllocatedHeight());
const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height;
const new_y = self_y + offset_y + (self_height / 2);
const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start;
if (new_valign != priv.valign_target) {
priv.valign_target = new_valign;
self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec);
self.as(gtk.Widget).queueResize();
}
}
//---------------------------------------------------------------
// Virtual methods
fn dispose(self: *Self) callconv(.c) void {
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
fn finalize(self: *Self) callconv(.c) void {
const priv = self.private();
if (priv.tables) |v| {
v.destroy();
}
if (priv.sequence) |v| {
v.destroy();
}
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
);
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const unref = C.unref;
const private = C.private;
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.c) void {
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 2,
.name = "key-state-overlay",
}),
);
// Template Callbacks
class.bindTemplateCallback("on_drag_end", &onDragEnd);
class.bindTemplateCallback("show_chevron", &closureShowChevron);
class.bindTemplateCallback("has_state", &closureHasState);
class.bindTemplateCallback("tables_text", &closureTablesText);
class.bindTemplateCallback("sequence_text", &closureSequenceText);
// Properties
gobject.ext.registerProperties(class, &.{
properties.tables.impl,
properties.@"has-tables".impl,
properties.sequence.impl,
properties.@"has-sequence".impl,
properties.@"valign-target".impl,
});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@@ -26,6 +26,7 @@ const Application = @import("application.zig").Application;
const Config = @import("config.zig").Config;
const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
const SearchOverlay = @import("search_overlay.zig").SearchOverlay;
const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay;
const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
@@ -360,6 +361,44 @@ pub const Surface = extern struct {
},
);
};
pub const @"key-sequence" = struct {
pub const name = "key-sequence";
const impl = gobject.ext.defineProperty(
name,
Self,
?*ext.StringList,
.{
.accessor = gobject.ext.typedAccessor(
Self,
?*ext.StringList,
.{
.getter = getKeySequence,
.getter_transfer = .full,
},
),
},
);
};
pub const @"key-table" = struct {
pub const name = "key-table";
const impl = gobject.ext.defineProperty(
name,
Self,
?*ext.StringList,
.{
.accessor = gobject.ext.typedAccessor(
Self,
?*ext.StringList,
.{
.getter = getKeyTable,
.getter_transfer = .full,
},
),
},
);
};
};
pub const signals = struct {
@@ -553,6 +592,9 @@ pub const Surface = extern struct {
/// The search overlay
search_overlay: *SearchOverlay,
/// The key state overlay
key_state_overlay: *KeyStateOverlay,
/// The apprt Surface.
rt_surface: ApprtSurface = undefined,
@@ -617,6 +659,10 @@ pub const Surface = extern struct {
vscroll_policy: gtk.ScrollablePolicy = .natural,
vadj_signal_group: ?*gobject.SignalGroup = null,
// Key state tracking for key sequences and tables
key_sequence: std.ArrayListUnmanaged([:0]const u8) = .empty,
key_tables: std.ArrayListUnmanaged([:0]const u8) = .empty,
// Template binds
child_exited_overlay: *ChildExited,
context_menu: *gtk.PopoverMenu,
@@ -778,6 +824,74 @@ pub const Surface = extern struct {
if (priv.inspector) |v| v.queueRender();
}
/// Handle a key sequence action from the apprt.
pub fn keySequenceAction(
self: *Self,
value: apprt.action.KeySequence,
) Allocator.Error!void {
const priv = self.private();
const alloc = Application.default().allocator();
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
self.as(gobject.Object).notifyByPspec(properties.@"key-sequence".impl.param_spec);
switch (value) {
.trigger => |trigger| {
// Convert the trigger to a human-readable label
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
if (gtk_key.labelFromTrigger(&buf.writer, trigger)) |success| {
if (!success) return;
} else |_| return error.OutOfMemory;
// Make space
try priv.key_sequence.ensureUnusedCapacity(alloc, 1);
// Copy and append
const duped = try buf.toOwnedSliceSentinel(0);
errdefer alloc.free(duped);
priv.key_sequence.appendAssumeCapacity(duped);
},
.end => {
// Free all the stored strings and clear
for (priv.key_sequence.items) |s| alloc.free(s);
priv.key_sequence.clearAndFree(alloc);
},
}
}
/// Handle a key table action from the apprt.
pub fn keyTableAction(
self: *Self,
value: apprt.action.KeyTable,
) Allocator.Error!void {
const priv = self.private();
const alloc = Application.default().allocator();
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
self.as(gobject.Object).notifyByPspec(properties.@"key-table".impl.param_spec);
switch (value) {
.activate => |name| {
// Duplicate the name string and push onto stack
const duped = try alloc.dupeZ(u8, name);
errdefer alloc.free(duped);
try priv.key_tables.append(alloc, duped);
},
.deactivate => {
// Pop and free the top table
if (priv.key_tables.pop()) |s| alloc.free(s);
},
.deactivate_all => {
// Free all tables and clear
for (priv.key_tables.items) |s| alloc.free(s);
priv.key_tables.clearAndFree(alloc);
},
}
}
pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool {
const priv = self.private();
return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0;
@@ -1787,6 +1901,14 @@ pub const Surface = extern struct {
glib.free(@ptrCast(@constCast(v)));
priv.title_override = null;
}
// Clean up key sequence and key table state
const alloc = Application.default().allocator();
for (priv.key_sequence.items) |s| alloc.free(s);
priv.key_sequence.deinit(alloc);
for (priv.key_tables.items) |s| alloc.free(s);
priv.key_tables.deinit(alloc);
self.clearCgroup();
gobject.Object.virtual_methods.finalize.call(
@@ -1873,6 +1995,20 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"default-size".impl.param_spec);
}
/// Get the key sequence list. Full transfer.
fn getKeySequence(self: *Self) ?*ext.StringList {
const priv = self.private();
const alloc = Application.default().allocator();
return ext.StringList.create(alloc, priv.key_sequence.items) catch null;
}
/// Get the key table list. Full transfer.
fn getKeyTable(self: *Self) ?*ext.StringList {
const priv = self.private();
const alloc = Application.default().allocator();
return ext.StringList.create(alloc, priv.key_tables.items) catch null;
}
/// Return the min size, if set.
pub fn getMinSize(self: *Self) ?*Size {
const priv = self.private();
@@ -3236,6 +3372,7 @@ pub const Surface = extern struct {
fn init(class: *Class) callconv(.c) void {
gobject.ext.ensureType(ResizeOverlay);
gobject.ext.ensureType(SearchOverlay);
gobject.ext.ensureType(KeyStateOverlay);
gobject.ext.ensureType(ChildExited);
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
@@ -3256,6 +3393,7 @@ pub const Surface = extern struct {
class.bindTemplateChildPrivate("progress_bar_overlay", .{});
class.bindTemplateChildPrivate("resize_overlay", .{});
class.bindTemplateChildPrivate("search_overlay", .{});
class.bindTemplateChildPrivate("key_state_overlay", .{});
class.bindTemplateChildPrivate("terminal_page", .{});
class.bindTemplateChildPrivate("drop_target", .{});
class.bindTemplateChildPrivate("im_context", .{});
@@ -3307,6 +3445,8 @@ pub const Surface = extern struct {
properties.@"error".impl,
properties.@"font-size-request".impl,
properties.focused.impl,
properties.@"key-sequence".impl,
properties.@"key-table".impl,
properties.@"min-size".impl,
properties.@"mouse-shape".impl,
properties.@"mouse-hidden".impl,

View File

@@ -46,6 +46,18 @@ label.url-overlay.right {
outline-width: 1px;
}
/*
* GhosttySurface key state overlay
*/
.key-state-overlay {
padding: 6px 10px;
margin: 8px;
border-radius: 8px;
outline-style: solid;
outline-color: #555555;
outline-width: 1px;
}
/*
* GhosttySurface resize overlay
*/

View File

@@ -12,6 +12,8 @@ const gobject = @import("gobject");
const gtk = @import("gtk");
pub const actions = @import("ext/actions.zig");
const slice = @import("ext/slice.zig");
pub const StringList = slice.StringList;
/// Wrapper around `gobject.boxedCopy` to copy a boxed type `T`.
pub fn boxedCopy(comptime T: type, ptr: *const T) *T {
@@ -64,4 +66,5 @@ pub fn gValueHolds(value_: ?*const gobject.Value, g_type: gobject.Type) bool {
test {
_ = actions;
_ = slice;
}

111
src/apprt/gtk/ext/slice.zig Normal file
View File

@@ -0,0 +1,111 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const glib = @import("glib");
const gobject = @import("gobject");
/// A boxed type that holds a list of string slices.
pub const StringList = struct {
arena: ArenaAllocator,
strings: []const [:0]const u8,
pub fn create(
alloc: Allocator,
strings: []const [:0]const u8,
) Allocator.Error!*StringList {
var arena: ArenaAllocator = .init(alloc);
errdefer arena.deinit();
const arena_alloc = arena.allocator();
var stored = try arena_alloc.alloc([:0]const u8, strings.len);
for (strings, 0..) |s, i| stored[i] = try arena_alloc.dupeZ(u8, s);
const ptr = try alloc.create(StringList);
errdefer alloc.destroy(ptr);
ptr.* = .{ .arena = arena, .strings = stored };
return ptr;
}
pub fn deinit(self: *StringList) void {
self.arena.deinit();
}
pub fn destroy(self: *StringList) void {
const alloc = self.arena.child_allocator;
self.deinit();
alloc.destroy(self);
}
/// Returns the general-purpose allocator used by this StringList.
pub fn allocator(self: *const StringList) Allocator {
return self.arena.child_allocator;
}
pub const getGObjectType = gobject.ext.defineBoxed(
StringList,
.{
.name = "GhosttyStringList",
.funcs = .{
.copy = &struct {
fn copy(self: *StringList) callconv(.c) *StringList {
return StringList.create(
self.arena.child_allocator,
self.strings,
) catch @panic("OOM");
}
}.copy,
.free = &struct {
fn free(self: *StringList) callconv(.c) void {
self.destroy();
}
}.free,
},
},
);
};
test "StringList create and destroy" {
const testing = std.testing;
const alloc = testing.allocator;
const input: []const [:0]const u8 = &.{ "hello", "world" };
const list = try StringList.create(alloc, input);
defer list.destroy();
try testing.expectEqual(@as(usize, 2), list.strings.len);
try testing.expectEqualStrings("hello", list.strings[0]);
try testing.expectEqualStrings("world", list.strings[1]);
}
test "StringList create empty list" {
const testing = std.testing;
const alloc = testing.allocator;
const input: []const [:0]const u8 = &.{};
const list = try StringList.create(alloc, input);
defer list.destroy();
try testing.expectEqual(@as(usize, 0), list.strings.len);
}
test "StringList boxedCopy and boxedFree" {
const testing = std.testing;
const alloc = testing.allocator;
const input: []const [:0]const u8 = &.{ "foo", "bar", "baz" };
const original = try StringList.create(alloc, input);
defer original.destroy();
const copied: *StringList = @ptrCast(@alignCast(gobject.boxedCopy(
StringList.getGObjectType(),
original,
)));
defer gobject.boxedFree(StringList.getGObjectType(), copied);
try testing.expectEqual(@as(usize, 3), copied.strings.len);
try testing.expectEqualStrings("foo", copied.strings[0]);
try testing.expectEqualStrings("bar", copied.strings[1]);
try testing.expectEqualStrings("baz", copied.strings[2]);
try testing.expect(original.strings.ptr != copied.strings.ptr);
}

View File

@@ -74,6 +74,8 @@ fn writeTriggerKey(
try writer.print("{u}", .{cp});
}
},
.catch_all => return false,
}
return true;
@@ -231,6 +233,70 @@ pub fn keyvalFromKey(key: input.Key) ?c_uint {
}
}
/// Converts a trigger to a human-readable label for display in UI.
///
/// Uses GTK accelerator-style formatting (e.g., "Ctrl+Shift+A").
/// Returns false if the trigger cannot be formatted (e.g., catch_all).
pub fn labelFromTrigger(
writer: *std.Io.Writer,
trigger: input.Binding.Trigger,
) std.Io.Writer.Error!bool {
// Modifiers first, using human-readable format
if (trigger.mods.super) try writer.writeAll("Super+");
if (trigger.mods.ctrl) try writer.writeAll("Ctrl+");
if (trigger.mods.alt) try writer.writeAll("Alt+");
if (trigger.mods.shift) try writer.writeAll("Shift+");
// Write the key
return writeTriggerKeyLabel(writer, trigger);
}
/// Writes the key portion of a trigger in human-readable format.
fn writeTriggerKeyLabel(
writer: *std.Io.Writer,
trigger: input.Binding.Trigger,
) error{WriteFailed}!bool {
switch (trigger.key) {
.physical => |k| {
const keyval = keyvalFromKey(k) orelse return false;
const name = gdk.keyvalName(keyval) orelse return false;
// Capitalize the first letter for nicer display
const span = std.mem.span(name);
if (span.len > 0) {
if (span[0] >= 'a' and span[0] <= 'z') {
try writer.writeByte(span[0] - 'a' + 'A');
if (span.len > 1) try writer.writeAll(span[1..]);
} else {
try writer.writeAll(span);
}
}
},
.unicode => |cp| {
// Try to get a nice name from GDK first
if (gdk.keyvalName(cp)) |name| {
const span = std.mem.span(name);
if (span.len > 0) {
// Capitalize the first letter for nicer display
if (span[0] >= 'a' and span[0] <= 'z') {
try writer.writeByte(span[0] - 'a' + 'A');
if (span.len > 1) try writer.writeAll(span[1..]);
} else {
try writer.writeAll(span);
}
}
} else {
// Fall back to printing the character
try writer.print("{u}", .{cp});
}
},
.catch_all => return false,
}
return true;
}
test "accelFromTrigger" {
const testing = std.testing;
var buf: [256]u8 = undefined;
@@ -261,6 +327,64 @@ test "xdgShortcutFromTrigger" {
})).?);
}
test "labelFromTrigger" {
const testing = std.testing;
// Simple unicode key with modifier
{
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
try testing.expect(try labelFromTrigger(&buf.writer, .{
.mods = .{ .super = true },
.key = .{ .unicode = 'q' },
}));
try testing.expectEqualStrings("Super+Q", buf.written());
}
// Multiple modifiers
{
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
try testing.expect(try labelFromTrigger(&buf.writer, .{
.mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true },
.key = .{ .unicode = 92 },
}));
try testing.expectEqualStrings("Super+Ctrl+Alt+Shift+Backslash", buf.written());
}
// Physical key
{
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
try testing.expect(try labelFromTrigger(&buf.writer, .{
.mods = .{ .ctrl = true },
.key = .{ .physical = .key_a },
}));
try testing.expectEqualStrings("Ctrl+A", buf.written());
}
// No modifiers
{
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
try testing.expect(try labelFromTrigger(&buf.writer, .{
.mods = .{},
.key = .{ .physical = .escape },
}));
try testing.expectEqualStrings("Escape", buf.written());
}
// catch_all returns false
{
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
try testing.expect(!try labelFromTrigger(&buf.writer, .{
.mods = .{},
.key = .catch_all,
}));
}
}
/// A raw entry in the keymap. Our keymap contains mappings between
/// GDK keys and our own key enum.
const RawEntry = struct { c_uint, input.Key };

View File

@@ -0,0 +1,58 @@
using Gtk 4.0;
using Adw 1;
template $GhosttyKeyStateOverlay: Adw.Bin {
visible: bind $has_state(template.has-tables, template.has-sequence) as <bool>;
valign-target: end;
halign: center;
valign: bind template.valign-target;
GestureDrag {
button: 1;
propagation-phase: capture;
drag-end => $on_drag_end();
}
Adw.Bin {
Box container {
styles [
"background",
"key-state-overlay",
]
orientation: horizontal;
spacing: 6;
Image {
icon-name: "input-keyboard-symbolic";
pixel-size: 16;
}
Label tables_label {
visible: bind template.has-tables;
label: bind $tables_text(template.tables) as <string>;
xalign: 0.0;
}
Label chevron_label {
visible: bind $show_chevron(template.has-tables, template.has-sequence) as <bool>;
label: "";
styles [
"dim-label",
]
}
Label sequence_label {
visible: bind template.has-sequence;
label: bind $sequence_text(template.sequence) as <string>;
xalign: 0.0;
}
Spinner pending_spinner {
visible: bind template.has-sequence;
spinning: bind template.has-sequence;
}
}
}
}

View File

@@ -155,6 +155,12 @@ Overlay terminal_page {
previous-match => $search_previous_match();
}
[overlay]
$GhosttyKeyStateOverlay key_state_overlay {
tables: bind template.key-table;
sequence: bind template.key-sequence;
}
[overlay]
// Apply unfocused-split-fill and unfocused-split-opacity to current surface
// this is only applied when a tab has more than one surface

View File

@@ -95,18 +95,35 @@ const TriggerNode = struct {
};
const ChordBinding = struct {
table_name: ?[]const u8 = null,
triggers: std.SinglyLinkedList,
action: Binding.Action,
actions: []const Binding.Action,
// Order keybinds based on various properties
// 1. Longest chord sequence
// 2. Most active modifiers
// 3. Alphabetically by active modifiers
// 4. Trigger key order
// 1. Default bindings before table bindings (tables grouped at end)
// 2. Longest chord sequence
// 3. Most active modifiers
// 4. Alphabetically by active modifiers
// 5. Trigger key order
// 6. Within tables, sort by table name
// These properties propagate through chorded keypresses
//
// Adapted from Binding.lessThan
pub fn lessThan(_: void, lhs: ChordBinding, rhs: ChordBinding) bool {
const lhs_has_table = lhs.table_name != null;
const rhs_has_table = rhs.table_name != null;
if (lhs_has_table != rhs_has_table) {
return !lhs_has_table;
}
if (lhs_has_table) {
const table_cmp = std.mem.order(u8, lhs.table_name.?, rhs.table_name.?);
if (table_cmp != .eq) {
return table_cmp == .lt;
}
}
const lhs_len = lhs.triggers.len();
const rhs_len = rhs.triggers.len();
@@ -166,16 +183,19 @@ const ChordBinding = struct {
var r_trigger = rhs.triggers.first;
while (l_trigger != null and r_trigger != null) {
// We want catch_all to sort last.
const lhs_key: c_int = blk: {
switch (TriggerNode.get(l_trigger.?).data.key) {
.physical => |key| break :blk @intFromEnum(key),
.unicode => |key| break :blk @intCast(key),
.catch_all => break :blk std.math.maxInt(c_int),
}
};
const rhs_key: c_int = blk: {
switch (TriggerNode.get(r_trigger.?).data.key) {
.physical => |key| break :blk @intFromEnum(key),
.unicode => |key| break :blk @intCast(key),
.catch_all => break :blk std.math.maxInt(c_int),
}
};
@@ -228,10 +248,30 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
const win = vx.window();
// Generate a list of bindings, recursively traversing chorded keybindings
// Collect default bindings, recursively flattening chords
var iter = keybinds.set.bindings.iterator();
const bindings, const widest_chord = try iterateBindings(alloc, &iter, &win);
const default_bindings, var widest_chord = try iterateBindings(alloc, &iter, &win);
var bindings_list: std.ArrayList(ChordBinding) = .empty;
try bindings_list.appendSlice(alloc, default_bindings);
// Collect key table bindings
var widest_table_prefix: u16 = 0;
var table_iter = keybinds.tables.iterator();
while (table_iter.next()) |table_entry| {
const table_name = table_entry.key_ptr.*;
var binding_iter = table_entry.value_ptr.bindings.iterator();
const table_bindings, const table_width = try iterateBindings(alloc, &binding_iter, &win);
for (table_bindings) |*b| {
b.table_name = table_name;
}
try bindings_list.appendSlice(alloc, table_bindings);
widest_chord = @max(widest_chord, table_width);
widest_table_prefix = @max(widest_table_prefix, @as(u16, @intCast(win.gwidth(table_name) + win.gwidth("/"))));
}
const bindings = bindings_list.items;
std.mem.sort(ChordBinding, bindings, {}, ChordBinding.lessThan);
// Set up styles for each modifier
@@ -239,12 +279,22 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
const ctrl_style: vaxis.Style = .{ .fg = .{ .index = 2 } };
const alt_style: vaxis.Style = .{ .fg = .{ .index = 3 } };
const shift_style: vaxis.Style = .{ .fg = .{ .index = 4 } };
const table_style: vaxis.Style = .{ .fg = .{ .index = 8 } };
// Print the list
for (bindings) |bind| {
win.clear();
var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false };
if (bind.table_name) |name| {
result = win.printSegment(
.{ .text = name, .style = table_style },
.{ .col_offset = result.col },
);
result = win.printSegment(.{ .text = "/", .style = table_style }, .{ .col_offset = result.col });
}
var maybe_trigger = bind.triggers.first;
while (maybe_trigger) |node| : (maybe_trigger = node.next) {
const trigger: *TriggerNode = .get(node);
@@ -268,6 +318,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
const key = switch (trigger.data.key) {
.physical => |k| try std.fmt.allocPrint(alloc, "{t}", .{k}),
.unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}),
.catch_all => "catch_all",
};
result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col });
@@ -277,16 +328,32 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
}
}
const action = try std.fmt.allocPrint(alloc, "{f}", .{bind.action});
// If our action has an argument, we print the argument in a different color
if (std.mem.indexOfScalar(u8, action, ':')) |idx| {
_ = win.print(&.{
.{ .text = action[0..idx] },
.{ .text = action[idx .. idx + 1], .style = .{ .dim = true } },
.{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } },
}, .{ .col_offset = widest_chord + 3 });
} else {
_ = win.printSegment(.{ .text = action }, .{ .col_offset = widest_chord + 3 });
var action_col: u16 = widest_table_prefix + widest_chord + 3;
for (bind.actions, 0..) |act, i| {
if (i > 0) {
const chain_result = win.printSegment(
.{ .text = ", ", .style = .{ .dim = true } },
.{ .col_offset = action_col },
);
action_col = chain_result.col;
}
const action = try std.fmt.allocPrint(alloc, "{f}", .{act});
// If our action has an argument, we print the argument in a different color
if (std.mem.indexOfScalar(u8, action, ':')) |idx| {
const print_result = win.print(&.{
.{ .text = action[0..idx] },
.{ .text = action[idx .. idx + 1], .style = .{ .dim = true } },
.{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } },
}, .{ .col_offset = action_col });
action_col = print_result.col;
} else {
const print_result = win.printSegment(
.{ .text = action },
.{ .col_offset = action_col },
);
action_col = print_result.col;
}
}
try vx.prettyPrint(writer);
}
@@ -314,6 +381,7 @@ fn iterateBindings(
switch (t.key) {
.physical => |k| try buf.writer.print("{t}", .{k}),
.unicode => |c| try buf.writer.print("{u}", .{c}),
.catch_all => try buf.writer.print("catch_all", .{}),
}
break :blk win.gwidth(buf.written());
@@ -321,7 +389,6 @@ fn iterateBindings(
switch (bind.value_ptr.*) {
.leader => |leader| {
// Recursively iterate on the set of bindings for this leader key
var n_iter = leader.bindings.iterator();
const sub_bindings, const max_width = try iterateBindings(alloc, &n_iter, win);
@@ -342,10 +409,23 @@ fn iterateBindings(
const node = try alloc.create(TriggerNode);
node.* = .{ .data = bind.key_ptr.* };
const actions = try alloc.alloc(Binding.Action, 1);
actions[0] = leaf.action;
widest_chord = @max(widest_chord, width);
try bindings.append(alloc, .{
.triggers = .{ .first = &node.node },
.action = leaf.action,
.actions = actions,
});
},
.leaf_chained => |leaf| {
const node = try alloc.create(TriggerNode);
node.* = .{ .data = bind.key_ptr.* };
widest_chord = @max(widest_chord, width);
try bindings.append(alloc, .{
.triggers = .{ .first = &node.node },
.actions = leaf.actions.items,
});
},
}

View File

@@ -1477,6 +1477,13 @@ class: ?[:0]const u8 = null,
/// so if you specify both `a` and `KeyA`, the physical key will always be used
/// regardless of what order they are configured.
///
/// The special key `catch_all` can be used to match any key that is not
/// otherwise bound. This can be combined with modifiers, for example
/// `ctrl+catch_all` will match any key pressed with `ctrl` that is not
/// otherwise bound. When looking up a binding, Ghostty first tries to match
/// `catch_all` with modifiers. If no match is found and the event has
/// modifiers, it falls back to `catch_all` without modifiers.
///
/// Valid modifiers are `shift`, `ctrl` (alias: `control`), `alt` (alias: `opt`,
/// `option`), and `super` (alias: `cmd`, `command`). You may use the modifier
/// or the alias. When debugging keybinds, the non-aliased modifier will always
@@ -1514,6 +1521,11 @@ class: ?[:0]const u8 = null,
/// specifically output that key (e.g. `ctrl+a>ctrl+a=text:foo`) or
/// press an unbound key which will send both keys to the program.
///
/// * If an unbound key is pressed during a sequence and a `catch_all`
/// binding exists that would `ignore` the input, the entire sequence
/// is dropped and nothing happens. Otherwise, the entire sequence is
/// encoded and sent to the running program as if no keybind existed.
///
/// * If a prefix in a sequence is previously bound, the sequence will
/// override the previous binding. For example, if `ctrl+a` is bound to
/// `new_window` and `ctrl+a>n` is bound to `new_tab`, pressing `ctrl+a`
@@ -1659,6 +1671,90 @@ class: ?[:0]const u8 = null,
///
/// - Notably, global shortcuts have not been implemented on wlroots-based
/// compositors like Sway (see [upstream issue](https://github.com/emersion/xdg-desktop-portal-wlr/issues/240)).
///
/// ## Chained Actions
///
/// A keybind can have multiple actions by using the `chain` keyword for
/// subsequent actions. When a keybind is activated, all chained actions are
/// executed in order. The syntax is:
///
/// ```ini
/// keybind = ctrl+a=new_window
/// keybind = chain=goto_split:left
/// ```
///
/// This binds `ctrl+a` to first open a new window, then move focus to the
/// left split. Each `chain` entry appends an action to the most recently
/// defined keybind. You can chain as many actions as you want:
///
/// ```ini
/// keybind = ctrl+a=new_window
/// keybind = chain=goto_split:left
/// keybind = chain=toggle_fullscreen
/// ```
///
/// Chained actions cannot have prefixes like `global:` or `unconsumed:`.
/// The flags from the original keybind apply to the entire chain.
///
/// Chained actions work with key sequences as well. For example:
///
/// ```ini
/// keybind = ctrl+a>n=new_window
/// keybind = chain=goto_split:left
/// ````
///
/// Chains with key sequences apply to the most recent binding in the
/// sequence.
///
/// Chained keybinds are available since Ghostty 1.3.0.
///
/// ## Key Tables
///
/// You may also create a named set of keybindings known as a "key table."
/// A key table must be explicitly activated for the bindings to become
/// available. This can be used to implement features such as a
/// "copy mode", "vim mode", etc. Generically, this can implement modal
/// keyboard input.
///
/// Key tables are defined using the syntax `<table>/<binding>`. The
/// `<binding>` value is everything documented above for keybinds. The
/// `<table>` value is the name of the key table. Table names can contain
/// anything except `/`, `=`, `+`, and `>`. The characters `+` and `>` are
/// reserved for keybind syntax (modifier combinations and key sequences).
/// For example `foo/ctrl+a=new_window` defines a binding within a table
/// named `foo`.
///
/// Tables are activated and deactivated using the binding actions
/// `activate_key_table:<name>` and `deactivate_key_table`. Other table
/// related binding actions also exist; see the documentation for a full list.
/// These are the primary way to interact with key tables.
///
/// Binding lookup proceeds from the innermost table outward, so keybinds in
/// the default table remain available unless explicitly unbound in an inner
/// table.
///
/// A key table has some special syntax and handling:
///
/// * `<name>/` (with no binding) defines and clears a table, resetting all
/// of its keybinds and settings.
///
/// * You cannot activate a table that is already the innermost table; such
/// attempts are ignored. However, the same table can appear multiple times
/// in the stack as long as it is not innermost (e.g., `A -> B -> A -> B`
/// is valid, but `A -> B -> B` is not).
///
/// * A table can be activated in one-shot mode using
/// `activate_key_table_once:<name>`. A one-shot table is automatically
/// deactivated when any non-catch-all binding is invoked.
///
/// * Key sequences work within tables: `foo/ctrl+a>ctrl+b=new_window`.
/// If an invalid key is pressed, the sequence ends but the table remains
/// active.
///
/// * Prefixes like `global:` work within tables:
/// `foo/global:ctrl+a=new_window`.
///
/// Key tables are available since Ghostty 1.3.0.
keybind: Keybinds = .{},
/// Horizontal window padding. This applies padding between the terminal cells
@@ -3264,7 +3360,7 @@ else
/// more subtle border.
@"gtk-toolbar-style": GtkToolbarStyle = .raised,
/// The style of the GTK titlbar. Available values are `native` and `tabs`.
/// The style of the GTK titlebar. Available values are `native` and `tabs`.
///
/// The `native` titlebar style is a traditional titlebar with a title, a few
/// buttons and window controls. A separate tab bar will show up below the
@@ -5805,12 +5901,17 @@ pub const RepeatableFontVariation = struct {
pub const Keybinds = struct {
set: inputpkg.Binding.Set = .{},
/// Defined key tables. The default key table is always the root "set",
/// which allows all table names to be available without reservation.
tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty,
pub fn init(self: *Keybinds, alloc: Allocator) !void {
// We don't clear the memory because it's in the arena and unlikely
// to be free-able anyways (since arenas can only clear the last
// allocated value). This isn't a memory leak because the arena
// will be freed when the config is freed.
self.set = .{};
self.tables = .empty;
// keybinds for opening and reloading config
try self.set.put(
@@ -6580,21 +6681,86 @@ pub const Keybinds = struct {
// will be freed when the config is freed.
log.info("config has 'keybind = clear', all keybinds cleared", .{});
self.set = .{};
self.tables = .empty;
return;
}
// Let our much better tested binding package handle parsing and storage.
// Check for table syntax: "name/" or "name/binding"
// We look for '/' only before the first '=' to avoid matching
// action arguments like "foo=text:/hello".
const eq_idx = std.mem.indexOfScalar(u8, value, '=') orelse value.len;
if (std.mem.indexOfScalar(u8, value[0..eq_idx], '/')) |slash_idx| table: {
const table_name = value[0..slash_idx];
// Length zero is valid, so you can set `/=action` for the slash key
if (table_name.len == 0) break :table;
// Ignore '+', '>' because they can be part of sequences and
// triggers. This lets things like `ctrl+/=action` work.
if (std.mem.indexOfAny(
u8,
table_name,
"+>",
) != null) break :table;
const binding = value[slash_idx + 1 ..];
// Get or create the table
const gop = try self.tables.getOrPut(alloc, table_name);
if (!gop.found_existing) {
// We need to copy our table name into the arena
// for valid lookups later.
gop.key_ptr.* = try alloc.dupe(u8, table_name);
gop.value_ptr.* = .{};
}
// If there's no binding after the slash, this is a table
// definition/clear command
if (binding.len == 0) {
log.debug("config has 'keybind = {s}/', table cleared", .{table_name});
gop.value_ptr.* = .{};
return;
}
// Parse and add the binding to the table
try gop.value_ptr.parseAndPut(alloc, binding);
return;
}
// Parse into default set
try self.set.parseAndPut(alloc, value);
}
/// Deep copy of the struct. Required by Config.
pub fn clone(self: *const Keybinds, alloc: Allocator) Allocator.Error!Keybinds {
return .{ .set = try self.set.clone(alloc) };
var tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty;
try tables.ensureTotalCapacity(alloc, @intCast(self.tables.count()));
var it = self.tables.iterator();
while (it.next()) |entry| {
const key = try alloc.dupe(u8, entry.key_ptr.*);
tables.putAssumeCapacity(key, try entry.value_ptr.clone(alloc));
}
return .{
.set = try self.set.clone(alloc),
.tables = tables,
};
}
/// Compare if two of our value are requal. Required by Config.
pub fn equal(self: Keybinds, other: Keybinds) bool {
return equalSet(&self.set, &other.set);
if (!equalSet(&self.set, &other.set)) return false;
// Compare tables
if (self.tables.count() != other.tables.count()) return false;
var it = self.tables.iterator();
while (it.next()) |entry| {
const other_set = other.tables.get(entry.key_ptr.*) orelse return false;
if (!equalSet(entry.value_ptr, &other_set)) return false;
}
return true;
}
fn equalSet(
@@ -6637,6 +6803,21 @@ pub const Keybinds = struct {
other_leaf,
)) return false;
},
.leaf_chained => {
const self_chain = self_entry.value_ptr.*.leaf_chained;
const other_chain = other_entry.value_ptr.*.leaf_chained;
if (self_chain.flags != other_chain.flags) return false;
if (self_chain.actions.items.len != other_chain.actions.items.len) return false;
for (self_chain.actions.items, other_chain.actions.items) |a1, a2| {
if (!equalField(
inputpkg.Binding.Action,
a1,
a2,
)) return false;
}
},
}
}
@@ -6645,12 +6826,14 @@ pub const Keybinds = struct {
/// Like formatEntry but has an option to include docs.
pub fn formatEntryDocs(self: Keybinds, formatter: formatterpkg.EntryFormatter, docs: bool) !void {
if (self.set.bindings.size == 0) {
if (self.set.bindings.size == 0 and self.tables.count() == 0) {
try formatter.formatEntry(void, {});
return;
}
var buf: [1024]u8 = undefined;
// Format root set bindings
var iter = self.set.bindings.iterator();
while (iter.next()) |next| {
const k = next.key_ptr.*;
@@ -6677,6 +6860,23 @@ pub const Keybinds = struct {
writer.print("{f}", .{k}) catch return error.OutOfMemory;
try v.formatEntries(&writer, formatter);
}
// Format table bindings
var table_iter = self.tables.iterator();
while (table_iter.next()) |table_entry| {
const table_name = table_entry.key_ptr.*;
const table_set = table_entry.value_ptr.*;
var binding_iter = table_set.bindings.iterator();
while (binding_iter.next()) |next| {
const k = next.key_ptr.*;
const v = next.value_ptr.*;
var writer: std.Io.Writer = .fixed(&buf);
writer.print("{s}/{f}", .{ table_name, k }) catch return error.OutOfMemory;
try v.formatEntries(&writer, formatter);
}
}
}
/// Used by Formatter
@@ -6761,6 +6961,382 @@ pub const Keybinds = struct {
;
try std.testing.expectEqualStrings(want, buf.written());
}
test "parseCLI table definition" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Define a table by adding a binding to it
try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try testing.expectEqual(1, keybinds.tables.count());
try testing.expect(keybinds.tables.contains("foo"));
const table = keybinds.tables.get("foo").?;
try testing.expectEqual(1, table.bindings.count());
}
test "parseCLI table clear" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Add a binding to a table
try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count());
// Clear the table with "foo/"
try keybinds.parseCLI(alloc, "foo/");
try testing.expectEqual(0, keybinds.tables.get("foo").?.bindings.count());
}
test "parseCLI table multiple bindings" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window");
try testing.expectEqual(2, keybinds.tables.count());
try testing.expectEqual(2, keybinds.tables.get("foo").?.bindings.count());
try testing.expectEqual(1, keybinds.tables.get("bar").?.bindings.count());
}
test "parseCLI table does not affect root set" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
// Root set should have the first binding
try testing.expectEqual(1, keybinds.set.bindings.count());
// Table should have the second binding
try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count());
}
test "parseCLI table empty name is invalid" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try testing.expectError(error.InvalidFormat, keybinds.parseCLI(alloc, "/shift+a=copy_to_clipboard"));
}
test "parseCLI table with key sequence" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Key sequences should work within tables
try keybinds.parseCLI(alloc, "foo/ctrl+a>ctrl+b=new_window");
const table = keybinds.tables.get("foo").?;
try testing.expectEqual(1, table.bindings.count());
}
test "parseCLI slash in action argument is not a table" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// A slash after the = should not be interpreted as a table delimiter
try keybinds.parseCLI(alloc, "ctrl+a=text:/hello");
// Should be in root set, not a table
try testing.expectEqual(1, keybinds.set.bindings.count());
try testing.expectEqual(0, keybinds.tables.count());
}
test "parseCLI slash as key with modifier is not a table" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// ctrl+/ should be parsed as a keybind with '/' as the key, not a table
try keybinds.parseCLI(alloc, "ctrl+/=text:foo");
// Should be in root set, not a table
try testing.expectEqual(1, keybinds.set.bindings.count());
try testing.expectEqual(0, keybinds.tables.count());
}
test "parseCLI shift+slash as key is not a table" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// shift+/ should be parsed as a keybind, not a table
try keybinds.parseCLI(alloc, "shift+/=ignore");
// Should be in root set, not a table
try testing.expectEqual(1, keybinds.set.bindings.count());
try testing.expectEqual(0, keybinds.tables.count());
}
test "parseCLI bare slash as key is not a table" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Bare / as a key should work (empty table name is rejected)
try keybinds.parseCLI(alloc, "/=text:foo");
// Should be in root set, not a table
try testing.expectEqual(1, keybinds.set.bindings.count());
try testing.expectEqual(0, keybinds.tables.count());
}
test "parseCLI slash in key sequence is not a table" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Key sequence ending with / should work
try keybinds.parseCLI(alloc, "ctrl+a>ctrl+/=new_window");
// Should be in root set, not a table
try testing.expectEqual(1, keybinds.set.bindings.count());
try testing.expectEqual(0, keybinds.tables.count());
}
test "parseCLI table with slash in binding" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Table with a binding that uses / as the key
try keybinds.parseCLI(alloc, "mytable//=text:foo");
// Should be in the table
try testing.expectEqual(0, keybinds.set.bindings.count());
try testing.expectEqual(1, keybinds.tables.count());
try testing.expect(keybinds.tables.contains("mytable"));
try testing.expectEqual(1, keybinds.tables.get("mytable").?.bindings.count());
}
test "parseCLI table with sequence containing slash" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Table with a key sequence that ends with /
try keybinds.parseCLI(alloc, "mytable/a>/=new_window");
// Should be in the table
try testing.expectEqual(0, keybinds.set.bindings.count());
try testing.expectEqual(1, keybinds.tables.count());
try testing.expect(keybinds.tables.contains("mytable"));
}
test "clone with tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window");
const cloned = try keybinds.clone(alloc);
// Verify the clone has the same structure
try testing.expectEqual(keybinds.set.bindings.count(), cloned.set.bindings.count());
try testing.expectEqual(keybinds.tables.count(), cloned.tables.count());
try testing.expectEqual(
keybinds.tables.get("foo").?.bindings.count(),
cloned.tables.get("foo").?.bindings.count(),
);
try testing.expectEqual(
keybinds.tables.get("bar").?.bindings.count(),
cloned.tables.get("bar").?.bindings.count(),
);
}
test "equal with tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds1: Keybinds = .{};
try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
var keybinds2: Keybinds = .{};
try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try testing.expect(keybinds1.equal(keybinds2));
}
test "equal with tables different table count" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds1: Keybinds = .{};
try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
var keybinds2: Keybinds = .{};
try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try keybinds2.parseCLI(alloc, "bar/shift+b=paste_from_clipboard");
try testing.expect(!keybinds1.equal(keybinds2));
}
test "equal with tables different table names" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds1: Keybinds = .{};
try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
var keybinds2: Keybinds = .{};
try keybinds2.parseCLI(alloc, "bar/shift+a=copy_to_clipboard");
try testing.expect(!keybinds1.equal(keybinds2));
}
test "equal with tables different bindings" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds1: Keybinds = .{};
try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
var keybinds2: Keybinds = .{};
try keybinds2.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
try testing.expect(!keybinds1.equal(keybinds2));
}
test "formatEntry with tables" {
const testing = std.testing;
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello");
try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer));
try testing.expectEqualStrings("keybind = foo/shift+a=csi:hello\n", buf.written());
}
test "formatEntry with tables and root set" {
const testing = std.testing;
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "shift+b=csi:world");
try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello");
try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer));
const output = buf.written();
try testing.expect(std.mem.indexOf(u8, output, "keybind = shift+b=csi:world\n") != null);
try testing.expect(std.mem.indexOf(u8, output, "keybind = foo/shift+a=csi:hello\n") != null);
}
test "parseCLI clear clears tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Add bindings to root set and tables
try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window");
try testing.expectEqual(1, keybinds.set.bindings.count());
try testing.expectEqual(2, keybinds.tables.count());
// Clear all keybinds
try keybinds.parseCLI(alloc, "clear");
// Both root set and tables should be cleared
try testing.expectEqual(0, keybinds.set.bindings.count());
try testing.expectEqual(0, keybinds.tables.count());
}
test "parseCLI reset clears tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Add bindings to tables
try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "bar/shift+b=paste_from_clipboard");
try testing.expectEqual(2, keybinds.tables.count());
// Reset to defaults (empty value)
try keybinds.parseCLI(alloc, "");
// Tables should be cleared, root set has defaults
try testing.expectEqual(0, keybinds.tables.count());
try testing.expect(keybinds.set.bindings.count() > 0);
}
};
/// See "font-codepoint-map" for documentation.
@@ -7465,15 +8041,37 @@ pub const SplitPreserveZoom = packed struct {
};
pub const RepeatableCommand = struct {
value: std.ArrayListUnmanaged(inputpkg.Command) = .empty,
const Self = @This();
pub fn init(self: *RepeatableCommand, alloc: Allocator) !void {
value: std.ArrayListUnmanaged(inputpkg.Command) = .empty,
value_c: std.ArrayListUnmanaged(inputpkg.Command.C) = .empty,
/// ghostty_config_command_list_s
pub const C = extern struct {
commands: [*]inputpkg.Command.C,
len: usize,
};
pub fn cval(self: *const Self) C {
return .{
.commands = self.value_c.items.ptr,
.len = self.value_c.items.len,
};
}
pub fn init(self: *Self, alloc: Allocator) !void {
self.value = .empty;
self.value_c = .empty;
errdefer {
self.value.deinit(alloc);
self.value_c.deinit(alloc);
}
try self.value.appendSlice(alloc, inputpkg.command.defaults);
try self.value_c.appendSlice(alloc, inputpkg.command.defaultsC);
}
pub fn parseCLI(
self: *RepeatableCommand,
self: *Self,
alloc: Allocator,
input_: ?[]const u8,
) !void {
@@ -7481,26 +8079,36 @@ pub const RepeatableCommand = struct {
const input = input_ orelse "";
if (input.len == 0) {
self.value.clearRetainingCapacity();
self.value_c.clearRetainingCapacity();
return;
}
// Reserve space in our lists
try self.value.ensureUnusedCapacity(alloc, 1);
try self.value_c.ensureUnusedCapacity(alloc, 1);
const cmd = try cli.args.parseAutoStruct(
inputpkg.Command,
alloc,
input,
null,
);
try self.value.append(alloc, cmd);
const cmd_c = try cmd.cval(alloc);
self.value.appendAssumeCapacity(cmd);
self.value_c.appendAssumeCapacity(cmd_c);
}
/// Deep copy of the struct. Required by Config.
pub fn clone(self: *const RepeatableCommand, alloc: Allocator) Allocator.Error!RepeatableCommand {
pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self {
const value = try self.value.clone(alloc);
for (value.items) |*item| {
item.* = try item.clone(alloc);
}
return .{ .value = value };
return .{
.value = value,
.value_c = try self.value_c.clone(alloc),
};
}
/// Compare if two of our value are equal. Required by Config.
@@ -7656,6 +8264,50 @@ pub const RepeatableCommand = struct {
try testing.expectEqualStrings("kurwa", item.action.text);
}
}
test "RepeatableCommand cval" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: RepeatableCommand = .{};
try list.parseCLI(alloc, "title:Foo,action:ignore");
try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle");
try testing.expectEqual(@as(usize, 2), list.value.items.len);
try testing.expectEqual(@as(usize, 2), list.value_c.items.len);
const cv = list.cval();
try testing.expectEqual(@as(usize, 2), cv.len);
// First entry
try testing.expectEqualStrings("Foo", std.mem.sliceTo(cv.commands[0].title, 0));
try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action_key, 0));
try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action, 0));
// Second entry
try testing.expectEqualStrings("Bar", std.mem.sliceTo(cv.commands[1].title, 0));
try testing.expectEqualStrings("bobr", std.mem.sliceTo(cv.commands[1].description, 0));
try testing.expectEqualStrings("text", std.mem.sliceTo(cv.commands[1].action_key, 0));
try testing.expectEqualStrings("text:ale bydle", std.mem.sliceTo(cv.commands[1].action, 0));
}
test "RepeatableCommand cval cleared" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: RepeatableCommand = .{};
try list.parseCLI(alloc, "title:Foo,action:ignore");
try testing.expectEqual(@as(usize, 1), list.cval().len);
try list.parseCLI(alloc, "");
try testing.expectEqual(@as(usize, 0), list.cval().len);
}
};
/// OSC 4, 10, 11, and 12 default color reporting format.

View File

@@ -221,7 +221,7 @@ pub fn open(
// Unlikely scenario: the theme doesn't exist. In this case, we reset
// our iterator, reiterate over in order to build a better error message.
// This does double allocate some memory but for errors I think thats
// This does double allocate some memory but for errors I think that's
// fine.
it.reset();
while (try it.next()) |loc| {

View File

@@ -217,6 +217,13 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
pub fn deleteOldest(self: *Self, n: usize) void {
assert(n <= self.storage.len);
// Special case n == 0 otherwise we will accidentally break
// our circular buffer.
if (n == 0) {
@branchHint(.cold);
return;
}
// Clear the values back to default
const slices = self.getPtrSlice(0, n);
inline for (slices) |slice| @memset(slice, default);
@@ -233,6 +240,12 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
/// the end of our buffer. This never "rotates" the buffer because
/// the offset can only be within the size of the buffer.
pub fn getPtrSlice(self: *Self, offset: usize, slice_len: usize) [2][]T {
// Special case the empty slice fast-path.
if (slice_len == 0) {
@branchHint(.cold);
return .{ &.{}, &.{} };
}
// Note: this assertion is very important, it hints the compiler
// which generates ~10% faster code than without it.
assert(offset + slice_len <= self.capacity());
@@ -779,3 +792,75 @@ test "CircBuf resize shrink" {
try testing.expectEqual(@as(u8, 3), slices[0][2]);
}
}
test "CircBuf append empty slice" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 5);
defer buf.deinit(alloc);
// Appending an empty slice to empty buffer should be a no-op
buf.appendSliceAssumeCapacity("");
try testing.expectEqual(@as(usize, 0), buf.len());
try testing.expect(!buf.full);
// Buffer should still work normally after appending empty slice
buf.appendSliceAssumeCapacity("hi");
try testing.expectEqual(@as(usize, 2), buf.len());
// Appending an empty slice to non-empty buffer should also be a no-op
buf.appendSliceAssumeCapacity("");
try testing.expectEqual(@as(usize, 2), buf.len());
}
test "CircBuf getPtrSlice zero length" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 5);
defer buf.deinit(alloc);
// getPtrSlice with zero length on empty buffer should return empty slices
const slices = buf.getPtrSlice(0, 0);
try testing.expectEqual(@as(usize, 0), slices[0].len);
try testing.expectEqual(@as(usize, 0), slices[1].len);
try testing.expectEqual(@as(usize, 0), buf.len());
// Fill buffer partially
buf.appendSliceAssumeCapacity("abc");
try testing.expectEqual(@as(usize, 3), buf.len());
// getPtrSlice with zero length on non-empty buffer should also work
const slices2 = buf.getPtrSlice(0, 0);
try testing.expectEqual(@as(usize, 0), slices2[0].len);
try testing.expectEqual(@as(usize, 0), slices2[1].len);
try testing.expectEqual(@as(usize, 3), buf.len());
}
test "CircBuf deleteOldest zero" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 5);
defer buf.deinit(alloc);
// deleteOldest(0) on empty buffer should be a no-op
buf.deleteOldest(0);
try testing.expectEqual(@as(usize, 0), buf.len());
// Fill buffer
buf.appendSliceAssumeCapacity("hello");
try testing.expectEqual(@as(usize, 5), buf.len());
// deleteOldest(0) on non-empty buffer should be a no-op
buf.deleteOldest(0);
try testing.expectEqual(@as(usize, 5), buf.len());
// Verify data is unchanged
var it = buf.iterator(.forward);
try testing.expectEqual(@as(u8, 'h'), it.next().?.*);
}

View File

@@ -562,7 +562,7 @@ test "exact fit" {
try testing.expectError(Error.AtlasFull, atlas.reserve(alloc, 1, 1));
}
test "doesnt fit" {
test "doesn't fit" {
const alloc = testing.allocator;
var atlas = try init(alloc, 32, .grayscale);
defer atlas.deinit(alloc);

View File

@@ -52,7 +52,7 @@ pub const Shaper = struct {
/// Cached attributes dict for creating CTTypesetter objects.
/// The values in this never change so we can avoid overhead
/// by just creating it once and saving it for re-use.
/// by just creating it once and saving it for reuse.
typesetter_attr_dict: *macos.foundation.Dictionary,
/// List where we cache fonts, so we don't have to remake them for
@@ -103,6 +103,17 @@ pub const Shaper = struct {
}
};
const RunOffset = struct {
x: f64 = 0,
y: f64 = 0,
};
const CellOffset = struct {
cluster: u32 = 0,
x: f64 = 0,
y: f64 = 0,
};
/// Create a CoreFoundation Dictionary suitable for
/// settings the font features of a CoreText font.
fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary {
@@ -377,12 +388,15 @@ pub const Shaper = struct {
const line = typesetter.createLine(.{ .location = 0, .length = 0 });
self.cf_release_pool.appendAssumeCapacity(line);
// This keeps track of the current offsets within a single cell.
var cell_offset: struct {
cluster: u32 = 0,
x: f64 = 0,
y: f64 = 0,
} = .{};
// This keeps track of the current offsets within a run.
var run_offset: RunOffset = .{};
// This keeps track of the current offsets within a cell.
var cell_offset: CellOffset = .{};
// For debugging positions, turn this on:
//var start_index: usize = 0;
//var end_index: usize = 0;
// Clear our cell buf and make sure we have enough room for the whole
// line of glyphs, so that we can just assume capacity when appending
@@ -411,15 +425,18 @@ pub const Shaper = struct {
// Get our glyphs and positions
const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc);
const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc);
const positions = ctrun.getPositionsPtr() orelse try ctrun.getPositions(alloc);
const indices = ctrun.getStringIndicesPtr() orelse try ctrun.getStringIndices(alloc);
assert(glyphs.len == advances.len);
assert(glyphs.len == positions.len);
assert(glyphs.len == indices.len);
for (
glyphs,
advances,
positions,
indices,
) |glyph, advance, index| {
) |glyph, advance, position, index| {
// Our cluster is also our cell X position. If the cluster changes
// then we need to reset our current cell offsets.
const cluster = state.codepoints.items[index].cluster;
@@ -431,20 +448,41 @@ pub const Shaper = struct {
// wait for that.
if (cell_offset.cluster > cluster) break :pad;
cell_offset = .{ .cluster = cluster };
cell_offset = .{
.cluster = cluster,
.x = run_offset.x,
.y = run_offset.y,
};
// For debugging positions, turn this on:
// start_index = index;
// end_index = index;
//} else {
// if (index < start_index) {
// start_index = index;
// }
// if (index > end_index) {
// end_index = index;
// }
}
// For debugging positions, turn this on:
//try self.debugPositions(alloc, run_offset, cell_offset, position, start_index, end_index, index);
const x_offset = position.x - cell_offset.x;
const y_offset = position.y - cell_offset.y;
self.cell_buf.appendAssumeCapacity(.{
.x = @intCast(cluster),
.x_offset = @intFromFloat(@round(cell_offset.x)),
.y_offset = @intFromFloat(@round(cell_offset.y)),
.x_offset = @intFromFloat(@round(x_offset)),
.y_offset = @intFromFloat(@round(y_offset)),
.glyph_index = glyph,
});
// Add our advances to keep track of our current cell offsets.
// Add our advances to keep track of our run offsets.
// Advances apply to the NEXT cell.
cell_offset.x += advance.width;
cell_offset.y += advance.height;
run_offset.x += advance.width;
run_offset.y += advance.height;
}
}
@@ -613,6 +651,63 @@ pub const Shaper = struct {
_ = self;
}
};
fn debugPositions(
self: *Shaper,
alloc: Allocator,
run_offset: RunOffset,
cell_offset: CellOffset,
position: macos.graphics.Point,
start_index: usize,
end_index: usize,
index: usize,
) !void {
const state = &self.run_state;
const x_offset = position.x - cell_offset.x;
const y_offset = position.y - cell_offset.y;
const advance_x_offset = run_offset.x - cell_offset.x;
const advance_y_offset = run_offset.y - cell_offset.y;
const x_offset_diff = x_offset - advance_x_offset;
const y_offset_diff = y_offset - advance_y_offset;
if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) {
var allocating = std.Io.Writer.Allocating.init(alloc);
const writer = &allocating.writer;
const codepoints = state.codepoints.items[start_index .. end_index + 1];
for (codepoints) |cp| {
if (cp.codepoint == 0) continue; // Skip surrogate pair padding
try writer.print("\\u{{{x}}}", .{cp.codepoint});
}
try writer.writeAll("");
for (codepoints) |cp| {
if (cp.codepoint == 0) continue; // Skip surrogate pair padding
try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))});
}
const formatted_cps = try allocating.toOwnedSlice();
// Note that the codepoints from `start_index .. end_index + 1`
// might not include all the codepoints being shaped. Sometimes a
// codepoint gets represented in a glyph with a later codepoint
// such that the index for the former codepoint is skipped and just
// the index for the latter codepoint is used. Additionally, this
// gets called as we iterate through the glyphs, so it won't
// include the codepoints that come later that might be affecting
// positions for the current glyph. Usually though, for that case
// the positions of the later glyphs will also be affected and show
// up in the logs.
log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{
cell_offset.cluster,
x_offset,
y_offset,
advance_x_offset,
advance_y_offset,
x_offset_diff,
y_offset_diff,
state.codepoints.items[index].codepoint,
formatted_cps,
});
}
}
};
test "run iterator" {
@@ -1268,7 +1363,7 @@ test "shape with empty cells in between" {
}
}
test "shape Chinese characters" {
test "shape Combining characters" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -1286,6 +1381,9 @@ test "shape Chinese characters" {
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
@@ -1333,6 +1431,9 @@ test "shape Devanagari string" {
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Disable grapheme clustering
t.modes.set(.grapheme_cluster, false);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("अपार्टमेंट");
@@ -1365,6 +1466,62 @@ test "shape Devanagari string" {
try testing.expect(try it.next(alloc) == null);
}
test "shape Tai Tham vowels (position differs from advance)" {
const testing = std.testing;
const alloc = testing.allocator;
// We need a font that supports Tai Tham for this to work, if we can't find
// Noto Sans Tai Tham, which is a system font on macOS, we just skip the
// test.
var testdata = testShaperWithDiscoveredFont(
alloc,
"Noto Sans Tai Tham",
) catch return error.SkipZigTest;
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ
buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ
// Make a screen with some data
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
const cell_width = run.grid.metrics.cell_width;
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 0), cells[1].x);
// The first glyph renders in the next cell
try testing.expectEqual(@as(i16, @intCast(cell_width)), cells[0].x_offset);
try testing.expectEqual(@as(i16, 0), cells[1].x_offset);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape box glyphs" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -405,7 +405,7 @@ fn testDrawRanges(
const padding_x = width / 4;
const padding_y = height / 4;
// Canvas to draw glyphs on, we'll re-use this for all glyphs.
// Canvas to draw glyphs on, we'll reuse this for all glyphs.
var canvas = try font.sprite.Canvas.init(
alloc,
width,

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@ pub const Command = struct {
return true;
}
/// Convert this command to a C struct.
/// Convert this command to a C struct at comptime.
pub fn comptimeCval(self: Command) C {
assert(@inComptime());
@@ -55,6 +55,27 @@ pub const Command = struct {
};
}
/// Convert this command to a C struct at runtime.
///
/// This shares memory with the original command.
///
/// The action string is allocated using the provided allocator. You can
/// free the slice directly if you need to but we recommend an arena
/// for this.
pub fn cval(self: Command, alloc: Allocator) Allocator.Error!C {
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
self.action.format(&buf.writer) catch return error.OutOfMemory;
const action = try buf.toOwnedSliceSentinel(0);
return .{
.action_key = @tagName(self.action),
.action = action.ptr,
.title = self.title,
.description = self.description,
};
}
/// Implements a comparison function for std.mem.sortUnstable
/// and similar functions. The sorting is defined by Ghostty
/// to be what we prefer. If a caller wants some other sorting,
@@ -671,6 +692,10 @@ fn actionCommands(action: Action.Key) []const Command {
.write_scrollback_file,
.goto_tab,
.resize_split,
.activate_key_table,
.activate_key_table_once,
.deactivate_key_table,
.deactivate_all_key_tables,
.crash,
=> comptime &.{},

View File

@@ -153,7 +153,7 @@ fn kitty(
// IME confirmation still sends an enter key so if we have enter
// and UTF8 text we just send it directly since we assume that is
// whats happening. See legacy()'s similar logic for more details
// what's happening. See legacy()'s similar logic for more details
// on how to verify this.
if (event.utf8.len > 0) utf8: {
switch (event.key) {

View File

@@ -10,7 +10,7 @@ pub const ButtonState = enum(c_int) {
press,
};
/// Possible mouse buttons. We only track up to 11 because thats the maximum
/// Possible mouse buttons. We only track up to 11 because that's the maximum
/// button input that terminal mouse tracking handles without becoming
/// ambiguous.
///

View File

@@ -1213,7 +1213,7 @@ fn renderTermioWindow(self: *Inspector) void {
cimgui.c.igText("%s", ev.str.ptr);
// If the event is selected, we render info about it. For now
// we put this in the last column because thats the widest and
// we put this in the last column because that's the widest and
// imgui has no way to make a column span.
if (ev.imgui_selected) {
{

View File

@@ -13,7 +13,8 @@ pub const Event = struct {
event: input.KeyEvent,
/// The binding that was triggered as a result of this event.
binding: ?input.Binding.Action = null,
/// Multiple bindings are possible if they are chained.
binding: []const input.Binding.Action = &.{},
/// The data sent to the pty as a result of this keyboard event.
/// This is allocated using the inspector allocator.
@@ -32,6 +33,7 @@ pub const Event = struct {
}
pub fn deinit(self: *const Event, alloc: Allocator) void {
alloc.free(self.binding);
if (self.event.utf8.len > 0) alloc.free(self.event.utf8);
if (self.pty.len > 0) alloc.free(self.pty);
}
@@ -79,12 +81,28 @@ pub const Event = struct {
);
defer cimgui.c.igEndTable();
if (self.binding) |binding| {
if (self.binding.len > 0) {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableSetColumnIndex(0);
cimgui.c.igText("Triggered Binding");
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%s", @tagName(binding).ptr);
const height: f32 = height: {
const item_count: f32 = @floatFromInt(@min(self.binding.len, 5));
const padding = cimgui.c.igGetStyle().*.FramePadding.y * 2;
break :height cimgui.c.igGetTextLineHeightWithSpacing() * item_count + padding;
};
if (cimgui.c.igBeginListBox("##bindings", .{ .x = 0, .y = height })) {
defer cimgui.c.igEndListBox();
for (self.binding) |action| {
_ = cimgui.c.igSelectable_Bool(
@tagName(action).ptr,
false,
cimgui.c.ImGuiSelectableFlags_None,
.{ .x = 0, .y = 0 },
);
}
}
}
pty: {

View File

@@ -55,6 +55,9 @@ blending: configpkg.Config.AlphaBlending,
/// the "shared" storage mode, instead we have to use the "managed" mode.
default_storage_mode: mtl.MTLResourceOptions.StorageMode,
/// The maximum 2D texture width and height supported by the device.
max_texture_size: u32,
/// We start an AutoreleasePool before `drawFrame` and end it afterwards.
autorelease_pool: ?*objc.AutoreleasePool = null,
@@ -72,8 +75,17 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal {
const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{});
errdefer queue.release();
const default_storage_mode: mtl.MTLResourceOptions.StorageMode =
if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed;
// Grab metadata about the device.
const default_storage_mode: mtl.MTLResourceOptions.StorageMode = switch (comptime builtin.os.tag) {
// manage mode is not supported by iOS
.ios => .shared,
else => if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed,
};
const max_texture_size = queryMaxTextureSize(device);
log.debug(
"device properties default_storage_mode={} max_texture_size={}",
.{ default_storage_mode, max_texture_size },
);
const ViewInfo = struct {
view: objc.Object,
@@ -114,7 +126,8 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal {
},
.ios => {
info.view.msgSend(void, objc.sel("addSublayer"), .{layer.layer.value});
const view_layer = objc.Object.fromId(info.view.getProperty(?*anyopaque, "layer"));
view_layer.msgSend(void, objc.sel("addSublayer:"), .{layer.layer.value});
},
else => @compileError("unsupported target for Metal"),
@@ -138,6 +151,7 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal {
.queue = queue,
.blending = opts.config.blending,
.default_storage_mode = default_storage_mode,
.max_texture_size = max_texture_size,
};
}
@@ -202,9 +216,19 @@ pub fn initShaders(
pub fn surfaceSize(self: *const Metal) !struct { width: u32, height: u32 } {
const bounds = self.layer.layer.getProperty(graphics.Rect, "bounds");
const scale = self.layer.layer.getProperty(f64, "contentsScale");
// We need to clamp our runtime surface size to the maximum
// possible texture size since we can't create a screen buffer (texture)
// larger than that.
return .{
.width = @intFromFloat(bounds.size.width * scale),
.height = @intFromFloat(bounds.size.height * scale),
.width = @min(
@as(u32, @intFromFloat(bounds.size.width * scale)),
self.max_texture_size,
),
.height = @min(
@as(u32, @intFromFloat(bounds.size.height * scale)),
self.max_texture_size,
),
};
}
@@ -412,3 +436,23 @@ fn chooseDevice() error{NoMetalDevice}!objc.Object {
const device = chosen_device orelse return error.NoMetalDevice;
return device.retain();
}
/// Determines the maximum 2D texture size supported by the device.
/// We need to clamp our frame size to this if it's larger.
fn queryMaxTextureSize(device: objc.Object) u32 {
// https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf
if (device.msgSend(
bool,
objc.sel("supportsFamily:"),
.{mtl.MTLGPUFamily.apple10},
)) return 32768;
if (device.msgSend(
bool,
objc.sel("supportsFamily:"),
.{mtl.MTLGPUFamily.apple3},
)) return 16384;
return 8192;
}

View File

@@ -2099,7 +2099,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
// We also need to reset the shaper cache so shaper info
// from the previous font isn't re-used for the new font.
// from the previous font isn't reused for the new font.
const font_shaper_cache = font.ShaperCache.init();
self.font_shaper_cache.deinit(self.alloc);
self.font_shaper_cache = font_shaper_cache;

View File

@@ -391,6 +391,27 @@ pub const MTLRenderStage = enum(c_ulong) {
mesh = 16,
};
/// https://developer.apple.com/documentation/metal/mtlgpufamily?language=objc
pub const MTLGPUFamily = enum(c_long) {
apple1 = 1001,
apple2 = 1002,
apple3 = 1003,
apple4 = 1004,
apple5 = 1005,
apple6 = 1006,
apple7 = 1007,
apple8 = 1008,
apple9 = 1009,
apple10 = 1010,
common1 = 3001,
common2 = 3002,
common3 = 3003,
metal3 = 5001,
metal4 = 5002,
};
pub const MTLClearColor = extern struct {
red: f64,
green: f64,

View File

@@ -3821,6 +3821,15 @@ pub const PageIterator = struct {
pub fn fullPage(self: Chunk) bool {
return self.start == 0 and self.end == self.node.data.size.rows;
}
/// Returns true if this chunk overlaps with the given other chunk
/// in any way.
pub fn overlaps(self: Chunk, other: Chunk) bool {
if (self.node != other.node) return false;
if (self.end <= other.start) return false;
if (self.start >= other.end) return false;
return true;
}
};
};

View File

@@ -675,7 +675,7 @@ fn printCell(
// TODO: this case was not handled in the old terminal implementation
// but it feels like we should do something. investigate other
// terminals (xterm mainly) and see whats up.
// terminals (xterm mainly) and see what's up.
.spacer_head => {},
}
}
@@ -9058,7 +9058,7 @@ test "Terminal: insertBlanks shift graphemes" {
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
// Disable grapheme clustering
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
try t.printString("A");

View File

@@ -256,7 +256,7 @@ pub const Placement = struct {
if (img_scale_source.y < img_scaled.y_offset) {
// If our source rect y is within the offset area, we need to
// adjust our source rect and destination since the source texture
// doesnt actually have the offset area blank.
// doesn't actually have the offset area blank.
const offset: f64 = img_scaled.y_offset - img_scale_source.y;
img_scale_source.height -= offset;
y_offset = offset;
@@ -286,7 +286,7 @@ pub const Placement = struct {
if (img_scale_source.x < img_scaled.x_offset) {
// If our source rect x is within the offset area, we need to
// adjust our source rect and destination since the source texture
// doesnt actually have the offset area blank.
// doesn't actually have the offset area blank.
const offset: f64 = img_scaled.x_offset - img_scale_source.x;
img_scale_source.width -= offset;
x_offset = offset;

View File

@@ -215,7 +215,7 @@ pub fn RefCountedSet(
OutOfMemory,
/// The set needs to be rehashed, as there are many dead
/// items with lower IDs which are inaccessible for re-use.
/// items with lower IDs which are inaccessible for reuse.
NeedsRehash,
};
@@ -437,7 +437,7 @@ pub fn RefCountedSet(
}
/// Delete an item, removing any references from
/// the table, and freeing its ID to be re-used.
/// the table, and freeing its ID to be reused.
fn deleteItem(self: *Self, base: anytype, id: Id, ctx: Context) void {
const table = self.table.ptr(base);
const items = self.items.ptr(base);
@@ -585,7 +585,7 @@ pub fn RefCountedSet(
const item = &items[id];
// If there's a dead item then we resurrect it
// for our value so that we can re-use its ID,
// for our value so that we can reuse its ID,
// unless its ID is greater than the one we're
// given (i.e. prefer smaller IDs).
if (item.meta.ref == 0) {
@@ -645,7 +645,7 @@ pub fn RefCountedSet(
}
// Our chosen ID may have changed if we decided
// to re-use a dead item's ID, so we make sure
// to reuse a dead item's ID, so we make sure
// the chosen bucket contains the correct ID.
table[new_item.meta.bucket] = chosen_id;

View File

@@ -816,12 +816,19 @@ pub const RenderState = struct {
const row_pins = row_slice.items(.pin);
const row_cells = row_slice.items(.cells);
// Our viewport point is sent in by the caller and can't be trusted.
// If it is outside the valid area then just return empty because
// we can't possibly have a link there.
if (viewport_point.x >= self.cols or
viewport_point.y >= row_pins.len) return result;
// Grab our link ID
const link_page: *page.Page = &row_pins[viewport_point.y].node.data;
const link_pin: PageList.Pin = row_pins[viewport_point.y];
const link_page: *page.Page = &link_pin.node.data;
const link = link: {
const rac = link_page.getRowAndCell(
viewport_point.x,
viewport_point.y,
link_pin.y,
);
// The likely scenario is that our mouse isn't even over a link.
@@ -848,7 +855,7 @@ pub const RenderState = struct {
const other_page: *page.Page = &pin.node.data;
const other = link: {
const rac = other_page.getRowAndCell(x, y);
const rac = other_page.getRowAndCell(x, pin.y);
const link_id = other_page.lookupHyperlink(rac.cell) orelse continue;
break :link other_page.hyperlink_set.get(
other_page.memory,
@@ -1317,6 +1324,86 @@ test "string" {
try testing.expectEqualStrings(expected, result);
}
test "linkCells with scrollback spanning pages" {
const testing = std.testing;
const alloc = testing.allocator;
const viewport_rows: size.CellCountInt = 10;
const tail_rows: size.CellCountInt = 5;
var t = try Terminal.init(alloc, .{
.cols = page.std_capacity.cols,
.rows = viewport_rows,
.max_scrollback = 10_000,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
const pages = &t.screens.active.pages;
const first_page_cap = pages.pages.first.?.data.capacity.rows;
// Fill first page
for (0..first_page_cap - 1) |_| try s.nextSlice("\r\n");
// Create second page with hyperlink
try s.nextSlice("\r\n");
try s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\");
for (0..(tail_rows - 1)) |_| try s.nextSlice("\r\n");
var state: RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
const expected_viewport_y: usize = viewport_rows - tail_rows;
// BUG: This crashes without the fix
var cells = try state.linkCells(alloc, .{
.x = 0,
.y = expected_viewport_y,
});
defer cells.deinit(alloc);
try testing.expectEqual(@as(usize, 4), cells.count());
}
test "linkCells with invalid viewport point" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{
.cols = 10,
.rows = 5,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
var state: RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Row out of bound
{
var cells = try state.linkCells(
alloc,
.{ .x = 0, .y = t.rows + 10 },
);
defer cells.deinit(alloc);
try testing.expectEqual(0, cells.count());
}
// Col out of bound
{
var cells = try state.linkCells(
alloc,
.{ .x = t.cols + 10, .y = 0 },
);
defer cells.deinit(alloc);
try testing.expectEqual(0, cells.count());
}
}
test "dirty row resets highlights" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -257,18 +257,46 @@ fn select(self: *Thread, sel: ScreenSearch.Select) !void {
self.opts.mutex.lock();
defer self.opts.mutex.unlock();
// The selection will trigger a selection change notification
// if it did change.
if (try screen_search.select(sel)) scroll: {
if (screen_search.selected) |m| {
// Selection changed, let's scroll the viewport to see it
// since we have the lock anyways.
const screen = self.opts.terminal.screens.get(
s.last_screen.key,
) orelse break :scroll;
screen.scroll(.{ .pin = m.highlight.start.* });
// Make the selection. Ignore the result because we don't
// care if the selection didn't change.
_ = try screen_search.select(sel);
// Grab our match if we have one. If we don't have a selection
// then we do nothing.
const flattened = screen_search.selectedMatch() orelse return;
// No matter what we reset our selected match cache. This will
// trigger a callback which will trigger the renderer to wake up
// so it can be notified the screen scrolled.
s.last_screen.selected = null;
// Grab the current screen and see if this match is visible within
// the viewport already. If it is, we do nothing.
const screen = self.opts.terminal.screens.get(
s.last_screen.key,
) orelse return;
// Grab the viewport. Viewports and selections are usually small
// so this check isn't very expensive, despite appearing O(N^2),
// both Ns are usually equal to 1.
var it = screen.pages.pageIterator(
.right_down,
.{ .viewport = .{} },
null,
);
const hl_chunks = flattened.chunks.slice();
while (it.next()) |chunk| {
for (0..hl_chunks.len) |i| {
const hl_chunk = hl_chunks.get(i);
if (chunk.overlaps(.{
.node = hl_chunk.node,
.start = hl_chunk.start,
.end = hl_chunk.end,
})) return;
}
}
screen.scroll(.{ .pin = flattened.startPin() });
}
/// Change the search term to the given value.

View File

@@ -575,9 +575,16 @@ pub const SlidingWindow = struct {
);
}
// If our written data is empty, then there is nothing to
// add to our data set.
const written = encoded.written();
if (written.len == 0) {
self.assertIntegrity();
return 0;
}
// Get our written data. If we're doing a reverse search then we
// need to reverse all our encodings.
const written = encoded.written();
switch (self.direction) {
.forward => {},
.reverse => {
@@ -1637,3 +1644,33 @@ test "SlidingWindow single append reversed soft wrapped" {
try testing.expect(w.next() == null);
try testing.expect(w.next() == null);
}
// This tests a real bug that occurred where a whitespace-only page
// that encodes to zero bytes would crash.
test "SlidingWindow append whitespace only node" {
const testing = std.testing;
const alloc = testing.allocator;
var w: SlidingWindow = try .init(alloc, .forward, "x");
defer w.deinit();
var s = try Screen.init(alloc, .{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
});
defer s.deinit();
// By setting the empty page to wrap we get a zero-byte page.
// This is invasive but its otherwise hard to reproduce naturally
// without creating a slow test.
const node: *PageList.List.Node = s.pages.pages.first.?;
const last_row = node.data.getRow(node.data.size.rows - 1);
last_row.wrap = true;
try testing.expect(s.pages.pages.first == s.pages.pages.last);
_ = try w.append(node);
// No matches expected
try testing.expect(w.next() == null);
}

View File

@@ -208,7 +208,7 @@ pub const Viewer = struct {
/// caller is responsible for diffing the new window list against
/// the prior one. Remember that for a given Viewer, window IDs
/// are guaranteed to be stable. Additionally, tmux (as of Dec 2025)
/// never re-uses window IDs within a server process lifetime.
/// never reuses window IDs within a server process lifetime.
windows: []const Window,
pub fn format(self: Action, writer: *std.Io.Writer) !void {

View File

@@ -750,15 +750,15 @@ const Subprocess = struct {
else => "sh",
} };
// Always set up shell features (GHOSTTY_SHELL_FEATURES). These are
// used by both automatic and manual shell integrations.
try shell_integration.setupFeatures(
&env,
cfg.shell_integration_features,
);
const force: ?shell_integration.Shell = switch (cfg.shell_integration) {
.none => {
// Even if shell integration is none, we still want to
// set up the feature env vars
try shell_integration.setupFeatures(
&env,
cfg.shell_integration_features,
);
// This is a source of confusion for users despite being
// opt-in since it results in some Ghostty features not
// working. We always want to log it.
@@ -784,7 +784,6 @@ const Subprocess = struct {
default_shell_command,
&env,
force,
cfg.shell_integration_features,
) orelse {
log.warn("shell could not be detected, no automatic shell integration will be injected", .{});
break :shell default_shell_command;

View File

@@ -44,7 +44,6 @@ pub fn setup(
command: config.Command,
env: *EnvMap,
force_shell: ?Shell,
features: config.ShellIntegrationFeatures,
) !?ShellIntegration {
const exe = if (force_shell) |shell| switch (shell) {
.bash => "bash",
@@ -70,9 +69,6 @@ pub fn setup(
exe,
);
// Setup our feature env vars
try setupFeatures(env, features);
return result;
}
@@ -114,7 +110,7 @@ fn setupShell(
}
if (std.mem.eql(u8, "elvish", exe)) {
try setupXdgDataDirs(alloc_arena, resource_dir, env);
if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null;
return .{
.shell = .elvish,
.command = try command.clone(alloc_arena),
@@ -122,7 +118,7 @@ fn setupShell(
}
if (std.mem.eql(u8, "fish", exe)) {
try setupXdgDataDirs(alloc_arena, resource_dir, env);
if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null;
return .{
.shell = .fish,
.command = try command.clone(alloc_arena),
@@ -130,7 +126,7 @@ fn setupShell(
}
if (std.mem.eql(u8, "zsh", exe)) {
try setupZsh(resource_dir, env);
if (!try setupZsh(resource_dir, env)) return null;
return .{
.shell = .zsh,
.command = try command.clone(alloc_arena),
@@ -152,18 +148,43 @@ test "force shell" {
inline for (@typeInfo(Shell).@"enum".fields) |field| {
const shell = @field(Shell, field.name);
var res: TmpResourcesDir = try .init(alloc, shell);
defer res.deinit();
const result = try setup(
alloc,
".",
res.path,
.{ .shell = "sh" },
&env,
shell,
.{},
);
try testing.expectEqual(shell, result.?.shell);
}
}
test "shell integration failure" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var env = EnvMap.init(alloc);
defer env.deinit();
const result = try setup(
alloc,
"/nonexistent",
.{ .shell = "sh" },
&env,
null,
);
try testing.expect(result == null);
try testing.expectEqual(0, env.count());
}
/// Set up the shell integration features environment variable.
pub fn setupFeatures(
env: *EnvMap,
@@ -230,7 +251,7 @@ test "setup features" {
var env = EnvMap.init(alloc);
defer env.deinit();
try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false });
try setupFeatures(&env, std.mem.zeroes(config.ShellIntegrationFeatures));
try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null);
}
@@ -318,6 +339,28 @@ fn setupBash(
try cmd.appendArg(arg);
}
}
// Preserve an existing ENV value. We're about to overwrite it.
if (env.get("ENV")) |v| {
try env.put("GHOSTTY_BASH_ENV", v);
}
// Set our new ENV to point to our integration script.
var script_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const script_path = try std.fmt.bufPrint(
&script_path_buf,
"{s}/shell-integration/bash/ghostty.bash",
.{resource_dir},
);
if (std.fs.openFileAbsolute(script_path, .{})) |file| {
file.close();
try env.put("ENV", script_path);
} else |err| {
log.warn("unable to open {s}: {}", .{ script_path, err });
env.remove("GHOSTTY_BASH_ENV");
return null;
}
try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]);
if (rcfile) |v| {
try env.put("GHOSTTY_BASH_RCFILE", v);
@@ -339,20 +382,6 @@ fn setupBash(
}
}
// Preserve an existing ENV value. We're about to overwrite it.
if (env.get("ENV")) |v| {
try env.put("GHOSTTY_BASH_ENV", v);
}
// Set our new ENV to point to our integration script.
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const integ_dir = try std.fmt.bufPrint(
&path_buf,
"{s}/shell-integration/bash/ghostty.bash",
.{resource_dir},
);
try env.put("ENV", integ_dir);
// Get the command string from the builder, then copy it to the arena
// allocator. The stackFallback allocator's memory becomes invalid after
// this function returns, so we must copy to the arena.
@@ -366,14 +395,21 @@ test "bash" {
defer arena.deinit();
const alloc = arena.allocator();
var res: TmpResourcesDir = try .init(alloc, .bash);
defer res.deinit();
var env = EnvMap.init(alloc);
defer env.deinit();
const command = try setupBash(alloc, .{ .shell = "bash" }, ".", &env);
const command = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env);
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?);
try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?);
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
try testing.expectEqualStrings(
try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}),
env.get("ENV").?,
);
}
test "bash: unsupported options" {
@@ -382,6 +418,9 @@ test "bash: unsupported options" {
defer arena.deinit();
const alloc = arena.allocator();
var res: TmpResourcesDir = try .init(alloc, .bash);
defer res.deinit();
const cmdlines = [_][:0]const u8{
"bash --posix",
"bash --rcfile script.sh --posix",
@@ -394,10 +433,8 @@ test "bash: unsupported options" {
var env = EnvMap.init(alloc);
defer env.deinit();
try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, ".", &env) == null);
try testing.expect(env.get("GHOSTTY_BASH_INJECT") == null);
try testing.expect(env.get("GHOSTTY_BASH_RCFILE") == null);
try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null);
try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, res.path, &env) == null);
try testing.expectEqual(0, env.count());
}
}
@@ -407,13 +444,15 @@ test "bash: inject flags" {
defer arena.deinit();
const alloc = arena.allocator();
var res: TmpResourcesDir = try .init(alloc, .bash);
defer res.deinit();
// bash --norc
{
var env = EnvMap.init(alloc);
defer env.deinit();
const command = try setupBash(alloc, .{ .shell = "bash --norc" }, ".", &env);
const command = try setupBash(alloc, .{ .shell = "bash --norc" }, res.path, &env);
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?);
}
@@ -423,8 +462,7 @@ test "bash: inject flags" {
var env = EnvMap.init(alloc);
defer env.deinit();
const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, ".", &env);
const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, res.path, &env);
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?);
}
@@ -436,19 +474,22 @@ test "bash: rcfile" {
defer arena.deinit();
const alloc = arena.allocator();
var res: TmpResourcesDir = try .init(alloc, .bash);
defer res.deinit();
var env = EnvMap.init(alloc);
defer env.deinit();
// bash --rcfile
{
const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, ".", &env);
const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, res.path, &env);
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
}
// bash --init-file
{
const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, ".", &env);
const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, res.path, &env);
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
}
@@ -460,12 +501,15 @@ test "bash: HISTFILE" {
defer arena.deinit();
const alloc = arena.allocator();
var res: TmpResourcesDir = try .init(alloc, .bash);
defer res.deinit();
// HISTFILE unset
{
var env = EnvMap.init(alloc);
defer env.deinit();
_ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env);
_ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env);
try testing.expect(std.mem.endsWith(u8, env.get("HISTFILE").?, ".bash_history"));
try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE").?);
}
@@ -477,7 +521,7 @@ test "bash: HISTFILE" {
try env.put("HISTFILE", "my_history");
_ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env);
_ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env);
try testing.expectEqualStrings("my_history", env.get("HISTFILE").?);
try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null);
}
@@ -489,14 +533,22 @@ test "bash: ENV" {
defer arena.deinit();
const alloc = arena.allocator();
var res: TmpResourcesDir = try .init(alloc, .bash);
defer res.deinit();
var env = EnvMap.init(alloc);
defer env.deinit();
try env.put("ENV", "env.sh");
_ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env);
try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?);
_ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env);
try testing.expectEqualStrings("env.sh", env.get("GHOSTTY_BASH_ENV").?);
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
try testing.expectEqualStrings(
try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}),
env.get("ENV").?,
);
}
test "bash: additional arguments" {
@@ -505,22 +557,44 @@ test "bash: additional arguments" {
defer arena.deinit();
const alloc = arena.allocator();
var res: TmpResourcesDir = try .init(alloc, .bash);
defer res.deinit();
var env = EnvMap.init(alloc);
defer env.deinit();
// "-" argument separator
{
const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, ".", &env);
const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, res.path, &env);
try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell);
}
// "--" argument separator
{
const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, ".", &env);
const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, res.path, &env);
try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell);
}
}
test "bash: missing resources" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
defer alloc.free(resources_dir);
var env = EnvMap.init(alloc);
defer env.deinit();
try testing.expect(try setupBash(alloc, .{ .shell = "bash" }, resources_dir, &env) == null);
try testing.expectEqual(0, env.count());
}
/// Setup automatic shell integration for shells that include
/// their modules from paths in `XDG_DATA_DIRS` env variable.
///
@@ -532,20 +606,25 @@ fn setupXdgDataDirs(
alloc_arena: Allocator,
resource_dir: []const u8,
env: *EnvMap,
) !void {
) !bool {
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
// Get our path to the shell integration directory.
const integ_dir = try std.fmt.bufPrint(
const integ_path = try std.fmt.bufPrint(
&path_buf,
"{s}/shell-integration",
.{resource_dir},
);
var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| {
log.warn("unable to open {s}: {}", .{ integ_path, err });
return false;
};
integ_dir.close();
// Set an env var so we can remove this from XDG_DATA_DIRS later.
// This happens in the shell integration config itself. We do this
// so that our modifications don't interfere with other commands.
try env.put("GHOSTTY_SHELL_INTEGRATION_XDG_DIR", integ_dir);
try env.put("GHOSTTY_SHELL_INTEGRATION_XDG_DIR", integ_path);
// We attempt to avoid allocating by using the stack up to 4K.
// Max stack size is considerably larger on mac
@@ -565,9 +644,11 @@ fn setupXdgDataDirs(
try internal_os.prependEnv(
stack_alloc,
env.get(xdg_data_dirs_key) orelse "/usr/local/share:/usr/share",
integ_dir,
integ_path,
),
);
return true;
}
test "xdg: empty XDG_DATA_DIRS" {
@@ -577,13 +658,23 @@ test "xdg: empty XDG_DATA_DIRS" {
defer arena.deinit();
const alloc = arena.allocator();
var res: TmpResourcesDir = try .init(alloc, .fish);
defer res.deinit();
var env = EnvMap.init(alloc);
defer env.deinit();
try setupXdgDataDirs(alloc, ".", &env);
try testing.expect(try setupXdgDataDirs(alloc, res.path, &env));
try testing.expectEqualStrings("./shell-integration", env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?);
try testing.expectEqualStrings("./shell-integration:/usr/local/share:/usr/share", env.get("XDG_DATA_DIRS").?);
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
try testing.expectEqualStrings(
try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}),
env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?,
);
try testing.expectEqualStrings(
try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/usr/local/share:/usr/share", .{res.path}),
env.get("XDG_DATA_DIRS").?,
);
}
test "xdg: existing XDG_DATA_DIRS" {
@@ -593,14 +684,43 @@ test "xdg: existing XDG_DATA_DIRS" {
defer arena.deinit();
const alloc = arena.allocator();
var res: TmpResourcesDir = try .init(alloc, .fish);
defer res.deinit();
var env = EnvMap.init(alloc);
defer env.deinit();
try env.put("XDG_DATA_DIRS", "/opt/share");
try setupXdgDataDirs(alloc, ".", &env);
try testing.expect(try setupXdgDataDirs(alloc, res.path, &env));
try testing.expectEqualStrings("./shell-integration", env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?);
try testing.expectEqualStrings("./shell-integration:/opt/share", env.get("XDG_DATA_DIRS").?);
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
try testing.expectEqualStrings(
try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}),
env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?,
);
try testing.expectEqualStrings(
try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/opt/share", .{res.path}),
env.get("XDG_DATA_DIRS").?,
);
}
test "xdg: missing resources" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
defer alloc.free(resources_dir);
var env = EnvMap.init(alloc);
defer env.deinit();
try testing.expect(!try setupXdgDataDirs(alloc, resources_dir, &env));
try testing.expectEqual(0, env.count());
}
/// Setup the zsh automatic shell integration. This works by setting
@@ -609,7 +729,7 @@ test "xdg: existing XDG_DATA_DIRS" {
fn setupZsh(
resource_dir: []const u8,
env: *EnvMap,
) !void {
) !bool {
// Preserve an existing ZDOTDIR value. We're about to overwrite it.
if (env.get("ZDOTDIR")) |old| {
try env.put("GHOSTTY_ZSH_ZDOTDIR", old);
@@ -617,34 +737,118 @@ fn setupZsh(
// Set our new ZDOTDIR to point to our shell resource directory.
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const integ_dir = try std.fmt.bufPrint(
const integ_path = try std.fmt.bufPrint(
&path_buf,
"{s}/shell-integration/zsh",
.{resource_dir},
);
try env.put("ZDOTDIR", integ_dir);
var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| {
log.warn("unable to open {s}: {}", .{ integ_path, err });
return false;
};
integ_dir.close();
try env.put("ZDOTDIR", integ_path);
return true;
}
test "zsh" {
const testing = std.testing;
var res: TmpResourcesDir = try .init(testing.allocator, .zsh);
defer res.deinit();
var env = EnvMap.init(testing.allocator);
defer env.deinit();
try setupZsh(".", &env);
try testing.expectEqualStrings("./shell-integration/zsh", env.get("ZDOTDIR").?);
try testing.expect(try setupZsh(res.path, &env));
try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?);
try testing.expect(env.get("GHOSTTY_ZSH_ZDOTDIR") == null);
}
test "zsh: ZDOTDIR" {
const testing = std.testing;
var res: TmpResourcesDir = try .init(testing.allocator, .zsh);
defer res.deinit();
var env = EnvMap.init(testing.allocator);
defer env.deinit();
try env.put("ZDOTDIR", "$HOME/.config/zsh");
try setupZsh(".", &env);
try testing.expectEqualStrings("./shell-integration/zsh", env.get("ZDOTDIR").?);
try testing.expect(try setupZsh(res.path, &env));
try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?);
try testing.expectEqualStrings("$HOME/.config/zsh", env.get("GHOSTTY_ZSH_ZDOTDIR").?);
}
test "zsh: missing resources" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, ".");
defer alloc.free(resources_dir);
var env = EnvMap.init(alloc);
defer env.deinit();
try testing.expect(!try setupZsh(resources_dir, &env));
try testing.expectEqual(0, env.count());
}
/// Test helper that creates a temporary resources directory with shell integration paths.
const TmpResourcesDir = struct {
allocator: Allocator,
tmp_dir: std.testing.TmpDir,
path: []const u8,
shell_path: []const u8,
fn init(allocator: Allocator, shell: Shell) !TmpResourcesDir {
var tmp_dir = std.testing.tmpDir(.{});
errdefer tmp_dir.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const relative_shell_path = try std.fmt.bufPrint(
&path_buf,
"shell-integration/{s}",
.{@tagName(shell)},
);
try tmp_dir.dir.makePath(relative_shell_path);
const path = try tmp_dir.dir.realpathAlloc(allocator, ".");
errdefer allocator.free(path);
const shell_path = try std.fmt.allocPrint(
allocator,
"{s}/{s}",
.{ path, relative_shell_path },
);
errdefer allocator.free(shell_path);
switch (shell) {
.bash => try tmp_dir.dir.writeFile(.{
.sub_path = "shell-integration/bash/ghostty.bash",
.data = "",
}),
else => {},
}
return .{
.allocator = allocator,
.tmp_dir = tmp_dir,
.path = path,
.shell_path = shell_path,
};
}
fn deinit(self: *TmpResourcesDir) void {
self.allocator.free(self.shell_path);
self.allocator.free(self.path);
self.tmp_dir.cleanup();
}
};

View File

@@ -56,6 +56,8 @@ DECID = "DECID"
flate = "flate"
typ = "typ"
kend = "kend"
# Tai Tham is a script/writing system
Tham = "Tham"
# GTK
GIR = "GIR"
# terminfo

View File

@@ -72,7 +72,16 @@
fun:gdk_surface_handle_event
...
}
{
GTK CSS Node Validation
Memcheck:Leak
match-leak-kinds: possible
fun:malloc
...
fun:gtk_css_node_validate_internal
fun:gtk_css_node_validate
...
}
{
GTK CSS Provider Leak
Memcheck:Leak
@@ -196,8 +205,44 @@
fun:svga_context_flush
...
}
{
SVGA Stuff
Memcheck:Leak
match-leak-kinds: definite
fun:calloc
fun:svga_create_surface_view
fun:svga_set_framebuffer_state
fun:st_update_framebuffer_state
fun:st_Clear
fun:gsk_gpu_render_pass_op_gl_command
...
}
{
GTK Icon
Memcheck:Leak
match-leak-kinds: possible
fun:*alloc
...
fun:gtk_icon_theme_set_display
fun:gtk_icon_theme_get_for_display
...
}
{
GDK Wayland Connection
Memcheck:Leak
match-leak-kinds: possible
fun:calloc
fun:wl_closure_init
fun:wl_connection_demarshal
fun:wl_display_read_events
fun:gdk_wayland_poll_source_check
fun:g_main_context_check_unlocked
fun:g_main_context_iterate_unlocked.isra.0
fun:g_main_context_iteration
...
}
{
GSK Renderer GPU Stuff
Memcheck:Leak
match-leak-kinds: possible
@@ -297,6 +342,21 @@
fun:g_main_context_iteration
...
}
{
GSK More Forms
Memcheck:Leak
match-leak-kinds: possible
...
fun:gsk_gl_device_use_program
fun:gsk_gl_frame_use_program
fun:gsk_gpu_shader_op_gl_command_n
fun:gsk_gpu_render_pass_op_gl_command
fun:gsk_gl_frame_submit
fun:gsk_gpu_renderer_render_texture
fun:gsk_renderer_render_texture
fun:render_contents
...
}
{
GTK Shader Selector
Memcheck:Leak