Merge branch 'main' into feat/quickterm-with-tab

This commit is contained in:
Soh Satoh
2025-01-25 16:41:32 +09:00
102 changed files with 3666 additions and 1179 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ test/cases/**/*.actual.png
glad.zip
/Box_test.ppm
/Box_test_diff.ppm
/ghostty.qcow2

View File

@ -77,3 +77,100 @@ pull request will be accepted with a high degree of certainty.
> **Pull requests are NOT a place to discuss feature design.** Please do
> not open a WIP pull request to discuss a feature. Instead, use a discussion
> and link to your branch.
## Nix Virtual Machines
Several Nix virtual machine definitions are provided by the project for testing
and developing Ghostty against multiple different Linux desktop environments.
Running these requires a working Nix installation, either Nix on your
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
requirements for macOS are detailed below.
VMs should only be run on your local desktop and then powered off when not in
use, which will discard any changes to the VM.
The VM definitions provide minimal software "out of the box" but additional
software can be installed by using standard Nix mechanisms like `nix run nixpkgs#<package>`.
### Linux
1. Check out the Ghostty source and change to the directory.
2. Run `nix run .#<vmtype>`. `<vmtype>` can be any of the VMs defined in the
`nix/vm` directory (without the `.nix` suffix) excluding any file prefixed
with `common` or `create`.
3. The VM will build and then launch. Depending on the speed of your system, this
can take a while, but eventually you should get a new VM window.
4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending
on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be
writable by the VM user, so be careful!
### macOS
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
blog post for more information about the Linux builder and how to tune the performance.
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
above to launch a VM.
### Custom VMs
To easily create a custom VM without modifying the Ghostty source, create a new
directory, then create a file called `flake.nix` with the following text in the
new directory.
```
{
inputs = {
nixpkgs.url = "nixpkgs/nixpkgs-unstable";
ghostty.url = "github:ghostty-org/ghostty";
};
outputs = {
nixpkgs,
ghostty,
...
}: {
nixosConfigurations.custom-vm = ghostty.create-gnome-vm {
nixpkgs = nixpkgs;
system = "x86_64-linux";
overlay = ghostty.overlays.releasefast;
# module = ./configuration.nix # also works
module = {pkgs, ...}: {
environment.systemPackages = [
pkgs.btop
];
};
};
};
}
```
The custom VM can then be run with a command like this:
```
nix run .#nixosConfigurations.custom-vm.config.system.build.vm
```
A file named `ghostty.qcow2` will be created that is used to persist any changes
made in the VM. To "reset" the VM to default delete the file and it will be
recreated the next time you run the VM.
### Contributing new VM definitions
#### VM Acceptance Criteria
We welcome the contribution of new VM definitions, as long as they meet the following criteria:
1. The should be different enough from existing VM definitions that they represent a distinct
user (and developer) experience.
2. There's a significant Ghostty user population that uses a similar environment.
3. The VMs can be built using only packages from the current stable NixOS release.
#### VM Definition Criteria
1. VMs should be as minimal as possible so that they build and launch quickly.
Additional software can be added at runtime with a command like `nix run nixpkgs#<package name>`.
2. VMs should not expose any services to the network, or run any remote access
software like SSH daemons, VNC or RDP.
3. VMs should auto-login using the "ghostty" user.

View File

@ -5,8 +5,8 @@
.dependencies = .{
// Zig libs
.libxev = .{
.url = "https://github.com/mitchellh/libxev/archive/db6a52bafadf00360e675fefa7926e8e6c0e9931.tar.gz",
.hash = "12206029de146b685739f69b10a6f08baee86b3d0a5f9a659fa2b2b66c9602078bbf",
.url = "https://github.com/mitchellh/libxev/archive/31eed4e337fed7b0149319e5cdbb62b848c24fbd.tar.gz",
.hash = "1220ebf88622c4d502dc59e71347e4d28c47e033f11b59aff774ae5787565c40999c",
},
.mach_glfw = .{
.url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz",
@ -79,8 +79,8 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/4762ad5bd6d3906e28babdc2bda8a967d63a63be.tar.gz",
.hash = "1220a263b22113273d01bd33e3c06b8119cb2f63b4e5d414a85d88e3aa95bb68a2de",
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/0e23daf59234fc892cba949562d7bf69204594bb.tar.gz",
.hash = "12204fc99743d8232e691ac22e058519bfc6ea92de4a11c6dba59b117531c847cd6a",
},
},
}

0
dist/linux/ghostty_dolphin.desktop vendored Normal file → Executable file
View File

View File

