diff --git a/.gitignore b/.gitignore index 0e301f8c4..db8457e1f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ test/cases/**/*.actual.png glad.zip /Box_test.ppm /Box_test_diff.ppm +/ghostty.qcow2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af3c30be7..a7233b2c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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#`. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#`. `` 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#`. +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. diff --git a/build.zig.zon b/build.zig.zon index 3c6ab85d6..a8f45e6ea 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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", }, }, } diff --git a/dist/linux/ghostty_dolphin.desktop b/dist/linux/ghostty_dolphin.desktop old mode 100644 new mode 100755 diff --git a/flake.nix b/flake.nix index 83d4af414..3256c7c15 100644 --- a/flake.nix +++ b/flake.nix @@ -31,38 +31,81 @@ zig, ... }: - builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (builtins.map (system: let - pkgs-stable = nixpkgs-stable.legacyPackages.${system}; - pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; - in { - devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { - zig = zig.packages.${system}."0.13.0"; - wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; - }; + builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} ( + builtins.map ( + system: let + pkgs-stable = nixpkgs-stable.legacyPackages.${system}; + pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; + in { + devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { + zig = zig.packages.${system}."0.13.0"; + wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; + }; - packages.${system} = let - mkArgs = optimize: { - inherit optimize; + packages.${system} = let + mkArgs = optimize: { + inherit optimize; - revision = self.shortRev or self.dirtyShortRev or "dirty"; - }; - in rec { - ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug"); - ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); - ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); + revision = self.shortRev or self.dirtyShortRev or "dirty"; + }; + in rec { + ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug"); + ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); + ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); - ghostty = ghostty-releasefast; - default = ghostty; - }; + ghostty = ghostty-releasefast; + default = ghostty; + }; - formatter.${system} = pkgs-stable.alejandra; + formatter.${system} = pkgs-stable.alejandra; - # Our supported systems are the same supported systems as the Zig binaries. - }) (builtins.attrNames zig.packages)) + 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 = final: prev: { - ghostty = self.packages.${prev.system}.default; + 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 = { diff --git a/include/ghostty.h b/include/ghostty.h index 29da8f37b..246fb9ed3 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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, diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a948c91ce..fe1f2cd75 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; + A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = ""; }; + A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = ""; }; A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; 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 = ""; }; @@ -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 */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 9e33d2dd6..313a5a327 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -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 - context.duration = derivedConfig.quickTerminalAnimationDuration - context.timingFunction = .init(name: .easeIn) - position.setFinal(in: window.animator(), on: screen) - }, - 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 } + NSAnimationContext.runAnimationGroup({ context in + context.duration = derivedConfig.quickTerminalAnimationDuration + context.timingFunction = .init(name: .easeIn) + position.setFinal(in: window.animator(), on: screen) + }, 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 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,17 +442,20 @@ class QuickTerminalController: BaseTerminalController { } } - NSAnimationContext.runAnimationGroup( - { context in - context.duration = derivedConfig.quickTerminalAnimationDuration - context.timingFunction = .init(name: .easeIn) - position.setInitial(in: window.animator(), on: screen) - }, - completionHandler: { - // This causes the window to be removed from the screen list and macOS - // handles what should be focused next. - window.orderOut(self) - }) + // 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: { + // This causes the window to be removed from the screen list and macOS + // handles what should be focused next. + window.orderOut(self) + }) } private func syncAppearance() { @@ -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 { diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 0acbfec1b..7ba124a30 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -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 + } + } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift index 552b87e25..005808a23 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift @@ -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] } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index bda6d62bf..bace20f05 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -452,6 +452,7 @@ class BaseTerminalController: NSWindowController, self.alert = nil switch (response) { case .alertFirstButtonReturn: + alert.window.orderOut(nil) window.close() default: diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 08306a854..f24261b9b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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 = [] @@ -315,28 +315,28 @@ class TerminalController: BaseTerminalController { window.styleMask = [ // We need `titled` in the mask to get the normal window frame .titled, - + // Full size content view so we can extend // content in to the hidden titlebar's area - .fullSizeContentView, - - .resizable, + .fullSizeContentView, + + .resizable, .closable, .miniaturizable, ] - + // Hide the title window.titleVisibility = .hidden window.titlebarAppearsTransparent = true - + // Hide the traffic lights (window control buttons) window.standardWindowButton(.closeButton)?.isHidden = true window.standardWindowButton(.miniaturizeButton)?.isHidden = true window.standardWindowButton(.zoomButton)?.isHidden = true - + // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. window.tabbingMode = .disallowed - + // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are // some operations that appear to bring back the titlebar visibility so this ensures // it is gone forever. @@ -345,7 +345,7 @@ class TerminalController: BaseTerminalController { titleBarContainer.isHidden = true } } - + override func windowDidLoad() { super.windowDidLoad() guard let window = window as? TerminalWindow else { return } @@ -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 diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 0eb8daeeb..9d29c193f 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -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? diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1b3263fc3..9c8042c63 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -132,15 +132,6 @@ extension Ghostty { return v } - var windowColorspace: String { - guard let config = self.config else { return "" } - var v: UnsafePointer? = 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? = 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? = 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 + } + } + } } diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index cc3bef149..cec178245 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -205,6 +205,7 @@ extension Ghostty { alert.beginSheetModal(for: window, completionHandler: { response in switch (response) { case .alertFirstButtonReturn: + alert.window.orderOut(nil) node = nil default: diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 4abf87c7f..beae50331 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 14143313e..f5cb93580 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -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 = [ + .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 + } +} diff --git a/macos/Sources/Helpers/Dock.swift b/macos/Sources/Helpers/Dock.swift new file mode 100644 index 000000000..a71fcaa5b --- /dev/null +++ b/macos/Sources/Helpers/Dock.swift @@ -0,0 +1,38 @@ +import Cocoa + +// Private API to get Dock location +@_silgen_name("CoreDockGetOrientationAndPinning") +func CoreDockGetOrientationAndPinning( + _ outOrientation: UnsafeMutablePointer, + _ outPinning: UnsafeMutablePointer) + +// 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) } + } +} diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index a16f329f8..320eca013 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -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. diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/NSApplication+Extension.swift new file mode 100644 index 000000000..0580cd5fc --- /dev/null +++ b/macos/Sources/Helpers/NSApplication+Extension.swift @@ -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) + } +} diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/NSPasteboard+Extension.swift index 7315739c6..11815fbc8 100644 --- a/macos/Sources/Helpers/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/NSPasteboard+Extension.swift @@ -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) diff --git a/nix/vm/common-cinnamon.nix b/nix/vm/common-cinnamon.nix new file mode 100644 index 000000000..dabe5e701 --- /dev/null +++ b/nix/vm/common-cinnamon.nix @@ -0,0 +1,18 @@ +{...}: { + imports = [ + ./common.nix + ]; + + services.xserver = { + displayManager = { + lightdm = { + enable = true; + }; + }; + desktopManager = { + cinnamon = { + enable = true; + }; + }; + }; +} diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix new file mode 100644 index 000000000..0c2bef150 --- /dev/null +++ b/nix/vm/common-gnome.nix @@ -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 + ''; + }; + }; +} diff --git a/nix/vm/common-plasma6.nix b/nix/vm/common-plasma6.nix new file mode 100644 index 000000000..e5c9bd4d8 --- /dev/null +++ b/nix/vm/common-plasma6.nix @@ -0,0 +1,21 @@ +{...}: { + imports = [ + ./common.nix + ]; + + services = { + displayManager = { + sddm = { + enable = true; + wayland = { + enable = true; + }; + }; + }; + desktopManager = { + plasma6 = { + enable = true; + }; + }; + }; +} diff --git a/nix/vm/common-xfce.nix b/nix/vm/common-xfce.nix new file mode 100644 index 000000000..12a20d8d8 --- /dev/null +++ b/nix/vm/common-xfce.nix @@ -0,0 +1,18 @@ +{...}: { + imports = [ + ./common.nix + ]; + + services.xserver = { + displayManager = { + lightdm = { + enable = true; + }; + }; + desktopManager = { + xfce = { + enable = true; + }; + }; + }; +} diff --git a/nix/vm/common.nix b/nix/vm/common.nix new file mode 100644 index 000000000..eefd7c1c0 --- /dev/null +++ b/nix/vm/common.nix @@ -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; + }; +} diff --git a/nix/vm/create-cinnamon.nix b/nix/vm/create-cinnamon.nix new file mode 100644 index 000000000..a9d9e44d7 --- /dev/null +++ b/nix/vm/create-cinnamon.nix @@ -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; +} diff --git a/nix/vm/create-gnome.nix b/nix/vm/create-gnome.nix new file mode 100644 index 000000000..bcd31f2b6 --- /dev/null +++ b/nix/vm/create-gnome.nix @@ -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; +} diff --git a/nix/vm/create-plasma6.nix b/nix/vm/create-plasma6.nix new file mode 100644 index 000000000..ede5371f3 --- /dev/null +++ b/nix/vm/create-plasma6.nix @@ -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; +} diff --git a/nix/vm/create-xfce.nix b/nix/vm/create-xfce.nix new file mode 100644 index 000000000..d1789472d --- /dev/null +++ b/nix/vm/create-xfce.nix @@ -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; +} diff --git a/nix/vm/create.nix b/nix/vm/create.nix new file mode 100644 index 000000000..f8fe8500d --- /dev/null +++ b/nix/vm/create.nix @@ -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 + ]; + } diff --git a/nix/vm/wayland-cinnamon.nix b/nix/vm/wayland-cinnamon.nix new file mode 100644 index 000000000..531c882b6 --- /dev/null +++ b/nix/vm/wayland-cinnamon.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./common-cinnamon.nix + ]; + + services.displayManager.defaultSession = "cinnamon-wayland"; +} diff --git a/nix/vm/wayland-gnome.nix b/nix/vm/wayland-gnome.nix new file mode 100644 index 000000000..eb277d5d1 --- /dev/null +++ b/nix/vm/wayland-gnome.nix @@ -0,0 +1,9 @@ +{...}: { + imports = [ + ./common-gnome.nix + ]; + + services.displayManager = { + defaultSession = "gnome"; + }; +} diff --git a/nix/vm/wayland-plasma6.nix b/nix/vm/wayland-plasma6.nix new file mode 100644 index 000000000..6e5a253b8 --- /dev/null +++ b/nix/vm/wayland-plasma6.nix @@ -0,0 +1,6 @@ +{...}: { + imports = [ + ./common-plasma6.nix + ]; + services.displayManager.defaultSession = "plasma"; +} diff --git a/nix/vm/x11-cinnamon.nix b/nix/vm/x11-cinnamon.nix new file mode 100644 index 000000000..636f235a2 --- /dev/null +++ b/nix/vm/x11-cinnamon.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./common-cinnamon.nix + ]; + + services.displayManager.defaultSession = "cinnamon"; +} diff --git a/nix/vm/x11-gnome.nix b/nix/vm/x11-gnome.nix new file mode 100644 index 000000000..1994aea82 --- /dev/null +++ b/nix/vm/x11-gnome.nix @@ -0,0 +1,9 @@ +{...}: { + imports = [ + ./common-gnome.nix + ]; + + services.displayManager = { + defaultSession = "gnome-xorg"; + }; +} diff --git a/nix/vm/x11-plasma6.nix b/nix/vm/x11-plasma6.nix new file mode 100644 index 000000000..7818a80ca --- /dev/null +++ b/nix/vm/x11-plasma6.nix @@ -0,0 +1,6 @@ +{...}: { + imports = [ + ./common-plasma6.nix + ]; + services.displayManager.defaultSession = "plasmax11"; +} diff --git a/nix/vm/x11-xfce.nix b/nix/vm/x11-xfce.nix new file mode 100644 index 000000000..71eb87f2f --- /dev/null +++ b/nix/vm/x11-xfce.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./common-xfce.nix + ]; + + services.displayManager.defaultSession = "xfce"; +} diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index db909a936..66b8eb8b6 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -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=" diff --git a/pkg/macos/graphics/color_space.zig b/pkg/macos/graphics/color_space.zig index 459f06302..16960591b 100644 --- a/pkg/macos/graphics/color_space.zig +++ b/pkg/macos/graphics/color_space.zig @@ -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 { diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig index 4cd1cf9f9..a9fa5d4fe 100644 --- a/pkg/opengl/Texture.zig +++ b/pkg/opengl/Texture.zig @@ -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 + ); + } }; diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig index 69628f582..c07278eed 100644 --- a/pkg/wuffs/src/jpeg.zig +++ b/pkg/wuffs/src/jpeg.zig @@ -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, diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig index b85e4d747..1f37bb375 100644 --- a/pkg/wuffs/src/png.zig +++ b/pkg/wuffs/src/png.zig @@ -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, diff --git a/src/Surface.zig b/src/Surface.zig index 4682f4fb5..d9a985aa7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 25e1cd640..fe2039e52 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -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, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 44c4c5f20..3c3723d4f 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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()), ); } diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 8094baeb8..686a70ddb 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -237,6 +237,7 @@ pub const App = struct { .color_change, .pwd, .config_change, + .toggle_maximize, => log.info("unimplemented action={}", .{action}), } } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 70fc182e5..df74cefb2 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -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,93 +1585,58 @@ 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(); + 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) { + self.colorSchemeEvent(if (c.g_variant_get_uint32(inner) == 1) + .dark + else + .light); + return; } - // 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); + } 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", + "Read", + 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, + ); + return; + } - 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; - } + // Otherwise, log the error and return .light + log.warn("unable to get current color scheme: {s}", .{e.message}); } - 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, - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Settings", - "Read", - 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| 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; - } - } - return .light; + // Fall back + self.colorSchemeEvent(.light); } /// This will be called by D-Bus when the style changes between light & dark. @@ -1864,16 +1865,14 @@ 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(); - defer c.g_object_unref(submenu); + const section = c.g_menu_new(); + defer c.g_object_unref(section); + const submenu = c.g_menu_new(); + defer c.g_object_unref(submenu); - initMenuContent(@ptrCast(submenu)); - c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - } + 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; } diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index c16f696b1..3677c5e8d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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,11 +1380,9 @@ 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| { - log.warn("failed to notify window protocol of resize={}", .{err}); - }; - } + window.winproto.resizeEvent() catch |err| { + log.warn("failed to notify window protocol of resize={}", .{err}); + }; } self.resize_overlay.maybeShow(); @@ -1496,31 +1490,37 @@ 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 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") { - self.grabFocus(); + 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 (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") { + self.grabFocus(); + } + + // Our pos changed, update + self.cursor_pos = pos; + + // Get our modifiers + const gtk_mods = c.gdk_event_get_modifier_state(event); + const mods = gtk_key.translateMods(gtk_mods); + + self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { + log.err("error in cursor pos callback err={}", .{err}); + return; + }; } - - // Our pos changed, update - self.cursor_pos = pos; - - // Get our modifiers - const gtk_mods = c.gdk_event_get_modifier_state(event); - const mods = gtk_key.translateMods(gtk_mods); - - self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { - 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. 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, + // }); - // 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 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 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; - }; + // 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; - // 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 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; + // 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; } + // 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; } - // This prevents staying in composing state after commit even though - // input method has changed. + // 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; }; } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 8f111cbc9..58f5659f0 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -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(>kWindowNotifyDecorated), 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(>kWindowNotifyMaximized), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_window, "notify::fullscreened", c.G_CALLBACK(>kWindowNotifyFullscreened), 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 { - // 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"); + 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. + 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 diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig index 2b47ea4b7..0f7f15bf8 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -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 { diff --git a/src/apprt/gtk/headerbar_adw.zig b/src/apprt/gtk/headerbar_adw.zig index c0d622207..1ae23e6d9 100644 --- a/src/apprt/gtk/headerbar_adw.zig +++ b/src/apprt/gtk/headerbar_adw.zig @@ -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); } diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index 89a316332..790b3aa35 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -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); } } }; diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index cb873fe01..e6020f49e 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -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(), + }; + } }; diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index 14f3dc6a7..38703aecb 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -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; + } }; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 3f7ad0068..8df3e57b3 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -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, + }; + } }; diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 4eac9cdf3..7a6b8b4c7 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -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 diff --git a/src/build/Config.zig b/src/build/Config.zig index 71dffce4a..c6f0e6d09 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -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, diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index cae907ec2..a7ff40cbd 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -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,23 +52,29 @@ 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); - - { - // 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. - const copy_step = RunStep.create(b, "copy terminfo db"); - copy_step.addArgs(&.{ "cp", "-R" }); - copy_step.addFileArg(path); - copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); - try steps.append(©_step.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. + const copy_step = RunStep.create(b, "copy terminfo db"); + 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(©_step.step); } } diff --git a/src/build/GhosttyWebdata.zig b/src/build/GhosttyWebdata.zig index 6e0acaf17..860feb705 100644 --- a/src/build/GhosttyWebdata.zig +++ b/src/build/GhosttyWebdata.zig @@ -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 }; } diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 16e7381fa..64068658d 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -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); diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig index 0159de442..7a4b0a5a2 100644 --- a/src/build/UnicodeTables.zig +++ b/src/build/UnicodeTables.zig @@ -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, diff --git a/src/build/webgen/main_commands.zig b/src/build/webgen/main_commands.zig new file mode 100644 index 000000000..6e6b00c5e --- /dev/null +++ b/src/build/webgen/main_commands.zig @@ -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"); + } + } +} diff --git a/src/cli/README.md b/src/cli/README.md new file mode 100644 index 000000000..7a1d99409 --- /dev/null +++ b/src/cli/README.md @@ -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`. diff --git a/src/cli/action.zig b/src/cli/action.zig index a84a40024..693d509fc 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -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. diff --git a/src/cli/args.zig b/src/cli/args.zig index 23dcf7733..166b2daf5 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -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. diff --git a/src/cli/help.zig b/src/cli/help.zig index daadc37cc..22fe27d8d 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -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(); diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig index 65b9dcdad..6f67a92d2 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -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(); diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig index 9d1f34cd1..e8a010ecd 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -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(); diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index ddaf75177..6cd989201 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -42,11 +42,15 @@ 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 -/// friendly for Unix tooling. This is default when not printing to a tty. +/// * `--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 = .{}; defer opts.deinit(); @@ -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) { - result = win.printSegment(.{ .text = "super", .style = super_style }, .{ .col_offset = result.col }); - result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col }); - } - if (trigger.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) { - result = win.printSegment(.{ .text = "alt ", .style = alt_style }, .{ .col_offset = result.col }); - result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col }); - } - if (trigger.mods.shift) { - result = win.printSegment(.{ .text = "shift", .style = shift_style }, .{ .col_offset = result.col }); - result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col }); - } + 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.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.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.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.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}), + }; + result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col }); - const key = switch (trigger.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 }); - - 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 }; +} diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 22e22a972..f7ee10ce6 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -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 = .{}; diff --git a/src/cli/validate_config.zig b/src/cli/validate_config.zig index 1615ef66b..5bc6ff406 100644 --- a/src/cli/validate_config.zig +++ b/src/cli/validate_config.zig @@ -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(); diff --git a/src/cli/version.zig b/src/cli/version.zig index b00152589..4a6af242c 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -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; diff --git a/src/config/Config.zig b/src/config/Config.zig index 144796554..839656169 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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, diff --git a/src/config/c_get.zig b/src/config/c_get.zig index 6804b0ae0..251a95e77 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -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); } } diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 6661295f3..3749b4824 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -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), }; diff --git a/src/global.zig b/src/global.zig index c00ce27a4..d5a7af630 100644 --- a/src/global.zig +++ b/src/global.zig @@ -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 diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 2fdbc4cba..a1e759bf8 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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, diff --git a/src/main.zig b/src/main.zig index ecf38fbb3..121a3b7d2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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"), diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 0a66c5987..bef101acc 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -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| { diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index faac4bd27..09570554e 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -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 argZ = try arena.dupeZ(u8, arg); - c.g_ptr_array_add(args_ptr, argZ.ptr); - } + const args = try arena.alloc(?[*:0]u8, self.argv.len + 1); + for (0.., self.argv) |i, arg| { + const argZ = try arena.dupeZ(u8, arg); + 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), diff --git a/src/os/pipe.zig b/src/os/pipe.zig index 392f72083..2cb7bd4a3 100644 --- a/src/os/pipe.zig +++ b/src/os/pipe.zig @@ -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; diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index c0f82dec5..4ef256c1a 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -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; diff --git a/src/pty.zig b/src/pty.zig index c0d082411..b6dc2e145 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -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 } }; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 09dafd1fc..52a5437c6 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -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).* = .{ diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index e5dec6b2b..3e674c715 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -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(); } } diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index 48056ae5e..6ab42bbd6 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -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 diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index b909a2f2a..62d363173 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -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. diff --git a/src/renderer/opengl/custom.zig b/src/renderer/opengl/custom.zig index 2cab0940c..859277ce5 100644 --- a/src/renderer/opengl/custom.zig +++ b/src/renderer/opengl/custom.zig @@ -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(); diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index 2a107402b..17f811a19 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -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 textureGrayscale [[texture(0)]], - texture2d textureColor [[texture(1)]] + texture2d 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 image [[texture(0)]] + texture2d 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 + ); } diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 976cf4924..3d5159c71 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -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 diff --git a/src/shell-integration/bash/bash-preexec.sh b/src/shell-integration/bash/bash-preexec.sh index 14a677888..e07da0d1e 100644 --- a/src/shell-integration/bash/bash-preexec.sh +++ b/src/shell-integration/bash/bash-preexec.sh @@ -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() { diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 71c644b69..7fae435a3 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -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 diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 420a49528..cd4f56105 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -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 diff --git a/src/stb/stb_image.h b/src/stb/stb_image.h index 5e807a0a6..3ae1815c1 100644 --- a/src/stb/stb_image.h +++ b/src/stb/stb_image.h @@ -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) { diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 260733b94..b838332b0 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -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, diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 9aebdbd3a..a779c3350 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -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') { - log.warn( - "CSI colon or mixed separators only allowed for 'm' command, got: {}", - .{result}, - ); - break :csi_dispatch null; - }, + 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)); } } diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index cdf39657b..52bfb2c31 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -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 }, + self.idx += 1; + switch (slice[1]) { + 0 => return .reset_underline, + 1 => return .{ .underline = .single }, + 2 => return .{ .underline = .double }, + 3 => return .{ .underline = .curly }, + 4 => return .{ .underline = .dotted }, + 5 => return .{ .underline = .dashed }, - // 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 }, - - // For unknown underline styles, just render - // a single underline. - else => return Attribute{ .underline = .single }, - } - }, - - // Colon-separated must only be 2. - else => break :blk, + // For unknown underline styles, just render + // a single underline. + else => return .{ .underline = .single }, } } - return Attribute{ .underline = .single }; + return .{ .underline = .single }; }, - 5 => return Attribute{ .blink = {} }, + 5 => return .blink, - 6 => return Attribute{ .blink = {} }, + 6 => return .blink, - 7 => return Attribute{ .inverse = {} }, + 7 => return .inverse, - 8 => return Attribute{ .invisible = {} }, + 8 => return .invisible, - 9 => return Attribute{ .strikethrough = {} }, + 9 => return .strikethrough, - 21 => return Attribute{ .underline = .double }, + 21 => return .{ .underline = .double }, - 22 => return Attribute{ .reset_bold = {} }, + 22 => return .reset_bold, - 23 => return Attribute{ .reset_italic = {} }, + 23 => return .reset_italic, - 24 => return Attribute{ .reset_underline = {} }, + 24 => return .reset_underline, - 25 => return Attribute{ .reset_blink = {} }, + 25 => return .reset_blink, - 27 => return Attribute{ .reset_inverse = {} }, + 27 => return .reset_inverse, - 28 => return Attribute{ .reset_invisible = {} }, + 28 => return .reset_invisible, - 29 => return Attribute{ .reset_strikethrough = {} }, + 29 => return .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); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 5657d63f4..eb5ab2c65 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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); diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 1a3b8cad0..4428b16e1 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -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; + } } } diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 8cd2a92ae..423e2f518 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -58,69 +58,75 @@ pub fn setup( break :exe std.fs.path.basename(command[0..idx]); }; - const result: ShellIntegration = shell: { - if (std.mem.eql(u8, "bash", exe)) { - // Apple distributes their own patched version of Bash 3.2 - // on macOS that disables the ENV-based POSIX startup path. - // This means we're unable to perform our automatic shell - // integration sequence in this specific environment. - // - // If we're running "/bin/bash" on Darwin, we can assume - // we're using Apple's Bash because /bin is non-writable - // on modern macOS due to System Integrity Protection. - if (comptime builtin.target.isDarwin()) { - if (std.mem.eql(u8, "/bin/bash", command)) { - return null; - } - } - - const new_command = try setupBash( - alloc_arena, - command, - resource_dir, - env, - ) orelse return null; - break :shell .{ - .shell = .bash, - .command = new_command, - }; - } - - if (std.mem.eql(u8, "elvish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); - break :shell .{ - .shell = .elvish, - .command = try alloc_arena.dupe(u8, command), - }; - } - - if (std.mem.eql(u8, "fish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); - break :shell .{ - .shell = .fish, - .command = try alloc_arena.dupe(u8, command), - }; - } - - if (std.mem.eql(u8, "zsh", exe)) { - try setupZsh(resource_dir, env); - break :shell .{ - .shell = .zsh, - .command = try alloc_arena.dupe(u8, command), - }; - } - - return null; - }; + const result = try setupShell(alloc_arena, resource_dir, command, env, exe); // Setup our feature env vars - if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1"); - if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1"); - if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1"); + try setupFeatures(env, features); return result; } +fn setupShell( + alloc_arena: Allocator, + resource_dir: []const u8, + command: []const u8, + env: *EnvMap, + exe: []const u8, +) !?ShellIntegration { + if (std.mem.eql(u8, "bash", exe)) { + // Apple distributes their own patched version of Bash 3.2 + // on macOS that disables the ENV-based POSIX startup path. + // This means we're unable to perform our automatic shell + // integration sequence in this specific environment. + // + // If we're running "/bin/bash" on Darwin, we can assume + // we're using Apple's Bash because /bin is non-writable + // on modern macOS due to System Integrity Protection. + if (comptime builtin.target.isDarwin()) { + if (std.mem.eql(u8, "/bin/bash", command)) { + return null; + } + } + + const new_command = try setupBash( + alloc_arena, + command, + resource_dir, + env, + ) orelse return null; + return .{ + .shell = .bash, + .command = new_command, + }; + } + + if (std.mem.eql(u8, "elvish", exe)) { + try setupXdgDataDirs(alloc_arena, resource_dir, env); + return .{ + .shell = .elvish, + .command = try alloc_arena.dupe(u8, command), + }; + } + + if (std.mem.eql(u8, "fish", exe)) { + try setupXdgDataDirs(alloc_arena, resource_dir, env); + return .{ + .shell = .fish, + .command = try alloc_arena.dupe(u8, command), + }; + } + + if (std.mem.eql(u8, "zsh", exe)) { + try setupZsh(resource_dir, env); + return .{ + .shell = .zsh, + .command = try alloc_arena.dupe(u8, command), + }; + } + + return null; +} + test "force shell" { const testing = std.testing; @@ -138,6 +144,58 @@ test "force shell" { } } +/// Setup shell integration feature environment variables without +/// performing full shell integration setup. +pub fn setupFeatures( + env: *EnvMap, + features: config.ShellIntegrationFeatures, +) !void { + if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1"); + if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1"); + if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1"); +} + +test "setup features" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Test: all features enabled (no environment variables should be set) + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true }); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR") == null); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE") == null); + } + + // Test: all features disabled + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false }); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO").?); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?); + } + + // Test: mixed features + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false }); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?); + } +} + /// Setup the bash automatic shell integration. This works by /// starting bash in POSIX mode and using the ENV environment /// variable to load our bash integration script. This prevents @@ -145,8 +203,6 @@ test "force shell" { /// our script's responsibility (along with disabling POSIX /// mode). /// -/// This approach requires bash version 4 or later. -/// /// This returns a new (allocated) shell command string that /// enables the integration or null if integration failed. fn setupBash( @@ -188,12 +244,6 @@ fn setupBash( // Unsupported options: // -c -c is always non-interactive // --posix POSIX mode (a la /bin/sh) - // - // Some additional cases we don't yet cover: - // - // - If additional file arguments are provided (after a `-` or `--` flag), - // and the `i` shell option isn't being explicitly set, we can assume a - // non-interactive shell session and skip loading our shell integration. var rcfile: ?[]const u8 = null; while (iter.next()) |arg| { if (std.mem.eql(u8, arg, "--posix")) { @@ -210,6 +260,14 @@ fn setupBash( return null; } try args.append(arg); + } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { + // All remaining arguments should be passed directly to the shell + // command. We shouldn't perform any further option processing. + try args.append(arg); + while (iter.next()) |remaining_arg| { + try args.append(remaining_arg); + } + break; } else { try args.append(arg); } @@ -372,6 +430,30 @@ test "bash: HISTFILE" { } } +test "bash: additional arguments" { + const testing = std.testing; + const alloc = testing.allocator; + + var env = EnvMap.init(alloc); + defer env.deinit(); + + // "-" argument separator + { + const command = try setupBash(alloc, "bash - --arg file1 file2", ".", &env); + defer if (command) |c| alloc.free(c); + + try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?); + } + + // "--" argument separator + { + const command = try setupBash(alloc, "bash -- --arg file1 file2", ".", &env); + defer if (command) |c| alloc.free(c); + + try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?); + } +} + /// Setup automatic shell integration for shells that include /// their modules from paths in `XDG_DATA_DIRS` env variable. /// diff --git a/src/unicode/props.zig b/src/unicode/props.zig index d77bf4c8a..8c7621b79 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -131,7 +131,9 @@ pub fn get(cp: u21) Properties { /// Runnable binary to generate the lookup tables and output to stdout. pub fn main() !void { - const alloc = std.heap.c_allocator; + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_state.deinit(); + const alloc = arena_state.allocator(); const gen: lut.Generator( Properties,