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

pull/9883/head
Jacob Sandlund 2025-12-11 09:32:01 -05:00
commit 942f326c58
39 changed files with 3332 additions and 156 deletions

View File

@ -62,7 +62,7 @@ jobs:
run: nix build .#ghostty
- name: Create pull request
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
title: Update iTerm2 colorschemes
base: main

View File

@ -30,4 +30,5 @@ A file for [guiding coding agents](https://agents.md/).
- Do not use `xcodebuild`
- Use `zig build` to build the macOS app and any shared Zig code
- Use `zig build run` to build and run the macOS app
- Run Xcode tests using `zig build test`

View File

@ -2,6 +2,7 @@ const std = @import("std");
const assert = std.debug.assert;
const builtin = @import("builtin");
const buildpkg = @import("src/build/main.zig");
const appVersion = @import("build.zig.zon").version;
const minimumZigVersion = @import("build.zig.zon").minimum_zig_version;
@ -317,3 +318,8 @@ pub fn build(b: *std.Build) !void {
try translations_step.addError("cannot update translations when i18n is disabled", .{});
}
}
/// Marker used by Config.zig to detect if ghostty is the build root.
/// This avoids running logic such as Git tag checking when Ghostty
/// is used as a dependency.
pub const _ghostty_build_root = true;

View File

@ -55,10 +55,10 @@
.lazy = true,
},
.gobject = .{
// https://github.com/jcollie/ghostty-gobject based on zig_gobject
// https://github.com/ghostty-org/zig-gobject based on zig_gobject
// Temporary until we generate them at build time automatically.
.url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst",
.hash = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV",
.url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst",
.hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-",
.lazy = true,
},

6
build.zig.zon.json generated
View File