@ -31,7 +31,9 @@
zig,
...
}:
builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (builtins.map (system: let
builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (
builtins.map (
system: let
pkgs-stable = nixpkgs-stable.legacyPackages.${system};
pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
in {
@ -57,12 +59,53 @@
formatter.${system} = pkgs-stable.alejandra;
# Our supported systems are the same supported systems as the Zig binaries.
}) (builtins.attrNames zig.packages))
// {
overlays.default = final: prev: {
ghostty = self.packages.${prev.system}.default;
apps.${system} = let
runVM = (
module: let
vm = import ./nix/vm/create.nix {
inherit system module;
nixpkgs = nixpkgs-stable;
overlay = self.overlays.debug;
};
program = pkgs-stable.writeShellScript "run-ghostty-vm" ''
SHARED_DIR=$(pwd)
export SHARED_DIR
${pkgs-stable.lib.getExe vm.config.system.build.vm} "$@"
'';
in {
type = "app";
program = "${program}";
}
);
in {
wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix;
wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
x11-gnome = runVM ./nix/vm/x11-gnome.nix;
x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
x11-xfce = runVM ./nix/vm/x11-xfce.nix;
};
}
# Our supported systems are the same supported systems as the Zig binaries.
) (builtins.attrNames zig.packages)
)
// {
overlays = {
default = self.overlays.releasefast;
releasefast = final: prev: {
ghostty = self.packages.${prev.system}.ghostty-releasefast;
};
debug = final: prev: {
ghostty = self.packages.${prev.system}.ghostty-debug;
};
};
create-vm = import ./nix/vm/create.nix;
create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix;
create-gnome-vm = import ./nix/vm/create-gnome.nix;
create-plasma6-vm = import ./nix/vm/create-plasma6.nix;
create-xfce-vm = import ./nix/vm/create-xfce.nix;
};
nixConfig = {

View File

@ -159,7 +159,7 @@ typedef enum {
GHOSTTY_KEY_EQUAL,
GHOSTTY_KEY_LEFT_BRACKET, // [
GHOSTTY_KEY_RIGHT_BRACKET, // ]
GHOSTTY_KEY_BACKSLASH, // /
GHOSTTY_KEY_BACKSLASH, // \
// control
GHOSTTY_KEY_UP,
@ -565,6 +565,7 @@ typedef enum {
GHOSTTY_ACTION_CLOSE_TAB,
GHOSTTY_ACTION_NEW_SPLIT,
GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
GHOSTTY_ACTION_TOGGLE_MAXIMIZE,
GHOSTTY_ACTION_TOGGLE_FULLSCREEN,
GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,

View File

@ -69,6 +69,8 @@
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; };
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; };
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; };
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; };
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; };
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; };
@ -167,6 +169,8 @@
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = "<group>"; };
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = "<group>"; };
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; };
A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = "<group>"; };
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = "<group>"; };
A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = "<group>"; };
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -279,6 +283,7 @@
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */,
A5CBD0572C9F30860017A1AE /* Cursor.swift */,
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
A5A2A3C92D4445E20033CF96 /* Dock.swift */,
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
@ -286,6 +291,7 @@
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
@ -656,6 +662,7 @@
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
@ -681,6 +688,7 @@
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
A5FEB3002ABB69450068369E /* main.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,

View File

@ -27,6 +27,9 @@ class QuickTerminalController: BaseTerminalController {
// The active space when the quick terminal was last shown.
private var previousActiveSpace: size_t = 0
/// Non-nil if we have hidden dock state.
private var hiddenDock: HiddenDock? = nil
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
@ -48,6 +51,11 @@ class QuickTerminalController: BaseTerminalController {
// Setup our notifications for behaviors
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(applicationWillTerminate(_:)),
name: NSApplication.willTerminateNotification,
object: nil)
center.addObserver(
self,
selector: #selector(onToggleFullscreen),
@ -83,6 +91,9 @@ class QuickTerminalController: BaseTerminalController {
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
// Make sure we restore our hidden dock
hiddenDock = nil
}
// MARK: NSWindowController
@ -137,6 +148,17 @@ class QuickTerminalController: BaseTerminalController {
// MARK: NSWindowDelegate
override func windowDidBecomeKey(_ notification: Notification) {
super.windowDidBecomeKey(notification)
// If we're not visible we don't care to run the logic below. It only
// applies if we can be seen.
guard visible else { return }
// Re-hide the dock if we were hiding it before.
hiddenDock?.hide()
}
override func windowDidResignKey(_ notification: Notification) {
super.windowDidResignKey(notification)
@ -157,6 +179,10 @@ class QuickTerminalController: BaseTerminalController {
self.previousApp = nil
}
// Regardless of autohide, we always want to bring the dock back
// when we lose focus.
hiddenDock?.restore()
if derivedConfig.quickTerminalAutoHide {
switch derivedConfig.quickTerminalSpaceBehavior {
case .remain:
@ -277,25 +303,50 @@ class QuickTerminalController: BaseTerminalController {
// Move our window off screen to the top
position.setInitial(in: window, on: screen)
// We need to set our window level to a high value. In testing, only
// popUpMenu and above do what we want. This gets it above the menu bar
// and lets us render off screen.
window.level = .popUpMenu
// Move it to the visible position since animation requires this
DispatchQueue.main.async {
window.makeKeyAndOrderFront(nil)
}
// If our dock position would conflict with our target location then
// we autohide the dock.
if position.conflictsWithDock(on: screen) {
if (hiddenDock == nil) {
hiddenDock = .init()
}
hiddenDock?.hide()
} else {
// Ensure we don't have any hidden dock if we don't conflict.
// The deinit will restore.
hiddenDock = nil
}
// Run the animation that moves our window into the proper place and makes
// it visible.
NSAnimationContext.runAnimationGroup(
{ context in
NSAnimationContext.runAnimationGroup({ context in
context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn)
position.setFinal(in: window.animator(), on: screen)
},
completionHandler: {
}, completionHandler: {
// There is a very minor delay here so waiting at least an event loop tick
// keeps us safe from the view not being on the window.
DispatchQueue.main.async {
// If we canceled our animation in we do nothing
guard self.visible else { return }
// If we canceled our animation clean up some state.
guard self.visible else {
self.hiddenDock = nil
return
}
// After animating in, we reset the window level to a value that
// is above other windows but not as high as popUpMenu. This allows
// things like IME dropdowns to appear properly.
window.level = .floating
// Now that the window is visible, sync our appearance. This function
// requires the window is visible.
@ -359,6 +410,9 @@ class QuickTerminalController: BaseTerminalController {
}
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
// If we hid the dock then we unhide it.
hiddenDock = nil
// If the window isn't on our active space then we don't animate, we just
// hide it.
if !window.isOnActiveSpace {
@ -388,13 +442,16 @@ class QuickTerminalController: BaseTerminalController {
}
}
NSAnimationContext.runAnimationGroup(
{ context in
// We need to set our window level to a high value. In testing, only
// popUpMenu and above do what we want. This gets it above the menu bar
// and lets us render off screen.
window.level = .popUpMenu
NSAnimationContext.runAnimationGroup({ context in
context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn)
position.setInitial(in: window.animator(), on: screen)
},
completionHandler: {
}, completionHandler: {
// This causes the window to be removed from the screen list and macOS
// handles what should be focused next.
window.orderOut(self)
@ -411,19 +468,6 @@ class QuickTerminalController: BaseTerminalController {
// Some APIs such as window blur have no effect unless the window is visible.
guard window.isVisible else { return }
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden.
switch self.derivedConfig.windowColorspace {
case "display-p3":
window.colorSpace = .displayP3
case "srgb":
fallthrough
default:
window.colorSpace = .sRGB
}
// If we have window transparency then set it transparent. Otherwise set it opaque.
if self.derivedConfig.backgroundOpacity < 1 {
window.isOpaque = false
@ -462,6 +506,13 @@ class QuickTerminalController: BaseTerminalController {
// MARK: Notifications
@objc private func applicationWillTerminate(_ notification: Notification) {
// If the application is going to terminate we want to make sure we
// restore any global dock state. I think deinit should be called which
// would call this anyways but I can't be sure so I will do this too.
hiddenDock = nil
}
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
@ -497,7 +548,6 @@ class QuickTerminalController: BaseTerminalController {
let quickTerminalAnimationDuration: Double
let quickTerminalAutoHide: Bool
let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
let windowColorspace: String
let backgroundOpacity: Double
init() {
@ -505,7 +555,6 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalAnimationDuration = 0.2
self.quickTerminalAutoHide = true
self.quickTerminalSpaceBehavior = .move
self.windowColorspace = ""
self.backgroundOpacity = 1.0
}
@ -514,10 +563,38 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
self.quickTerminalAutoHide = config.quickTerminalAutoHide
self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
self.windowColorspace = config.windowColorspace
self.backgroundOpacity = config.backgroundOpacity
}
}
/// Hides the dock globally (not just NSApp). This is only used if the quick terminal is
/// in a conflicting position with the dock.
private class HiddenDock {
let previousAutoHide: Bool
private var hidden: Bool = false
init() {
previousAutoHide = Dock.autoHideEnabled
}
deinit {
restore()
}
func hide() {
guard !hidden else { return }
NSApp.acquirePresentationOption(.autoHideDock)
Dock.autoHideEnabled = true
hidden = true
}
func restore() {
guard hidden else { return }
NSApp.releasePresentationOption(.autoHideDock)
Dock.autoHideEnabled = previousAutoHide
hidden = false
}
}
}
extension Notification.Name {

View File

@ -69,7 +69,7 @@ enum QuickTerminalPosition : String {
finalSize.width = screen.frame.width
case .left, .right:
finalSize.height = screen.frame.height
finalSize.height = screen.visibleFrame.height
case .center:
finalSize.width = screen.frame.width / 2
@ -118,4 +118,22 @@ enum QuickTerminalPosition : String {
return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
}
}
func conflictsWithDock(on screen: NSScreen) -> Bool {
// Screen must have a dock for it to conflict
guard screen.hasDock else { return false }
// Get the dock orientation for this screen
guard let orientation = Dock.orientation else { return false }
// Depending on the orientation of the dock, we conflict if our quick terminal
// would potentially "hit" the dock. In the future we should probably consider
// the frame of the quick terminal.
return switch (orientation) {
case .top: self == .top || self == .left || self == .right
case .bottom: self == .bottom || self == .left || self == .right
case .left: self == .top || self == .bottom
case .right: self == .top || self == .bottom
}
}
}

View File

@ -28,23 +28,5 @@ class QuickTerminalWindow: NSPanel {
// We don't want to activate the owning app when quick terminal is triggered.
self.styleMask.insert(.nonactivatingPanel)
// We need to set our window level to a high value. In testing, only
// popUpMenu and above do what we want. This gets it above the menu bar
// and lets us render off screen.
self.level = .popUpMenu
// This plus the level above was what was needed for the animation to work,
// because it gets the window off screen properly. Plus we add some fields
// we just want the behavior of.
self.collectionBehavior = [
// We want this to be part of every space because it is a singleton.
.canJoinAllSpaces,
// We don't want to be part of command-tilde
.ignoresCycle,
// We want to show the window on another space if it is visible
.fullScreenAuxiliary]
}
}

View File

@ -452,6 +452,7 @@ class BaseTerminalController: NSWindowController,
self.alert = nil
switch (response) {
case .alertFirstButtonReturn:
alert.window.orderOut(nil)
window.close()
default:

View File

@ -22,7 +22,7 @@ class TerminalController: BaseTerminalController {
private var restorable: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
private(set) var derivedConfig: DerivedConfig
/// The notification cancellable for focused surface property changes.
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
@ -366,19 +366,6 @@ class TerminalController: BaseTerminalController {
// If window decorations are disabled, remove our title
if (!config.windowDecorations) { window.styleMask.remove(.titled) }
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden.
switch (config.windowColorspace) {
case "display-p3":
window.colorSpace = .displayP3
case "srgb":
fallthrough
default:
window.colorSpace = .sRGB
}
// If we have only a single surface (no splits) and that surface requested
// an initial size then we set it here now.
if case let .leaf(leaf) = surfaceTree {
@ -789,7 +776,7 @@ class TerminalController: BaseTerminalController {
toggleFullscreen(mode: fullscreenMode)
}
private struct DerivedConfig {
struct DerivedConfig {
let backgroundColor: Color
let macosTitlebarStyle: String

View File

@ -115,6 +115,21 @@ class TerminalWindow: NSWindow {
}
}
// We override this so that with the hidden titlebar style the titlebar
// area is not draggable.
override var contentLayoutRect: CGRect {
var rect = super.contentLayoutRect
// If we are using a hidden titlebar style, the content layout is the
// full frame making it so that it is not draggable.
if let controller = windowController as? TerminalController,
controller.derivedConfig.macosTitlebarStyle == "hidden" {
rect.origin.y = 0
rect.size.height = self.frame.height
}
return rect
}
// The window theme configuration from Ghostty. This is used to control some
// behaviors that don't look quite right in certain situations.
var windowTheme: TerminalWindowTheme?

View File

@ -132,15 +132,6 @@ extension Ghostty {
return v
}
var windowColorspace: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
let key = "window-colorspace"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" }
guard let ptr = v else { return "" }
return String(cString: ptr)
}
var windowSaveState: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
@ -174,11 +165,14 @@ extension Ghostty {
}
var windowDecorations: Bool {
guard let config = self.config else { return true }
var v = false;
let defaultValue = true
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = nil
let key = "window-decoration"
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v;
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
guard let ptr = v else { return defaultValue }
let str = String(cString: ptr)
return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue
}
var windowTheme: String? {
@ -345,7 +339,7 @@ extension Ghostty {
var backgroundBlurRadius: Int {
guard let config = self.config else { return 1 }
var v: Int = 0
let key = "background-blur-radius"
let key = "background-blur"
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v;
}
@ -563,4 +557,18 @@ extension Ghostty.Config {
}
}
}
enum WindowDecoration: String {
case none
case client
case server
case auto
func enabled() -> Bool {
switch self {
case .client, .server, .auto: return true
case .none: return false
}
}
}
}

View File

@ -205,6 +205,7 @@ extension Ghostty {
alert.beginSheetModal(for: window, completionHandler: { response in
switch (response) {
case .alertFirstButtonReturn:
alert.window.orderOut(nil)
node = nil
default:

View File

@ -92,22 +92,6 @@ extension Ghostty {
windowFocus = false
}
}
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
providers.forEach { provider in
_ = provider.loadObject(ofClass: URL.self) { url, _ in
guard let url = url else { return }
let path = Shell.escape(url.path)
DispatchQueue.main.async {
surfaceView.insertText(
path,
replacementRange: NSMakeRange(0, 0)
)
}
}
}
return true
}
#endif
// If our geo size changed then we show the resize overlay as configured.

View File

@ -1,3 +1,4 @@
import AppKit
import SwiftUI
import CoreText
import UserNotifications
@ -230,6 +231,9 @@ extension Ghostty {
ghostty_surface_set_color_scheme(surface, scheme)
}
// The UTTypes that can be dragged onto this view.
registerForDraggedTypes(Array(Self.dropTypes))
}
required init?(coder: NSCoder) {
@ -1509,3 +1513,62 @@ extension Ghostty.SurfaceView: NSMenuItemValidation {
}
}
}
// MARK: NSDraggingDestination
extension Ghostty.SurfaceView {
static let dropTypes: Set<NSPasteboard.PasteboardType> = [
.string,
.fileURL,
.URL
]
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard let types = sender.draggingPasteboard.types else { return [] }
// If the dragging object contains none of our types then we return none.
// This shouldn't happen because AppKit should guarantee that we only
// receive types we registered for but its good to check.
if Set(types).isDisjoint(with: Self.dropTypes) {
return []
}
// We use copy to get the proper icon
return .copy
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
let pb = sender.draggingPasteboard
let content: String?
if let url = pb.string(forType: .URL) {
// URLs first, they get escaped as-is.
content = Ghostty.Shell.escape(url)
} else if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL],
urls.count > 0 {
// File URLs next. They get escaped individually and then joined by a
// space if there are multiple.
content = urls
.map { Ghostty.Shell.escape($0.path) }
.joined(separator: " ")
} else if let str = pb.string(forType: .string) {
// Strings are not escaped because they may be copy/pasting a
// command they want to execute.
content = str
} else {
content = nil
}
if let content {
DispatchQueue.main.async {
self.insertText(
content,
replacementRange: NSMakeRange(0, 0)
)
}
return true
}
return false
}
}

View File

@ -0,0 +1,38 @@
import Cocoa
// Private API to get Dock location
@_silgen_name("CoreDockGetOrientationAndPinning")
func CoreDockGetOrientationAndPinning(
_ outOrientation: UnsafeMutablePointer<Int32>,
_ outPinning: UnsafeMutablePointer<Int32>)
// Private API to get the current Dock auto-hide state
@_silgen_name("CoreDockGetAutoHideEnabled")
func CoreDockGetAutoHideEnabled() -> Bool
// Toggles the Dock's auto-hide state
@_silgen_name("CoreDockSetAutoHideEnabled")
func CoreDockSetAutoHideEnabled(_ flag: Bool)
enum DockOrientation: Int {
case top = 1
case bottom = 2
case left = 3
case right = 4
}
class Dock {
/// Returns the orientation of the dock or nil if it can't be determined.
static var orientation: DockOrientation? {
var orientation: Int32 = 0
var pinning: Int32 = 0
CoreDockGetOrientationAndPinning(&orientation, &pinning)
return .init(rawValue: Int(orientation)) ?? nil
}
/// Set the dock autohide.
static var autoHideEnabled: Bool {
get { return CoreDockGetAutoHideEnabled() }
set { CoreDockSetAutoHideEnabled(newValue) }
}
}

View File

@ -307,21 +307,21 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
// MARK: Dock
private func hideDock() {
NSApp.presentationOptions.insert(.autoHideDock)
NSApp.acquirePresentationOption(.autoHideDock)
}
private func unhideDock() {
NSApp.presentationOptions.remove(.autoHideDock)
NSApp.releasePresentationOption(.autoHideDock)
}
// MARK: Menu
func hideMenu() {
NSApp.presentationOptions.insert(.autoHideMenuBar)
NSApp.acquirePresentationOption(.autoHideMenuBar)
}
func unhideMenu() {
NSApp.presentationOptions.remove(.autoHideMenuBar)
NSApp.releasePresentationOption(.autoHideMenuBar)
}
/// The state that must be saved for non-native fullscreen to exit fullscreen.

View File

@ -0,0 +1,31 @@
import Cocoa
extension NSApplication {
private static var presentationOptionCounts: [NSApplication.PresentationOptions.Element: UInt] = [:]
/// Add a presentation option to the application and main a reference count so that and equal
/// number of pops is required to disable it. This is useful so that multiple classes can affect global
/// app state without overriding others.
func acquirePresentationOption(_ option: NSApplication.PresentationOptions.Element) {
Self.presentationOptionCounts[option, default: 0] += 1
presentationOptions.insert(option)
}
/// See acquirePresentationOption
func releasePresentationOption(_ option: NSApplication.PresentationOptions.Element) {
guard let value = Self.presentationOptionCounts[option] else { return }
guard value > 0 else { return }
if (value == 1) {
presentationOptions.remove(option)
Self.presentationOptionCounts.removeValue(forKey: option)
} else {
Self.presentationOptionCounts[option] = value - 1
}
}
}
extension NSApplication.PresentationOptions.Element: @retroactive Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(rawValue)
}
}

View File

@ -9,13 +9,15 @@ extension NSPasteboard {
/// Gets the contents of the pasteboard as a string following a specific set of semantics.
/// Does these things in order:
/// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one.
/// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one and ensures the file path is properly escaped.
/// - Tries to get any string from the pasteboard.
/// If all of the above fail, returns None.
func getOpinionatedStringContents() -> String? {
if let urls = readObjects(forClasses: [NSURL.self]) as? [URL],
urls.count > 0 {
return urls.map { $0.path }.joined(separator: " ")
return urls
.map { $0.isFileURL ? Ghostty.Shell.escape($0.path) : $0.absoluteString }
.joined(separator: " ")
}
return self.string(forType: .string)

View File

@ -0,0 +1,18 @@
{...}: {
imports = [
./common.nix
];
services.xserver = {
displayManager = {
lightdm = {
enable = true;
};
};
desktopManager = {
cinnamon = {
enable = true;
};
};
};
}

136
nix/vm/common-gnome.nix Normal file
View File

@ -0,0 +1,136 @@
{
config,
lib,
pkgs,
...
}: {
imports = [
./common.nix
];
services.xserver = {
displayManager = {
gdm = {
enable = true;
autoSuspend = false;
};
};
desktopManager = {
gnome = {
enable = true;
};
};
};
environment.systemPackages = [
pkgs.gnomeExtensions.no-overview
];
environment.gnome.excludePackages = with pkgs; [
atomix
baobab
cheese
epiphany
evince
file-roller
geary
gnome-backgrounds
gnome-calculator
gnome-calendar
gnome-clocks
gnome-connections
gnome-contacts
gnome-disk-utility
gnome-extension-manager
gnome-logs
gnome-maps
gnome-music
gnome-photos
gnome-software
gnome-system-monitor
gnome-text-editor
gnome-themes-extra
gnome-tour
gnome-user-docs
gnome-weather
hitori
iagno
loupe
nautilus
orca
seahorse
simple-scan
snapshot
sushi
tali
totem
yelp
];
programs.dconf = {
enable = true;
profiles.user.databases = [
{
settings = with lib.gvariant; {
"org/gnome/desktop/background" = {
picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png";
picture-uri-dark = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png";
picture-options = "centered";
primary-color = "#000000000000";
secondary-color = "#000000000000";
};
"org/gnome/desktop/interface" = {
color-scheme = "prefer-dark";
};
"org/gnome/desktop/notifications" = {
show-in-lock-screen = false;
};
"org/gnome/desktop/screensaver" = {
lock-enabled = false;
picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png";
picture-options = "centered";
primary-color = "#000000000000";
secondary-color = "#000000000000";
};
"org/gnome/desktop/session" = {
idle-delay = mkUint32 0;
};
"org/gnome/shell" = {
disable-user-extensions = false;
enabled-extensions = builtins.map (x: x.extensionUuid) (
lib.filter (p: p ? extensionUuid) config.environment.systemPackages
);
};
};
}
];
};
programs.geary.enable = false;
services.gnome = {
gnome-browser-connector.enable = false;
gnome-initial-setup.enable = false;
gnome-online-accounts.enable = false;
gnome-remote-desktop.enable = false;
rygel.enable = false;
};
system.activationScripts = {
face = {
text = ''
mkdir -p /var/lib/AccountsService/{icons,users}
cp ${pkgs.ghostty}/share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png /var/lib/AccountsService/icons/ghostty
echo -e "[User]\nIcon=/var/lib/AccountsService/icons/ghostty\n" > /var/lib/AccountsService/users/ghostty
chown root:root /var/lib/AccountsService/users/ghostty
chmod 0600 /var/lib/AccountsService/users/ghostty
chown root:root /var/lib/AccountsService/icons/ghostty
chmod 0444 /var/lib/AccountsService/icons/ghostty
'';
};
};
}

21
nix/vm/common-plasma6.nix Normal file
View File

@ -0,0 +1,21 @@
{...}: {
imports = [
./common.nix
];
services = {
displayManager = {
sddm = {
enable = true;
wayland = {
enable = true;
};
};
};
desktopManager = {
plasma6 = {
enable = true;
};
};
};
}

18
nix/vm/common-xfce.nix Normal file
View File

@ -0,0 +1,18 @@
{...}: {
imports = [
./common.nix
];
services.xserver = {
displayManager = {
lightdm = {
enable = true;
};
};
desktopManager = {
xfce = {
enable = true;
};
};
};
}

83
nix/vm/common.nix Normal file
View File

@ -0,0 +1,83 @@
{pkgs, ...}: {
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
documentation.nixos.enable = false;
networking.hostName = "ghostty";
networking.domain = "mitchellh.com";
virtualisation.vmVariant = {
virtualisation.memorySize = 2048;
};
nix = {
settings = {
trusted-users = [
"root"
"ghostty"
];
};
extraOptions = ''
experimental-features = nix-command flakes
'';
};
users.mutableUsers = false;
users.groups.ghostty = {};
users.users.ghostty = {
description = "Ghostty";
group = "ghostty";
extraGroups = ["wheel"];
isNormalUser = true;
initialPassword = "ghostty";
};
environment.etc = {
"xdg/autostart/com.mitchellh.ghostty.desktop" = {
source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop";
};
};
environment.systemPackages = [
pkgs.kitty
pkgs.fish
pkgs.ghostty
pkgs.helix
pkgs.neovim
pkgs.xterm
pkgs.zsh
];
security.polkit = {
enable = true;
};
services.dbus = {
enable = true;
};
services.displayManager = {
autoLogin = {
user = "ghostty";
};
};
services.libinput = {
enable = true;
};
services.qemuGuest = {
enable = true;
};
services.spice-vdagentd = {
enable = true;
};
services.xserver = {
enable = true;
};
}

View File

@ -0,0 +1,12 @@
{
system,
nixpkgs,
overlay,
module,
uid ? 1000,
gid ? 1000,
}:
import ./create.nix {
inherit system nixpkgs overlay module uid gid;
common = ./common-cinnamon.nix;
}

12
nix/vm/create-gnome.nix Normal file
View File

@ -0,0 +1,12 @@
{
system,
nixpkgs,
overlay,
module,
uid ? 1000,
gid ? 1000,
}:
import ./create.nix {
inherit system nixpkgs overlay module uid gid;
common = ./common-gnome.nix;
}

12
nix/vm/create-plasma6.nix Normal file
View File

@ -0,0 +1,12 @@
{
system,
nixpkgs,
overlay,
module,
uid ? 1000,
gid ? 1000,
}:
import ./create.nix {
inherit system nixpkgs overlay module uid gid;
common = ./common-plasma6.nix;
}

12
nix/vm/create-xfce.nix Normal file
View File

@ -0,0 +1,12 @@
{
system,
nixpkgs,
overlay,
module,
uid ? 1000,
gid ? 1000,
}:
import ./create.nix {
inherit system nixpkgs overlay module uid gid;
common = ./common-xfce.nix;
}

42
nix/vm/create.nix Normal file
View File

@ -0,0 +1,42 @@
{
system,
nixpkgs,
overlay,
module,
common ? ./common.nix,
uid ? 1000,
gid ? 1000,
}: let
pkgs = import nixpkgs {
inherit system;
overlays = [
overlay
];
};
in
nixpkgs.lib.nixosSystem {
system = builtins.replaceStrings ["darwin"] ["linux"] system;
modules = [
{
virtualisation.vmVariant = {
virtualisation.host.pkgs = pkgs;
};
nixpkgs.overlays = [
overlay
];
users.groups.ghostty = {
gid = gid;
};
users.users.ghostty = {
uid = uid;
};
system.stateVersion = nixpkgs.lib.trivial.release;
}
common
module
];
}

View File

@ -0,0 +1,7 @@
{...}: {
imports = [
./common-cinnamon.nix
];
services.displayManager.defaultSession = "cinnamon-wayland";
}

9
nix/vm/wayland-gnome.nix Normal file
View File

@ -0,0 +1,9 @@
{...}: {
imports = [
./common-gnome.nix
];
services.displayManager = {
defaultSession = "gnome";
};
}

View File

@ -0,0 +1,6 @@
{...}: {
imports = [
./common-plasma6.nix
];
services.displayManager.defaultSession = "plasma";
}

7
nix/vm/x11-cinnamon.nix Normal file
View File

@ -0,0 +1,7 @@
{...}: {
imports = [
./common-cinnamon.nix
];
services.displayManager.defaultSession = "cinnamon";
}

9
nix/vm/x11-gnome.nix Normal file
View File

@ -0,0 +1,9 @@
{...}: {
imports = [
./common-gnome.nix
];
services.displayManager = {
defaultSession = "gnome-xorg";
};
}

6
nix/vm/x11-plasma6.nix Normal file
View File

@ -0,0 +1,6 @@
{...}: {
imports = [
./common-plasma6.nix
];
services.displayManager.defaultSession = "plasmax11";
}

7
nix/vm/x11-xfce.nix Normal file
View File

@ -0,0 +1,7 @@
{...}: {
imports = [
./common-xfce.nix
];
services.displayManager.defaultSession = "xfce";
}

View File

@ -1,3 +1,3 @@
# This file is auto-generated! check build-support/check-zig-cache-hash.sh for
# more details.
"sha256-Nx1tOhDnEZ7LVi/pKxYS3sg/Sf8TAUXDmST6EtBgDoQ="
"sha256-Bjy31evaKgpRX1mGwAFkai44eiiorTV1gW3VdP9Ins8="

View File

@ -18,9 +18,72 @@ pub const ColorSpace = opaque {
) orelse Allocator.Error.OutOfMemory;
}
pub fn createNamed(name: Name) Allocator.Error!*ColorSpace {
return @as(
?*ColorSpace,
@ptrFromInt(@intFromPtr(c.CGColorSpaceCreateWithName(name.cfstring()))),
) orelse Allocator.Error.OutOfMemory;
}
pub fn release(self: *ColorSpace) void {
c.CGColorSpaceRelease(@ptrCast(self));
}
pub const Name = enum {
/// This color space uses the DCI P3 primaries, a D65 white point, and
/// the sRGB transfer function.
displayP3,
/// The Display P3 color space with a linear transfer function and
/// extended-range values.
extendedLinearDisplayP3,
/// The sRGB colorimetry and non-linear transfer function are specified
/// in IEC 61966-2-1.
sRGB,
/// This color space has the same colorimetry as `sRGB`, but uses a
/// linear transfer function.
linearSRGB,
/// This color space has the same colorimetry as `sRGB`, but you can
/// encode component values below `0.0` and above `1.0`. Negative values
/// are encoded as the signed reflection of the original encoding
/// function, as shown in the formula below:
/// ```
/// extendedTransferFunction(x) = sign(x) * sRGBTransferFunction(abs(x))
/// ```
extendedSRGB,
/// This color space has the same colorimetry as `sRGB`; in addition,
/// you may encode component values below `0.0` and above `1.0`.
extendedLinearSRGB,
/// ...
genericGrayGamma2_2,
/// ...
linearGray,
/// This color space has the same colorimetry as `genericGrayGamma2_2`,
/// but you can encode component values below `0.0` and above `1.0`.
/// Negative values are encoded as the signed reflection of the
/// original encoding function, as shown in the formula below:
/// ```
/// extendedGrayTransferFunction(x) = sign(x) * gamma22Function(abs(x))
/// ```
extendedGray,
/// This color space has the same colorimetry as `linearGray`; in
/// addition, you may encode component values below `0.0` and above `1.0`.
extendedLinearGray,
fn cfstring(self: Name) c.CFStringRef {
return switch (self) {
.displayP3 => c.kCGColorSpaceDisplayP3,
.extendedLinearDisplayP3 => c.kCGColorSpaceExtendedLinearDisplayP3,
.sRGB => c.kCGColorSpaceSRGB,
.extendedSRGB => c.kCGColorSpaceExtendedSRGB,
.linearSRGB => c.kCGColorSpaceLinearSRGB,
.extendedLinearSRGB => c.kCGColorSpaceExtendedLinearSRGB,
.genericGrayGamma2_2 => c.kCGColorSpaceGenericGrayGamma2_2,
.extendedGray => c.kCGColorSpaceExtendedGray,
.linearGray => c.kCGColorSpaceLinearGray,
.extendedLinearGray => c.kCGColorSpaceExtendedLinearGray,
};
}
};
};
test {

View File

@ -162,4 +162,26 @@ pub const Binding = struct {
data,
);
}
pub fn copySubImage2D(
b: Binding,
level: c.GLint,
xoffset: c.GLint,
yoffset: c.GLint,
x: c.GLint,
y: c.GLint,
width: c.GLsizei,
height: c.GLsizei,
) !void {
glad.context.CopyTexSubImage2D.?(
@intFromEnum(b.target),
level,
xoffset,
yoffset,
x,
y,
width,
height
);
}
};

View File

@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
c.wuffs_base__pixel_config__set(
&image_config.pixcfg,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL,
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
width,
height,
@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
try check(log, &status);
}
var frame_config: c.wuffs_base__frame_config = undefined;
{
const status = c.wuffs_jpeg__decoder__decode_frame_config(
decoder,
&frame_config,
&source_buffer,
);
try check(log, &status);
}
{
const status = c.wuffs_jpeg__decoder__decode_frame(
decoder,

View File

@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
c.wuffs_base__pixel_config__set(
&image_config.pixcfg,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL,
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
width,
height,
@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
try check(log, &status);
}
var frame_config: c.wuffs_base__frame_config = undefined;
{
const status = c.wuffs_png__decoder__decode_frame_config(
decoder,
&frame_config,
&source_buffer,
);
try check(log, &status);
}
{
const status = c.wuffs_png__decoder__decode_frame(
decoder,

View File

@ -1041,6 +1041,9 @@ fn mouseRefreshLinks(
pos_vp: terminal.point.Coordinate,
over_link: bool,
) !void {
// If the position is outside our viewport, do nothing
if (pos.x < 0 or pos.y < 0) return;
self.mouse.link_point = pos_vp;
if (try self.linkAtPos(pos)) |link| {
@ -3563,22 +3566,21 @@ fn dragLeftClickTriple(
const screen = &self.io.terminal.screen;
const click_pin = self.mouse.left_click_pin.?.*;
// Get the word under our current point. If there isn't a word, do nothing.
const word = screen.selectLine(.{ .pin = drag_pin }) orelse return;
// Get the line selection under our current drag point. If there isn't a
// line, do nothing.
const line = screen.selectLine(.{ .pin = drag_pin }) orelse return;
// Get our selection to grow it. If we don't have a selection, start it now.
// We may not have a selection if we started our dbl-click in an area
// that had no data, then we dragged our mouse into an area with data.
var sel = screen.selectLine(.{ .pin = click_pin }) orelse {
try self.setSelection(word);
return;
};
// Get the selection under our click point. We first try to trim
// whitespace if we've selected a word. But if no word exists then
// we select the blank line.
const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse
screen.selectLine(.{ .pin = click_pin, .whitespace = null });
// Grow our selection
var sel = sel_ orelse return;
if (drag_pin.before(click_pin)) {
sel.startPtr().* = word.start();
sel.startPtr().* = line.start();
} else {
sel.endPtr().* = word.end();
sel.endPtr().* = line.end();
}
try self.setSelection(sel);
}
@ -4177,6 +4179,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.toggle_maximize => try self.rt_app.performAction(
.{ .surface = self },
.toggle_maximize,
{},
),
.toggle_fullscreen => try self.rt_app.performAction(
.{ .surface = self },
.toggle_fullscreen,

View File

@ -92,6 +92,9 @@ pub const Action = union(Key) {
/// Close all open windows.
close_all_windows,
/// Toggle maximized window state.
toggle_maximize,
/// Toggle fullscreen mode.
toggle_fullscreen: Fullscreen,
@ -231,6 +234,7 @@ pub const Action = union(Key) {
close_tab,
new_split,
close_all_windows,
toggle_maximize,
toggle_fullscreen,
toggle_tab_overview,
toggle_window_decorations,

View File

@ -638,7 +638,7 @@ pub const Surface = struct {
.y = @floatCast(opts.scale_factor),
},
.size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 },
.cursor_pos = .{ .x = -1, .y = -1 },
.keymap_state = .{},
};
@ -1958,7 +1958,7 @@ pub const CAPI = struct {
_ = CGSSetWindowBackgroundBlurRadius(
CGSDefaultConnectionForThread(),
nswindow.msgSend(usize, objc.sel("windowNumber"), .{}),
@intCast(config.@"background-blur-radius".cval()),
@intCast(config.@"background-blur".cval()),
);
}

View File

@ -237,6 +237,7 @@ pub const App = struct {
.color_change,
.pwd,
.config_change,
.toggle_maximize,
=> log.info("unimplemented action={}", .{action}),
}
}

View File

@ -73,6 +73,11 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null,
/// This is set to false when the main loop should exit.
running: bool = true,
/// If we should retry querying D-Bus for the color scheme with the deprecated
/// Read method, instead of the recommended ReadOne method. This is kind of
/// nasty to have as struct state but its just a byte...
dbus_color_scheme_retry: bool = true,
/// The base path of the transient cgroup used to put all surfaces
/// into their own cgroup. This is only set if cgroups are enabled
/// and initialization was successful.
@ -507,6 +512,7 @@ pub fn performAction(
.app => null,
.surface => |v| v,
}),
.toggle_maximize => self.toggleMaximize(target),
.toggle_fullscreen => self.toggleFullscreen(target, value),
.new_tab => try self.newTab(target),
@ -578,7 +584,7 @@ fn closeTab(_: *App, target: apprt.Target) !void {
return;
};
tab.window.closeTab(tab);
tab.closeWithConfirmation();
},
}
}
@ -709,6 +715,22 @@ fn controlInspector(
surface.controlInspector(mode);
}
fn toggleMaximize(_: *App, target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleMaximize invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
window.toggleMaximize();
},
}
}
fn toggleFullscreen(
_: *App,
target: apprt.Target,
@ -1254,7 +1276,8 @@ pub fn run(self: *App) !void {
self.transient_cgroup_base = path;
} else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"});
// Setup our D-Bus connection for listening to settings changes.
// Setup our D-Bus connection for listening to settings changes,
// and asynchronously request the initial color scheme
self.initDbus();
// Setup our menu items
@ -1262,9 +1285,6 @@ pub fn run(self: *App) !void {
self.initMenu();
self.initContextMenu();
// Setup our initial color scheme
self.colorSchemeEvent(self.getColorScheme());
// On startup, we want to check for configuration errors right away
// so we can show our error window. We also need to setup other initial
// state.
@ -1312,6 +1332,22 @@ fn initDbus(self: *App) void {
self,
null,
);
// Request the initial color scheme asynchronously.
c.g_dbus_connection_call(
dbus,
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
"ReadOne",
c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
c.G_VARIANT_TYPE("(v)"),
c.G_DBUS_CALL_FLAGS_NONE,
-1,
null,
dbusColorSchemeCallback,
self,
);
}
// This timeout function is started when no surfaces are open. It can be
@ -1549,61 +1585,37 @@ fn gtkWindowIsActive(
core_app.focusEvent(false);
}
/// Call a D-Bus method to determine the current color scheme. If there
/// is any error at any point we'll log the error and return "light"
pub fn getColorScheme(self: *App) apprt.ColorScheme {
const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app));
fn dbusColorSchemeCallback(
source_object: [*c]c.GObject,
res: ?*c.GAsyncResult,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *App = @ptrCast(@alignCast(ud.?));
const dbus: *c.GDBusConnection = @ptrCast(source_object);
var err: ?*c.GError = null;
defer if (err) |e| c.g_error_free(e);
const value = c.g_dbus_connection_call_sync(
dbus_connection,
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
"ReadOne",
c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
c.G_VARIANT_TYPE("(v)"),
c.G_DBUS_CALL_FLAGS_NONE,
-1,
null,
&err,
) orelse {
if (err) |e| {
// If ReadOne is not yet implemented, fall back to deprecated "Read" method
// Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method ReadOne
if (e.code == 19) {
return self.getColorSchemeDeprecated();
}
// Otherwise, log the error and return .light
log.err("unable to get current color scheme: {s}", .{e.message});
}
return .light;
};
defer c.g_variant_unref(value);
if (c.g_dbus_connection_call_finish(dbus, res, &err)) |value| {
if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) {
var inner: ?*c.GVariant = null;
c.g_variant_get(value, "(v)", &inner);
defer c.g_variant_unref(inner);
if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) {
return if (c.g_variant_get_uint32(inner) == 1) .dark else .light;
self.colorSchemeEvent(if (c.g_variant_get_uint32(inner) == 1)
.dark
else
.light);
return;
}
}
return .light;
}
/// Call the deprecated D-Bus "Read" method to determine the current color scheme. If
/// there is any error at any point we'll log the error and return "light"
fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme {
const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app));
var err: ?*c.GError = null;
defer if (err) |e| c.g_error_free(e);
const value = c.g_dbus_connection_call_sync(
dbus_connection,
} else if (err) |e| {
// If ReadOne is not yet implemented, fall back to deprecated "Read" method
// Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method ReadOne
if (self.dbus_color_scheme_retry and e.code == 19) {
self.dbus_color_scheme_retry = false;
c.g_dbus_connection_call(
dbus,
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
@ -1613,29 +1625,18 @@ fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme {
c.G_DBUS_CALL_FLAGS_NONE,
-1,
null,
&err,
) orelse {
if (err) |e| log.err("Read method failed: {s}", .{e.message});
return .light;
};
defer c.g_variant_unref(value);
if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) {
var inner: ?*c.GVariant = null;
c.g_variant_get(value, "(v)", &inner);
defer if (inner) |i| c.g_variant_unref(i);
if (inner) |i| {
const child = c.g_variant_get_child_value(i, 0) orelse {
return .light;
};
defer c.g_variant_unref(child);
const val = c.g_variant_get_uint32(child);
return if (val == 1) .dark else .light;
dbusColorSchemeCallback,
self,
);
return;
}
// Otherwise, log the error and return .light
log.warn("unable to get current color scheme: {s}", .{e.message});
}
return .light;
// Fall back
self.colorSchemeEvent(.light);
}
/// This will be called by D-Bus when the style changes between light & dark.
@ -1864,7 +1865,6 @@ fn initContextMenu(self: *App) void {
c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector");
}
if (!self.config.@"window-decoration") {
const section = c.g_menu_new();
defer c.g_object_unref(section);
const submenu = c.g_menu_new();
@ -1873,7 +1873,6 @@ fn initContextMenu(self: *App) void {
initMenuContent(@ptrCast(submenu));
c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu)));
c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section)));
}
self.context_menu = menu;
}

View File

@ -368,10 +368,9 @@ cursor_pos: apprt.CursorPos,
inspector: ?*inspector.Inspector = null,
/// Key input states. See gtkKeyPressed for detailed descriptions.
in_keypress: bool = false,
in_keyevent: bool = false,
im_context: *c.GtkIMContext,
im_composing: bool = false,
im_commit_buffered: bool = false,
im_buf: [128]u8 = undefined,
im_len: u7 = 0,
@ -560,7 +559,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
.font_size = font_size,
.init_config = init_config,
.size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 },
.cursor_pos = .{ .x = -1, .y = -1 },
.im_context = im_context,
.cgroup_path = cgroup_path,
};
@ -634,9 +633,6 @@ fn realize(self: *Surface) !void {
try self.core_surface.setFontSize(size);
}
// Set the initial color scheme
try self.core_surface.colorSchemeCallback(self.app.getColorScheme());
// Note we're realized
self.realized = true;
}
@ -1137,7 +1133,7 @@ pub fn setClipboardString(
c.gdk_clipboard_set_text(clipboard, val.ptr);
// We only toast if we are copying to the standard clipboard.
if (clipboard_type == .standard and
self.app.config.@"adw-toast".@"clipboard-copy")
self.app.config.@"app-notifications".@"clipboard-copy")
{
if (self.container.window()) |window|
window.sendToast("Copied to clipboard");
@ -1384,12 +1380,10 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque)
};
if (self.container.window()) |window| {
if (window.winproto) |*winproto| {
winproto.resizeEvent() catch |err| {
window.winproto.resizeEvent() catch |err| {
log.warn("failed to notify window protocol of resize={}", .{err});
};
}
}
self.resize_overlay.maybeShow();
}
@ -1496,17 +1490,22 @@ fn gtkMouseMotion(
.y = @floatCast(scaled.y),
};
// When the GLArea is resized under the mouse, GTK issues a mouse motion
// event. This has the unfortunate side effect of causing focus to potentially
// change when `focus-follows-mouse` is enabled. To prevent this, we check
// if the cursor is still in the same place as the last event and only grab
// focus if it has moved.
// There seem to be at least two cases where GTK issues a mouse motion
// event without the cursor actually moving:
// 1. GLArea is resized under the mouse. This has the unfortunate
// side effect of causing focus to potentially change when
// `focus-follows-mouse` is enabled.
// 2. The window title is updated. This can cause the mouse to unhide
// incorrectly when hide-mouse-when-typing is enabled.
// To prevent incorrect behavior, we'll only grab focus and
// continue with callback logic if the cursor has actually moved.
const is_cursor_still = @abs(self.cursor_pos.x - pos.x) < 1 and
@abs(self.cursor_pos.y - pos.y) < 1;
if (!is_cursor_still) {
// If we don't have focus, and we want it, grab it.
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
if (!is_cursor_still and c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") {
if (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") {
self.grabFocus();
}
@ -1521,6 +1520,7 @@ fn gtkMouseMotion(
log.err("error in cursor pos callback err={}", .{err});
return;
};
}
}
fn gtkMouseLeave(
@ -1600,30 +1600,36 @@ fn gtkKeyReleased(
)) 1 else 0;
}
/// Key press event. This is where we do ALL of our key handling,
/// translation to keyboard layouts, dead key handling, etc. Key handling
/// is complicated so this comment will explain what's going on.
/// Key press event (press or release).
///
/// At a high level, we want to construct an `input.KeyEvent` and
/// pass that to `keyCallback`. At a low level, this is more complicated
/// than it appears because we need to construct all of this information
/// and its not given to us.
///
/// For press events, we run the keypress through the input method context
/// in order to determine if we're in a dead key state, completed unicode
/// char, etc. This all happens through various callbacks: preedit, commit,
/// etc. These inspect "in_keypress" if they have to and set some instance
/// state.
/// For all events, we run the GdkEvent through the input method context.
/// This allows the input method to capture the event and trigger
/// callbacks such as preedit, commit, etc.
///
/// We then take all of the information in order to determine if we have
/// There are a couple important aspects to the prior paragraph: we must
/// send ALL events through the input method context. This is because
/// input methods use both key press and key release events to determine
/// the state of the input method. For example, fcitx uses key release
/// events on modifiers (i.e. ctrl+shift) to switch the input method.
///
/// We set some state to note we're in a key event (self.in_keyevent)
/// because some of the input method callbacks change behavior based on
/// this state. For example, we don't want to send character events
/// like "a" via the input "commit" event if we're actively processing
/// a keypress because we'd lose access to the keycode information.
/// However, a "commit" event may still happen outside of a keypress
/// event from e.g. a tablet or on-screen keyboard.
///
/// Finally, we take all of the information in order to determine if we have
/// a unicode character or if we have to map the keyval to a code to
/// get the underlying logical key, etc.
///
/// Finally, we can emit the keyCallback.
///
/// Note we ALSO have an IMContext attached directly to the widget
/// which can emit preedit and commit callbacks. But, if we're not
/// in a keypress, we let those automatically work.
/// Then we can emit the keyCallback.
pub fn keyEvent(
self: *Surface,
action: input.Action,
@ -1632,26 +1638,15 @@ pub fn keyEvent(
keycode: c.guint,
gtk_mods: c.GdkModifierType,
) bool {
// log.warn("GTKIM: keyEvent action={}", .{action});
const event = c.gtk_event_controller_get_current_event(
@ptrCast(ec_key),
) orelse return false;
const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
// Get the unshifted unicode value of the keyval. This is used
// by the Kitty keyboard protocol.
const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
@ptrCast(self.gl_area),
event,
keycode,
);
// We always reset our committed text when ending a keypress so that
// future keypresses don't think we have a commit event.
defer self.im_len = 0;
// We only want to send the event through the IM context if we're a press
if (action == .press or action == .repeat) {
// The block below is all related to input method handling. See the function
// comment for some high level details and then the comments within
// the block for more specifics.
{
// This can trigger an input method so we need to notify the im context
// where the cursor is so it can render the dropdowns in the correct
// place.
@ -1663,41 +1658,94 @@ pub fn keyEvent(
.height = 1,
});
// We mark that we're in a keypress event. We use this in our
// IM commit callback to determine if we need to send a char callback
// to the core surface or not.
self.in_keypress = true;
defer self.in_keypress = false;
// Pass the event through the IM controller to handle dead key states.
// Filter is true if the event was handled by the IM controller.
const im_handled = c.gtk_im_context_filter_keypress(self.im_context, event) != 0;
// log.warn("im_handled={} im_len={} im_composing={}", .{ im_handled, self.im_len, self.im_composing });
// If this is a dead key, then we're composing a character and
// we need to set our proper preedit state.
if (self.im_composing) preedit: {
const text = self.im_buf[0..self.im_len];
self.core_surface.preeditCallback(text) catch |err| {
log.err("error in preedit callback err={}", .{err});
break :preedit;
// Pass the event through the IM controller. This will return true
// if the input method handled the event.
//
// Confusingly, not all events handled by the input method result
// in this returning true so we have to maintain some local state to
// find those and in one case we simply lose information.
//
// - If we change the input method via keypress while we have preedit
// text, the input method will commit the pending text but will not
// mark it as handled. We use the `was_composing` variable to detect
// this case.
//
// - If we switch input methods (i.e. via ctrl+shift with fcitx),
// the input method will handle the key release event but will not
// mark it as handled. I don't know any way to detect this case so
// it will result in a key event being sent to the key callback.
// For Kitty text encoding, this will result in modifiers being
// triggered despite being technically consumed. At the time of
// writing, both Kitty and Alacritty have the same behavior. I
// know of no way to fix this.
const was_composing = self.im_composing;
const im_handled = filter: {
// We note that we're in a keypress because we want some logic to
// depend on this. For example, we don't want to send character events
// like "a" via the input "commit" event if we're actively processing
// a keypress because we'd lose access to the keycode information.
self.in_keyevent = true;
defer self.in_keyevent = false;
break :filter c.gtk_im_context_filter_keypress(
self.im_context,
event,
) != 0;
};
// log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{
// im_handled,
// self.im_len,
// self.im_composing,
// });
// If we're composing then we don't want to send the key
// event to the core surface so we always return immediately.
if (im_handled) return true;
} else {
// If we aren't composing, then we set our preedit to
// empty no matter what.
self.core_surface.preeditCallback(null) catch {};
// If the input method handled the event, you would think we would
// never proceed with key encoding for Ghostty but that is not the
// case. Input methods will handle basic character encoding like
// typing "a" and we want to associate that with the key event.
// So we have to check additional state to determine if we exit.
if (im_handled) {
// If we are composing then we're in a preedit state and do
// not want to encode any keys. For example: type a deadkey
// such as single quote on a US international keyboard layout.
if (self.im_composing) return true;
// If the IM handled this and we have no text, then we just
// return because this probably just changed the input method
// or something.
if (im_handled and self.im_len == 0) return true;
// If we were composing and now we're not it means that we committed
// the text. We also don't want to encode a key event for this.
// Example: enable Japanese input method, press "konn" and then
// press enter. The final enter should not be encoded and "konn"
// (in hiragana) should be written as "こん".
if (was_composing) return true;
// Not composing and our input method buffer is empty. This could
// mean that the input method reacted to this event by activating
// an onscreen keyboard or something equivalent. We don't know.
// But the input method handled it and didn't give us text so
// we will just assume we should not encode this. This handles a
// real scenario when ibus starts the emoji input method
// (super+.).
if (self.im_len == 0) return true;
}
// At this point, for the sake of explanation of internal state:
// it is possible that im_len > 0 and im_composing == false. This
// means that we received a commit event from the input method that
// we want associated with the key event. This is common: its how
// basic character translation for simple inputs like "a" work.
}
// We always reset the length of the im buffer. There's only one scenario
// we reach this point with im_len > 0 and that's if we received a commit
// event from the input method. We don't want to keep that state around
// since we've handled it here.
defer self.im_len = 0;
// Get the keyvals for this event.
const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
@ptrCast(self.gl_area),
event,
keycode,
);
// We want to get the physical unmapped key to process physical keybinds.
// (These are keybinds explicitly marked as requesting physical mapping).
const physical_key = keycode: for (input.keycodes.entries) |entry| {
@ -1830,12 +1878,11 @@ fn gtkInputPreeditStart(
_: *c.GtkIMContext,
ud: ?*anyopaque,
) callconv(.C) void {
//log.debug("preedit start", .{});
// log.warn("GTKIM: preedit start", .{});
const self = userdataSelf(ud.?);
if (!self.in_keypress) return;
// Mark that we are now composing a string with a dead key state.
// We'll record the string in the preedit-changed callback.
// Start our composing state for the input method and reset our
// input buffer to empty.
self.im_composing = true;
self.im_len = 0;
}
@ -1844,54 +1891,35 @@ fn gtkInputPreeditChanged(
ctx: *c.GtkIMContext,
ud: ?*anyopaque,
) callconv(.C) void {
// log.warn("GTKIM: preedit change", .{});
const self = userdataSelf(ud.?);
// If there's buffered character, send the characters directly to the surface.
if (self.im_composing and self.im_commit_buffered) {
defer self.im_commit_buffered = false;
defer self.im_len = 0;
_ = self.core_surface.keyCallback(.{
.action = .press,
.key = .invalid,
.physical_key = .invalid,
.mods = .{},
.consumed_mods = .{},
.composing = false,
.utf8 = self.im_buf[0..self.im_len],
}) catch |err| {
log.err("error in key callback err={}", .{err});
return;
};
}
if (!self.in_keypress) return;
// Get our pre-edit string that we'll use to show the user.
var buf: [*c]u8 = undefined;
_ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null);
defer c.g_free(buf);
const str = std.mem.sliceTo(buf, 0);
// If our string becomes empty we ignore this. This can happen after
// a commit event when the preedit is being cleared and we don't want
// to set im_len to zero. This is safe because preeditstart always sets
// im_len to zero.
if (str.len == 0) return;
// Copy the preedit string into the im_buf. This is safe because
// commit will always overwrite this.
self.im_len = @intCast(@min(self.im_buf.len, str.len));
@memcpy(self.im_buf[0..self.im_len], str);
// Update our preedit state in Ghostty core
self.core_surface.preeditCallback(str) catch |err| {
log.err("error in preedit callback err={}", .{err});
};
}
fn gtkInputPreeditEnd(
_: *c.GtkIMContext,
ud: ?*anyopaque,
) callconv(.C) void {
//log.debug("preedit end", .{});
// log.warn("GTKIM: preedit end", .{});
const self = userdataSelf(ud.?);
if (!self.in_keypress) return;
// End our composing state for GTK, allowing us to commit the text.
self.im_composing = false;
// End our preedit state in Ghostty core
self.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err});
};
}
fn gtkInputCommit(
@ -1899,38 +1927,45 @@ fn gtkInputCommit(
bytes: [*:0]u8,
ud: ?*anyopaque,
) callconv(.C) void {
// log.warn("GTKIM: input commit", .{});
const self = userdataSelf(ud.?);
const str = std.mem.sliceTo(bytes, 0);
// If we're in a key event, then we want to buffer the commit so
// that we can send the proper keycallback followed by the char
// callback.
if (self.in_keypress) {
if (str.len <= self.im_buf.len) {
@memcpy(self.im_buf[0..str.len], str);
self.im_len = @intCast(str.len);
// If composing is done and character should be committed,
// It should be committed in preedit callback.
if (self.im_composing) {
self.im_commit_buffered = true;
}
// log.debug("input commit len={}", .{self.im_len});
} else {
// If we're in a keyEvent (i.e. a keyboard event) and we're not composing,
// then this is just a normal key press resulting in UTF-8 text. We
// want the keyEvent to handle this so that the UTF-8 text can be associated
// with a keyboard event.
if (!self.im_composing and self.in_keyevent) {
if (str.len > self.im_buf.len) {
log.warn("not enough buffer space for input method commit", .{});
}
return;
}
// This prevents staying in composing state after commit even though
// input method has changed.
// Copy our committed text to the buffer
@memcpy(self.im_buf[0..str.len], str);
self.im_len = @intCast(str.len);
// log.debug("input commit len={}", .{self.im_len});
return;
}
// If we reach this point from above it means we're composing OR
// not in a keypress. In either case, we want to commit the text
// given to us because that's what GTK is asking us to do. If we're
// not in a keypress it means that this commit came via a non-keyboard
// event (i.e. on-screen keyboard, tablet of some kind, etc.).
// Committing ends composing state
self.im_composing = false;
// We're not in a keypress, so this was sent from an on-screen emoji
// keyboard or something like that. Send the characters directly to
// the surface.
// End our preedit state. Well-behaved input methods do this for us
// by triggering a preedit-end event but some do not (ibus 1.5.29).
self.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err});
};
// Send the text to the core surface, associated with no key (an
// invalid key, which should produce no PTY encoding).
_ = self.core_surface.keyCallback(.{
.action = .press,
.key = .invalid,
@ -1940,7 +1975,7 @@ fn gtkInputCommit(
.composing = false,
.utf8 = str,
}) catch |err| {
log.err("error in key callback err={}", .{err});
log.warn("error in key callback err={}", .{err});
return;
};
}

View File

@ -57,7 +57,7 @@ toast_overlay: ?*c.GtkWidget,
adw_tab_overview_focus_timer: ?c.guint = null,
/// State and logic for windowing protocol for a window.
winproto: ?winproto.Window,
winproto: winproto.Window,
pub fn create(alloc: Allocator, app: *App) !*Window {
// Allocate a fixed pointer for our window. We try to minimize
@ -83,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void {
.notebook = undefined,
.context_menu = undefined,
.toast_overlay = undefined,
.winproto = null,
.winproto = .none,
};
// Create the window
@ -204,11 +204,8 @@ pub fn init(self: *Window, app: *App) !void {
}
_ = c.g_signal_connect_data(gtk_window, "notify::decorated", c.G_CALLBACK(&gtkWindowNotifyDecorated), self, null, c.G_CONNECT_DEFAULT);
// If we are disabling decorations then disable them right away.
if (!app.config.@"window-decoration") {
c.gtk_window_set_decorated(gtk_window, 0);
}
_ = c.g_signal_connect_data(gtk_window, "notify::maximized", c.G_CALLBACK(&gtkWindowNotifyMaximized), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gtk_window, "notify::fullscreened", c.G_CALLBACK(&gtkWindowNotifyFullscreened), self, null, c.G_CONNECT_DEFAULT);
// If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we
// need to stick the headerbar into the content box.
@ -265,6 +262,9 @@ pub fn init(self: *Window, app: *App) !void {
c.gtk_popover_set_has_arrow(@ptrCast(@alignCast(self.context_menu)), 0);
c.gtk_widget_set_halign(self.context_menu, c.GTK_ALIGN_START);
// If we want the window to be maximized, we do that here.
if (app.config.maximize) c.gtk_window_maximize(self.window);
// If we are in fullscreen mode, new windows start fullscreen.
if (app.config.fullscreen) c.gtk_window_fullscreen(self.window);
@ -374,7 +374,11 @@ pub fn updateConfig(
self: *Window,
config: *const configpkg.Config,
) !void {
if (self.winproto) |*v| try v.updateConfigEvent(config);
self.winproto.updateConfigEvent(config) catch |err| {
// We want to continue attempting to make the other config
// changes necessary so we just log the error and continue.
log.warn("failed to update window protocol config error={}", .{err});
};
// We always resync our appearance whenever the config changes.
try self.syncAppearance(config);
@ -386,16 +390,52 @@ pub fn updateConfig(
/// TODO: Many of the initial style settings in `create` could possibly be made
/// reactive by moving them here.
pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void {
if (config.@"background-opacity" < 1) {
c.gtk_widget_remove_css_class(@ptrCast(self.window), "background");
} else {
c.gtk_widget_add_css_class(@ptrCast(self.window), "background");
}
// Window protocol specific appearance updates
if (self.winproto) |*v| v.syncAppearance() catch |err| {
log.warn("failed to sync window protocol appearance error={}", .{err});
self.winproto.syncAppearance() catch |err| {
log.warn("failed to sync winproto appearance error={}", .{err});
};
toggleCssClass(
@ptrCast(self.window),
"background",
config.@"background-opacity" >= 1,
);
// If we are disabling CSDs then disable them right away.
const csd_enabled = self.winproto.clientSideDecorationEnabled();
c.gtk_window_set_decorated(self.window, @intFromBool(csd_enabled));
// If we are not decorated then we hide the titlebar.
self.headerbar.setVisible(config.@"gtk-titlebar" and csd_enabled);
// Disable the title buttons (close, maximize, minimize, ...)
// *inside* the tab overview if CSDs are disabled.
// We do spare the search button, though.
if ((comptime adwaita.versionAtLeast(1, 4, 0)) and
adwaita.enabled(&self.app.config))
{
if (self.tab_overview) |tab_overview| {
c.adw_tab_overview_set_show_start_title_buttons(
@ptrCast(tab_overview),
@intFromBool(csd_enabled),
);
c.adw_tab_overview_set_show_end_title_buttons(
@ptrCast(tab_overview),
@intFromBool(csd_enabled),
);
}
}
}
fn toggleCssClass(
widget: *c.GtkWidget,
class: [:0]const u8,
v: bool,
) void {
if (v) {
c.gtk_widget_add_css_class(widget, class);
} else {
c.gtk_widget_remove_css_class(widget, class);
}
}
/// Sets up the GTK actions for the window scope. Actions are how GTK handles
@ -435,7 +475,7 @@ fn initActions(self: *Window) void {
pub fn deinit(self: *Window) void {
c.gtk_widget_unparent(@ptrCast(self.context_menu));
if (self.winproto) |*v| v.deinit(self.app.core_app.alloc);
self.winproto.deinit(self.app.core_app.alloc);
if (self.adw_tab_overview_focus_timer) |timer| {
_ = c.g_source_remove(timer);
@ -497,9 +537,9 @@ pub fn moveTab(self: *Window, surface: *Surface, position: c_int) void {
self.notebook.moveTab(tab, position);
}
/// Go to the next tab for a surface.
/// Go to the last tab for a surface.
pub fn gotoLastTab(self: *Window) void {
const max = self.notebook.nPages() -| 1;
const max = self.notebook.nPages();
self.gotoTab(@intCast(max));
}
@ -522,6 +562,15 @@ pub fn toggleTabOverview(self: *Window) void {
}
}
/// Toggle the maximized state for this window.
pub fn toggleMaximize(self: *Window) void {
if (c.gtk_window_is_maximized(self.window) == 0) {
c.gtk_window_maximize(self.window);
} else {
c.gtk_window_unmaximize(self.window);
}
}
/// Toggle fullscreen for this window.
pub fn toggleFullscreen(self: *Window) void {
const is_fullscreen = c.gtk_window_is_fullscreen(self.window);
@ -534,15 +583,11 @@ pub fn toggleFullscreen(self: *Window) void {
/// Toggle the window decorations for this window.
pub fn toggleWindowDecorations(self: *Window) void {
const old_decorated = c.gtk_window_get_decorated(self.window) == 1;
const new_decorated = !old_decorated;
c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated));
// If we have a titlebar, then we also show/hide it depending on the
// decorated state. GTK tends to consider the titlebar part of the frame
// and hides it with decorations, but libadwaita doesn't. This makes it
// explicit.
self.headerbar.setVisible(new_decorated);
self.app.config.@"window-decoration" = switch (self.app.config.@"window-decoration") {
.auto, .client, .server => .none,
.none => .client,
};
self.updateConfig(&self.app.config) catch {};
}
/// Grabs focus on the currently selected tab.
@ -588,22 +633,60 @@ fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool {
return true;
}
fn gtkWindowNotifyMaximized(
_: *c.GObject,
_: *c.GParamSpec,
ud: ?*anyopaque,
) callconv(.C) void {
const self = userdataSelf(ud orelse return);
// Only toggle visibility of the header bar when we're using CSDs,
// and actually intend on displaying the header bar
if (!self.winproto.clientSideDecorationEnabled()) return;
// If we aren't maximized, we should show the headerbar again
// if it was originally visible.
const maximized = c.gtk_window_is_maximized(self.window) != 0;
if (!maximized) {
self.headerbar.setVisible(self.app.config.@"gtk-titlebar");
return;
}
// If we are maximized, we should hide the headerbar if requested.
if (self.app.config.@"gtk-titlebar-hide-when-maximized") {
self.headerbar.setVisible(false);
}
}
fn gtkWindowNotifyDecorated(
object: *c.GObject,
_: *c.GParamSpec,
_: ?*anyopaque,
) callconv(.C) void {
if (c.gtk_window_get_decorated(@ptrCast(object)) == 1) {
c.gtk_widget_remove_css_class(@ptrCast(object), "ssd");
c.gtk_widget_remove_css_class(@ptrCast(object), "no-border-radius");
} else {
const is_decorated = c.gtk_window_get_decorated(@ptrCast(object)) == 1;
// Fix any artifacting that may occur in window corners. The .ssd CSS
// class is defined in the GtkWindow documentation:
// https://docs.gtk.org/gtk4/class.Window.html#css-nodes. A definition
// for .ssd is provided by GTK and Adwaita.
c.gtk_widget_add_css_class(@ptrCast(object), "ssd");
c.gtk_widget_add_css_class(@ptrCast(object), "no-border-radius");
toggleCssClass(@ptrCast(object), "ssd", !is_decorated);
toggleCssClass(@ptrCast(object), "no-border-radius", !is_decorated);
}
fn gtkWindowNotifyFullscreened(
object: *c.GObject,
_: *c.GParamSpec,
ud: ?*anyopaque,
) callconv(.C) void {
const self = userdataSelf(ud orelse return);
const fullscreened = c.gtk_window_is_fullscreen(@ptrCast(object)) != 0;
if (!fullscreened) {
const csd_enabled = self.winproto.clientSideDecorationEnabled();
self.headerbar.setVisible(self.app.config.@"gtk-titlebar" and csd_enabled);
return;
}
self.headerbar.setVisible(false);
}
// Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab

View File

@ -18,9 +18,6 @@ pub const HeaderBar = union(enum) {
} else {
HeaderBarGtk.init(self);
}
if (!window.app.config.@"gtk-titlebar" or !window.app.config.@"window-decoration")
self.setVisible(false);
}
pub fn setVisible(self: HeaderBar, visible: bool) void {

View File

@ -65,6 +65,7 @@ pub fn packStart(self: HeaderBarAdw, widget: *c.GtkWidget) void {
}
pub fn setTitle(self: HeaderBarAdw, title: [:0]const u8) void {
c.gtk_window_set_title(self.window.window, title);
if (comptime adwaita.versionAtLeast(0, 0, 0)) {
c.adw_window_title_set_title(self.title, title);
}

View File

@ -137,6 +137,8 @@ pub const NotebookAdw = struct {
// If we have no more tabs we close the window
if (self.nPages() == 0) {
const window = tab.window.window;
// libadw versions <= 1.3.x leak the final page view
// which causes our surface to not properly cleanup. We
// unref to force the cleanup. This will trigger a critical
@ -150,7 +152,7 @@ pub const NotebookAdw = struct {
// `self` will become invalid after this call because it will have
// been freed up as part of the process of closing the window.
c.gtk_window_destroy(tab.window.window);
c.gtk_window_destroy(window);
}
}
};

View File

@ -62,7 +62,7 @@ pub const App = union(Protocol) {
/// Per-Window state for the underlying windowing protocol.
///
/// In both X and Wayland, the terminology used is "Surface" and this is
/// In Wayland, the terminology used is "Surface" and for it, this is
/// really "Surface"-specific state. But Ghostty uses the term "Surface"
/// heavily to mean something completely different, so we use "Window" here
/// to better match what it generally maps to in the Ghostty codebase.
@ -125,4 +125,10 @@ pub const Window = union(Protocol) {
inline else => |*v| try v.syncAppearance(),
}
}
pub fn clientSideDecorationEnabled(self: Window) bool {
return switch (self) {
inline else => |v| v.clientSideDecorationEnabled(),
};
}
};

View File

@ -53,4 +53,12 @@ pub const Window = struct {
pub fn resizeEvent(_: *Window) !void {}
pub fn syncAppearance(_: *Window) !void {}
/// This returns true if CSD is enabled for this window. This
/// should be the actual present state of the window, not the
/// desired state.
pub fn clientSideDecorationEnabled(self: Window) bool {
_ = self;
return true;
}
};

View File

@ -18,6 +18,12 @@ pub const App = struct {
const Context = struct {
kde_blur_manager: ?*org.KdeKwinBlurManager = null,
// FIXME: replace with `zxdg_decoration_v1` once GTK merges
// https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null,
default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null,
};
pub fn init(
@ -53,6 +59,12 @@ pub const App = struct {
registry.setListener(*Context, registryListener, context);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
if (context.kde_decoration_manager != null) {
// FIXME: Roundtrip again because we have to wait for the decoration
// manager to respond with the preferred default mode. Ew.
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
}
return .{
.display = display,
.context = context,
@ -78,17 +90,22 @@ pub const App = struct {
) void {
switch (event) {
// https://wayland.app/protocols/wayland#wl_registry:event:global
.global => |global| global: {
.global => |global| {
log.debug("wl_registry.global: interface={s}", .{global.interface});
if (registryBind(
org.KdeKwinBlurManager,
registry,
global,
1,
)) |blur_manager| {
context.kde_blur_manager = blur_manager;
break :global;
} else if (registryBind(
org.KdeKwinServerDecorationManager,
registry,
global,
)) |deco_manager| {
context.kde_decoration_manager = deco_manager;
deco_manager.setListener(*Context, decoManagerListener, context);
}
},
@ -97,11 +114,16 @@ pub const App = struct {
}
}
/// Bind a Wayland interface to a global object. Returns non-null
/// if the binding was successful, otherwise null.
///
/// The type T is the Wayland interface type that we're requesting.
/// This function will verify that the global object is the correct
/// interface and version before binding.
fn registryBind(
comptime T: type,
registry: *wl.Registry,
global: anytype,
version: u32,
) ?*T {
if (std.mem.orderZ(
u8,
@ -109,7 +131,7 @@ pub const App = struct {
T.interface.name,
) != .eq) return null;
return registry.bind(global.name, T, version) catch |err| {
return registry.bind(global.name, T, T.generated_version) catch |err| {
log.warn("error binding interface {s} error={}", .{
global.interface,
err,
@ -117,6 +139,18 @@ pub const App = struct {
return null;
};
}
fn decoManagerListener(
_: *org.KdeKwinServerDecorationManager,
event: org.KdeKwinServerDecorationManager.Event,
context: *Context,
) void {
switch (event) {
.default_mode => |mode| {
context.default_deco_mode = @enumFromInt(mode.mode);
},
}
}
};
/// Per-window (wl_surface) state for the Wayland protocol.
@ -130,14 +164,20 @@ pub const Window = struct {
app_context: *App.Context,
/// A token that, when present, indicates that the window is blurred.
blur_token: ?*org.KdeKwinBlur = null,
blur_token: ?*org.KdeKwinBlur,
/// Object that controls the decoration mode (client/server/auto)
/// of the window.
decoration: ?*org.KdeKwinServerDecoration,
const DerivedConfig = struct {
blur: bool,
window_decoration: Config.WindowDecoration,
pub fn init(config: *const Config) DerivedConfig {
return .{
.blur = config.@"background-blur-radius".enabled(),
.blur = config.@"background-blur".enabled(),
.window_decoration = config.@"window-decoration",
};
}
};
@ -165,19 +205,41 @@ pub const Window = struct {
gdk_surface,
) orelse return error.NoWaylandSurface);
// Get our decoration object so we can control the
// CSD vs SSD status of this surface.
const deco: ?*org.KdeKwinServerDecoration = deco: {
const mgr = app.context.kde_decoration_manager orelse
break :deco null;
const deco: *org.KdeKwinServerDecoration = mgr.create(
wl_surface,
) catch |err| {
log.warn("could not create decoration object={}", .{err});
break :deco null;
};
break :deco deco;
};
return .{
.config = DerivedConfig.init(config),
.surface = wl_surface,
.app_context = app.context,
.blur_token = null,
.decoration = deco,
};
}
pub fn deinit(self: Window, alloc: Allocator) void {
_ = alloc;
if (self.blur_token) |blur| blur.release();
if (self.decoration) |deco| deco.release();
}
pub fn updateConfigEvent(self: *Window, config: *const Config) !void {
pub fn updateConfigEvent(
self: *Window,
config: *const Config,
) !void {
self.config = DerivedConfig.init(config);
}
@ -185,6 +247,18 @@ pub const Window = struct {
pub fn syncAppearance(self: *Window) !void {
try self.syncBlur();
try self.syncDecoration();
}
pub fn clientSideDecorationEnabled(self: Window) bool {
// Compositor doesn't support the SSD protocol
if (self.decoration == null) return true;
return switch (self.getDecorationMode()) {
.Client => true,
.Server, .None => false,
else => unreachable,
};
}
/// Update the blur state of the window.
@ -208,4 +282,21 @@ pub const Window = struct {
}
}
}
fn syncDecoration(self: *Window) !void {
const deco = self.decoration orelse return;
// The protocol requests uint instead of enum so we have
// to convert it.
deco.requestMode(@intCast(@intFromEnum(self.getDecorationMode())));
}
fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode {
return switch (self.config.window_decoration) {
.auto => self.app_context.default_deco_mode orelse .Client,
.client => .Client,
.server => .Server,
.none => .None,
};
}
};

View File

@ -161,10 +161,15 @@ pub const Window = struct {
const DerivedConfig = struct {
blur: bool,
has_decoration: bool,
pub fn init(config: *const Config) DerivedConfig {
return .{
.blur = config.@"background-blur-radius".enabled(),
.blur = config.@"background-blur".enabled(),
.has_decoration = switch (config.@"window-decoration") {
.none => false,
.auto, .client, .server => true,
},
};
}
};
@ -239,6 +244,10 @@ pub const Window = struct {
try self.syncBlur();
}
pub fn clientSideDecorationEnabled(self: Window) bool {
return self.config.has_decoration;
}
fn syncBlur(self: *Window) !void {
// FIXME: This doesn't currently factor in rounded corners on Adwaita,
// which means that the blur region will grow slightly outside of the

View File

@ -55,6 +55,8 @@ emit_helpgen: bool = false,
emit_docs: bool = false,
emit_webdata: bool = false,
emit_xcframework: bool = false,
emit_terminfo: bool = false,
emit_termcap: bool = false,
/// Environmental properties
env: std.process.EnvMap,
@ -306,6 +308,27 @@ pub fn init(b: *std.Build) !Config {
break :emit_docs path != null;
};
config.emit_terminfo = b.option(
bool,
"emit-terminfo",
"Install Ghostty terminfo source file",
) orelse switch (target.result.os.tag) {
.windows => true,
else => switch (optimize) {
.Debug => true,
.ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
},
};
config.emit_termcap = b.option(
bool,
"emit-termcap",
"Install Ghostty termcap file",
) orelse switch (optimize) {
.Debug => true,
.ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
};
config.emit_webdata = b.option(
bool,
"emit-webdata",
@ -486,6 +509,7 @@ pub const ExeEntrypoint = enum {
mdgen_ghostty_5,
webgen_config,
webgen_actions,
webgen_commands,
bench_parser,
bench_stream,
bench_codepoint_width,

View File

@ -23,9 +23,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
// Write it
var wf = b.addWriteFiles();
const src_source = wf.add("share/terminfo/ghostty.terminfo", str.items);
const src_install = b.addInstallFile(src_source, "share/terminfo/ghostty.terminfo");
try steps.append(&src_install.step);
const source = wf.add("ghostty.terminfo", str.items);
if (cfg.emit_terminfo) {
const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo");
try steps.append(&source_install.step);
}
// Windows doesn't have the binaries below.
if (cfg.target.result.os.tag == .windows) break :terminfo;
@ -33,10 +36,10 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
// Convert to termcap source format if thats helpful to people and
// install it. The resulting value here is the termcap source in case
// that is used for other commands.
{
if (cfg.emit_termcap) {
const run_step = RunStep.create(b, "infotocap");
run_step.addArg("infotocap");
run_step.addFileArg(src_source);
run_step.addFileArg(source);
const out_source = run_step.captureStdOut();
_ = run_step.captureStdErr(); // so we don't see stderr
@ -49,14 +52,20 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
const run_step = RunStep.create(b, "tic");
run_step.addArgs(&.{ "tic", "-x", "-o" });
const path = run_step.addOutputFileArg("terminfo");
run_step.addFileArg(src_source);
run_step.addFileArg(source);
_ = run_step.captureStdErr(); // so we don't see stderr
// Depend on the terminfo source install step so that Zig build
// creates the "share" directory for us.
run_step.step.dependOn(&src_install.step);
// Ensure that `share/terminfo` is a directory, otherwise the `cp
// -R` will create a file named `share/terminfo`
const mkdir_step = RunStep.create(b, "make share/terminfo directory");
switch (cfg.target.result.os.tag) {
// windows mkdir shouldn't need "-p"
.windows => mkdir_step.addArgs(&.{"mkdir"}),
else => mkdir_step.addArgs(&.{ "mkdir", "-p" }),
}
mkdir_step.addArg(b.fmt("{s}/share/terminfo", .{b.install_path}));
try steps.append(&mkdir_step.step);
{
// Use cp -R instead of Step.InstallDir because we need to preserve
// symlinks in the terminfo database. Zig's InstallDir step doesn't
// handle symlinks correctly yet.
@ -64,10 +73,10 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
copy_step.addArgs(&.{ "cp", "-R" });
copy_step.addFileArg(path);
copy_step.addArg(b.fmt("{s}/share", .{b.install_path}));
copy_step.step.dependOn(&mkdir_step.step);
try steps.append(&copy_step.step);
}
}
}
// Shell-integration
{

View File

@ -73,6 +73,35 @@ pub fn init(
).step);
}
{
const webgen_commands = b.addExecutable(.{
.name = "webgen_commands",
.root_source_file = b.path("src/main.zig"),
.target = b.host,
});
deps.help_strings.addImport(webgen_commands);
{
const buildconfig = config: {
var copy = deps.config.*;
copy.exe_entrypoint = .webgen_commands;
break :config copy;
};
const options = b.addOptions();
try buildconfig.addOptions(options);
webgen_commands.root_module.addOptions("build_options", options);
}
const webgen_commands_step = b.addRunArtifact(webgen_commands);
const webgen_commands_out = webgen_commands_step.captureStdOut();
try steps.append(&b.addInstallFile(
webgen_commands_out,
"share/ghostty/webdata/commands.mdx",
).step);
}
return .{ .steps = steps.items };
}

View File

@ -450,10 +450,14 @@ pub fn add(
.target = target,
.optimize = optimize,
});
// FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml"));
scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/server-decoration.xml"));
scanner.generate("wl_compositor", 1);
scanner.generate("org_kde_kwin_blur_manager", 1);
scanner.generate("org_kde_kwin_server_decoration_manager", 1);
step.root_module.addImport("wayland", wayland);
step.linkSystemLibrary2("wayland-client", dynamic_link_opts);

View File

@ -15,7 +15,6 @@ pub fn init(b: *std.Build) !UnicodeTables {
.root_source_file = b.path("src/unicode/props.zig"),
.target = b.host,
});
exe.linkLibC();
const ziglyph_dep = b.dependency("ziglyph", .{
.target = b.host,

View File

@ -0,0 +1,51 @@
const std = @import("std");
const Action = @import("../../cli/action.zig").Action;
const help_strings = @import("help_strings");
pub fn main() !void {
const output = std.io.getStdOut().writer();
try genActions(output);
}
// Note: as a shortcut for defining inline editOnGithubLinks per cli action the user
// is directed to the folder view on Github. This includes a README pointing them to
// the files to edit.
pub fn genActions(writer: anytype) !void {
// Write the header
try writer.writeAll(
\\---
\\title: Reference
\\description: Reference of all Ghostty action subcommands.
\\editOnGithubLink: https://github.com/ghostty-org/ghostty/tree/main/src/cli
\\---
\\Ghostty includes a number of utility actions that can be accessed as subcommands.
\\Actions provide utilities to work with config, list keybinds, list fonts, demo themes,
\\and debug.
\\
);
inline for (@typeInfo(Action).Enum.fields) |field| {
const action = std.meta.stringToEnum(Action, field.name).?;
switch (action) {
.help, .version => try writer.writeAll("## " ++ field.name ++ "\n"),
else => try writer.writeAll("## " ++ field.name ++ "\n"),
}
if (@hasDecl(help_strings.Action, field.name)) {
var iter = std.mem.splitScalar(u8, @field(help_strings.Action, field.name), '\n');
var first = true;
while (iter.next()) |s| {
try writer.writeAll(s);
try writer.writeAll("\n");
first = false;
}
try writer.writeAll("\n```\n");
switch (action) {
.help, .version => try writer.writeAll("ghostty --" ++ field.name ++ "\n"),
else => try writer.writeAll("ghostty +" ++ field.name ++ "\n"),
}
try writer.writeAll("```\n\n");
}
}
}

13
src/cli/README.md Normal file
View File

@ -0,0 +1,13 @@
# Subcommand Actions
This is the cli specific code. It contains cli actions and tui definitions and
argument parsing.
This README is meant as developer documentation and not as user documentation.
For user documentation, see the main README or [ghostty.org](https://ghostty.org/docs).
## Updating documentation
Each cli action is defined in it's own file. Documentation for each action is defined
in the doc comment associated with the `run` function. For example the `run` function
in `list_keybinds.zig` contains the help text for `ghostty +list-keybinds`.

View File

@ -45,12 +45,12 @@ pub const Action = enum {
// Validate passed config file
@"validate-config",
// List, (eventually) view, and (eventually) send crash reports.
@"crash-report",
// Show which font face Ghostty loads a codepoint from.
@"show-face",
// List, (eventually) view, and (eventually) send crash reports.
@"crash-report",
pub const Error = error{
/// Multiple actions were detected. You can specify at most one
/// action on the CLI otherwise the behavior desired is ambiguous.

View File

@ -38,6 +38,12 @@ pub const Error = error{
/// "DiagnosticList" and any diagnostic messages will be added to that list.
/// When diagnostics are present, only allocation errors will be returned.
///
/// If the destination type has a decl "renamed", it must be of type
/// std.StaticStringMap([]const u8) and contains a mapping from the old
/// field name to the new field name. This is used to allow renaming fields
/// while still supporting the old name. If a renamed field is set, parsing
/// will automatically set the new field name.
///
/// Note: If the arena is already non-null, then it will be used. In this
/// case, in the case of an error some memory might be leaked into the arena.
pub fn parse(
@ -49,6 +55,24 @@ pub fn parse(
const info = @typeInfo(T);
assert(info == .Struct);
comptime {
// Verify all renamed fields are valid (source does not exist,
// destination does exist).
if (@hasDecl(T, "renamed")) {
for (T.renamed.keys(), T.renamed.values()) |key, value| {
if (@hasField(T, key)) {
@compileLog(key);
@compileError("renamed field source exists");
}
if (!@hasField(T, value)) {
@compileLog(value);
@compileError("renamed field destination does not exist");
}
}
}
}
// Make an arena for all our allocations if we support it. Otherwise,
// use an allocator that always fails. If the arena is already set on
// the config, then we reuse that. See memory note in parse docs.
@ -367,6 +391,16 @@ pub fn parseIntoField(
}
}
// Unknown field, is the field renamed?
if (@hasDecl(T, "renamed")) {
for (T.renamed.keys(), T.renamed.values()) |old, new| {
if (mem.eql(u8, old, key)) {
try parseIntoField(T, alloc, dst, new, value);
return;
}
}
}
return error.InvalidField;
}
@ -1104,6 +1138,24 @@ test "parseIntoField: tagged union missing tag" {
);
}
test "parseIntoField: renamed field" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var data: struct {
a: []const u8,
const renamed = std.StaticStringMap([]const u8).initComptime(&.{
.{ "old", "a" },
});
} = undefined;
try parseIntoField(@TypeOf(data), alloc, &data, "old", "42");
try testing.expectEqualStrings("42", data.a);
}
/// An iterator that considers its location to be CLI args. It
/// iterates through an underlying iterator and increments a counter
/// to track the current CLI arg index.

View File

@ -15,9 +15,11 @@ pub const Options = struct {
}
};
/// The `help` command shows general help about Ghostty. You can also specify
/// `--help` or `-h` along with any action such as `+list-themes` to see help
/// for a specific action.
/// The `help` command shows general help about Ghostty. Recognized as either
/// `-h, `--help`, or like other actions `+help`.
///
/// You can also specify `--help` or `-h` along with any action such as
/// `+list-themes` to see help for a specific action.
pub fn run(alloc: Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();

View File

@ -24,7 +24,9 @@ pub const Options = struct {
/// actions for Ghostty. These are distinct from the CLI Actions which can
/// be listed via `+help`
///
/// The `--docs` argument will print out the documentation for each action.
/// Flags:
///
/// * `--docs`: will print out the documentation for each action.
pub fn run(alloc: Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();

View File

@ -44,14 +44,21 @@ pub const Options = struct {
/// the sorting will be disabled and the results instead will be shown in the
/// same priority order Ghostty would use to pick a font.
///
/// The `--family` argument can be used to filter results to a specific family.
/// The family handling is identical to the `font-family` set of Ghostty
/// configuration values, so this can be used to debug why your desired font may
/// not be loading.
/// Flags:
///
/// The `--bold` and `--italic` arguments can be used to filter results to
/// specific styles. It is not guaranteed that only those styles are returned,
/// it will just prioritize fonts that match those styles.
/// * `--bold`: Filter results to specific bold styles. It is not guaranteed
/// that only those styles are returned. They are only prioritized.
///
/// * `--italic`: Filter results to specific italic styles. It is not guaranteed
/// that only those styles are returned. They are only prioritized.
///
/// * `--style`: Filter results based on the style string advertised by a font.
/// It is not guaranteed that only those styles are returned. They are only
/// prioritized.
///
/// * `--family`: Filter results to a specific font family. The family handling
/// is identical to the `font-family` set of Ghostty configuration values, so
/// this can be used to debug why your desired font may not be loading.
pub fn run(alloc: Allocator) !u8 {
var iter = try args.argsIterator(alloc);
defer iter.deinit();

View File

@ -42,10 +42,14 @@ pub const Options = struct {
/// changes to the keybinds it will print out the default ones configured for
/// Ghostty
///
/// The `--default` argument will print out all the default keybinds configured
/// for Ghostty
/// Flags:
///
/// The `--plain` flag will disable formatting and make the output more
/// * `--default`: will print out all the default keybinds
///
/// * `--docs`: currently does nothing, intended to print out documentation
/// about the action associated with the keybinds
///
/// * `--plain`: will disable formatting and make the output more
/// friendly for Unix tooling. This is default when not printing to a tty.
pub fn run(alloc: Allocator) !u8 {
var opts: Options = .{};
@ -64,7 +68,9 @@ pub fn run(alloc: Allocator) !u8 {
// Despite being under the posix namespace, this also works on Windows as of zig 0.13.0
if (tui.can_pretty_print and !opts.plain and std.posix.isatty(stdout.handle)) {
return prettyPrint(alloc, config.keybind);
var arena = std.heap.ArenaAllocator.init(alloc);
defer arena.deinit();
return prettyPrint(arena.allocator(), config.keybind);
} else {
try config.keybind.formatEntryDocs(
configpkg.entryFormatter("keybind", stdout.writer()),
@ -75,6 +81,111 @@ pub fn run(alloc: Allocator) !u8 {
return 0;
}
const TriggerList = std.SinglyLinkedList(Binding.Trigger);
const ChordBinding = struct {
triggers: TriggerList,
action: 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
// These properties propagate through chorded keypresses
//
// Adapted from Binding.lessThan
pub fn lessThan(_: void, lhs: ChordBinding, rhs: ChordBinding) bool {
const lhs_len = lhs.triggers.len();
const rhs_len = rhs.triggers.len();
std.debug.assert(lhs_len != 0);
std.debug.assert(rhs_len != 0);
if (lhs_len != rhs_len) {
return lhs_len > rhs_len;
}
const lhs_count: usize = blk: {
var count: usize = 0;
var maybe_trigger = lhs.triggers.first;
while (maybe_trigger) |trigger| : (maybe_trigger = trigger.next) {
if (trigger.data.mods.super) count += 1;
if (trigger.data.mods.ctrl) count += 1;
if (trigger.data.mods.shift) count += 1;
if (trigger.data.mods.alt) count += 1;
}
break :blk count;
};
const rhs_count: usize = blk: {
var count: usize = 0;
var maybe_trigger = rhs.triggers.first;
while (maybe_trigger) |trigger| : (maybe_trigger = trigger.next) {
if (trigger.data.mods.super) count += 1;
if (trigger.data.mods.ctrl) count += 1;
if (trigger.data.mods.shift) count += 1;
if (trigger.data.mods.alt) count += 1;
}
break :blk count;
};
if (lhs_count != rhs_count)
return lhs_count > rhs_count;
{
var l_trigger = lhs.triggers.first;
var r_trigger = rhs.triggers.first;
while (l_trigger != null and r_trigger != null) {
const l_int = l_trigger.?.data.mods.int();
const r_int = r_trigger.?.data.mods.int();
if (l_int != r_int) {
return l_int > r_int;
}
l_trigger = l_trigger.?.next;
r_trigger = r_trigger.?.next;
}
}
var l_trigger = lhs.triggers.first;
var r_trigger = rhs.triggers.first;
while (l_trigger != null and r_trigger != null) {
const lhs_key: c_int = blk: {
switch (l_trigger.?.data.key) {
.translated => |key| break :blk @intFromEnum(key),
.physical => |key| break :blk @intFromEnum(key),
.unicode => |key| break :blk @intCast(key),
}
};
const rhs_key: c_int = blk: {
switch (r_trigger.?.data.key) {
.translated => |key| break :blk @intFromEnum(key),
.physical => |key| break :blk @intFromEnum(key),
.unicode => |key| break :blk @intCast(key),
}
};
l_trigger = l_trigger.?.next;
r_trigger = r_trigger.?.next;
if (l_trigger == null or r_trigger == null) {
return lhs_key < rhs_key;
}
if (lhs_key != rhs_key) {
return lhs_key < rhs_key;
}
}
// The previous loop will always return something on its final iteration so we cannot
// reach this point
unreachable;
}
};
fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
// Set up vaxis
var tty = try vaxis.Tty.init();
@ -107,26 +218,11 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
const win = vx.window();
// Get all of our keybinds into a list. We also search for the longest printed keyname so we can
// align things nicely
// Generate a list of bindings, recursively traversing chorded keybindings
var iter = keybinds.set.bindings.iterator();
var bindings = std.ArrayList(Binding).init(alloc);
var widest_key: u16 = 0;
var buf: [64]u8 = undefined;
while (iter.next()) |bind| {
const action = switch (bind.value_ptr.*) {
.leader => continue, // TODO: support this
.leaf => |leaf| leaf.action,
};
const key = switch (bind.key_ptr.key) {
.translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}),
.physical => |k| try std.fmt.bufPrint(&buf, "physical:{s}", .{@tagName(k)}),
.unicode => |c| try std.fmt.bufPrint(&buf, "{u}", .{c}),
};
widest_key = @max(widest_key, win.gwidth(key));
try bindings.append(.{ .trigger = bind.key_ptr.*, .action = action });
}
std.mem.sort(Binding, bindings.items, {}, Binding.lessThan);
const bindings, const widest_chord = try iterateBindings(alloc, &iter, &win);
std.mem.sort(ChordBinding, bindings, {}, ChordBinding.lessThan);
// Set up styles for each modifier
const super_style: vaxis.Style = .{ .fg = .{ .index = 1 } };
@ -134,41 +230,41 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
const alt_style: vaxis.Style = .{ .fg = .{ .index = 3 } };
const shift_style: vaxis.Style = .{ .fg = .{ .index = 4 } };
var longest_col: u16 = 0;
// Print the list
for (bindings.items) |bind| {
for (bindings) |bind| {
win.clear();
var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false };
const trigger = bind.trigger;
if (trigger.mods.super) {
var maybe_trigger = bind.triggers.first;
while (maybe_trigger) |trigger| : (maybe_trigger = trigger.next) {
if (trigger.data.mods.super) {
result = win.printSegment(.{ .text = "super", .style = super_style }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
}
if (trigger.mods.ctrl) {
if (trigger.data.mods.ctrl) {
result = win.printSegment(.{ .text = "ctrl ", .style = ctrl_style }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
}
if (trigger.mods.alt) {
if (trigger.data.mods.alt) {
result = win.printSegment(.{ .text = "alt ", .style = alt_style }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
}
if (trigger.mods.shift) {
if (trigger.data.mods.shift) {
result = win.printSegment(.{ .text = "shift", .style = shift_style }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
}
const key = switch (trigger.key) {
const key = switch (trigger.data.key) {
.translated => |k| try std.fmt.allocPrint(alloc, "{s}", .{@tagName(k)}),
.physical => |k| try std.fmt.allocPrint(alloc, "physical:{s}", .{@tagName(k)}),
.unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}),
};
// We don't track the key print because we index the action off the *widest* key so we get
// nice alignment no matter what was printed for mods
_ = win.printSegment(.{ .text = key }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col });
if (longest_col < result.col) longest_col = result.col;
// Print a separator between chorded keys
if (trigger.next != null) {
result = win.printSegment(.{ .text = " > ", .style = .{ .bold = true, .fg = .{ .index = 6 } } }, .{ .col_offset = result.col });
}
}
const action = try std.fmt.allocPrint(alloc, "{}", .{bind.action});
// If our action has an argument, we print the argument in a different color
@ -177,12 +273,69 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
.{ .text = action[0..idx] },
.{ .text = action[idx .. idx + 1], .style = .{ .dim = true } },
.{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } },
}, .{ .col_offset = longest_col + widest_key + 2 });
}, .{ .col_offset = widest_chord + 3 });
} else {
_ = win.printSegment(.{ .text = action }, .{ .col_offset = longest_col + widest_key + 2 });
_ = win.printSegment(.{ .text = action }, .{ .col_offset = widest_chord + 3 });
}
try vx.prettyPrint(writer);
}
try buf_writer.flush();
return 0;
}
fn iterateBindings(alloc: Allocator, iter: anytype, win: *const vaxis.Window) !struct { []ChordBinding, u16 } {
var widest_chord: u16 = 0;
var bindings = std.ArrayList(ChordBinding).init(alloc);
while (iter.next()) |bind| {
const width = blk: {
var buf = std.ArrayList(u8).init(alloc);
const t = bind.key_ptr.*;
if (t.mods.super) try std.fmt.format(buf.writer(), "super + ", .{});
if (t.mods.ctrl) try std.fmt.format(buf.writer(), "ctrl + ", .{});
if (t.mods.alt) try std.fmt.format(buf.writer(), "alt + ", .{});
if (t.mods.shift) try std.fmt.format(buf.writer(), "shift + ", .{});
switch (t.key) {
.translated => |k| try std.fmt.format(buf.writer(), "{s}", .{@tagName(k)}),
.physical => |k| try std.fmt.format(buf.writer(), "physical:{s}", .{@tagName(k)}),
.unicode => |c| try std.fmt.format(buf.writer(), "{u}", .{c}),
}
break :blk win.gwidth(buf.items);
};
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);
// Prepend the current keybind onto the list of sub-binds
for (sub_bindings) |*nb| {
const prepend_node = try alloc.create(TriggerList.Node);
prepend_node.* = TriggerList.Node{ .data = bind.key_ptr.* };
nb.triggers.prepend(prepend_node);
}
// Add the longest sub-bind width to the current bind width along with a padding
// of 5 for the ' > ' spacer
widest_chord = @max(widest_chord, width + max_width + 5);
try bindings.appendSlice(sub_bindings);
},
.leaf => |leaf| {
const node = try alloc.create(TriggerList.Node);
node.* = TriggerList.Node{ .data = bind.key_ptr.* };
const triggers = TriggerList{
.first = node,
};
widest_chord = @max(widest_chord, width);
try bindings.append(.{ .triggers = triggers, .action = leaf.action });
},
}
}
return .{ try bindings.toOwnedSlice(), widest_chord };
}

View File

@ -91,6 +91,7 @@ const ThemeListElement = struct {
/// Flags:
///
/// * `--path`: Show the full path to the theme.
///
/// * `--plain`: Force a plain listing of themes.
pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
var opts: Options = .{};

View File

@ -23,10 +23,13 @@ pub const Options = struct {
/// The `validate-config` command is used to validate a Ghostty config file.
///
/// When executed without any arguments, this will load the config from the default location.
/// When executed without any arguments, this will load the config from the default
/// location.
///
/// The `--config-file` argument can be passed to validate a specific target config
/// file in a non-default location.
/// Flags:
///
/// * `--config-file`: can be passed to validate a specific target config file in
/// a non-default location
pub fn run(alloc: std.mem.Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();

View File

@ -10,7 +10,8 @@ const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").
pub const Options = struct {};
/// The `version` command is used to display information about Ghostty.
/// The `version` command is used to display information about Ghostty. Recognized as
/// either `+version` or `--version`.
pub fn run(alloc: Allocator) !u8 {
_ = alloc;

View File

@ -42,6 +42,15 @@ const c = @cImport({
@cInclude("unistd.h");
});
/// Renamed fields, used by cli.parse
pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{
// Ghostty 1.1 introduced background-blur support for Linux which
// doesn't support a specific radius value. The renaming is to let
// one field be used for both platforms (macOS retained the ability
// to set a radius).
.{ "background-blur-radius", "background-blur" },
});
/// The font families to use.
///
/// You can generate the list of valid values using the CLI:
@ -248,6 +257,32 @@ const c = @cImport({
/// This is currently only supported on macOS.
@"font-thicken-strength": u8 = 255,
/// What color space to use when performing alpha blending.
///
/// This affects how text looks for different background/foreground color pairs.
///
/// Valid values:
///
/// * `native` - Perform alpha blending in the native color space for the OS.
/// On macOS this corresponds to Display P3, and on Linux it's sRGB.
///
/// * `linear` - Perform alpha blending in linear space. This will eliminate
/// the darkening artifacts around the edges of text that are very visible
/// when certain color combinations are used (e.g. red / green), but makes
/// dark text look much thinner than normal and light text much thicker.
/// This is also sometimes known as "gamma correction".
/// (Currently only supported on macOS. Has no effect on Linux.)
///
/// * `linear-corrected` - Corrects the thinning/thickening effect of linear
/// by applying a correction curve to the text alpha depending on its
/// brightness. This compensates for the thinning and makes the weight of
/// most text appear very similar to when it's blended non-linearly.
///
/// Note: This setting affects more than just text, images will also be blended
/// in the selected color space, and custom shaders will receive colors in that
/// color space as well.
@"text-blending": TextBlending = .native,
/// All of the configurations behavior adjust various metrics determined by the
/// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%,
/// etc.). In each case, the values represent the amount to change the original
@ -256,7 +291,7 @@ const c = @cImport({
/// For example, a value of `1` increases the value by 1; it does not set it to
/// literally 1. A value of `20%` increases the value by 20%. And so on.
///
/// There is little to no validation on these values so the wrong values (i.e.
/// There is little to no validation on these values so the wrong values (e.g.
/// `-100%`) can cause the terminal to be unusable. Use with caution and reason.
///
/// Some values are clamped to minimum or maximum values. This can make it
@ -441,7 +476,7 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
/// The minimum contrast ratio between the foreground and background colors.
/// The contrast ratio is a value between 1 and 21. A value of 1 allows for no
/// contrast (i.e. black on black). This value is the contrast ratio as defined
/// contrast (e.g. black on black). This value is the contrast ratio as defined
/// by the [WCAG 2.0 specification](https://www.w3.org/TR/WCAG20/).
///
/// If you want to avoid invisible text (same color as background), a value of
@ -623,7 +658,7 @@ palette: Palette = .{},
/// need to set environment-specific settings and/or install third-party plugins
/// in order to support background blur, as there isn't a unified interface for
/// doing so.
@"background-blur-radius": BackgroundBlur = .false,
@"background-blur": BackgroundBlur = .false,
/// The opacity level (opposite of transparency) of an unfocused split.
/// Unfocused splits by default are slightly faded out to make it easier to see
@ -696,7 +731,7 @@ command: ?[]const u8 = null,
/// injecting any configured shell integration into the command's
/// environment. With `-e` its highly unlikely that you're executing a
/// shell and forced shell integration is likely to cause problems
/// (i.e. by wrapping your command in a shell, setting env vars, etc.).
/// (e.g. by wrapping your command in a shell, setting env vars, etc.).
/// This is a safety measure to prevent unexpected behavior. If you want
/// shell integration with a `-e`-executed command, you must either
/// name your binary appropriately or source the shell integration script
@ -744,7 +779,7 @@ command: ?[]const u8 = null,
/// Match a regular expression against the terminal text and associate clicking
/// it with an action. This can be used to match URLs, file paths, etc. Actions
/// can be opening using the system opener (i.e. `open` or `xdg-open`) or
/// can be opening using the system opener (e.g. `open` or `xdg-open`) or
/// executing any arbitrary binding action.
///
/// Links that are configured earlier take precedence over links that are
@ -764,6 +799,11 @@ link: RepeatableLink = .{},
/// `link`). If you want to customize URL matching, use `link` and disable this.
@"link-url": bool = true,
/// Whether to start the window in a maximized state. This setting applies
/// to new windows and does not apply to tabs, splits, etc. However, this setting
/// will apply to all new windows, not just the first one.
maximize: bool = false,
/// Start new windows in fullscreen. This setting applies to new windows and
/// does not apply to tabs, splits, etc. However, this setting will apply to all
/// new windows, not just the first one.
@ -845,7 +885,7 @@ class: ?[:0]const u8 = null,
/// Valid keys are currently only listed in the
/// [Ghostty source code](https://github.com/ghostty-org/ghostty/blob/d6e76858164d52cff460fedc61ddf2e560912d71/src/input/key.zig#L255).
/// This is a documentation limitation and we will improve this in the future.
/// A common gotcha is that numeric keys are written as words: i.e. `one`,
/// A common gotcha is that numeric keys are written as words: e.g. `one`,
/// `two`, `three`, etc. and not `1`, `2`, `3`. This will also be improved in
/// the future.
///
@ -888,7 +928,7 @@ class: ?[:0]const u8 = null,
/// * Ghostty will wait an indefinite amount of time for the next key in
/// the sequence. There is no way to specify a timeout. The only way to
/// force the output of a prefix key is to assign another keybind to
/// specifically output that key (i.e. `ctrl+a>ctrl+a=text:foo`) or
/// 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 a prefix in a sequence is previously bound, the sequence will
@ -918,13 +958,13 @@ class: ?[:0]const u8 = null,
/// including `physical:`-prefixed triggers without specifying the
/// prefix.
///
/// * `csi:text` - Send a CSI sequence. i.e. `csi:A` sends "cursor up".
/// * `csi:text` - Send a CSI sequence. e.g. `csi:A` sends "cursor up".
///
/// * `esc:text` - Send an escape sequence. i.e. `esc:d` deletes to the
/// * `esc:text` - Send an escape sequence. e.g. `esc:d` deletes to the
/// end of the word to the right.
///
/// * `text:text` - Send a string. Uses Zig string literal syntax.
/// i.e. `text:\x15` sends Ctrl-U.
/// e.g. `text:\x15` sends Ctrl-U.
///
/// * All other actions can be found in the documentation or by using the
/// `ghostty +list-actions` command.
@ -950,12 +990,12 @@ class: ?[:0]const u8 = null,
/// keybinds only apply to the focused terminal surface. If this is true,
/// then the keybind will be sent to all terminal surfaces. This only
/// applies to actions that are surface-specific. For actions that
/// are already global (i.e. `quit`), this prefix has no effect.
/// are already global (e.g. `quit`), this prefix has no effect.
///
/// * `global:` - Make the keybind global. By default, keybinds only work
/// within Ghostty and under the right conditions (application focused,
/// sometimes terminal focused, etc.). If you want a keybind to work
/// globally across your system (i.e. even when Ghostty is not focused),
/// globally across your system (e.g. even when Ghostty is not focused),
/// specify this prefix. This prefix implies `all:`. Note: this does not
/// work in all environments; see the additional notes below for more
/// information.
@ -1056,7 +1096,7 @@ keybind: Keybinds = .{},
/// any of the heuristics that disable extending noted below.
///
/// The "extend" value will be disabled in certain scenarios. On primary
/// screen applications (i.e. not something like Neovim), the color will not
/// screen applications (e.g. not something like Neovim), the color will not
/// be extended vertically if any of the following are true:
///
/// * The nearest row has any cells that have the default background color.
@ -1096,21 +1136,52 @@ keybind: Keybinds = .{},
/// configuration `font-size` will be used.
@"window-inherit-font-size": bool = true,
/// Configure a preference for window decorations. This setting specifies
/// a _preference_; the actual OS, desktop environment, window manager, etc.
/// may override this preference. Ghostty will do its best to respect this
/// preference but it may not always be possible.
///
/// Valid values:
///
/// * `true`
/// * `false` - windows won't have native decorations, i.e. titlebar and
/// borders. On macOS this also disables tabs and tab overview.
/// * `none` - All window decorations will be disabled. Titlebar,
/// borders, etc. will not be shown. On macOS, this will also disable
/// tabs (enforced by the system).
///
/// * `auto` - Automatically decide to use either client-side or server-side
/// decorations based on the detected preferences of the current OS and
/// desktop environment. This option usually makes Ghostty look the most
/// "native" for your desktop.
///
/// * `client` - Prefer client-side decorations.
///
/// * `server` - Prefer server-side decorations. This is only relevant
/// on Linux with GTK. This currently only works on Linux with Wayland
/// and the `org_kde_kwin_server_decoration` protocol available (e.g.
/// KDE Plasma, but almost any non-GNOME desktop supports this protocol).
///
/// If `server` is set but the environment doesn't support server-side
/// decorations, client-side decorations will be used instead.
///
/// The default value is `auto`.
///
/// For the sake of backwards compatibility and convenience, this setting also
/// accepts boolean true and false values. If set to `true`, this is equivalent
/// to `auto`. If set to `false`, this is equivalent to `none`.
/// This is convenient for users who live primarily on systems that don't
/// differentiate between client and server-side decorations (e.g. macOS and
/// Windows).
///
/// The "toggle_window_decorations" keybind action can be used to create
/// a keybinding to toggle this setting at runtime.
/// a keybinding to toggle this setting at runtime. This will always toggle
/// back to "auto" if the current value is "none" (this is an issue
/// that will be fixed in the future).
///
/// Changing this configuration in your configuration and reloading will
/// only affect new windows. Existing windows will not be affected.
///
/// macOS: To hide the titlebar without removing the native window borders
/// or rounded corners, use `macos-titlebar-style = hidden` instead.
@"window-decoration": bool = true,
@"window-decoration": WindowDecoration = .auto,
/// The font that will be used for the application's window and tab titles.
///
@ -1333,7 +1404,7 @@ keybind: Keybinds = .{},
@"resize-overlay-duration": Duration = .{ .duration = 750 * std.time.ns_per_ms },
/// If true, when there are multiple split panes, the mouse selects the pane
/// that is focused. This only applies to the currently focused window; i.e.
/// that is focused. This only applies to the currently focused window; e.g.
/// mousing over a split in an unfocused window will not focus that split
/// and bring the window to front.
///
@ -1377,7 +1448,7 @@ keybind: Keybinds = .{},
/// and a minor amount of user interaction).
@"title-report": bool = false,
/// The total amount of bytes that can be used for image data (i.e. the Kitty
/// The total amount of bytes that can be used for image data (e.g. the Kitty
/// image protocol) per terminal screen. The maximum value is 4,294,967,295
/// (4GiB). The default is 320MB. If this is set to zero, then all image
/// protocols will be disabled.
@ -1608,7 +1679,9 @@ keybind: Keybinds = .{},
/// The default value is `detect`.
@"shell-integration": ShellIntegration = .detect,
/// Shell integration features to enable if shell integration itself is enabled.
/// Shell integration features to enable. These require our shell integration
/// to be loaded, either automatically via shell-integration or manually.
///
/// The format of this is a list of features to enable separated by commas. If
/// you prefix a feature with `no-` then it is disabled. If you omit a feature,
/// its default value is used, so you must explicitly disable features you don't
@ -1637,7 +1710,7 @@ keybind: Keybinds = .{},
///
/// * `none` - OSC 4/10/11 queries receive no reply
///
/// * `8-bit` - Color components are return unscaled, i.e. `rr/gg/bb`
/// * `8-bit` - Color components are return unscaled, e.g. `rr/gg/bb`
///
/// * `16-bit` - Color components are returned scaled, e.g. `rrrr/gggg/bbbb`
///
@ -1698,6 +1771,31 @@ keybind: Keybinds = .{},
/// open terminals.
@"custom-shader-animation": CustomShaderAnimation = .true,
/// Control the in-app notifications that Ghostty shows.
///
/// On Linux (GTK) with Adwaita, in-app notifications show up as toasts. Toasts
/// appear overlaid on top of the terminal window. They are used to show
/// information that is not critical but may be important.
///
/// Possible notifications are:
///
/// - `clipboard-copy` (default: true) - Show a notification when text is copied
/// to the clipboard.
///
/// To specify a notification to enable, specify the name of the notification.
/// To specify a notification to disable, prefix the name with `no-`. For
/// example, to disable `clipboard-copy`, set this configuration to
/// `no-clipboard-copy`. To enable it, set this configuration to `clipboard-copy`.
///
/// Multiple notifications can be enabled or disabled by separating them
/// with a comma.
///
/// A value of "false" will disable all notifications. A value of "true" will
/// enable all notifications.
///
/// This configuration only applies to GTK with Adwaita enabled.
@"app-notifications": AppNotifications = .{},
/// If anything other than false, fullscreen mode on macOS will not use the
/// native fullscreen, but make the window fullscreen without animations and
/// using a new space. It's faster than the native fullscreen mode since it
@ -1736,7 +1834,7 @@ keybind: Keybinds = .{},
/// typical for a macOS application and may not work well with all themes.
///
/// The "transparent" style will also update in real-time to dynamic
/// changes to the window background color, i.e. via OSC 11. To make this
/// changes to the window background color, e.g. via OSC 11. To make this
/// more aesthetically pleasing, this only happens if the terminal is
/// a window, tab, or split that borders the top of the window. This
/// avoids a disjointed appearance where the titlebar color changes
@ -1752,9 +1850,12 @@ keybind: Keybinds = .{},
/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`,
/// however, it does not remove the frame from the window or cause it to have
/// squared corners. Changing to or from this option at run-time may affect
/// existing windows in buggy ways. The top titlebar area of the window will
/// continue to drag the window around and you will not be able to use
/// the mouse for terminal events in this space.
/// existing windows in buggy ways.
///
/// When "hidden", the top titlebar area can no longer be used for dragging
/// the window. To drag the window, you can use option+click on the resizable
/// areas of the frame to drag the window. This is a standard macOS behavior
/// and not something Ghostty enables.
///
/// The default value is "transparent". This is an opinionated choice
/// but its one I think is the most aesthetically pleasing and works in
@ -1803,7 +1904,7 @@ keybind: Keybinds = .{},
/// - U.S. International
///
/// Note that if an *Option*-sequence doesn't produce a printable character, it
/// will be treated as *Alt* regardless of this setting. (i.e. `alt+ctrl+a`).
/// will be treated as *Alt* regardless of this setting. (e.g. `alt+ctrl+a`).
///
/// Explicit values that can be set:
///
@ -2027,6 +2128,10 @@ keybind: Keybinds = .{},
/// title bar, or you can switch tabs with keybinds.
@"gtk-tabs-location": GtkTabsLocation = .top,
/// If this is `true`, the titlebar will be hidden when the window is maximized,
/// and shown when the titlebar is unmaximized. GTK only.
@"gtk-titlebar-hide-when-maximized": bool = false,
/// Determines the appearance of the top and bottom bars when using the
/// Adwaita tab bar. This requires `gtk-adwaita` to be enabled (it is
/// by default).
@ -2041,29 +2146,6 @@ keybind: Keybinds = .{},
/// Changing this value at runtime will only affect new windows.
@"adw-toolbar-style": AdwToolbarStyle = .raised,
/// Control the toasts that Ghostty shows. Toasts are small notifications
/// that appear overlaid on top of the terminal window. They are used to
/// show information that is not critical but may be important.
///
/// Possible toasts are:
///
/// - `clipboard-copy` (default: true) - Show a toast when text is copied
/// to the clipboard.
///
/// To specify a toast to enable, specify the name of the toast. To specify
/// a toast to disable, prefix the name with `no-`. For example, to disable
/// the clipboard-copy toast, set this configuration to `no-clipboard-copy`.
/// To enable the clipboard-copy toast, set this configuration to
/// `clipboard-copy`.
///
/// Multiple toasts can be enabled or disabled by separating them with a comma.
///
/// A value of "false" will disable all toasts. A value of "true" will
/// enable all toasts.
///
/// This configuration only applies to GTK with Adwaita enabled.
@"adw-toast": AdwToast = .{},
/// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs
/// are the new typical Gnome style where tabs fill their available space.
/// If you set this to `false` then tabs will only take up space they need,
@ -2302,13 +2384,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) },
.{ .write_scrollback_file = .paste },
.{ .write_screen_file = .paste },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true, .alt = true }) },
.{ .write_scrollback_file = .open },
.{ .write_screen_file = .open },
);
// Expand Selection
@ -5665,8 +5747,8 @@ pub const AdwToolbarStyle = enum {
@"raised-border",
};
/// See adw-toast
pub const AdwToast = packed struct {
/// See app-notifications
pub const AppNotifications = packed struct {
@"clipboard-copy": bool = true,
};
@ -5744,6 +5826,20 @@ pub const GraphemeWidthMethod = enum {
unicode,
};
/// See text-blending
pub const TextBlending = enum {
native,
linear,
@"linear-corrected",
pub fn isLinear(self: TextBlending) bool {
return switch (self) {
.native => false,
.linear, .@"linear-corrected" => true,
};
}
};
/// See freetype-load-flag
pub const FreetypeLoadFlags = packed struct {
// The defaults here at the time of writing this match the defaults
@ -5769,7 +5865,7 @@ pub const AutoUpdate = enum {
download,
};
/// See background-blur-radius
/// See background-blur
pub const BackgroundBlur = union(enum) {
false,
true,
@ -5841,6 +5937,62 @@ pub const BackgroundBlur = union(enum) {
}
};
/// See window-decoration
pub const WindowDecoration = enum {
auto,
client,
server,
none,
pub fn parseCLI(input_: ?[]const u8) !WindowDecoration {
const input = input_ orelse return .auto;
return if (cli.args.parseBool(input)) |b|
if (b) .auto else .none
else |_| if (std.meta.stringToEnum(WindowDecoration, input)) |v|
v
else
error.InvalidValue;
}
test "parse WindowDecoration" {
const testing = std.testing;
{
const v = try WindowDecoration.parseCLI(null);
try testing.expectEqual(WindowDecoration.auto, v);
}
{
const v = try WindowDecoration.parseCLI("true");
try testing.expectEqual(WindowDecoration.auto, v);
}
{
const v = try WindowDecoration.parseCLI("false");
try testing.expectEqual(WindowDecoration.none, v);
}
{
const v = try WindowDecoration.parseCLI("server");
try testing.expectEqual(WindowDecoration.server, v);
}
{
const v = try WindowDecoration.parseCLI("client");
try testing.expectEqual(WindowDecoration.client, v);
}
{
const v = try WindowDecoration.parseCLI("auto");
try testing.expectEqual(WindowDecoration.auto, v);
}
{
const v = try WindowDecoration.parseCLI("none");
try testing.expectEqual(WindowDecoration.none, v);
}
{
try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI(""));
try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("aaaa"));
}
}
};
/// See theme
pub const Theme = struct {
light: []const u8,

View File

@ -192,21 +192,21 @@ test "c_get: background-blur" {
defer c.deinit();
{
c.@"background-blur-radius" = .false;
c.@"background-blur" = .false;
var cval: u8 = undefined;
try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval)));
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(0, cval);
}
{
c.@"background-blur-radius" = .true;
c.@"background-blur" = .true;
var cval: u8 = undefined;
try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval)));
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(20, cval);
}
{
c.@"background-blur-radius" = .{ .radius = 42 };
c.@"background-blur" = .{ .radius = 42 };
var cval: u8 = undefined;
try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval)));
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(42, cval);
}
}

View File

@ -343,13 +343,12 @@ pub const Face = struct {
} = if (!self.isColorGlyph(glyph_index)) .{
.color = false,
.depth = 1,
.space = try macos.graphics.ColorSpace.createDeviceGray(),
.context_opts = @intFromEnum(macos.graphics.BitmapInfo.alpha_mask) &
@intFromEnum(macos.graphics.ImageAlphaInfo.only),
.space = try macos.graphics.ColorSpace.createNamed(.linearGray),
.context_opts = @intFromEnum(macos.graphics.ImageAlphaInfo.only),
} else .{
.color = true,
.depth = 4,
.space = try macos.graphics.ColorSpace.createDeviceRGB(),
.space = try macos.graphics.ColorSpace.createNamed(.displayP3),
.context_opts = @intFromEnum(macos.graphics.BitmapInfo.byte_order_32_little) |
@intFromEnum(macos.graphics.ImageAlphaInfo.premultiplied_first),
};

View File

@ -111,6 +111,9 @@ pub const GlobalState = struct {
}
}
// Setup our signal handlers before logging
initSignals();
// Output some debug information right away
std.log.info("ghostty version={s}", .{build_config.version_string});
std.log.info("ghostty build optimize={s}", .{build_config.mode_string});
@ -175,6 +178,28 @@ pub const GlobalState = struct {
_ = value.deinit();
}
}
fn initSignals() void {
// Only posix systems.
if (comptime builtin.os.tag == .windows) return;
const p = std.posix;
var sa: p.Sigaction = .{
.handler = .{ .handler = p.SIG.IGN },
.mask = p.empty_sigset,
.flags = 0,
};
// We ignore SIGPIPE because it is a common signal we may get
// due to how we implement termio. When a terminal is closed we
// often write to a broken pipe to exit the read thread. This should
// be fixed one day but for now this helps make this a bit more
// robust.
p.sigaction(p.SIG.PIPE, &sa, null) catch |err| {
std.log.warn("failed to ignore SIGPIPE err={}", .{err});
};
}
};
/// Maintains the Unix resource limits that we set for our process. This

View File

@ -284,8 +284,15 @@ pub const Action = union(enum) {
scroll_page_fractional: f32,
scroll_page_lines: i16,
/// Adjust an existing selection in a given direction. This action
/// does nothing if there is no active selection.
/// Adjust the current selection in a given direction. Does nothing if no
/// selection exists.
///
/// Arguments:
/// - left, right, up, down, page_up, page_down, home, end,
/// beginning_of_line, end_of_line
///
/// Example: Extend selection to the right
/// keybind = shift+right=adjust_selection:right
adjust_selection: AdjustSelection,
/// Jump the viewport forward or back by prompt. Positive number is the
@ -341,8 +348,13 @@ pub const Action = union(enum) {
/// This only works with libadwaita enabled currently.
toggle_tab_overview: void,
/// Create a new split in the given direction. The new split will appear in
/// the direction given. For example `new_split:up`. Valid values are left, right, up, down and auto.
/// Create a new split in the given direction.
///
/// Arguments:
/// - right, down, left, up, auto (splits along the larger direction)
///
/// Example: Create split on the right
/// keybind = cmd+shift+d=new_split:right
new_split: SplitDirection,
/// Focus on a split in a given direction. For example `goto_split:up`.
@ -352,15 +364,26 @@ pub const Action = union(enum) {
/// zoom/unzoom the current split.
toggle_split_zoom: void,
/// Resize the current split by moving the split divider in the given
/// direction. For example `resize_split:left,10`. The valid directions are up, down, left and right.
/// Resize the current split in a given direction.
///
/// Arguments:
/// - up, down, left, right
/// - the number of pixels to resize the split by
///
/// Example: Move divider up 10 pixels
/// keybind = cmd+shift+up=resize_split:up,10
resize_split: SplitResizeParameter,
/// Equalize all splits in the current window
equalize_splits: void,
/// Show, hide, or toggle the terminal inspector for the currently focused
/// terminal.
/// Control the terminal inspector visibility.
///
/// Arguments:
/// - toggle, show, hide
///
/// Example: Toggle inspector visibility
/// keybind = cmd+i=inspector:toggle
inspector: InspectorMode,
/// Open the configuration file in the default OS editor. If your default OS
@ -391,6 +414,9 @@ pub const Action = union(enum) {
/// This only works for macOS currently.
close_all_windows: void,
/// Toggle maximized window state. This only works on Linux.
toggle_maximize: void,
/// Toggle fullscreen mode of window.
toggle_fullscreen: void,
@ -416,7 +442,7 @@ pub const Action = union(enum) {
/// is preserved between appearances, so you can always press the keybinding
/// to bring it back up.
///
/// To enable the quick terminally globally so that Ghostty doesn't
/// To enable the quick terminal globally so that Ghostty doesn't
/// have to be focused, prefix your keybind with `global`. Example:
///
/// ```ini
@ -513,7 +539,6 @@ pub const Action = union(enum) {
pub const SplitFocusDirection = enum {
previous,
next,
up,
left,
down,
@ -737,6 +762,7 @@ pub const Action = union(enum) {
.close_surface,
.close_tab,
.close_window,
.toggle_maximize,
.toggle_fullscreen,
.toggle_window_decorations,
.toggle_secure_input,

View File

@ -9,6 +9,7 @@ const entrypoint = switch (build_config.exe_entrypoint) {
.mdgen_ghostty_5 => @import("build/mdgen/main_ghostty_5.zig"),
.webgen_config => @import("build/webgen/main_config.zig"),
.webgen_actions => @import("build/webgen/main_actions.zig"),
.webgen_commands => @import("build/webgen/main_commands.zig"),
.bench_parser => @import("bench/parser.zig"),
.bench_stream => @import("bench/stream.zig"),
.bench_codepoint_width => @import("bench/codepoint-width.zig"),

View File

@ -77,7 +77,22 @@ pub fn cloneInto(cgroup: []const u8) !posix.pid_t {
// Get a file descriptor that refers to the cgroup directory in the cgroup
// sysfs to pass to the kernel in clone3.
const fd: linux.fd_t = fd: {
const rc = linux.open(path, linux.O{ .PATH = true, .DIRECTORY = true }, 0);
const rc = linux.open(
path,
.{
// Self-explanatory: we expect to open a directory, and
// we only need the path-level permissions.
.PATH = true,
.DIRECTORY = true,
// We don't want to leak this fd to the child process
// when we clone below since we're using this fd for
// a cgroup clone.
.CLOEXEC = true,
},
0,
);
switch (posix.errno(rc)) {
.SUCCESS => break :fd @as(linux.fd_t, @intCast(rc)),
else => |errno| {

View File

@ -265,16 +265,12 @@ pub const FlatpakHostCommand = struct {
}
// Build our args
const args_ptr = c.g_ptr_array_new();
{
errdefer _ = c.g_ptr_array_free(args_ptr, 1);
for (self.argv) |arg| {
const args = try arena.alloc(?[*:0]u8, self.argv.len + 1);
for (0.., self.argv) |i, arg| {
const argZ = try arena.dupeZ(u8, arg);
c.g_ptr_array_add(args_ptr, argZ.ptr);
args[i] = argZ.ptr;
}
}
const args = c.g_ptr_array_free(args_ptr, 0);
defer c.g_free(@as(?*anyopaque, @ptrCast(args)));
args[args.len - 1] = null;
// Get the cwd in case we don't have ours set. A small optimization
// would be to do this only if we need it but this isn't a
@ -286,7 +282,7 @@ pub const FlatpakHostCommand = struct {
const params = c.g_variant_new(
"(^ay^aay@a{uh}@a{ss}u)",
@as(*const anyopaque, if (self.cwd) |*cwd| cwd.ptr else g_cwd),
args,
args.ptr,
c.g_variant_builder_end(fd_builder),
c.g_variant_builder_end(env_builder),
@as(c_int, 0),

View File

@ -3,10 +3,11 @@ const builtin = @import("builtin");
const windows = @import("windows.zig");
const posix = std.posix;
/// pipe() that works on Windows and POSIX.
/// pipe() that works on Windows and POSIX. For POSIX systems, this sets
/// CLOEXEC on the file descriptors.
pub fn pipe() ![2]posix.fd_t {
switch (builtin.os.tag) {
else => return try posix.pipe(),
else => return try posix.pipe2(.{ .CLOEXEC = true }),
.windows => {
var read: windows.HANDLE = undefined;
var write: windows.HANDLE = undefined;

View File

@ -21,7 +21,11 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 {
// This is the sentinel value we look for in the path to know
// we've found the resources directory.
const sentinel = "terminfo/ghostty.termcap";
const sentinel = switch (comptime builtin.target.os.tag) {
.windows => "terminfo/ghostty.terminfo",
.macos => "terminfo/78/xterm-ghostty",
else => "terminfo/x/xterm-ghostty",
};
// Get the path to our running binary
var exe_buf: [std.fs.max_path_bytes]u8 = undefined;

View File

@ -94,6 +94,9 @@ const PosixPty = struct {
};
/// The file descriptors for the master and slave side of the pty.
/// The slave side is never closed automatically by this struct
/// so the caller is responsible for closing it if things
/// go wrong.
master: Fd,
slave: Fd,
@ -117,6 +120,24 @@ const PosixPty = struct {
_ = posix.system.close(slave_fd);
}
// Set CLOEXEC on the master fd, only the slave fd should be inherited
// by the child process (shell/command).
cloexec: {
const flags = std.posix.fcntl(master_fd, std.posix.F.GETFD, 0) catch |err| {
log.warn("error getting flags for master fd err={}", .{err});
break :cloexec;
};
_ = std.posix.fcntl(
master_fd,
std.posix.F.SETFD,
flags | std.posix.FD_CLOEXEC,
) catch |err| {
log.warn("error setting CLOEXEC on master fd err={}", .{err});
break :cloexec;
};
}
// Enable UTF-8 mode. I think this is on by default on Linux but it
// is NOT on by default on macOS so we ensure that it is always set.
var attrs: c.termios = undefined;
@ -126,7 +147,7 @@ const PosixPty = struct {
if (c.tcsetattr(master_fd, c.TCSANOW, &attrs) != 0)
return error.OpenptyFailed;
return Pty{
return .{
.master = master_fd,
.slave = slave_fd,
};
@ -134,7 +155,6 @@ const PosixPty = struct {
pub fn deinit(self: *Pty) void {
_ = posix.system.close(self.master);
_ = posix.system.close(self.slave);
self.* = undefined;
}
@ -181,6 +201,7 @@ const PosixPty = struct {
try posix.sigaction(posix.SIG.HUP, &sa, null);
try posix.sigaction(posix.SIG.ILL, &sa, null);
try posix.sigaction(posix.SIG.INT, &sa, null);
try posix.sigaction(posix.SIG.PIPE, &sa, null);
try posix.sigaction(posix.SIG.SEGV, &sa, null);
try posix.sigaction(posix.SIG.TRAP, &sa, null);
try posix.sigaction(posix.SIG.TERM, &sa, null);
@ -201,8 +222,6 @@ const PosixPty = struct {
// Can close master/slave pair now
posix.close(self.slave);
posix.close(self.master);
// TODO: reset signals
}
};

View File

@ -21,6 +21,7 @@ const renderer = @import("../renderer.zig");
const math = @import("../math.zig");
const Surface = @import("../Surface.zig");
const link = @import("link.zig");
const graphics = macos.graphics;
const fgMode = @import("cell.zig").fgMode;
const isCovering = @import("cell.zig").isCovering;
const shadertoy = @import("shadertoy.zig");
@ -105,10 +106,6 @@ default_cursor_color: ?terminal.color.RGB,
/// foreground color as the cursor color.
cursor_invert: bool,
/// The current frame background color. This is only updated during
/// the updateFrame method.
current_background_color: terminal.color.RGB,
/// The current set of cells to render. This is rebuilt on every frame
/// but we keep this around so that we don't reallocate. Each set of
/// cells goes into a separate shader.
@ -151,6 +148,9 @@ layer: objc.Object, // CAMetalLayer
/// a display link.
display_link: ?DisplayLink = null,
/// The `CGColorSpace` that represents our current terminal color space
terminal_colorspace: *graphics.ColorSpace,
/// Custom shader state. This is only set if we have custom shaders.
custom_shader_state: ?CustomShaderState = null,
@ -390,6 +390,8 @@ pub const DerivedConfig = struct {
custom_shaders: configpkg.RepeatablePath,
links: link.Set,
vsync: bool,
colorspace: configpkg.Config.WindowColorspace,
blending: configpkg.Config.TextBlending,
pub fn init(
alloc_gpa: Allocator,
@ -460,7 +462,8 @@ pub const DerivedConfig = struct {
.custom_shaders = custom_shaders,
.links = links,
.vsync = config.@"window-vsync",
.colorspace = config.@"window-colorspace",
.blending = config.@"text-blending",
.arena = arena,
};
}
@ -490,10 +493,6 @@ pub fn surfaceInit(surface: *apprt.Surface) !void {
}
pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
var arena = ArenaAllocator.init(alloc);
defer arena.deinit();
const arena_alloc = arena.allocator();
const ViewInfo = struct {
view: objc.Object,
scaleFactor: f64,
@ -512,7 +511,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
nswindow.getProperty(?*anyopaque, "contentView").?,
);
const scaleFactor = nswindow.getProperty(
macos.graphics.c.CGFloat,
graphics.c.CGFloat,
"backingScaleFactor",
);
@ -553,6 +552,40 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
layer.setProperty("opaque", options.config.background_opacity >= 1);
layer.setProperty("displaySyncEnabled", options.config.vsync);
// Set our layer's pixel format appropriately.
layer.setProperty(
"pixelFormat",
// Using an `*_srgb` pixel format makes Metal gamma encode
// the pixels written to it *after* blending, which means
// we get linear alpha blending rather than gamma-incorrect
// blending.
if (options.config.blending.isLinear())
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
else
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
);
// Set our layer's color space to Display P3.
// This allows us to have "Apple-style" alpha blending,
// since it seems to be the case that Apple apps like
// Terminal and TextEdit render text in the display's
// color space using converted colors, which reduces,
// but does not fully eliminate blending artifacts.
const colorspace = try graphics.ColorSpace.createNamed(.displayP3);
defer colorspace.release();
layer.setProperty("colorspace", colorspace);
// Create a colorspace the represents our terminal colors
// this will allow us to create e.g. `CGColor`s for things
// like the current background color.
const terminal_colorspace = try graphics.ColorSpace.createNamed(
switch (options.config.colorspace) {
.@"display-p3" => .displayP3,
.srgb => .sRGB,
},
);
errdefer terminal_colorspace.release();
// Make our view layer-backed with our Metal layer. On iOS views are
// always layer backed so we don't need to do this. But on iOS the
// caller MUST be sure to set the layerClass to CAMetalLayer.
@ -578,54 +611,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
});
errdefer font_shaper.deinit();
// Load our custom shaders
const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles(
arena_alloc,
options.config.custom_shaders,
.msl,
) catch |err| err: {
log.warn("error loading custom shaders err={}", .{err});
break :err &.{};
};
// If we have custom shaders then setup our state
var custom_shader_state: ?CustomShaderState = state: {
if (custom_shaders.len == 0) break :state null;
// Build our sampler for our texture
var sampler = try mtl_sampler.Sampler.init(gpu_state.device);
errdefer sampler.deinit();
break :state .{
// Resolution and screen textures will be fixed up by first
// call to setScreenSize. Draw calls will bail out early if
// the screen size hasn't been set yet, so it won't error.
.front_texture = undefined,
.back_texture = undefined,
.sampler = sampler,
.uniforms = .{
.resolution = .{ 0, 0, 1 },
.time = 1,
.time_delta = 1,
.frame_rate = 1,
.frame = 1,
.channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
.channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
.mouse = .{ 0, 0, 0, 0 },
.date = .{ 0, 0, 0, 0 },
.sample_rate = 1,
},
.first_frame_time = try std.time.Instant.now(),
.last_frame_time = try std.time.Instant.now(),
};
};
errdefer if (custom_shader_state) |*state| state.deinit();
// Initialize our shaders
var shaders = try Shaders.init(alloc, gpu_state.device, custom_shaders);
errdefer shaders.deinit(alloc);
// Initialize all the data that requires a critical font section.
const font_critical: struct {
metrics: font.Metrics,
@ -661,7 +646,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.cursor_color = null,
.default_cursor_color = options.config.cursor_color,
.cursor_invert = options.config.cursor_invert,
.current_background_color = options.config.background,
// Render state
.cells = .{},
@ -674,7 +658,16 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.min_contrast = options.config.min_contrast,
.cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) },
.cursor_color = undefined,
.bg_color = .{
options.config.background.r,
options.config.background.g,
options.config.background.b,
@intFromFloat(@round(options.config.background_opacity * 255.0)),
},
.cursor_wide = false,
.use_display_p3 = options.config.colorspace == .@"display-p3",
.use_linear_blending = options.config.blending.isLinear(),
.use_experimental_linear_correction = options.config.blending == .@"linear-corrected",
},
// Fonts
@ -682,16 +675,19 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.font_shaper = font_shaper,
.font_shaper_cache = font.ShaperCache.init(),
// Shaders
.shaders = shaders,
// Shaders (initialized below)
.shaders = undefined,
// Metal stuff
.layer = layer,
.display_link = display_link,
.custom_shader_state = custom_shader_state,
.terminal_colorspace = terminal_colorspace,
.custom_shader_state = null,
.gpu_state = gpu_state,
};
try result.initShaders();
// Do an initialize screen size setup to ensure our undefined values
// above are initialized.
try result.setScreenSize(result.size);
@ -709,6 +705,8 @@ pub fn deinit(self: *Metal) void {
}
}
self.terminal_colorspace.release();
self.cells.deinit(self.alloc);
self.font_shaper.deinit();
@ -723,11 +721,82 @@ pub fn deinit(self: *Metal) void {
}
self.image_placements.deinit(self.alloc);
self.deinitShaders();
self.* = undefined;
}
fn deinitShaders(self: *Metal) void {
if (self.custom_shader_state) |*state| state.deinit();
self.shaders.deinit(self.alloc);
}
self.* = undefined;
fn initShaders(self: *Metal) !void {
var arena = ArenaAllocator.init(self.alloc);
defer arena.deinit();
const arena_alloc = arena.allocator();
// Load our custom shaders
const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles(
arena_alloc,
self.config.custom_shaders,
.msl,
) catch |err| err: {
log.warn("error loading custom shaders err={}", .{err});
break :err &.{};
};
var custom_shader_state: ?CustomShaderState = state: {
if (custom_shaders.len == 0) break :state null;
// Build our sampler for our texture
var sampler = try mtl_sampler.Sampler.init(self.gpu_state.device);
errdefer sampler.deinit();
break :state .{
// Resolution and screen textures will be fixed up by first
// call to setScreenSize. Draw calls will bail out early if
// the screen size hasn't been set yet, so it won't error.
.front_texture = undefined,
.back_texture = undefined,
.sampler = sampler,
.uniforms = .{
.resolution = .{ 0, 0, 1 },
.time = 1,
.time_delta = 1,
.frame_rate = 1,
.frame = 1,
.channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
.channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
.mouse = .{ 0, 0, 0, 0 },
.date = .{ 0, 0, 0, 0 },
.sample_rate = 1,
},
.first_frame_time = try std.time.Instant.now(),
.last_frame_time = try std.time.Instant.now(),
};
};
errdefer if (custom_shader_state) |*state| state.deinit();
var shaders = try Shaders.init(
self.alloc,
self.gpu_state.device,
custom_shaders,
// Using an `*_srgb` pixel format makes Metal gamma encode
// the pixels written to it *after* blending, which means
// we get linear alpha blending rather than gamma-incorrect
// blending.
if (self.config.blending.isLinear())
mtl.MTLPixelFormat.bgra8unorm_srgb
else
mtl.MTLPixelFormat.bgra8unorm,
);
errdefer shaders.deinit(self.alloc);
self.shaders = shaders;
self.custom_shader_state = custom_shader_state;
}
/// This is called just prior to spinning up the renderer thread for
@ -977,19 +1046,6 @@ pub fn updateFrame(
}
}
// If our terminal screen size doesn't match our expected renderer
// size then we skip a frame. This can happen if the terminal state
// is resized between when the renderer mailbox is drained and when
// the state mutex is acquired inside this function.
//
// For some reason this doesn't seem to cause any significant issues
// with flickering while resizing. '\_('-')_/'
if (self.cells.size.rows != state.terminal.rows or
self.cells.size.columns != state.terminal.cols)
{
return;
}
// Get the viewport pin so that we can compare it to the current.
const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?;
@ -1111,7 +1167,38 @@ pub fn updateFrame(
self.cells_viewport = critical.viewport_pin;
// Update our background color
self.current_background_color = critical.bg;
self.uniforms.bg_color = .{
critical.bg.r,
critical.bg.g,
critical.bg.b,
@intFromFloat(@round(self.config.background_opacity * 255.0)),
};
// Update the background color on our layer
//
// TODO: Is this expensive? Should we be checking if our
// bg color has changed first before doing this work?
{
const color = graphics.c.CGColorCreate(
@ptrCast(self.terminal_colorspace),
&[4]f64{
@as(f64, @floatFromInt(critical.bg.r)) / 255.0,
@as(f64, @floatFromInt(critical.bg.g)) / 255.0,
@as(f64, @floatFromInt(critical.bg.b)) / 255.0,
self.config.background_opacity,
},
);
defer graphics.c.CGColorRelease(color);
// We use a CATransaction so that Core Animation knows that we
// updated the background color property. Otherwise it behaves
// weird, not updating the color until we resize.
const CATransaction = objc.getClass("CATransaction").?;
CATransaction.msgSend(void, "begin", .{});
defer CATransaction.msgSend(void, "commit", .{});
self.layer.setProperty("backgroundColor", color);
}
// Go through our images and see if we need to setup any textures.
{
@ -1233,10 +1320,10 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void {
attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store));
attachment.setProperty("texture", screen_texture.value);
attachment.setProperty("clearColor", mtl.MTLClearColor{
.red = @as(f32, @floatFromInt(self.current_background_color.r)) / 255 * self.config.background_opacity,
.green = @as(f32, @floatFromInt(self.current_background_color.g)) / 255 * self.config.background_opacity,
.blue = @as(f32, @floatFromInt(self.current_background_color.b)) / 255 * self.config.background_opacity,
.alpha = self.config.background_opacity,
.red = 0.0,
.green = 0.0,
.blue = 0.0,
.alpha = 0.0,
});
}
@ -1252,19 +1339,19 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void {
defer encoder.msgSend(void, objc.sel("endEncoding"), .{});
// Draw background images first
try self.drawImagePlacements(encoder, self.image_placements.items[0..self.image_bg_end]);
try self.drawImagePlacements(encoder, frame, self.image_placements.items[0..self.image_bg_end]);
// Then draw background cells
try self.drawCellBgs(encoder, frame);
// Then draw images under text
try self.drawImagePlacements(encoder, self.image_placements.items[self.image_bg_end..self.image_text_end]);
try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_bg_end..self.image_text_end]);
// Then draw fg cells
try self.drawCellFgs(encoder, frame, fg_count);
// Then draw remaining images
try self.drawImagePlacements(encoder, self.image_placements.items[self.image_text_end..]);
try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_text_end..]);
}
// If we have custom shaders, then we render them.
@ -1457,6 +1544,7 @@ fn drawPostShader(
fn drawImagePlacements(
self: *Metal,
encoder: objc.Object,
frame: *const FrameState,
placements: []const mtl_image.Placement,
) !void {
if (placements.len == 0) return;
@ -1468,15 +1556,16 @@ fn drawImagePlacements(
.{self.shaders.image_pipeline.value},
);
// Set our uniform, which is the only shared buffer
// Set our uniforms
encoder.msgSend(
void,
objc.sel("setVertexBytes:length:atIndex:"),
.{
@as(*const anyopaque, @ptrCast(&self.uniforms)),
@as(c_ulong, @sizeOf(@TypeOf(self.uniforms))),
@as(c_ulong, 1),
},
objc.sel("setVertexBuffer:offset:atIndex:"),
.{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
);
encoder.msgSend(
void,
objc.sel("setFragmentBuffer:offset:atIndex:"),
.{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
);
for (placements) |placement| {
@ -1588,6 +1677,11 @@ fn drawCellBgs(
);
// Set our buffers
encoder.msgSend(
void,
objc.sel("setVertexBuffer:offset:atIndex:"),
.{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
);
encoder.msgSend(
void,
objc.sel("setFragmentBuffer:offset:atIndex:"),
@ -1647,18 +1741,17 @@ fn drawCellFgs(
encoder.msgSend(
void,
objc.sel("setFragmentTexture:atIndex:"),
.{
frame.grayscale.value,
@as(c_ulong, 0),
},
.{ frame.grayscale.value, @as(c_ulong, 0) },
);
encoder.msgSend(
void,
objc.sel("setFragmentTexture:atIndex:"),
.{
frame.color.value,
@as(c_ulong, 1),
},
.{ frame.color.value, @as(c_ulong, 1) },
);
encoder.msgSend(
void,
objc.sel("setFragmentBuffer:offset:atIndex:"),
.{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 2) },
);
encoder.msgSend(
@ -2003,17 +2096,73 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void {
// Set our new minimum contrast
self.uniforms.min_contrast = config.min_contrast;
// Set our new color space and blending
self.uniforms.use_display_p3 = config.colorspace == .@"display-p3";
self.uniforms.use_linear_blending = config.blending.isLinear();
self.uniforms.use_experimental_linear_correction = config.blending == .@"linear-corrected";
// Set our new colors
self.default_background_color = config.background;
self.default_foreground_color = config.foreground;
self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null;
self.cursor_invert = config.cursor_invert;
// Update our layer's opaqueness and display sync in case they changed.
{
// We use a CATransaction so that Core Animation knows that we
// updated the opaque property. Otherwise it behaves weird, not
// properly going from opaque to transparent unless we resize.
const CATransaction = objc.getClass("CATransaction").?;
CATransaction.msgSend(void, "begin", .{});
defer CATransaction.msgSend(void, "commit", .{});
self.layer.setProperty("opaque", config.background_opacity >= 1);
self.layer.setProperty("displaySyncEnabled", config.vsync);
}
// Update our terminal colorspace if it changed
if (self.config.colorspace != config.colorspace) {
const terminal_colorspace = try graphics.ColorSpace.createNamed(
switch (config.colorspace) {
.@"display-p3" => .displayP3,
.srgb => .sRGB,
},
);
errdefer terminal_colorspace.release();
self.terminal_colorspace.release();
self.terminal_colorspace = terminal_colorspace;
}
const old_blending = self.config.blending;
const old_custom_shaders = self.config.custom_shaders;
self.config.deinit();
self.config = config.*;
// Reset our viewport to force a rebuild, in case of a font change.
self.cells_viewport = null;
// We reinitialize our shaders if our
// blending or custom shaders changed.
if (old_blending != config.blending or
!old_custom_shaders.equal(config.custom_shaders))
{
self.deinitShaders();
try self.initShaders();
// We call setScreenSize to reinitialize
// the textures used for custom shaders.
if (self.custom_shader_state != null) {
try self.setScreenSize(self.size);
}
// And we update our layer's pixel format appropriately.
self.layer.setProperty(
"pixelFormat",
if (config.blending.isLinear())
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
else
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
);
}
}
/// Resize the screen.
@ -2057,7 +2206,7 @@ pub fn setScreenSize(
}
// Set the size of the drawable surface to the bounds
self.layer.setProperty("drawableSize", macos.graphics.Size{
self.layer.setProperty("drawableSize", graphics.Size{
.width = @floatFromInt(size.screen.width),
.height = @floatFromInt(size.screen.height),
});
@ -2089,7 +2238,11 @@ pub fn setScreenSize(
.min_contrast = old.min_contrast,
.cursor_pos = old.cursor_pos,
.cursor_color = old.cursor_color,
.bg_color = old.bg_color,
.cursor_wide = old.cursor_wide,
.use_display_p3 = old.use_display_p3,
.use_linear_blending = old.use_linear_blending,
.use_experimental_linear_correction = old.use_experimental_linear_correction,
};
// Reset our cell contents if our grid size has changed.
@ -2124,7 +2277,17 @@ pub fn setScreenSize(
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init;
};
desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm));
desc.setProperty(
"pixelFormat",
// Using an `*_srgb` pixel format makes Metal gamma encode
// the pixels written to it *after* blending, which means
// we get linear alpha blending rather than gamma-incorrect
// blending.
if (self.config.blending.isLinear())
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
else
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
);
desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
desc.setProperty(
@ -2154,7 +2317,17 @@ pub fn setScreenSize(
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init;
};
desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm));
desc.setProperty(
"pixelFormat",
// Using an `*_srgb` pixel format makes Metal gamma encode
// the pixels written to it *after* blending, which means
// we get linear alpha blending rather than gamma-incorrect
// blending.
if (self.config.blending.isLinear())
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
else
@intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
);
desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
desc.setProperty(
@ -2251,12 +2424,22 @@ fn rebuildCells(
}
}
// Go row-by-row to build the cells. We go row by row because we do
// font shaping by row. In the future, we will also do dirty tracking
// by row.
// We rebuild the cells row-by-row because we
// do font shaping and dirty tracking by row.
var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null);
var y: terminal.size.CellCountInt = screen.pages.rows;
// If our cell contents buffer is shorter than the screen viewport,
// we render the rows that fit, starting from the bottom. If instead
// the viewport is shorter than the cell contents buffer, we align
// the top of the viewport with the top of the contents buffer.
var y: terminal.size.CellCountInt = @min(
screen.pages.rows,
self.cells.size.rows,
);
while (row_it.next()) |row| {
// The viewport may have more rows than our cell contents,
// so we need to break from the loop early if we hit y = 0.
if (y == 0) break;
y -= 1;
if (!rebuild) {
@ -2315,7 +2498,11 @@ fn rebuildCells(
var shaper_cells: ?[]const font.shape.Cell = null;
var shaper_cells_i: usize = 0;
const row_cells = row.cells(.all);
const row_cells_all = row.cells(.all);
// If our viewport is wider than our cell contents buffer,
// we still only process cells up to the width of the buffer.
const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)];
for (row_cells, 0..) |*cell, x| {
// If this cell falls within our preedit range then we
@ -2466,8 +2653,10 @@ fn rebuildCells(
// Foreground alpha for this cell.
const alpha: u8 = if (style.flags.faint) 175 else 255;
// If the cell has a background color, set it.
if (bg) |rgb| {
// Set the cell's background color.
{
const rgb = bg orelse self.background_color orelse self.default_background_color;
// Determine our background alpha. If we have transparency configured
// then this is dynamic depending on some situations. This is all
// in an attempt to make transparency look the best for various
@ -2477,23 +2666,19 @@ fn rebuildCells(
if (self.config.background_opacity >= 1) break :bg_alpha default;
// If we're selected, we do not apply background opacity
// Cells that are selected should be fully opaque.
if (selected) break :bg_alpha default;
// If we're reversed, do not apply background opacity
// Cells that are reversed should be fully opaque.
if (style.flags.inverse) break :bg_alpha default;
// If we have a background and its not the default background
// then we apply background opacity
if (style.bg(cell, color_palette) != null and !rgb.eql(self.background_color orelse self.default_background_color)) {
// Cells that have an explicit bg color should be fully opaque.
if (bg_style != null) {
break :bg_alpha default;
}
// We apply background opacity.
var bg_alpha: f64 = @floatFromInt(default);
bg_alpha *= self.config.background_opacity;
bg_alpha = @ceil(bg_alpha);
break :bg_alpha @intFromFloat(bg_alpha);
// Otherwise, we use the configured background opacity.
break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0));
};
self.cells.bgCell(y, x).* = .{

View File

@ -706,8 +706,6 @@ pub fn updateFrame(
// Update all our data as tightly as possible within the mutex.
var critical: Critical = critical: {
const grid_size = self.size.grid();
state.mutex.lock();
defer state.mutex.unlock();
@ -748,19 +746,6 @@ pub fn updateFrame(
}
}
// If our terminal screen size doesn't match our expected renderer
// size then we skip a frame. This can happen if the terminal state
// is resized between when the renderer mailbox is drained and when
// the state mutex is acquired inside this function.
//
// For some reason this doesn't seem to cause any significant issues
// with flickering while resizing. '\_('-')_/'
if (grid_size.rows != state.terminal.rows or
grid_size.columns != state.terminal.cols)
{
return;
}
// Get the viewport pin so that we can compare it to the current.
const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?;
@ -1276,10 +1261,23 @@ pub fn rebuildCells(
}
}
// Build each cell
const grid_size = self.size.grid();
// We rebuild the cells row-by-row because we do font shaping by row.
var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null);
var y: terminal.size.CellCountInt = screen.pages.rows;
// If our cell contents buffer is shorter than the screen viewport,
// we render the rows that fit, starting from the bottom. If instead
// the viewport is shorter than the cell contents buffer, we align
// the top of the viewport with the top of the contents buffer.
var y: terminal.size.CellCountInt = @min(
screen.pages.rows,
grid_size.rows,
);
while (row_it.next()) |row| {
// The viewport may have more rows than our cell contents,
// so we need to break from the loop early if we hit y = 0.
if (y == 0) break;
y -= 1;
// True if we want to do font shaping around the cursor. We want to
@ -1356,7 +1354,11 @@ pub fn rebuildCells(
var shaper_cells: ?[]const font.shape.Cell = null;
var shaper_cells_i: usize = 0;
const row_cells = row.cells(.all);
const row_cells_all = row.cells(.all);
// If our viewport is wider than our cell contents buffer,
// we still only process cells up to the width of the buffer.
const row_cells = row_cells_all[0..@min(row_cells_all.len, grid_size.columns)];
for (row_cells, 0..) |*cell, x| {
// If this cell falls within our preedit range then we
@ -2350,11 +2352,9 @@ pub fn drawFrame(self: *OpenGL, surface: *apprt.Surface) !void {
}
/// Draw the custom shaders.
fn drawCustomPrograms(
self: *OpenGL,
custom_state: *custom.State,
) !void {
fn drawCustomPrograms(self: *OpenGL, custom_state: *custom.State) !void {
_ = self;
assert(custom_state.programs.len > 0);
// Bind our state that is global to all custom shaders
const custom_bind = try custom_state.bind();
@ -2365,10 +2365,10 @@ fn drawCustomPrograms(
// Go through each custom shader and draw it.
for (custom_state.programs) |program| {
// Bind our cell program state, buffers
const bind = try program.bind();
defer bind.unbind();
try bind.draw();
try custom_state.copyFramebuffer();
}
}

View File

@ -74,6 +74,7 @@ pub const MTLPixelFormat = enum(c_ulong) {
rgba8unorm = 70,
rgba8uint = 73,
bgra8unorm = 80,
bgra8unorm_srgb = 81,
};
/// https://developer.apple.com/documentation/metal/mtlpurgeablestate?language=objc

View File

@ -13,9 +13,7 @@ const log = std.log.scoped(.metal);
pub const Shaders = struct {
library: objc.Object,
/// The cell shader is the shader used to render the terminal cells.
/// It is a single shader that is used for both the background and
/// foreground.
/// Renders cell foreground elements (text, decorations).
cell_text_pipeline: objc.Object,
/// The cell background shader is the shader used to render the
@ -40,17 +38,18 @@ pub const Shaders = struct {
alloc: Allocator,
device: objc.Object,
post_shaders: []const [:0]const u8,
pixel_format: mtl.MTLPixelFormat,
) !Shaders {
const library = try initLibrary(device);
errdefer library.msgSend(void, objc.sel("release"), .{});
const cell_text_pipeline = try initCellTextPipeline(device, library);
const cell_text_pipeline = try initCellTextPipeline(device, library, pixel_format);
errdefer cell_text_pipeline.msgSend(void, objc.sel("release"), .{});
const cell_bg_pipeline = try initCellBgPipeline(device, library);
const cell_bg_pipeline = try initCellBgPipeline(device, library, pixel_format);
errdefer cell_bg_pipeline.msgSend(void, objc.sel("release"), .{});
const image_pipeline = try initImagePipeline(device, library);
const image_pipeline = try initImagePipeline(device, library, pixel_format);
errdefer image_pipeline.msgSend(void, objc.sel("release"), .{});
const post_pipelines: []const objc.Object = initPostPipelines(
@ -58,6 +57,7 @@ pub const Shaders = struct {
device,
library,
post_shaders,
pixel_format,
) catch |err| err: {
// If an error happens while building postprocess shaders we
// want to just not use any postprocess shaders since we don't
@ -137,9 +137,29 @@ pub const Uniforms = extern struct {
cursor_pos: [2]u16 align(4),
cursor_color: [4]u8 align(4),
// Whether the cursor is 2 cells wide.
/// The background color for the whole surface.
bg_color: [4]u8 align(4),
/// Whether the cursor is 2 cells wide.
cursor_wide: bool align(1),
/// Indicates that colors provided to the shader are already in
/// the P3 color space, so they don't need to be converted from
/// sRGB.
use_display_p3: bool align(1),
/// Indicates that the color attachments for the shaders have
/// an `*_srgb` pixel format, which means the shaders need to
/// output linear RGB colors rather than gamma encoded colors,
/// since blending will be performed in linear space and then
/// Metal itself will re-encode the colors for storage.
use_linear_blending: bool align(1),
/// Enables a weight correction step that makes text rendered
/// with linear alpha blending have a similar apparent weight
/// (thickness) to gamma-incorrect blending.
use_experimental_linear_correction: bool align(1) = false,
const PaddingExtend = packed struct(u8) {
left: bool = false,
right: bool = false,
@ -201,6 +221,7 @@ fn initPostPipelines(
device: objc.Object,
library: objc.Object,
shaders: []const [:0]const u8,
pixel_format: mtl.MTLPixelFormat,
) ![]const objc.Object {
// If we have no shaders, do nothing.
if (shaders.len == 0) return &.{};
@ -220,7 +241,12 @@ fn initPostPipelines(
// Build each shader. Note we don't use "0.." to build our index
// because we need to keep track of our length to clean up above.
for (shaders) |source| {
pipelines[i] = try initPostPipeline(device, library, source);
pipelines[i] = try initPostPipeline(
device,
library,
source,
pixel_format,
);
i += 1;
}
@ -232,6 +258,7 @@ fn initPostPipeline(
device: objc.Object,
library: objc.Object,
data: [:0]const u8,
pixel_format: mtl.MTLPixelFormat,
) !objc.Object {
// Create our library which has the shader source
const post_library = library: {
@ -301,8 +328,7 @@ fn initPostPipeline(
.{@as(c_ulong, 0)},
);
// Value is MTLPixelFormatBGRA8Unorm
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
}
// Make our state
@ -343,7 +369,11 @@ pub const CellText = extern struct {
};
/// Initialize the cell render pipeline for our shader library.
fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object {
fn initCellTextPipeline(
device: objc.Object,
library: objc.Object,
pixel_format: mtl.MTLPixelFormat,
) !objc.Object {
// Get our vertex and fragment functions
const func_vert = func_vert: {
const str = try macos.foundation.String.createWithBytes(
@ -427,8 +457,7 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object
.{@as(c_ulong, 0)},
);
// Value is MTLPixelFormatBGRA8Unorm
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
// Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg.
@ -458,11 +487,15 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object
pub const CellBg = [4]u8;
/// Initialize the cell background render pipeline for our shader library.
fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object {
fn initCellBgPipeline(
device: objc.Object,
library: objc.Object,
pixel_format: mtl.MTLPixelFormat,
) !objc.Object {
// Get our vertex and fragment functions
const func_vert = func_vert: {
const str = try macos.foundation.String.createWithBytes(
"full_screen_vertex",
"cell_bg_vertex",
.utf8,
false,
);
@ -507,8 +540,7 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object {
.{@as(c_ulong, 0)},
);
// Value is MTLPixelFormatBGRA8Unorm
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
// Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg.
@ -535,7 +567,11 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object {
}
/// Initialize the image render pipeline for our shader library.
fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object {
fn initImagePipeline(
device: objc.Object,
library: objc.Object,
pixel_format: mtl.MTLPixelFormat,
) !objc.Object {
// Get our vertex and fragment functions
const func_vert = func_vert: {
const str = try macos.foundation.String.createWithBytes(
@ -619,8 +655,7 @@ fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object {
.{@as(c_ulong, 0)},
);
// Value is MTLPixelFormatBGRA8Unorm
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
// Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg.

View File

@ -230,6 +230,21 @@ pub const State = struct {
};
}
/// Copy the fbo's attached texture to the backbuffer.
pub fn copyFramebuffer(self: *State) !void {
const texbind = try self.fb_texture.bind(.@"2D");
errdefer texbind.unbind();
try texbind.copySubImage2D(
0,
0,
0,
0,
0,
@intFromFloat(self.uniforms.resolution[0]),
@intFromFloat(self.uniforms.resolution[1]),
);
}
pub const Binding = struct {
vao: gl.VertexArray.Binding,
ebo: gl.Buffer.Binding,
@ -251,7 +266,6 @@ pub const Program = struct {
const program = try gl.Program.createVF(
@embedFile("../shaders/custom.v.glsl"),
src,
//@embedFile("../shaders/temp.f.glsl"),
);
errdefer program.destroy();

View File

@ -18,7 +18,11 @@ struct Uniforms {
float min_contrast;
ushort2 cursor_pos;
uchar4 cursor_color;
uchar4 bg_color;
bool cursor_wide;
bool use_display_p3;
bool use_linear_blending;
bool use_experimental_linear_correction;
};
//-------------------------------------------------------------------
@ -26,40 +30,82 @@ struct Uniforms {
//-------------------------------------------------------------------
#pragma mark - Colors
// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
float luminance_component(float c) {
if (c <= 0.03928f) {
return c / 12.92f;
} else {
return pow((c + 0.055f) / 1.055f, 2.4f);
}
// D50-adapted sRGB to XYZ conversion matrix.
// http://www.brucelindbloom.com/Eqn_RGB_XYZ_Matrix.html
constant float3x3 sRGB_XYZ = transpose(float3x3(
0.4360747, 0.3850649, 0.1430804,
0.2225045, 0.7168786, 0.0606169,
0.0139322, 0.0971045, 0.7141733
));
// XYZ to Display P3 conversion matrix.
// http://endavid.com/index.php?entry=79
constant float3x3 XYZ_DP3 = transpose(float3x3(
2.40414768,-0.99010704,-0.39759019,
-0.84239098, 1.79905954, 0.01597023,
0.04838763,-0.09752546, 1.27393636
));
// By composing the two above matrices we get
// our sRGB to Display P3 conversion matrix.
constant float3x3 sRGB_DP3 = XYZ_DP3 * sRGB_XYZ;
// Converts a color in linear sRGB to linear Display P3
//
// TODO: The color matrix should probably be computed
// dynamically and passed as a uniform, rather
// than being hard coded above.
float3 srgb_to_display_p3(float3 srgb) {
return sRGB_DP3 * srgb;
}
float relative_luminance(float3 color) {
color.r = luminance_component(color.r);
color.g = luminance_component(color.g);
color.b = luminance_component(color.b);
float3 weights = float3(0.2126f, 0.7152f, 0.0722f);
return dot(color, weights);
// Converts a color from sRGB gamma encoding to linear.
float4 linearize(float4 srgb) {
bool3 cutoff = srgb.rgb <= 0.04045;
float3 lower = srgb.rgb / 12.92;
float3 higher = pow((srgb.rgb + 0.055) / 1.055, 2.4);
srgb.rgb = mix(higher, lower, float3(cutoff));
return srgb;
}
// Converts a color from linear to sRGB gamma encoding.
float4 unlinearize(float4 linear) {
bool3 cutoff = linear.rgb <= 0.0031308;
float3 lower = linear.rgb * 12.92;
float3 higher = pow(linear.rgb, 1.0 / 2.4) * 1.055 - 0.055;
linear.rgb = mix(higher, lower, float3(cutoff));
return linear;
}
// Compute the luminance of the provided color.
//
// Takes colors in linear RGB space. If your colors are gamma
// encoded, linearize them before using them with this function.
float luminance(float3 color) {
return dot(color, float3(0.2126f, 0.7152f, 0.0722f));
}
// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
//
// Takes colors in linear RGB space. If your colors are gamma
// encoded, linearize them before using them with this function.
float contrast_ratio(float3 color1, float3 color2) {
float l1 = relative_luminance(color1);
float l2 = relative_luminance(color2);
float l1 = luminance(color1);
float l2 = luminance(color2);
return (max(l1, l2) + 0.05f) / (min(l1, l2) + 0.05f);
}
// Return the fg if the contrast ratio is greater than min, otherwise
// return a color that satisfies the contrast ratio. Currently, the color
// is always white or black, whichever has the highest contrast ratio.
//
// Takes colors in linear RGB space. If your colors are gamma
// encoded, linearize them before using them with this function.
float4 contrasted_color(float min, float4 fg, float4 bg) {
float3 fg_premult = fg.rgb * fg.a;
float3 bg_premult = bg.rgb * bg.a;
float ratio = contrast_ratio(fg_premult, bg_premult);
float ratio = contrast_ratio(fg.rgb, bg.rgb);
if (ratio < min) {
float white_ratio = contrast_ratio(float3(1.0f), bg_premult);
float black_ratio = contrast_ratio(float3(0.0f), bg_premult);
float white_ratio = contrast_ratio(float3(1.0f), bg.rgb);
float black_ratio = contrast_ratio(float3(0.0f), bg.rgb);
if (white_ratio > black_ratio) {
return float4(1.0f);
} else {
@ -70,6 +116,62 @@ float4 contrasted_color(float min, float4 fg, float4 bg) {
return fg;
}
// Load a 4 byte RGBA non-premultiplied color and linearize
// and convert it as necessary depending on the provided info.
//
// Returns a color in the Display P3 color space.
//
// If `display_p3` is true, then the provided color is assumed to
// already be in the Display P3 color space, otherwise it's treated
// as an sRGB color and is appropriately converted to Display P3.
//
// `linear` controls whether the returned color is linear or gamma encoded.
float4 load_color(
uchar4 in_color,
bool display_p3,
bool linear
) {
// 0 .. 255 -> 0.0 .. 1.0
float4 color = float4(in_color) / 255.0f;
// If our color is already in Display P3 and
// we aren't doing linear blending, then we
// already have the correct color here and
// can premultiply and return it.
if (display_p3 && !linear) {
color.rgb *= color.a;
return color;
}
// The color is in either the sRGB or Display P3 color space,
// so in either case, it's a color space which uses the sRGB
// transfer function, so we can use one function in order to
// linearize it in either case.
//
// Even if we aren't doing linear blending, the color
// needs to be in linear space to convert color spaces.
color = linearize(color);
// If we're *NOT* using display P3 colors, then we're dealing
// with an sRGB color, in which case we need to convert it in
// to the Display P3 color space, since our output is always
// Display P3.
if (!display_p3) {
color.rgb = srgb_to_display_p3(color.rgb);
}
// If we're not doing linear blending, then we need to
// unlinearize after doing the color space conversion.
if (!linear) {
color = unlinearize(color);
}
// Premultiply our color by its alpha.
color.rgb *= color.a;
return color;
}
//-------------------------------------------------------------------
// Full Screen Vertex Shader
//-------------------------------------------------------------------
@ -112,25 +214,62 @@ vertex FullScreenVertexOut full_screen_vertex(
//-------------------------------------------------------------------
#pragma mark - Cell BG Shader
struct CellBgVertexOut {
float4 position [[position]];
float4 bg_color;
};
vertex CellBgVertexOut cell_bg_vertex(
uint vid [[vertex_id]],
constant Uniforms& uniforms [[buffer(1)]]
) {
CellBgVertexOut out;
float4 position;
position.x = (vid == 2) ? 3.0 : -1.0;
position.y = (vid == 0) ? -3.0 : 1.0;
position.zw = 1.0;
out.position = position;
// Convert the background color to Display P3
out.bg_color = load_color(
uniforms.bg_color,
uniforms.use_display_p3,
uniforms.use_linear_blending
);
return out;
}
fragment float4 cell_bg_fragment(
FullScreenVertexOut in [[stage_in]],
CellBgVertexOut in [[stage_in]],
constant uchar4 *cells [[buffer(0)]],
constant Uniforms& uniforms [[buffer(1)]]
) {
int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size));
float4 bg = float4(0.0);
// If we have any background transparency then we render bg-colored cells as
// fully transparent, since the background is handled by the layer bg color
// and we don't want to double up our bg color, but if our bg color is fully
// opaque then our layer is opaque and can't handle transparency, so we need
// to return the bg color directly instead.
if (uniforms.bg_color.a == 255) {
bg = in.bg_color;
}
// Clamp x position, extends edge bg colors in to padding on sides.
if (grid_pos.x < 0) {
if (uniforms.padding_extend & EXTEND_LEFT) {
grid_pos.x = 0;
} else {
return float4(0.0);
return bg;
}
} else if (grid_pos.x > uniforms.grid_size.x - 1) {
if (uniforms.padding_extend & EXTEND_RIGHT) {
grid_pos.x = uniforms.grid_size.x - 1;
} else {
return float4(0.0);
return bg;
}
}
@ -139,18 +278,40 @@ fragment float4 cell_bg_fragment(
if (uniforms.padding_extend & EXTEND_UP) {
grid_pos.y = 0;
} else {
return float4(0.0);
return bg;
}
} else if (grid_pos.y > uniforms.grid_size.y - 1) {
if (uniforms.padding_extend & EXTEND_DOWN) {
grid_pos.y = uniforms.grid_size.y - 1;
} else {
return float4(0.0);
return bg;
}
}
// Retrieve color for cell and return it.
return float4(cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]) / 255.0;
// Load the color for the cell.
uchar4 cell_color = cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x];
// We have special case handling for when the cell color matches the bg color.
if (all(cell_color == uniforms.bg_color)) {
return bg;
}
// Convert the color and return it.
//
// TODO: We may want to blend the color with the background
// color, rather than purely replacing it, this needs
// some consideration about config options though.
//
// TODO: It might be a good idea to do a pass before this
// to convert all of the bg colors, so we don't waste
// a bunch of work converting the cell color in every
// fragment of each cell. It's not the most epxensive
// operation, but it is still wasted work.
return load_color(
cell_color,
uniforms.use_display_p3,
uniforms.use_linear_blending
);
}
//-------------------------------------------------------------------
@ -222,7 +383,6 @@ vertex CellTextVertexOut cell_text_vertex(
CellTextVertexOut out;
out.mode = in.mode;
out.color = float4(in.color) / 255.0f;
// === Grid Cell ===
// +X
@ -277,6 +437,14 @@ vertex CellTextVertexOut cell_text_vertex(
// be sampled with pixel coordinate mode.
out.tex_coord = float2(in.glyph_pos) + float2(in.glyph_size) * corner;
// Get our color. We always fetch a linearized version to
// make it easier to handle minimum contrast calculations.
out.color = load_color(
in.color,
uniforms.use_display_p3,
true
);
// If we have a minimum contrast, we need to check if we need to
// change the color of the text to ensure it has enough contrast
// with the background.
@ -285,7 +453,13 @@ vertex CellTextVertexOut cell_text_vertex(
// and Powerline glyphs to be unaffected (else parts of the line would
// have different colors as some parts are displayed via background colors).
if (uniforms.min_contrast > 1.0f && in.mode == MODE_TEXT) {
float4 bg_color = float4(bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x]) / 255.0f;
// Get the BG color
float4 bg_color = load_color(
bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x],
uniforms.use_display_p3,
true
);
// Ensure our minimum contrast
out.color = contrasted_color(uniforms.min_contrast, out.color, bg_color);
}
@ -308,7 +482,8 @@ vertex CellTextVertexOut cell_text_vertex(
fragment float4 cell_text_fragment(
CellTextVertexOut in [[stage_in]],
texture2d<float> textureGrayscale [[texture(0)]],
texture2d<float> textureColor [[texture(1)]]
texture2d<float> textureColor [[texture(1)]],
constant Uniforms& uniforms [[buffer(2)]]
) {
constexpr sampler textureSampler(
coord::pixel,
@ -322,20 +497,63 @@ fragment float4 cell_text_fragment(
case MODE_TEXT_CONSTRAINED:
case MODE_TEXT_POWERLINE:
case MODE_TEXT: {
// We premult the alpha to our whole color since our blend function
// uses One/OneMinusSourceAlpha to avoid blurry edges.
// We first premult our given color.
float4 premult = float4(in.color.rgb * in.color.a, in.color.a);
// Our input color is always linear.
float4 color = in.color;
// Then premult the texture color
// If we're not doing linear blending, then we need to
// re-apply the gamma encoding to our color manually.
//
// Since the alpha is premultiplied, we need to divide
// it out before unlinearizing and re-multiply it after.
if (!uniforms.use_linear_blending) {
color.rgb /= color.a;
color = unlinearize(color);
color.rgb *= color.a;
}
// Fetch our alpha mask for this pixel.
float a = textureGrayscale.sample(textureSampler, in.tex_coord).r;
premult = premult * a;
return premult;
// Experimental linear blending weight correction.
if (uniforms.use_experimental_linear_correction) {
float l = luminance(color.rgb);
// TODO: This is a dynamic dilation term that biases
// the alpha adjustment for small font sizes;
// it should be computed by dividing the font
// size in `pt`s by `13.0` and using that if
// it's less than `1.0`, but for now it's
// hard coded at 1.0, which has no effect.
float d = 13.0 / 13.0;
a += pow(a, d + d * l) - pow(a, d + 1.0 - d * l);
}
// Multiply our whole color by the alpha mask.
// Since we use premultiplied alpha, this is
// the correct way to apply the mask.
color *= a;
return color;
}
case MODE_TEXT_COLOR: {
return textureColor.sample(textureSampler, in.tex_coord);
// For now, we assume that color glyphs are
// already premultiplied Display P3 colors.
float4 color = textureColor.sample(textureSampler, in.tex_coord);
// If we aren't doing linear blending, we can return this right away.
if (!uniforms.use_linear_blending) {
return color;
}
// Otherwise we need to linearize the color. Since the alpha is
// premultiplied, we need to divide it out before linearizing.
color.rgb /= color.a;
color = linearize(color);
color.rgb *= color.a;
return color;
}
}
}
@ -409,7 +627,8 @@ vertex ImageVertexOut image_vertex(
fragment float4 image_fragment(
ImageVertexOut in [[stage_in]],
texture2d<uint> image [[texture(0)]]
texture2d<uint> image [[texture(0)]],
constant Uniforms& uniforms [[buffer(1)]]
) {
constexpr sampler textureSampler(address::clamp_to_edge, filter::linear);
@ -418,10 +637,12 @@ fragment float4 image_fragment(
// our texture to BGRA8Unorm.
uint4 rgba = image.sample(textureSampler, in.tex_coord);
// Convert to float4 and premultiply the alpha. We should also probably
// premultiply the alpha in the texture.
float4 result = float4(rgba) / 255.0f;
result.rgb *= result.a;
return result;
return load_color(
uchar4(rgba),
// We assume all images are sRGB regardless of the configured colorspace
// TODO: Maybe support wide gamut images?
false,
uniforms.use_linear_blending
);
}

View File

@ -6,7 +6,7 @@ supports.
This README is meant as developer documentation and not as
user documentation. For user documentation, see the main
README.
README or [ghostty.org](https://ghostty.org/docs)
## Implementation Details

View File

@ -250,10 +250,8 @@ __bp_preexec_invoke_exec() {
fi
local this_command
this_command=$(
export LC_ALL=C
HISTTIMEFORMAT='' builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //'
)
this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1)
this_command="${this_command#*[[:digit:]][* ] }"
# Sanity check to make sure we have something to invoke our function with.
if [[ -z "$this_command" ]]; then
@ -297,10 +295,8 @@ __bp_install() {
trap '__bp_preexec_invoke_exec "$_"' DEBUG
# Preserve any prior DEBUG trap as a preexec function
local prior_trap
# we can't easily do this with variable expansion. Leaving as sed command.
# shellcheck disable=SC2001
prior_trap=$(sed "s/[^']*'\(.*\)'[^']*/\1/" <<<"${__bp_trap_string:-}")
eval "local trap_argv=(${__bp_trap_string:-})"
local prior_trap=${trap_argv[2]:-}
unset __bp_trap_string
if [[ -n "$prior_trap" ]]; then
eval '__bp_original_debug_trap() {

