mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
Merge remote-tracking branch 'origin/main' into harfbuzz-positions
This commit is contained in:
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/release-tip.yml
vendored
6
.github/workflows/release-tip.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
6
build.zig.zon.json
generated
@@ -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
6
build.zig.zon.nix
generated
@@ -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
2
build.zig.zon.txt
generated
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
25
macos/Sources/Helpers/AnySortKey.swift
Normal file
25
macos/Sources/Helpers/AnySortKey.swift
Normal 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
|
||||
}
|
||||
}
|
||||
39
macos/Sources/Helpers/Extensions/NSColor+Extension.swift
Normal file
39
macos/Sources/Helpers/Extensions/NSColor+Extension.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = &.{
|
||||
|
||||
@@ -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(""));
|
||||
|
||||
53
src/App.zig
53
src/App.zig
@@ -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.
|
||||
|
||||
364
src/Surface.zig
364
src/Surface.zig
@@ -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"),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
342
src/apprt/gtk/class/key_state_overlay.zig
Normal file
342
src/apprt/gtk/class/key_state_overlay.zig
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
111
src/apprt/gtk/ext/slice.zig
Normal 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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
58
src/apprt/gtk/ui/1.2/key-state-overlay.blp
Normal file
58
src/apprt/gtk/ui/1.2/key-state-overlay.blp
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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().?.*);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 &.{},
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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) {
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user