@ -24,10 +24,10 @@
"url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz",
"hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U="
},
"gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV": {
"gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": {
"name": "gobject",
"url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst",
"hash": "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo="
"url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst",
"hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="
},
"N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": {
"name": "gtk4_layer_shell",

6
build.zig.zon.nix generated
View File

@ -123,11 +123,11 @@ in
};
}
{
name = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV";
name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-";
path = fetchZigArtifact {
name = "gobject";
url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst";
hash = "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo=";
url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst";
hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=";
};
}
{

2
build.zig.zon.txt generated
View File

@ -6,7 +6,6 @@ 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/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz
@ -27,6 +26,7 @@ https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.t
https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz
https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst
https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz
https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz
https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz

View File

@ -3,11 +3,11 @@
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1747046372,
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
@ -36,30 +36,17 @@
},
"nixpkgs": {
"locked": {
"lastModified": 315532800,
"narHash": "sha256-sV6pJNzFkiPc6j9Bi9JuHBnWdVhtKB/mHgVmMPvDFlk=",
"rev": "82c2e0d6dde50b17ae366d2aa36f224dc19af469",
"lastModified": 1763191728,
"narHash": "sha256-gI9PpaoX4/f28HkjcTbFVpFhtOxSDtOEdFaHZrdETe0=",
"rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre877938.82c2e0d6dde5/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre896415.1d4c88323ac3/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1758360447,
"narHash": "sha256-XDY3A83bclygHDtesRoaRTafUd80Q30D/Daf9KSG6bs=",
"rev": "8eaee110344796db060382e15d3af0a9fc396e0e",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre864002.8eaee1103447/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
}
},
"root": {
"inputs": {
"flake-compat": "flake-compat",
@ -97,11 +84,11 @@
]
},
"locked": {
"lastModified": 1760401936,
"narHash": "sha256-/zj5GYO5PKhBWGzbHbqT+ehY8EghuABdQ2WGfCwZpCQ=",
"lastModified": 1763295135,
"narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=",
"owner": "mitchellh",
"repo": "zig-overlay",
"rev": "365085b6652259753b598d43b723858184980bbe",
"rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a",
"type": "github"
},
"original": {
@ -112,7 +99,9 @@
},
"zon2nix": {
"inputs": {
"nixpkgs": "nixpkgs_2"
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1758405547,

View File

@ -6,7 +6,9 @@
# glibc versions used by our dependencies from Nix are compatible with the
# system glibc that the user is building for.
#
# We are currently on unstable to get Zig 0.15 for our package.nix
# We are currently on nixpkgs-unstable to get Zig 0.15 for our package.nix and
# Gnome 49/Gtk 4.20.
#
nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz";
flake-utils.url = "github:numtide/flake-utils";
@ -28,10 +30,7 @@
zon2nix = {
url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245";
inputs = {
# Don't override nixpkgs until Zig 0.15 is available in the Nix branch
# we are using for "normal" builds.
#
# nixpkgs.follows = "nixpkgs";
nixpkgs.follows = "nixpkgs";
};
};
};

View File

@ -31,9 +31,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst",
"dest": "vendor/p/gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV",
"sha256": "4978aa1a6f356949facaad7015780d4c050b74a3874bf473929e4e80d924717a"
"url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst",
"dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-",
"sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48"
},
{
"type": "archive",

View File

@ -714,6 +714,7 @@ typedef struct {
typedef enum {
GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS,
GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER,
GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT,
} ghostty_action_close_tab_mode_e;
// apprt.surface.Message.ChildExited

View File

@ -156,6 +156,7 @@
"Helpers/Extensions/NSAppearance+Extension.swift",
"Helpers/Extensions/NSApplication+Extension.swift",
"Helpers/Extensions/NSImage+Extension.swift",
"Helpers/Extensions/NSMenu+Extension.swift",
"Helpers/Extensions/NSMenuItem+Extension.swift",
"Helpers/Extensions/NSPasteboard+Extension.swift",
"Helpers/Extensions/NSScreen+Extension.swift",

View File

@ -566,6 +566,7 @@ class QuickTerminalController: BaseTerminalController {
private func syncAppearance() {
guard let window else { return }
defer { updateColorSchemeForSurfaceTree() }
// Change the collection behavior of the window depending on the configuration.
window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior

View File

@ -72,6 +72,9 @@ class BaseTerminalController: NSWindowController,
/// The previous frame information from the window
private var savedFrame: SavedFrame? = nil
/// Cache previously applied appearance to avoid unnecessary updates
private var appliedColorScheme: ghostty_color_scheme_e?
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
@ -1163,4 +1166,35 @@ extension BaseTerminalController: NSMenuItemValidation {
return true
}
}
// MARK: - Surface Color Scheme
/// Update the surface tree's color scheme only when it actually changes.
///
/// Calling ``ghostty_surface_set_color_scheme`` triggers
/// ``syncAppearance(_:)`` via notification,
/// so we avoid redundant calls.
func updateColorSchemeForSurfaceTree() {
/// Derive the target scheme from `window-theme` or system appearance.
/// We set the scheme on surfaces so they pick the correct theme
/// and let ``syncAppearance(_:)`` update the window accordingly.
///
/// Using App's effectiveAppearance here to prevent incorrect updates.
let themeAppearance = NSApplication.shared.effectiveAppearance
let scheme: ghostty_color_scheme_e
if themeAppearance.isDark {
scheme = GHOSTTY_COLOR_SCHEME_DARK
} else {
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
}
guard scheme != appliedColorScheme else {
return
}
for surfaceView in surfaceTree {
if let surface = surfaceView.surface {
ghostty_surface_set_color_scheme(surface, scheme)
}
}
appliedColorScheme = scheme
}
}

View File

@ -104,6 +104,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
selector: #selector(onCloseOtherTabs),
name: .ghosttyCloseOtherTabs,
object: nil)
center.addObserver(
self,
selector: #selector(onCloseTabsOnTheRight),
name: .ghosttyCloseTabsOnTheRight,
object: nil)
center.addObserver(
self,
selector: #selector(onResetWindowSize),
@ -425,15 +430,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
return
}
// This is a surface-level config update. If we have the surface, we
// update our appearance based on it.
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(surfaceView) else { return }
// We can't use surfaceView.derivedConfig because it may not be updated
// yet since it also responds to notifications.
syncAppearance(.init(config))
/// Surface-level config will be updated in
/// ``Ghostty/Ghostty/SurfaceView/derivedConfig`` then
/// ``TerminalController/focusedSurfaceDidChange(to:)``
}
/// Update the accessory view of each tab according to the keyboard
@ -633,6 +632,46 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
private func closeTabsOnTheRightImmediately() {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return }
let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex }
guard !tabsToClose.isEmpty else { return }
undoManager?.beginUndoGrouping()
defer {
undoManager?.endUndoGrouping()
}
for (_, candidate) in tabsToClose {
if let controller = candidate.windowController as? TerminalController {
controller.closeTabImmediately(registerRedo: false)
}
}
if let undoManager {
undoManager.setActionName("Close Tabs to the Right")
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
) { target in
DispatchQueue.main.async {
target.window?.makeKeyAndOrderFront(nil)
}
undoManager.registerUndo(
withTarget: target,
expiresAfter: target.undoExpiration
) { target in
target.closeTabsOnTheRightImmediately()
}
}
}
}
/// Closes the current window (including any other tabs) immediately and without
/// confirmation. This will setup proper undo state so the action can be undone.
private func closeWindowImmediately() {
@ -1110,6 +1149,35 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
@IBAction func closeTabsOnTheRight(_ sender: Any?) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return }
let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex }
guard !tabsToClose.isEmpty else { return }
let needsConfirm = tabsToClose.contains { (_, candidate) in
guard let controller = candidate.windowController as? TerminalController else {
return false
}
return controller.surfaceTree.contains(where: { $0.needsConfirmQuit })
}
if !needsConfirm {
self.closeTabsOnTheRightImmediately()
return
}
confirmClose(
messageText: "Close Tabs on the Right?",
informativeText: "At least one tab to the right still has a running process. If you close the tab the process will be killed."
) {
self.closeTabsOnTheRightImmediately()
}
}
@IBAction func returnToDefaultSize(_ sender: Any?) {
guard let window, let defaultSize else { return }
defaultSize.apply(to: window)
@ -1311,6 +1379,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
closeOtherTabs(self)
}
@objc private func onCloseTabsOnTheRight(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(target) else { return }
closeTabsOnTheRight(self)
}
@objc private func onCloseWindow(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(target) else { return }
@ -1373,6 +1447,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
extension TerminalController {
override func validateMenuItem(_ item: NSMenuItem) -> Bool {
switch item.action {
case #selector(closeTabsOnTheRight):
guard let window, let tabGroup = window.tabGroup else { return false }
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false }
return tabGroup.windows.enumerated().contains { $0.offset > currentIndex }
case #selector(returnToDefaultSize):
guard let window else { return false }

View File

@ -27,6 +27,8 @@ class TerminalWindow: NSWindow {
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private(set) var derivedConfig: DerivedConfig = .init()
private var tabMenuObserver: NSObjectProtocol? = nil
/// Whether this window supports the update accessory. If this is false, then views within this
/// window should determine how to show update notifications.
var supportsUpdateAccessory: Bool {
@ -54,6 +56,17 @@ class TerminalWindow: NSWindow {
// Notify that this terminal window has loaded
NotificationCenter.default.post(name: Self.terminalDidAwake, object: self)
// This is fragile, but there doesn't seem to be an official API for customizing
// native tab bar menus.
tabMenuObserver = NotificationCenter.default.addObserver(
forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"),
object: nil,
queue: .main
) { [weak self] n in
guard let self, let menu = n.object as? NSMenu else { return }
self.configureTabContextMenuIfNeeded(menu)
}
// This is required so that window restoration properly creates our tabs
// again. I'm not sure why this is required. If you don't do this, then
// tabs restore as separate windows.
@ -202,6 +215,8 @@ class TerminalWindow: NSWindow {
/// added.
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem")
func findTitlebarView() -> NSView? {
// Find our tab bar. If it doesn't exist we don't do anything.
//
@ -277,6 +292,52 @@ class TerminalWindow: NSWindow {
}
}
private func configureTabContextMenuIfNeeded(_ menu: NSMenu) {
guard isTabContextMenu(menu) else { return }
// Get the target from an existing menu item. The native tab context menu items
// target the specific window/controller that was right-clicked, not the focused one.
// We need to use that same target so validation and action use the correct tab.
let targetController = menu.items
.first { $0.action == NSSelectorFromString("performClose:") }
.flatMap { $0.target as? NSWindow }
.flatMap { $0.windowController as? TerminalController }
// Close tabs to the right
let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "")
item.identifier = Self.closeTabsOnRightMenuItemIdentifier
item.target = targetController
item.setImageIfDesired(systemSymbolName: "xmark")
if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) &&
!menu.insertItem(item, after: NSSelectorFromString("performClose:")) {
menu.addItem(item)
}
// Other close items should have the xmark to match Safari on macOS 26
for menuItem in menu.items {
if menuItem.action == NSSelectorFromString("performClose:") ||
menuItem.action == NSSelectorFromString("performCloseOtherTabs:") {
menuItem.setImageIfDesired(systemSymbolName: "xmark")
}
}
}
private func isTabContextMenu(_ menu: NSMenu) -> Bool {
guard NSApp.keyWindow === self else { return false }
// These are the target selectors, at least for macOS 26.
let tabContextSelectors: Set<String> = [
"performClose:",
"performCloseOtherTabs:",
"moveTabToNewWindow:",
"toggleTabOverview:"
]
let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) })
return !selectorNames.isDisjoint(with: tabContextSelectors)
}
// MARK: Tab Key Equivalents
var keyEquivalent: String? = nil {
@ -419,6 +480,7 @@ class TerminalWindow: NSWindow {
// have no effect if the window is not visible. Ultimately, we'll have this called
// at some point when a surface becomes focused.
guard isVisible else { return }
defer { updateColorSchemeForSurfaceTree() }
// Basic properties
appearance = surfaceConfig.windowAppearance
@ -481,6 +543,10 @@ class TerminalWindow: NSWindow {
return derivedConfig.backgroundColor.withAlphaComponent(alpha)
}
func updateColorSchemeForSurfaceTree() {
terminalController?.updateColorSchemeForSurfaceTree()
}
private func setInitialWindowPosition(x: Int16?, y: Int16?) {
// If we don't have an X/Y then we try to use the previously saved window pos.
guard let x, let y else {
@ -513,6 +579,12 @@ class TerminalWindow: NSWindow {
standardWindowButton(.zoomButton)?.isHidden = true
}
deinit {
if let observer = tabMenuObserver {
NotificationCenter.default.removeObserver(observer)
}
}
// MARK: Config
struct DerivedConfig {

View File

@ -67,6 +67,38 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
viewModel.isMainWindow = false
}
/// On our Tahoe titlebar tabs, we need to fix up right click events because they don't work
/// naturally due to whatever mess we made.
override func sendEvent(_ event: NSEvent) {
guard viewModel.hasTabBar else {
super.sendEvent(event)
return
}
let isRightClick =
event.type == .rightMouseDown ||
(event.type == .otherMouseDown && event.buttonNumber == 2) ||
(event.type == .leftMouseDown && event.modifierFlags.contains(.control))
guard isRightClick else {
super.sendEvent(event)
return
}
guard let tabBarView = findTabBar() else {
super.sendEvent(event)
return
}
let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil)
guard tabBarView.bounds.contains(locationInTabBar) else {
super.sendEvent(event)
return
}
tabBarView.rightMouseDown(with: event)
}
// This is called by macOS for native tabbing in order to add the tab bar. We hook into
// this, detect the tab bar being added, and override its behavior.
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {

View File

@ -861,6 +861,13 @@ extension Ghostty {
)
return
case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT:
NotificationCenter.default.post(
name: .ghosttyCloseTabsOnTheRight,
object: surfaceView
)
return
default:
assertionFailure()
}

View File

@ -380,6 +380,9 @@ extension Notification.Name {
/// Close other tabs
static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs")
/// Close tabs to the right of the focused tab
static let ghosttyCloseTabsOnTheRight = Notification.Name("com.mitchellh.ghostty.closeTabsOnTheRight")
/// Close window
static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow")

View File

@ -34,10 +34,15 @@ class SurfaceScrollView: NSView {
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = false
scrollView.usesPredominantAxisScrolling = true
// Always use the overlay style. See mouseMoved for how we make
// it usable without a scroll wheel or gestures.
scrollView.scrollerStyle = .overlay
// hide default background to show blur effect properly
scrollView.drawsBackground = false
// don't let the content view clip it's subviews, to enable the
// don't let the content view clip its subviews, to enable the
// surface to draw the background behind non-overlay scrollers
// (we currently only use overlay scrollers, but might as well
// configure the views correctly in case we change our mind)
scrollView.contentView.clipsToBounds = false
// The document view is what the scrollview is actually going
@ -107,7 +112,10 @@ class SurfaceScrollView: NSView {
observers.append(NotificationCenter.default.addObserver(
forName: NSScroller.preferredScrollerStyleDidChangeNotification,
object: nil,
queue: .main
// Since this observer is used to immediately override the event
// that produced the notification, we let it run synchronously on
// the posting thread.
queue: nil
) { [weak self] _ in
self?.handleScrollerStyleChange()
})
@ -176,10 +184,10 @@ class SurfaceScrollView: NSView {
private func synchronizeAppearance() {
let scrollbarConfig = surfaceView.derivedConfig.scrollbar
scrollView.hasVerticalScroller = scrollbarConfig != .never
scrollView.verticalScroller?.controlSize = .small
let hasLightBackground = OSColor(surfaceView.derivedConfig.backgroundColor).isLightColor
// Make sure the scrollers appearance matches the surface's background color.
scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua)
updateTrackingAreas()
}
/// Positions the surface view to fill the currently visible rectangle.
@ -240,6 +248,7 @@ class SurfaceScrollView: NSView {
/// Handles scrollbar style changes
private func handleScrollerStyleChange() {
scrollView.scrollerStyle = .overlay
synchronizeCoreSurface()
}
@ -350,4 +359,32 @@ class SurfaceScrollView: NSView {
}
return contentHeight
}
// MARK: Mouse events
override func mouseMoved(with: NSEvent) {
// When the OS preferred style is .legacy, the user should be able to
// click and drag the scroller without using scroll wheels or gestures,
// so we flash it when the mouse is moved over the scrollbar area.
guard NSScroller.preferredScrollerStyle == .legacy else { return }
scrollView.flashScrollers()
}
override func updateTrackingAreas() {
// To update our tracking area we just recreate it all.
trackingAreas.forEach { removeTrackingArea($0) }
super.updateTrackingAreas()
// Our tracking area is the scroller frame
guard let scroller = scrollView.verticalScroller else { return }
addTrackingArea(NSTrackingArea(
rect: convert(scroller.bounds, from: scroller),
options: [
.mouseMoved,
.activeInKeyWindow,
],
owner: self,
userInfo: nil))
}
}

View File

@ -369,26 +369,6 @@ extension Ghostty {
// Setup our tracking area so we get mouse moved events
updateTrackingAreas()
// Observe our appearance so we can report the correct value to libghostty.
// This is the best way I know of to get appearance change notifications.
self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in
guard let appearance = change.newValue else { return }
guard let surface = view.surface else { return }
let scheme: ghostty_color_scheme_e
switch (appearance.name) {
case .aqua, .vibrantLight:
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
case .darkAqua, .vibrantDark:
scheme = GHOSTTY_COLOR_SCHEME_DARK
default:
return
}
ghostty_surface_set_color_scheme(surface, scheme)
}
// The UTTypes that can be dragged onto this view.
registerForDraggedTypes(Array(Self.dropTypes))
}

View File

@ -0,0 +1,29 @@
import AppKit
extension NSMenu {
/// Inserts a menu item after an existing item with the specified action selector.
///
/// If an item with the same identifier already exists, it is removed first to avoid duplicates.
/// This is useful when menus are cached and reused across different targets.
///
/// - Parameters:
/// - item: The menu item to insert.
/// - action: The action selector to search for. The new item will be inserted after the first
/// item with this action.
/// - Returns: `true` if the item was inserted after the specified action, `false` if the action
/// was not found and the item was not inserted.
@discardableResult
func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool {
if let identifier = item.identifier,
let existing = items.first(where: { $0.identifier == identifier }) {
removeItem(existing)
}
guard let idx = items.firstIndex(where: { $0.action == action }) else {
return false
}
insertItem(item, at: idx + 1)
return true
}
}

View File

@ -9,7 +9,7 @@ for any localization that they may add.
## GTK
In the GTK app runtime, translable strings are mainly sourced from Blueprint
In the GTK app runtime, translatable strings are mainly sourced from Blueprint
files (located under `src/apprt/gtk/ui`). Blueprints have a native syntax for
translatable strings, which look like this:

View File

@ -5299,6 +5299,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
switch (v) {
.this => .this,
.other => .other,
.right => .right,
},
),

View File

@ -767,6 +767,8 @@ pub const CloseTabMode = enum(c_int) {
this,
/// Close all other tabs.
other,
/// Close all tabs to the right of the current tab.
right,
};
pub const CommandFinished = struct {

View File

@ -1583,7 +1583,7 @@ pub const Application = extern struct {
.dark;
log.debug("style manager changed scheme={}", .{scheme});
const priv = self.private();
const priv: *Private = self.private();
const core_app = priv.core_app;
core_app.colorSchemeEvent(self.rt(), scheme) catch |err| {
log.warn("error updating app color scheme err={}", .{err});
@ -1596,6 +1596,26 @@ pub const Application = extern struct {
);
};
}
if (gtk_version.atLeast(4, 20, 0)) {
const gtk_scheme: gtk.InterfaceColorScheme = switch (scheme) {
.light => gtk.InterfaceColorScheme.light,
.dark => gtk.InterfaceColorScheme.dark,
};
var value = gobject.ext.Value.newFrom(gtk_scheme);
gobject.Object.setProperty(
priv.css_provider.as(gobject.Object),
"prefers-color-scheme",
&value,
);
for (priv.custom_css_providers.items) |css_provider| {
gobject.Object.setProperty(
css_provider.as(gobject.Object),
"prefers-color-scheme",
&value,
);
}
}
}
fn handleReloadConfig(

View File

@ -347,6 +347,7 @@ pub const Tab = extern struct {
switch (mode) {
.this => tab_view.closePage(page),
.other => tab_view.closeOtherPages(page),
.right => tab_view.closePagesAfter(page),
}
}

View File

@ -218,6 +218,22 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config {
try std.SemanticVersion.parse(v)
else version: {
const app_version = try std.SemanticVersion.parse(appVersion);
// Detect if ghostty is being built as a dependency by checking if the
// build root has our marker. When used as a dependency, we skip git
// detection entirely to avoid reading the downstream project's git state.
const is_dependency = !@hasDecl(
@import("root"),
"_ghostty_build_root",
);
if (is_dependency) {
break :version .{
.major = app_version.major,
.minor = app_version.minor,
.patch = app_version.patch,
};
}
// If no explicit version is given, we try to detect it from git.
const vsn = GitVersion.detect(b) catch |err| switch (err) {
// If Git isn't available we just make an unknown dev version.

View File

@ -719,6 +719,9 @@ pub fn addSimd(
}
// Highway
if (b.systemIntegrationOption("highway", .{ .default = false })) {
m.linkSystemLibrary("libhwy", dynamic_link_opts);
} else {
if (b.lazyDependency("highway", .{
.target = target,
.optimize = optimize,
@ -729,6 +732,7 @@ pub fn addSimd(
highway_dep.artifact("highway").getEmittedBin(),
);
}
}
// utfcpp - This is used as a dependency on our hand-written C++ code
if (b.lazyDependency("utfcpp", .{
@ -746,6 +750,7 @@ pub fn addSimd(
m.addIncludePath(b.path("src"));
{
// From hwy/detect_targets.h
const HWY_AVX10_2: c_int = 1 << 3;
const HWY_AVX3_SPR: c_int = 1 << 4;
const HWY_AVX3_ZEN4: c_int = 1 << 6;
const HWY_AVX3_DL: c_int = 1 << 7;
@ -756,7 +761,7 @@ pub fn addSimd(
// The performance difference between AVX2 and AVX512 is not
// significant for our use case and AVX512 is very rare on consumer
// hardware anyways.
const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3;
const HWY_DISABLED_TARGETS: c_int = HWY_AVX10_2 | HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3;
m.addCSourceFiles(.{
.files = &.{

View File

@ -600,9 +600,8 @@ pub const Action = union(enum) {
/// of the `confirm-close-surface` configuration setting.
close_surface,
/// Close the current tab and all splits therein _or_ close all tabs and
/// splits thein of tabs _other_ than the current tab, depending on the
/// mode.
/// Close the current tab and all splits therein, close all other tabs, or
/// close every tab to the right of the current one depending on the mode.
///
/// If the mode is not specified, defaults to closing the current tab.
///
@ -1005,6 +1004,7 @@ pub const Action = union(enum) {
pub const CloseTabMode = enum {
this,
other,
right,
pub const default: CloseTabMode = .this;
};

View File

@ -538,6 +538,11 @@ fn actionCommands(action: Action.Key) []const Command {
.title = "Close Other Tabs",
.description = "Close all tabs in this window except the current one.",
},
.{
.action = .{ .close_tab = .right },
.title = "Close Tabs to the Right",
.description = "Close all tabs to the right of the current one.",
},
},
.close_window => comptime &.{.{

View File

@ -1,4 +1,4 @@
/// Generates bytes.
//! Generates bytes.
const Bytes = @This();
const std = @import("std");
@ -7,9 +7,7 @@ const Generator = @import("Generator.zig");
/// Random number generator.
rand: std.Random,
/// The minimum and maximum length of the generated bytes. The maximum
/// length will be capped to the length of the buffer passed in if the
/// buffer length is smaller.
/// The minimum and maximum length of the generated bytes.
min_len: usize = 1,
max_len: usize = std.math.maxInt(usize),
@ -18,23 +16,79 @@ max_len: usize = std.math.maxInt(usize),
/// side effect of the generator, not an intended use case.
alphabet: ?[]const u8 = null,
/// Predefined alphabets.
pub const Alphabet = struct {
pub const ascii = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~";
};
/// Generate an alphabet given a function that returns true/false for a
/// given byte.
pub fn generateAlphabet(comptime func: fn (u8) bool) []const u8 {
@setEvalBranchQuota(3000);
var count = 0;
for (0..256) |c| {
if (func(c)) count += 1;
}
var alphabet: [count]u8 = undefined;
var i = 0;
for (0..256) |c| {
if (func(c)) {
alphabet[i] = c;
i += 1;
}
}
const result = alphabet;
return &result;
}
pub fn generator(self: *Bytes) Generator {
return .init(self, next);
}
pub fn next(self: *Bytes, writer: *std.Io.Writer, max_len: usize) Generator.Error!void {
std.debug.assert(max_len >= 1);
const len = @min(
self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len),
max_len,
);
/// Return a copy of the Bytes, but with a new alphabet.
pub fn newAlphabet(self: *const Bytes, new_alphabet: ?[]const u8) Bytes {
return .{
.rand = self.rand,
.alphabet = new_alphabet,
.min_len = self.min_len,
.max_len = self.max_len,
};
}
/// Return a copy of the Bytes, but with a new min_len. The new min
/// len cannot be more than the previous max_len.
pub fn atLeast(self: *const Bytes, new_min_len: usize) Bytes {
return .{
.rand = self.rand,
.alphabet = self.alphabet,
.min_len = @min(self.max_len, new_min_len),
.max_len = self.max_len,
};
}
/// Return a copy of the Bytes, but with a new max_len. The new max_len cannot
/// be more the previous max_len.
pub fn atMost(self: *const Bytes, new_max_len: usize) Bytes {
return .{
.rand = self.rand,
.alphabet = self.alphabet,
.min_len = @min(self.min_len, @min(self.max_len, new_max_len)),
.max_len = @min(self.max_len, new_max_len),
};
}
pub fn next(self: *const Bytes, writer: *std.Io.Writer, max_len: usize) std.Io.Writer.Error!void {
_ = try self.atMost(max_len).write(writer);
}
pub fn format(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!void {
_ = try self.write(writer);
}
/// Write some random data and return the number of bytes written.
pub fn write(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!usize {
std.debug.assert(self.min_len >= 1);
std.debug.assert(self.max_len >= self.min_len);
const len = self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len);
var buf: [8]u8 = undefined;
var remaining = len;
while (remaining > 0) {
const data = buf[0..@min(remaining, buf.len)];
@ -45,6 +99,8 @@ pub fn next(self: *Bytes, writer: *std.Io.Writer, max_len: usize) Generator.Erro
try writer.writeAll(data);
remaining -= data.len;
}
return len;
}
test "bytes" {
@ -52,9 +108,11 @@ test "bytes" {
var prng = std.Random.DefaultPrng.init(0);
var buf: [256]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var v: Bytes = .{ .rand = prng.random() };
v.min_len = buf.len;
v.max_len = buf.len;
var v: Bytes = .{
.rand = prng.random(),
.min_len = buf.len,
.max_len = buf.len,
};
const gen = v.generator();
try gen.next(&writer, buf.len);
try testing.expectEqual(buf.len, writer.buffered().len);

View File

@ -35,19 +35,26 @@ p_valid: f64 = 1.0,
p_valid_kind: std.enums.EnumArray(ValidKind, f64) = .initFill(1.0),
p_invalid_kind: std.enums.EnumArray(InvalidKind, f64) = .initFill(1.0),
/// The alphabet for random bytes (omitting 0x1B and 0x07).
const bytes_alphabet: []const u8 = alphabet: {
var alphabet: [256]u8 = undefined;
for (0..alphabet.len) |i| {
if (i == 0x1B or i == 0x07) {
alphabet[i] = @intCast(i + 1);
} else {
alphabet[i] = @intCast(i);
}
}
const result = alphabet;
break :alphabet &result;
};
fn checkKvAlphabet(c: u8) bool {
return switch (c) {
std.ascii.control_code.esc, std.ascii.control_code.bel, ';', '=' => false,
else => std.ascii.isPrint(c),
};
}
/// The alphabet for random bytes in OSC key/value pairs (omitting 0x1B,
/// 0x07, ';', '=').
pub const kv_alphabet = Bytes.generateAlphabet(checkKvAlphabet);
fn checkOscAlphabet(c: u8) bool {
return switch (c) {
std.ascii.control_code.esc, std.ascii.control_code.bel => false,
else => true,
};
}
/// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07).
pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet);
pub fn generator(self: *Osc) Generator {
return .init(self, next);
@ -99,35 +106,43 @@ fn nextUnwrapped(self: *Osc, writer: *std.Io.Writer, max_len: usize) Generator.E
fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKind, max_len: usize) Generator.Error!void {
switch (k) {
.change_window_title => {
try writer.writeAll("0;"); // Set window title
var bytes_gen = self.bytes();
try bytes_gen.next(writer, max_len - 2);
.change_window_title => change_window_title: {
if (max_len < 3) break :change_window_title;
try writer.print("0;{f}", .{self.bytes().atMost(max_len - 3)}); // Set window title
},
.prompt_start => {
.prompt_start => prompt_start: {
if (max_len < 4) break :prompt_start;
var remaining = max_len;
try writer.writeAll("133;A"); // Start prompt
remaining -= 4;
// aid
if (self.rand.boolean()) {
var bytes_gen = self.bytes();
bytes_gen.max_len = 16;
if (self.rand.boolean()) aid: {
if (remaining < 6) break :aid;
try writer.writeAll(";aid=");
try bytes_gen.next(writer, max_len);
remaining -= 5;
remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(16, remaining)).write(writer);
}
// redraw
if (self.rand.boolean()) {
if (self.rand.boolean()) redraw: {
if (remaining < 9) break :redraw;
try writer.writeAll(";redraw=");
if (self.rand.boolean()) {
try writer.writeAll("1");
} else {
try writer.writeAll("0");
}
remaining -= 9;
}
},
.prompt_end => try writer.writeAll("133;B"), // End prompt
.prompt_end => prompt_end: {
if (max_len < 4) break :prompt_end;
try writer.writeAll("133;B"); // End prompt
},
}
}
@ -139,14 +154,11 @@ fn nextUnwrappedInvalidExact(
) Generator.Error!void {
switch (k) {
.random => {
var bytes_gen = self.bytes();
try bytes_gen.next(writer, max_len);
try self.bytes().atMost(max_len).format(writer);
},
.good_prefix => {
try writer.writeAll("133;");
var bytes_gen = self.bytes();
try bytes_gen.next(writer, max_len - 4);
try writer.print("133;{f}", .{self.bytes().atMost(max_len - 4)});
},
}
}
@ -154,7 +166,7 @@ fn nextUnwrappedInvalidExact(
fn bytes(self: *const Osc) Bytes {
return .{
.rand = self.rand,
.alphabet = bytes_alphabet,
.alphabet = osc_alphabet,
};
}

View File

@ -3,12 +3,21 @@ const Ascii = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const synthetic = @import("../main.zig");
const Bytes = @import("../Bytes.zig");
const log = std.log.scoped(.@"terminal-stream-bench");
pub const Options = struct {};
fn checkAsciiAlphabet(c: u8) bool {
return switch (c) {
' ' => false,
else => std.ascii.isPrint(c),
};
}
pub const ascii = Bytes.generateAlphabet(checkAsciiAlphabet);
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
@ -23,12 +32,10 @@ pub fn destroy(self: *Ascii, alloc: Allocator) void {
alloc.destroy(self);
}
pub fn run(self: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void {
_ = self;
var gen: synthetic.Bytes = .{
pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void {
var gen: Bytes = .{
.rand = rand,
.alphabet = synthetic.Bytes.Alphabet.ascii,
.alphabet = ascii,
};
while (true) {

View File

@ -6,6 +6,7 @@ pub const output = @import("tmux/output.zig");
pub const ControlParser = control.Parser;
pub const ControlNotification = control.Notification;
pub const Layout = layout.Layout;
pub const Viewer = @import("tmux/viewer.zig").Viewer;
test {
@import("std").testing.refAllDecls(@This());

View File

@ -531,6 +531,30 @@ pub const Notification = union(enum) {
session_id: usize,
name: []const u8,
},
pub fn format(self: Notification, writer: *std.Io.Writer) !void {
const T = Notification;
const info = @typeInfo(T).@"union";
try writer.writeAll(@typeName(T));
if (info.tag_type) |TagType| {
try writer.writeAll("{ .");
try writer.writeAll(@tagName(@as(TagType, self)));
try writer.writeAll(" = ");
inline for (info.fields) |u_field| {
if (self == @field(TagType, u_field.name)) {
const value = @field(self, u_field.name);
switch (u_field.type) {
[]const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}),
else => try writer.print("{any}", .{value}),
}
}
}
try writer.writeAll(" }");
}
}
};
test "tmux begin/end empty" {

View File

@ -36,6 +36,36 @@ pub fn parseFormatStruct(
return result;
}
pub fn comptimeFormat(
comptime vars: []const Variable,
comptime delimiter: u8,
) []const u8 {
comptime {
@setEvalBranchQuota(50000);
var counter: std.Io.Writer.Discarding = .init(&.{});
try format(&counter.writer, vars, delimiter);
var buf: [counter.count]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try format(&writer, vars, delimiter);
const final = buf;
return final[0..writer.end];
}
}
/// Format a set of variables into the proper format string for tmux
/// that we can handle with `parseFormatStruct`.
pub fn format(
writer: *std.Io.Writer,
vars: []const Variable,
delimiter: u8,
) std.Io.Writer.Error!void {
for (vars, 0..) |variable, i| {
if (i != 0) try writer.writeByte(delimiter);
try writer.print("#{{{t}}}", .{variable});
}
}
/// Returns a struct type that contains fields for each of the given
/// format variables. This can be used with `parseFormatStruct` to
/// parse an output string into a format struct.
@ -65,16 +95,109 @@ pub fn FormatStruct(comptime vars: []const Variable) type {
/// a subset of them here that are relevant to the use case of implementing
/// control mode for terminal emulators.
pub const Variable = enum {
/// 1 if pane is in alternate screen.
alternate_on,
/// Saved cursor X in alternate screen.
alternate_saved_x,
/// Saved cursor Y in alternate screen.
alternate_saved_y,
/// 1 if bracketed paste mode is enabled.
bracketed_paste,
/// 1 if the cursor is blinking.
cursor_blinking,
/// Cursor colour in pane. Possible formats:
/// - Named colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`,
/// `cyan`, `white`, `default`, `terminal`, or bright variants.
/// - 256 colors: `colour<N>` where N is 0-255 (e.g., `colour100`).
/// - RGB hex: `#RRGGBB` (e.g., `#ff0000`).
/// - Empty string if unset.
cursor_colour,
/// Pane cursor flag.
cursor_flag,
/// Cursor shape in pane. Possible values: `block`, `underline`, `bar`,
/// or `default`.
cursor_shape,
/// Cursor X position in pane.
cursor_x,
/// Cursor Y position in pane.
cursor_y,
/// 1 if focus reporting is enabled.
focus_flag,
/// Pane insert flag.
insert_flag,
/// Pane keypad cursor flag.
keypad_cursor_flag,
/// Pane keypad flag.
keypad_flag,
/// Pane mouse all flag.
mouse_all_flag,
/// Pane mouse any flag.
mouse_any_flag,
/// Pane mouse button flag.
mouse_button_flag,
/// Pane mouse SGR flag.
mouse_sgr_flag,
/// Pane mouse standard flag.
mouse_standard_flag,
/// Pane mouse UTF-8 flag.
mouse_utf8_flag,
/// Pane origin flag.
origin_flag,
/// Unique pane ID prefixed with `%` (e.g., `%0`, `%42`).
pane_id,
/// Pane tab positions as a comma-separated list of 0-indexed column
/// numbers (e.g., `8,16,24,32`). Empty string if no tabs are set.
pane_tabs,
/// Bottom of scroll region in pane.
scroll_region_lower,
/// Top of scroll region in pane.
scroll_region_upper,
/// Unique session ID prefixed with `$` (e.g., `$0`, `$42`).
session_id,
/// Server version (e.g., `3.5a`).
version,
/// Unique window ID prefixed with `@` (e.g., `@0`, `@42`).
window_id,
/// Width of window.
window_width,
/// Height of window.
window_height,
/// Window layout description, ignoring zoomed window panes. Format is
/// `<checksum>,<layout>` where checksum is a 4-digit hex CRC16 and layout
/// encodes pane dimensions as `WxH,X,Y[,ID]` with `{...}` for horizontal
/// splits and `[...]` for vertical splits.
window_layout,
/// Pane wrap flag.
wrap_flag,
/// Parse the given string value into the appropriate resulting
/// type for this variable.
pub fn parse(comptime self: Variable, value: []const u8) !Type(self) {
return switch (self) {
.alternate_on,
.bracketed_paste,
.cursor_blinking,
.cursor_flag,
.focus_flag,
.insert_flag,
.keypad_cursor_flag,
.keypad_flag,
.mouse_all_flag,
.mouse_any_flag,
.mouse_button_flag,
.mouse_sgr_flag,
.mouse_standard_flag,
.mouse_utf8_flag,
.origin_flag,
.wrap_flag,
=> std.mem.eql(u8, value, "1"),
.alternate_saved_x,
.alternate_saved_y,
.cursor_x,
.cursor_y,
.scroll_region_lower,
.scroll_region_upper,
=> try std.fmt.parseInt(usize, value, 10),
.session_id => if (value.len >= 2 and value[0] == '$')
try std.fmt.parseInt(usize, value[1..], 10)
else
@ -83,24 +206,107 @@ pub const Variable = enum {
try std.fmt.parseInt(usize, value[1..], 10)
else
return error.FormatError,
.pane_id => if (value.len >= 2 and value[0] == '%')
try std.fmt.parseInt(usize, value[1..], 10)
else
return error.FormatError,
.window_width => try std.fmt.parseInt(usize, value, 10),
.window_height => try std.fmt.parseInt(usize, value, 10),
.window_layout => value,
.cursor_colour,
.cursor_shape,
.pane_tabs,
.version,
.window_layout,
=> value,
};
}
/// The type of the parsed value for this variable type.
pub fn Type(comptime self: Variable) type {
return switch (self) {
.session_id => usize,
.window_id => usize,
.window_width => usize,
.window_height => usize,
.window_layout => []const u8,
.alternate_on,
.bracketed_paste,
.cursor_blinking,
.cursor_flag,
.focus_flag,
.insert_flag,
.keypad_cursor_flag,
.keypad_flag,
.mouse_all_flag,
.mouse_any_flag,
.mouse_button_flag,
.mouse_sgr_flag,
.mouse_standard_flag,
.mouse_utf8_flag,
.origin_flag,
.wrap_flag,
=> bool,
.alternate_saved_x,
.alternate_saved_y,
.cursor_x,
.cursor_y,
.scroll_region_lower,
.scroll_region_upper,
.session_id,
.window_id,
.pane_id,
.window_width,
.window_height,
=> usize,
.cursor_colour,
.cursor_shape,
.pane_tabs,
.version,
.window_layout,
=> []const u8,
};
}
};
test "parse alternate_on" {
try testing.expectEqual(true, try Variable.parse(.alternate_on, "1"));
try testing.expectEqual(false, try Variable.parse(.alternate_on, "0"));
try testing.expectEqual(false, try Variable.parse(.alternate_on, ""));
try testing.expectEqual(false, try Variable.parse(.alternate_on, "true"));
try testing.expectEqual(false, try Variable.parse(.alternate_on, "yes"));
}
test "parse alternate_saved_x" {
try testing.expectEqual(0, try Variable.parse(.alternate_saved_x, "0"));
try testing.expectEqual(42, try Variable.parse(.alternate_saved_x, "42"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_x, "abc"));
}
test "parse alternate_saved_y" {
try testing.expectEqual(0, try Variable.parse(.alternate_saved_y, "0"));
try testing.expectEqual(42, try Variable.parse(.alternate_saved_y, "42"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_y, "abc"));
}
test "parse cursor_x" {
try testing.expectEqual(0, try Variable.parse(.cursor_x, "0"));
try testing.expectEqual(79, try Variable.parse(.cursor_x, "79"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_x, "abc"));
}
test "parse cursor_y" {
try testing.expectEqual(0, try Variable.parse(.cursor_y, "0"));
try testing.expectEqual(23, try Variable.parse(.cursor_y, "23"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_y, "abc"));
}
test "parse scroll_region_upper" {
try testing.expectEqual(0, try Variable.parse(.scroll_region_upper, "0"));
try testing.expectEqual(5, try Variable.parse(.scroll_region_upper, "5"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_upper, "abc"));
}
test "parse scroll_region_lower" {
try testing.expectEqual(0, try Variable.parse(.scroll_region_lower, "0"));
try testing.expectEqual(23, try Variable.parse(.scroll_region_lower, "23"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_lower, "abc"));
}
test "parse session id" {
try testing.expectEqual(42, try Variable.parse(.session_id, "$42"));
try testing.expectEqual(0, try Variable.parse(.session_id, "$0"));
@ -146,6 +352,147 @@ test "parse window layout" {
try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)"));
}
test "parse cursor_flag" {
try testing.expectEqual(true, try Variable.parse(.cursor_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.cursor_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.cursor_flag, ""));
try testing.expectEqual(false, try Variable.parse(.cursor_flag, "true"));
}
test "parse insert_flag" {
try testing.expectEqual(true, try Variable.parse(.insert_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.insert_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.insert_flag, ""));
try testing.expectEqual(false, try Variable.parse(.insert_flag, "true"));
}
test "parse keypad_cursor_flag" {
try testing.expectEqual(true, try Variable.parse(.keypad_cursor_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, ""));
try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "true"));
}
test "parse keypad_flag" {
try testing.expectEqual(true, try Variable.parse(.keypad_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.keypad_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.keypad_flag, ""));
try testing.expectEqual(false, try Variable.parse(.keypad_flag, "true"));
}
test "parse mouse_any_flag" {
try testing.expectEqual(true, try Variable.parse(.mouse_any_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, ""));
try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "true"));
}
test "parse mouse_button_flag" {
try testing.expectEqual(true, try Variable.parse(.mouse_button_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, ""));
try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "true"));
}
test "parse mouse_sgr_flag" {
try testing.expectEqual(true, try Variable.parse(.mouse_sgr_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, ""));
try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "true"));
}
test "parse mouse_standard_flag" {
try testing.expectEqual(true, try Variable.parse(.mouse_standard_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, ""));
try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "true"));
}
test "parse mouse_utf8_flag" {
try testing.expectEqual(true, try Variable.parse(.mouse_utf8_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, ""));
try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "true"));
}
test "parse wrap_flag" {
try testing.expectEqual(true, try Variable.parse(.wrap_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.wrap_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.wrap_flag, ""));
try testing.expectEqual(false, try Variable.parse(.wrap_flag, "true"));
}
test "parse bracketed_paste" {
try testing.expectEqual(true, try Variable.parse(.bracketed_paste, "1"));
try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "0"));
try testing.expectEqual(false, try Variable.parse(.bracketed_paste, ""));
try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "true"));
}
test "parse cursor_blinking" {
try testing.expectEqual(true, try Variable.parse(.cursor_blinking, "1"));
try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "0"));
try testing.expectEqual(false, try Variable.parse(.cursor_blinking, ""));
try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "true"));
}
test "parse focus_flag" {
try testing.expectEqual(true, try Variable.parse(.focus_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.focus_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.focus_flag, ""));
try testing.expectEqual(false, try Variable.parse(.focus_flag, "true"));
}
test "parse mouse_all_flag" {
try testing.expectEqual(true, try Variable.parse(.mouse_all_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, ""));
try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "true"));
}
test "parse origin_flag" {
try testing.expectEqual(true, try Variable.parse(.origin_flag, "1"));
try testing.expectEqual(false, try Variable.parse(.origin_flag, "0"));
try testing.expectEqual(false, try Variable.parse(.origin_flag, ""));
try testing.expectEqual(false, try Variable.parse(.origin_flag, "true"));
}
test "parse pane_id" {
try testing.expectEqual(42, try Variable.parse(.pane_id, "%42"));
try testing.expectEqual(0, try Variable.parse(.pane_id, "%0"));
try testing.expectError(error.FormatError, Variable.parse(.pane_id, "0"));
try testing.expectError(error.FormatError, Variable.parse(.pane_id, "@0"));
try testing.expectError(error.FormatError, Variable.parse(.pane_id, "%"));
try testing.expectError(error.FormatError, Variable.parse(.pane_id, ""));
try testing.expectError(error.InvalidCharacter, Variable.parse(.pane_id, "%abc"));
}
test "parse cursor_colour" {
try testing.expectEqualStrings("red", try Variable.parse(.cursor_colour, "red"));
try testing.expectEqualStrings("#ff0000", try Variable.parse(.cursor_colour, "#ff0000"));
try testing.expectEqualStrings("", try Variable.parse(.cursor_colour, ""));
}
test "parse cursor_shape" {
try testing.expectEqualStrings("block", try Variable.parse(.cursor_shape, "block"));
try testing.expectEqualStrings("underline", try Variable.parse(.cursor_shape, "underline"));
try testing.expectEqualStrings("bar", try Variable.parse(.cursor_shape, "bar"));
try testing.expectEqualStrings("", try Variable.parse(.cursor_shape, ""));
}
test "parse pane_tabs" {
try testing.expectEqualStrings("0,8,16,24", try Variable.parse(.pane_tabs, "0,8,16,24"));
try testing.expectEqualStrings("", try Variable.parse(.pane_tabs, ""));
try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0"));
}
test "parse version" {
try testing.expectEqualStrings("3.5a", try Variable.parse(.version, "3.5a"));
try testing.expectEqualStrings("3.5", try Variable.parse(.version, "3.5"));
try testing.expectEqualStrings("next-3.5", try Variable.parse(.version, "next-3.5"));
try testing.expectEqualStrings("", try Variable.parse(.version, ""));
}
test "parseFormatStruct single field" {
const T = FormatStruct(&.{.session_id});
const result = try parseFormatStruct(T, "$42", ' ');
@ -203,3 +550,41 @@ test "parseFormatStruct with empty layout field" {
try testing.expectEqual(1, result.session_id);
try testing.expectEqualStrings("", result.window_layout);
}
fn testFormat(
comptime vars: []const Variable,
comptime delimiter: u8,
comptime expected: []const u8,
) !void {
const comptime_result = comptime comptimeFormat(vars, delimiter);
try testing.expectEqualStrings(expected, comptime_result);
var buf: [256]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try format(&writer, vars, delimiter);
try testing.expectEqualStrings(expected, buf[0..writer.end]);
}
test "format single variable" {
try testFormat(&.{.session_id}, ' ', "#{session_id}");
}
test "format multiple variables" {
try testFormat(&.{ .session_id, .window_id, .window_width, .window_height }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height}");
}
test "format with comma delimiter" {
try testFormat(&.{ .window_id, .window_layout }, ',', "#{window_id},#{window_layout}");
}
test "format with tab delimiter" {
try testFormat(&.{ .window_width, .window_height }, '\t', "#{window_width}\t#{window_height}");
}
test "format empty variables" {
try testFormat(&.{}, ' ', "");
}
test "format all variables" {
try testFormat(&.{ .session_id, .window_id, .window_width, .window_height, .window_layout }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height} #{window_layout}");
}

2292
src/terminal/tmux/viewer.zig Normal file

File diff suppressed because it is too large Load Diff

View File

@ -70,6 +70,9 @@ pub const StreamHandler = struct {
/// such as XTGETTCAP.
dcs: terminal.dcs.Handler = .{},
/// The tmux control mode viewer state.
tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {},
/// This is set to true when a message was written to the termio
/// mailbox. This can be used by callers to determine if they need
/// to wake up the termio thread.
@ -81,9 +84,18 @@ pub const StreamHandler = struct {
pub const Stream = terminal.Stream(StreamHandler);
/// True if we have tmux control mode built in.
pub const tmux_enabled = terminal.options.tmux_control_mode;
pub fn deinit(self: *StreamHandler) void {
self.apc.deinit();
self.dcs.deinit();
if (comptime tmux_enabled) tmux: {
const viewer = self.tmux_viewer orelse break :tmux;
viewer.deinit();
self.alloc.destroy(viewer);
self.tmux_viewer = null;
}
}
/// This queues a render operation with the renderer thread. The render
@ -368,9 +380,73 @@ pub const StreamHandler = struct {
fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void {
// log.warn("DCS command: {}", .{cmd});
switch (cmd.*) {
.tmux => |tmux| {
// TODO: process it
log.warn("tmux control mode event unimplemented cmd={}", .{tmux});
.tmux => |tmux| tmux: {
// If tmux control mode is disabled at the build level,
// then this whole block shouldn't be analyzed.
if (comptime !tmux_enabled) break :tmux;
log.info("tmux control mode event cmd={f}", .{tmux});
switch (tmux) {
.enter => {
// Setup our viewer state
assert(self.tmux_viewer == null);
const viewer = try self.alloc.create(terminal.tmux.Viewer);
errdefer self.alloc.destroy(viewer);
viewer.* = try .init(self.alloc);
errdefer viewer.deinit();
self.tmux_viewer = viewer;
break :tmux;
},
.exit => if (self.tmux_viewer) |viewer| {
// Free our viewer state
viewer.deinit();
self.alloc.destroy(viewer);
self.tmux_viewer = null;
break :tmux;
},
else => {},
}
assert(tmux != .enter);
assert(tmux != .exit);
const viewer = self.tmux_viewer orelse {
// This can only really happen if we failed to
// initialize the viewer on enter.
log.info(
"received tmux control mode command without viewer: {f}",
.{tmux},
);
break :tmux;
};
for (viewer.next(.{ .tmux = tmux })) |action| {
log.info("tmux viewer action={f}", .{action});
switch (action) {
.exit => {
// We ignore this because we will fully exit when
// our DCS connection ends. We may want to handle
// this in the future to notify our GUI we're
// disconnected though.
},
.command => |command| {
assert(command.len > 0);
assert(command[command.len - 1] == '\n');
self.messageWriter(try termio.Message.writeReq(
self.alloc,
command,
));
},
.windows => {
// TODO
},
}
}
},
.xtgettcap => |*gettcap| {