View File

@ -20,14 +20,13 @@
if [[ "$-" != *i* ]] ; then builtin return; fi
if [ -z "$GHOSTTY_RESOURCES_DIR" ]; then builtin return; fi
# When automatic shell integration is active, we need to manually
# load the normal bash startup files based on the injected state.
# When automatic shell integration is active, we were started in POSIX
# mode and need to manually recreate the bash startup sequence.
if [ -n "$GHOSTTY_BASH_INJECT" ]; then
builtin declare ghostty_bash_inject="$GHOSTTY_BASH_INJECT"
builtin unset GHOSTTY_BASH_INJECT ENV
# At this point, we're in POSIX mode and rely on the injected
# flags to guide is through the rest of the startup sequence.
# Store a temporary copy of our startup flags and unset these global
# environment variables so we can safely handle reentrancy.
builtin declare __ghostty_bash_flags="$GHOSTTY_BASH_INJECT"
builtin unset ENV GHOSTTY_BASH_INJECT
# Restore bash's default 'posix' behavior. Also reset 'inherit_errexit',
# which doesn't happen as part of the 'posix' reset.
@ -40,35 +39,34 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
builtin unset GHOSTTY_BASH_UNEXPORT_HISTFILE
fi
# Manually source the startup files, respecting the injected flags like
# --norc and --noprofile that we parsed with the shell integration code.
#
# See also: run_startup_files() in shell.c in the Bash source code
# Manually source the startup files. See INVOCATION in bash(1) and
# run_startup_files() in shell.c in the Bash source code.
if builtin shopt -q login_shell; then
if [[ $ghostty_bash_inject != *"--noprofile"* ]]; then
if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then
[ -r /etc/profile ] && builtin source "/etc/profile"
for rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
[ -r "$rcfile" ] && { builtin source "$rcfile"; break; }
for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
[ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; }
done
fi
else
if [[ $ghostty_bash_inject != *"--norc"* ]]; then
if [[ $__ghostty_bash_flags != *"--norc"* ]]; then
# The location of the system bashrc is determined at bash build
# time via -DSYS_BASHRC and can therefore vary across distros:
# Arch, Debian, Ubuntu use /etc/bash.bashrc
# Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC
# Void Linux uses /etc/bash/bashrc
# Nixos uses /etc/bashrc
for rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do
[ -r "$rcfile" ] && { builtin source "$rcfile"; break; }
for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do
[ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; }
done
if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi
[ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE"
fi
fi
builtin unset __ghostty_rcfile
builtin unset __ghostty_bash_flags
builtin unset GHOSTTY_BASH_RCFILE
builtin unset ghostty_bash_inject rcfile
fi
# Sudo

View File

@ -71,11 +71,11 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x")
# Wrap `sudo` command to ensure Ghostty terminfo is preserved
function sudo -d "Wrap sudo to preserve terminfo"
set --local sudo_has_sudoedit_flags "no"
set --function sudo_has_sudoedit_flags "no"
for arg in $argv
# Check if argument is '-e' or '--edit' (sudoedit flags)
if string match -q -- "-e" "$arg"; or string match -q -- "--edit" "$arg"
set --local sudo_has_sudoedit_flags "yes"
set --function sudo_has_sudoedit_flags "yes"
break
end
# Check if argument is neither an option nor a key-value pair

View File

@ -4962,7 +4962,7 @@ static int stbi__expand_png_palette(stbi__png *a, stbi_uc *palette, int len, int
p = (stbi_uc *) stbi__malloc_mad2(pixel_count, pal_img_n, 0);
if (p == NULL) return stbi__err("outofmem", "Out of memory");
// between here and free(out) below, exitting would leak
// between here and free(out) below, exiting would leak
temp_out = p;
if (pal_img_n == 3) {

View File

@ -520,6 +520,7 @@ pub fn clone(
assert(node.data.capacity.rows >= chunk.end - chunk.start);
defer node.data.assertIntegrity();
node.data.size.rows = chunk.end - chunk.start;
node.data.size.cols = chunk.node.data.size.cols;
try node.data.cloneFrom(
&chunk.node.data,
chunk.start,

View File

@ -6,6 +6,7 @@ const Parser = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const testing = std.testing;
const table = @import("parse_table.zig").table;
const osc = @import("osc.zig");
@ -81,11 +82,15 @@ pub const Action = union(enum) {
pub const CSI = struct {
intermediates: []u8,
params: []u16,
params_sep: SepList,
final: u8,
sep: Sep,
/// The list of separators used for CSI params. The value of the
/// bit can be mapped to Sep.
pub const SepList = std.StaticBitSet(MAX_PARAMS);
/// The separator used for CSI params.
pub const Sep = enum { semicolon, colon };
pub const Sep = enum(u1) { semicolon = 0, colon = 1 };
// Implement formatter for logging
pub fn format(
@ -183,15 +188,6 @@ pub const Action = union(enum) {
}
};
/// Keeps track of the parameter sep used for CSI params. We allow colons
/// to be used ONLY by the 'm' CSI action.
pub const ParamSepState = enum(u8) {
none = 0,
semicolon = ';',
colon = ':',
mixed = 1,
};
/// Maximum number of intermediate characters during parsing. This is
/// 4 because we also use the intermediates array for UTF8 decoding which
/// can be at most 4 bytes.
@ -207,8 +203,8 @@ intermediates_idx: u8 = 0,
/// Param tracking, building
params: [MAX_PARAMS]u16 = undefined,
params_sep: Action.CSI.SepList = Action.CSI.SepList.initEmpty(),
params_idx: u8 = 0,
params_sep: ParamSepState = .none,
param_acc: u16 = 0,
param_acc_idx: u8 = 0,
@ -312,13 +308,9 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
// Ignore too many parameters
if (self.params_idx >= MAX_PARAMS) break :param null;
// If this is our first time seeing a parameter, we track
// the separator used so that we can't mix separators later.
if (self.params_idx == 0) self.params_sep = @enumFromInt(c);
if (@as(ParamSepState, @enumFromInt(c)) != self.params_sep) self.params_sep = .mixed;
// Set param final value
self.params[self.params_idx] = self.param_acc;
if (c == ':') self.params_sep.set(self.params_idx);
self.params_idx += 1;
// Reset current param value to 0
@ -359,29 +351,18 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
.csi_dispatch = .{
.intermediates = self.intermediates[0..self.intermediates_idx],
.params = self.params[0..self.params_idx],
.params_sep = self.params_sep,
.final = c,
.sep = switch (self.params_sep) {
.none, .semicolon => .semicolon,
.colon => .colon,
// There is nothing that treats mixed separators specially
// afaik so we just treat it as a semicolon.
.mixed => .semicolon,
},
},
};
// We only allow colon or mixed separators for the 'm' command.
switch (self.params_sep) {
.none => {},
.semicolon => {},
.colon, .mixed => if (c != 'm') {
if (c != 'm' and self.params_sep.count() > 0) {
log.warn(
"CSI colon or mixed separators only allowed for 'm' command, got: {}",
.{result},
);
break :csi_dispatch null;
},
}
break :csi_dispatch result;
@ -400,7 +381,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
pub fn clear(self: *Parser) void {
self.intermediates_idx = 0;
self.params_idx = 0;
self.params_sep = .none;
self.params_sep = Action.CSI.SepList.initEmpty();
self.param_acc = 0;
self.param_acc_idx = 0;
}
@ -507,10 +488,11 @@ test "csi: SGR ESC [ 38 : 2 m" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 2);
try testing.expectEqual(@as(u16, 38), d.params[0]);
try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 2), d.params[1]);
try testing.expect(!d.params_sep.isSet(1));
}
}
@ -581,13 +563,17 @@ test "csi: SGR ESC [ 48 : 2 m" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 5);
try testing.expectEqual(@as(u16, 48), d.params[0]);
try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 2), d.params[1]);
try testing.expect(d.params_sep.isSet(1));
try testing.expectEqual(@as(u16, 240), d.params[2]);
try testing.expect(d.params_sep.isSet(2));
try testing.expectEqual(@as(u16, 143), d.params[3]);
try testing.expect(d.params_sep.isSet(3));
try testing.expectEqual(@as(u16, 104), d.params[4]);
try testing.expect(!d.params_sep.isSet(4));
}
}
@ -608,10 +594,11 @@ test "csi: SGR ESC [4:3m colon" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 2);
try testing.expectEqual(@as(u16, 4), d.params[0]);
try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 3), d.params[1]);
try testing.expect(!d.params_sep.isSet(1));
}
}
@ -634,14 +621,71 @@ test "csi: SGR with many blank and colon" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 6);
try testing.expectEqual(@as(u16, 58), d.params[0]);
try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 2), d.params[1]);
try testing.expect(d.params_sep.isSet(1));
try testing.expectEqual(@as(u16, 0), d.params[2]);
try testing.expect(d.params_sep.isSet(2));
try testing.expectEqual(@as(u16, 240), d.params[3]);
try testing.expect(d.params_sep.isSet(3));
try testing.expectEqual(@as(u16, 143), d.params[4]);
try testing.expect(d.params_sep.isSet(4));
try testing.expectEqual(@as(u16, 104), d.params[5]);
try testing.expect(!d.params_sep.isSet(5));
}
}
// This is from a Kakoune actual SGR sequence.
test "csi: SGR mixed colon and semicolon with blank" {
var p = init();
_ = p.next(0x1B);
for ("[;4:3;38;2;175;175;215;58:2::190:80:70") |c| {
const a = p.next(c);
try testing.expect(a[0] == null);
try testing.expect(a[1] == null);
try testing.expect(a[2] == null);
}
{
const a = p.next('m');
try testing.expect(p.state == .ground);
try testing.expect(a[0] == null);
try testing.expect(a[1].? == .csi_dispatch);
try testing.expect(a[2] == null);
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expectEqual(14, d.params.len);
try testing.expectEqual(@as(u16, 0), d.params[0]);
try testing.expect(!d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 4), d.params[1]);
try testing.expect(d.params_sep.isSet(1));
try testing.expectEqual(@as(u16, 3), d.params[2]);
try testing.expect(!d.params_sep.isSet(2));
try testing.expectEqual(@as(u16, 38), d.params[3]);
try testing.expect(!d.params_sep.isSet(3));
try testing.expectEqual(@as(u16, 2), d.params[4]);
try testing.expect(!d.params_sep.isSet(4));
try testing.expectEqual(@as(u16, 175), d.params[5]);
try testing.expect(!d.params_sep.isSet(5));
try testing.expectEqual(@as(u16, 175), d.params[6]);
try testing.expect(!d.params_sep.isSet(6));
try testing.expectEqual(@as(u16, 215), d.params[7]);
try testing.expect(!d.params_sep.isSet(7));
try testing.expectEqual(@as(u16, 58), d.params[8]);
try testing.expect(d.params_sep.isSet(8));
try testing.expectEqual(@as(u16, 2), d.params[9]);
try testing.expect(d.params_sep.isSet(9));
try testing.expectEqual(@as(u16, 0), d.params[10]);
try testing.expect(d.params_sep.isSet(10));
try testing.expectEqual(@as(u16, 190), d.params[11]);
try testing.expect(d.params_sep.isSet(11));
try testing.expectEqual(@as(u16, 80), d.params[12]);
try testing.expect(d.params_sep.isSet(12));
try testing.expectEqual(@as(u16, 70), d.params[13]);
try testing.expect(!d.params_sep.isSet(13));
}
}

View File

@ -1,13 +1,17 @@
//! SGR (Select Graphic Rendition) attrinvbute parsing and types.
const std = @import("std");
const assert = std.debug.assert;
const testing = std.testing;
const color = @import("color.zig");
const SepList = @import("Parser.zig").Action.CSI.SepList;
/// Attribute type for SGR
pub const Attribute = union(enum) {
pub const Tag = std.meta.FieldEnum(Attribute);
/// Unset all attributes
unset: void,
unset,
/// Unknown attribute, the raw CSI command parameters are here.
unknown: struct {
@ -19,43 +23,43 @@ pub const Attribute = union(enum) {
},
/// Bold the text.
bold: void,
reset_bold: void,
bold,
reset_bold,
/// Italic text.
italic: void,
reset_italic: void,
italic,
reset_italic,
/// Faint/dim text.
/// Note: reset faint is the same SGR code as reset bold
faint: void,
faint,
/// Underline the text
underline: Underline,
reset_underline: void,
reset_underline,
underline_color: color.RGB,
@"256_underline_color": u8,
reset_underline_color: void,
reset_underline_color,
// Overline the text
overline: void,
reset_overline: void,
overline,
reset_overline,
/// Blink the text
blink: void,
reset_blink: void,
blink,
reset_blink,
/// Invert fg/bg colors.
inverse: void,
reset_inverse: void,
inverse,
reset_inverse,
/// Invisible
invisible: void,
reset_invisible: void,
invisible,
reset_invisible,
/// Strikethrough the text.
strikethrough: void,
reset_strikethrough: void,
strikethrough,
reset_strikethrough,
/// Set foreground color as RGB values.
direct_color_fg: color.RGB,
@ -68,8 +72,8 @@ pub const Attribute = union(enum) {
@"8_fg": color.Name,
/// Reset the fg/bg to their default values.
reset_fg: void,
reset_bg: void,
reset_fg,
reset_bg,
/// Set the background/foreground as a named bright color attribute.
@"8_bright_bg": color.Name,
@ -94,11 +98,9 @@ pub const Attribute = union(enum) {
/// Parser parses the attributes from a list of SGR parameters.
pub const Parser = struct {
params: []const u16,
params_sep: SepList = SepList.initEmpty(),
idx: usize = 0,
/// True if the separator is a colon
colon: bool = false,
/// Next returns the next attribute or null if there are no more attributes.
pub fn next(self: *Parser) ?Attribute {
if (self.idx > self.params.len) return null;
@ -106,220 +108,261 @@ pub const Parser = struct {
// Implicitly means unset
if (self.params.len == 0) {
self.idx += 1;
return Attribute{ .unset = {} };
return .unset;
}
const slice = self.params[self.idx..self.params.len];
const colon = self.params_sep.isSet(self.idx);
self.idx += 1;
// Our last one will have an idx be the last value.
if (slice.len == 0) return null;
// If we have a colon separator then we need to ensure we're
// parsing a value that allows it.
if (colon) switch (slice[0]) {
4, 38, 48, 58 => {},
else => {
// Consume all the colon separated values.
const start = self.idx;
while (self.params_sep.isSet(self.idx)) self.idx += 1;
self.idx += 1;
return .{ .unknown = .{
.full = self.params,
.partial = slice[0 .. self.idx - start + 1],
} };
},
};
switch (slice[0]) {
0 => return Attribute{ .unset = {} },
0 => return .unset,
1 => return Attribute{ .bold = {} },
1 => return .bold,
2 => return Attribute{ .faint = {} },
2 => return .faint,
3 => return Attribute{ .italic = {} },
3 => return .italic,
4 => blk: {
if (self.colon) {
switch (slice.len) {
// 0 is unreachable because we're here and we read
// an element to get here.
0 => unreachable,
4 => underline: {
if (colon) {
assert(slice.len >= 2);
if (self.isColon()) {
self.consumeUnknownColon();
break :underline;
}
// 1 is possible if underline is the last element.
1 => return Attribute{ .underline = .single },
// 2 means we have a specific underline style.
2 => {
self.idx += 1;
switch (slice[1]) {
0 => return Attribute{ .reset_underline = {} },
1 => return Attribute{ .underline = .single },
2 => return Attribute{ .underline = .double },
3 => return Attribute{ .underline = .curly },
4 => return Attribute{ .underline = .dotted },
5 => return Attribute{ .underline = .dashed },
0 => return .reset_underline,
1 => return .{ .underline = .single },
2 => return .{ .underline = .double },
3 => return .{ .underline = .curly },
4 => return .{ .underline = .dotted },
5 => return .{ .underline = .dashed },
// For unknown underline styles, just render
// a single underline.
else => return Attribute{ .underline = .single },
else => return .{ .underline = .single },
}
}
return .{ .underline = .single };
},
// Colon-separated must only be 2.
else => break :blk,
}
}
5 => return .blink,
return Attribute{ .underline = .single };
},
6 => return .blink,
5 => return Attribute{ .blink = {} },
7 => return .inverse,
6 => return Attribute{ .blink = {} },
8 => return .invisible,
7 => return Attribute{ .inverse = {} },
9 => return .strikethrough,
8 => return Attribute{ .invisible = {} },
21 => return .{ .underline = .double },
9 => return Attribute{ .strikethrough = {} },
22 => return .reset_bold,
21 => return Attribute{ .underline = .double },
23 => return .reset_italic,
22 => return Attribute{ .reset_bold = {} },
24 => return .reset_underline,
23 => return Attribute{ .reset_italic = {} },
25 => return .reset_blink,
24 => return Attribute{ .reset_underline = {} },
27 => return .reset_inverse,
25 => return Attribute{ .reset_blink = {} },
28 => return .reset_invisible,
27 => return Attribute{ .reset_inverse = {} },
29 => return .reset_strikethrough,
28 => return Attribute{ .reset_invisible = {} },
29 => return Attribute{ .reset_strikethrough = {} },
30...37 => return Attribute{
30...37 => return .{
.@"8_fg" = @enumFromInt(slice[0] - 30),
},
38 => if (slice.len >= 2) switch (slice[1]) {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
2 => if (slice.len >= 5) {
self.idx += 4;
// When a colon separator is used, there may or may not be
// a color space identifier as the third param, which we
// need to ignore (it has no standardized behavior).
const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1;
break :rgb slice[3..6];
};
2 => if (self.parseDirectColor(
.direct_color_fg,
slice,
colon,
)) |v| return v,
// We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
return Attribute{
.direct_color_fg = .{
.r = @truncate(rgb[0]),
.g = @truncate(rgb[1]),
.b = @truncate(rgb[2]),
},
};
},
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2;
return Attribute{
return .{
.@"256_fg" = @truncate(slice[2]),
};
},
else => {},
},
39 => return Attribute{ .reset_fg = {} },
39 => return .reset_fg,
40...47 => return Attribute{
40...47 => return .{
.@"8_bg" = @enumFromInt(slice[0] - 40),
},
48 => if (slice.len >= 2) switch (slice[1]) {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
2 => if (slice.len >= 5) {
self.idx += 4;
// When a colon separator is used, there may or may not be
// a color space identifier as the third param, which we
// need to ignore (it has no standardized behavior).
const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1;
break :rgb slice[3..6];
};
2 => if (self.parseDirectColor(
.direct_color_bg,
slice,
colon,
)) |v| return v,
// We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
return Attribute{
.direct_color_bg = .{
.r = @truncate(rgb[0]),
.g = @truncate(rgb[1]),
.b = @truncate(rgb[2]),
},
};
},
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2;
return Attribute{
return .{
.@"256_bg" = @truncate(slice[2]),
};
},
else => {},
},
49 => return Attribute{ .reset_bg = {} },
49 => return .reset_bg,
53 => return Attribute{ .overline = {} },
55 => return Attribute{ .reset_overline = {} },
53 => return .overline,
55 => return .reset_overline,
58 => if (slice.len >= 2) switch (slice[1]) {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
2 => if (slice.len >= 5) {
self.idx += 4;
// When a colon separator is used, there may or may not be
// a color space identifier as the third param, which we
// need to ignore (it has no standardized behavior).
const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1;
break :rgb slice[3..6];
};
2 => if (self.parseDirectColor(
.underline_color,
slice,
colon,
)) |v| return v,
// We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
return Attribute{
.underline_color = .{
.r = @truncate(rgb[0]),
.g = @truncate(rgb[1]),
.b = @truncate(rgb[2]),
},
};
},
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2;
return Attribute{
return .{
.@"256_underline_color" = @truncate(slice[2]),
};
},
else => {},
},
59 => return Attribute{ .reset_underline_color = {} },
59 => return .reset_underline_color,
90...97 => return Attribute{
90...97 => return .{
// 82 instead of 90 to offset to "bright" colors
.@"8_bright_fg" = @enumFromInt(slice[0] - 82),
},
100...107 => return Attribute{
100...107 => return .{
.@"8_bright_bg" = @enumFromInt(slice[0] - 92),
},
else => {},
}
return Attribute{ .unknown = .{ .full = self.params, .partial = slice } };
return .{ .unknown = .{ .full = self.params, .partial = slice } };
}
fn parseDirectColor(
self: *Parser,
comptime tag: Attribute.Tag,
slice: []const u16,
colon: bool,
) ?Attribute {
// Any direct color style must have at least 5 values.
if (slice.len < 5) return null;
// Only used for direct color sets (38, 48, 58) and subparam 2.
assert(slice[1] == 2);
// Note: We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
// If we don't have a colon, then we expect exactly 3 semicolon
// separated values.
if (!colon) {
self.idx += 4;
return @unionInit(Attribute, @tagName(tag), .{
.r = @truncate(slice[2]),
.g = @truncate(slice[3]),
.b = @truncate(slice[4]),
});
}
// We have a colon, we might have either 5 or 6 values depending
// on if the colorspace is present.
const count = self.countColon();
switch (count) {
3 => {
self.idx += 4;
return @unionInit(Attribute, @tagName(tag), .{
.r = @truncate(slice[2]),
.g = @truncate(slice[3]),
.b = @truncate(slice[4]),
});
},
4 => {
self.idx += 5;
return @unionInit(Attribute, @tagName(tag), .{
.r = @truncate(slice[3]),
.g = @truncate(slice[4]),
.b = @truncate(slice[5]),
});
},
else => {
self.consumeUnknownColon();
return null;
},
}
}
/// Returns true if the present position has a colon separator.
/// This always returns false for the last value since it has no
/// separator.
fn isColon(self: *Parser) bool {
// The `- 1` here is because the last value has no separator.
if (self.idx >= self.params.len - 1) return false;
return self.params_sep.isSet(self.idx);
}
fn countColon(self: *Parser) usize {
var count: usize = 0;
var idx = self.idx;
while (idx < self.params.len - 1 and self.params_sep.isSet(idx)) : (idx += 1) {
count += 1;
}
return count;
}
/// Consumes all the remaining parameters separated by a colon and
/// returns an unknown attribute.
fn consumeUnknownColon(self: *Parser) void {
const count = self.countColon();
self.idx += count + 1;
}
};
@ -329,7 +372,7 @@ fn testParse(params: []const u16) Attribute {
}
fn testParseColon(params: []const u16) Attribute {
var p: Parser = .{ .params = params, .colon = true };
var p: Parser = .{ .params = params, .params_sep = SepList.initFull() };
return p.next().?;
}
@ -366,6 +409,35 @@ test "sgr: Parser multiple" {
try testing.expect(p.next() == null);
}
test "sgr: unsupported with colon" {
var p: Parser = .{
.params = &[_]u16{ 0, 4, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(0);
break :sep list;
},
};
try testing.expect(p.next().? == .unknown);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: unsupported with multiple colon" {
var p: Parser = .{
.params = &[_]u16{ 0, 4, 2, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(0);
list.set(1);
break :sep list;
},
};
try testing.expect(p.next().? == .unknown);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: bold" {
{
const v = testParse(&[_]u16{1});
@ -439,6 +511,37 @@ test "sgr: underline styles" {
}
}
test "sgr: underline style with more" {
var p: Parser = .{
.params = &[_]u16{ 4, 2, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(0);
break :sep list;
},
};
try testing.expect(p.next().? == .underline);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: underline style with too many colons" {
var p: Parser = .{
.params = &[_]u16{ 4, 2, 3, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(0);
list.set(1);
break :sep list;
},
};
try testing.expect(p.next().? == .unknown);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: blink" {
{
const v = testParse(&[_]u16{5});
@ -592,13 +695,13 @@ test "sgr: underline, bg, and fg" {
test "sgr: direct color fg missing color" {
// This used to crash
var p: Parser = .{ .params = &[_]u16{ 38, 5 }, .colon = false };
var p: Parser = .{ .params = &[_]u16{ 38, 5 } };
while (p.next()) |_| {}
}
test "sgr: direct color bg missing color" {
// This used to crash
var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false };
var p: Parser = .{ .params = &[_]u16{ 48, 5 } };
while (p.next()) |_| {}
}
@ -608,7 +711,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
// Colon version should skip the optional color space identifier
{
// 3 8 : 2 : Pi : Pr : Pg : Pb
const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
@ -616,7 +719,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 4 8 : 2 : Pi : Pr : Pg : Pb
const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_bg);
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g);
@ -624,7 +727,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 5 8 : 2 : Pi : Pr : Pg : Pb
const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3 });
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 1), v.underline_color.r);
try testing.expectEqual(@as(u8, 2), v.underline_color.g);
@ -634,7 +737,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
// Semicolon version should not parse optional color space identifier
{
// 3 8 ; 2 ; Pr ; Pg ; Pb
const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 0), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.g);
@ -642,7 +745,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 4 8 ; 2 ; Pr ; Pg ; Pb
const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_bg);
try testing.expectEqual(@as(u8, 0), v.direct_color_bg.r);
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.g);
@ -650,10 +753,114 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 5 8 ; 2 ; Pr ; Pg ; Pb
const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3 });
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 0), v.underline_color.r);
try testing.expectEqual(@as(u8, 1), v.underline_color.g);
try testing.expectEqual(@as(u8, 2), v.underline_color.b);
}
}
test "sgr: direct fg colon with too many colons" {
var p: Parser = .{
.params = &[_]u16{ 38, 2, 0, 1, 2, 3, 4, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
for (0..6) |idx| list.set(idx);
break :sep list;
},
};
try testing.expect(p.next().? == .unknown);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: direct fg colon with colorspace and extra param" {
var p: Parser = .{
.params = &[_]u16{ 38, 2, 0, 1, 2, 3, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
for (0..5) |idx| list.set(idx);
break :sep list;
},
};
{
const v = p.next().?;
std.log.warn("WHAT={}", .{v});
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b);
}
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: direct fg colon no colorspace and extra param" {
var p: Parser = .{
.params = &[_]u16{ 38, 2, 1, 2, 3, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
for (0..4) |idx| list.set(idx);
break :sep list;
},
};
{
const v = p.next().?;
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b);
}
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
// Kakoune sent this complex SGR sequence that caused invalid behavior.
test "sgr: kakoune input" {
// This used to crash
var p: Parser = .{
.params = &[_]u16{ 0, 4, 3, 38, 2, 175, 175, 215, 58, 2, 0, 190, 80, 70 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(1);
list.set(8);
list.set(9);
list.set(10);
list.set(11);
list.set(12);
break :sep list;
},
};
{
const v = p.next().?;
try testing.expect(v == .unset);
}
{
const v = p.next().?;
try testing.expect(v == .underline);
try testing.expectEqual(Attribute.Underline.curly, v.underline);
}
{
const v = p.next().?;
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 175), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 175), v.direct_color_fg.g);
try testing.expectEqual(@as(u8, 215), v.direct_color_fg.b);
}
{
const v = p.next().?;
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 190), v.underline_color.r);
try testing.expectEqual(@as(u8, 80), v.underline_color.g);
try testing.expectEqual(@as(u8, 70), v.underline_color.b);
}
//try testing.expect(p.next() == null);
}

View File

@ -253,15 +253,11 @@ pub fn Stream(comptime Handler: type) type {
// A parameter separator:
':', ';' => if (self.parser.params_idx < 16) {
self.parser.params[self.parser.params_idx] = self.parser.param_acc;
if (c == ':') self.parser.params_sep.set(self.parser.params_idx);
self.parser.params_idx += 1;
self.parser.param_acc = 0;
self.parser.param_acc_idx = 0;
// Keep track of separator state.
const sep: Parser.ParamSepState = @enumFromInt(c);
if (self.parser.params_idx == 1) self.parser.params_sep = sep;
if (self.parser.params_sep != sep) self.parser.params_sep = .mixed;
},
// Explicitly ignored:
0x7F => {},
@ -937,7 +933,10 @@ pub fn Stream(comptime Handler: type) type {
'm' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setAttribute")) {
// log.info("parse SGR params={any}", .{action.params});
var p: sgr.Parser = .{ .params = input.params, .colon = input.sep == .colon };
var p: sgr.Parser = .{
.params = input.params,
.params_sep = input.params_sep,
};
while (p.next()) |attr| {
// log.info("SGR attribute: {}", .{attr});
try self.handler.setAttribute(attr);

View File

@ -179,8 +179,17 @@ pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void {
// Quit our read thread after exiting the subprocess so that
// we don't get stuck waiting for data to stop flowing if it is
// a particularly noisy process.
_ = posix.write(exec.read_thread_pipe, "x") catch |err|
log.warn("error writing to read thread quit pipe err={}", .{err});
_ = posix.write(exec.read_thread_pipe, "x") catch |err| switch (err) {
// BrokenPipe means that our read thread is closed already,
// which is completely fine since that is what we were trying
// to achieve.
error.BrokenPipe => {},
else => log.warn(
"error writing to read thread quit pipe err={}",
.{err},
),
};
if (comptime builtin.os.tag == .windows) {
// Interrupt the blocking read so the thread can see the quit message
@ -875,7 +884,11 @@ const Subprocess = struct {
};
const force: ?shell_integration.Shell = switch (cfg.shell_integration) {
.none => break :shell .{ null, default_shell_command },
.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);
break :shell .{ null, default_shell_command };
},
.detect => null,
.bash => .bash,
.elvish => .elvish,
@ -971,12 +984,12 @@ const Subprocess = struct {
// which we may not want. If we specify "-l" then we can avoid
// this behavior but now the shell isn't a login shell.
//
// There is another issue: `login(1)` only checks for ".hushlogin"
// in the working directory. This means that if we specify "-l"
// then we won't get hushlogin honored if its in the home
// directory (which is standard). To get around this, we
// check for hushlogin ourselves and if present specify the
// "-q" flag to login(1).
// There is another issue: `login(1)` on macOS 14.3 and earlier
// checked for ".hushlogin" in the working directory. This means
// that if we specify "-l" then we won't get hushlogin honored
// if its in the home directory (which is standard). To get
// around this, we check for hushlogin ourselves and if present
// specify the "-q" flag to login(1).
//
// So to get all the behaviors we want, we specify "-l" but
// execute "bash" (which is built-in to macOS). We then use
@ -1094,6 +1107,10 @@ const Subprocess = struct {
});
self.pty = pty;
errdefer {
if (comptime builtin.os.tag != .windows) {
_ = posix.close(pty.slave);
}
pty.deinit();
self.pty = null;
}
@ -1178,6 +1195,13 @@ const Subprocess = struct {
log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"});
}
if (comptime builtin.os.tag != .windows) {
// Once our subcommand is started we can close the slave
// side. This prevents the slave fd from being leaked to
// future children.
_ = posix.close(pty.slave);
}
self.command = cmd;
return switch (builtin.os.tag) {
.windows => .{
@ -1452,6 +1476,13 @@ pub const ReadThread = struct {
log.info("read thread got quit signal", .{});
return;
}
// If our pty fd is closed, then we're also done with our
// read thread.
if (pollfds[0].revents & posix.POLL.HUP != 0) {
log.info("pty fd closed, read thread exiting", .{});
return;
}
}
}

Some files were not shown because too many files have changed in this diff Show More