diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 59be2a17c..cf94bf23e 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -105,8 +105,8 @@ jobs: with: name: source-tarball path: |- - "ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz" - "ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz.minisig" + ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz + ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz.minisig ghostty-source.tar.gz ghostty-source.tar.gz.minisig @@ -360,8 +360,8 @@ jobs: run: | mkdir blob mkdir -p blob/${GHOSTTY_VERSION} - mv "ghostty-${GHOSTTY_VERSION}.tar.gz blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz" - mv ghostty-${GHOSTTY_VERSION}.tar.gz.minisig blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz.minisig + mv "ghostty-${GHOSTTY_VERSION}.tar.gz" blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz + mv "ghostty-${GHOSTTY_VERSION}.tar.gz.minisig" blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz.minisig mv ghostty-source.tar.gz blob/${GHOSTTY_VERSION}/ghostty-source.tar.gz mv ghostty-source.tar.gz.minisig blob/${GHOSTTY_VERSION}/ghostty-source.tar.gz.minisig mv ghostty-macos-universal.zip blob/${GHOSTTY_VERSION}/ghostty-macos-universal.zip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f8d2671c..81d58a1ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -376,6 +376,41 @@ jobs: -Dgtk-adwaita=${{ matrix.adwaita }} \ -Dgtk-x11=${{ matrix.x11 }} + test-sentry-linux: + strategy: + fail-fast: false + matrix: + sentry: ["true", "false"] + name: Build -Dsentry=${{ matrix.sentry }} + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@v1.2.0 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test Sentry Build + run: | + nix develop -c zig build -Dsentry=${{ matrix.sentry }} + test-macos: runs-on: namespace-profile-ghostty-macos needs: test @@ -478,3 +513,38 @@ jobs: useDaemon: false # sometimes fails on short jobs - name: typos check run: nix develop -c typos + + test-pkg-linux: + strategy: + fail-fast: false + matrix: + pkg: ["wuffs"] + name: Test pkg/${{ matrix.pkg }} + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@v1.2.0 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test ${{ matrix.pkg }} Build + run: | + nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test" diff --git a/PACKAGING.md b/PACKAGING.md index 4cea7cf6a..82c7c5673 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -117,3 +117,11 @@ relevant to package maintainers: often necessary for system packages to specify a specific minimum Linux version, glibc, etc. Run `zig targets` to a get a full list of available targets. + +> [!WARNING] +> +> **The GLFW runtime is not meant for distribution.** The GLFW runtime +> (`-Dapp-runtime=glfw`) is meant for development and testing only. It is +> missing many features, has known memory leak scenarios, known crashes, +> and more. Please do not package the GLFW-based Ghostty runtime for +> distribution. diff --git a/build.zig b/build.zig index 6b92a095e..da722a2fa 100644 --- a/build.zig +++ b/build.zig @@ -43,7 +43,7 @@ comptime { } /// The version of the next release. -const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 1 }; +const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 2 }; pub fn build(b: *std.Build) !void { const optimize = b.standardOptimizeOption(.{}); @@ -152,12 +152,36 @@ pub fn build(b: *std.Build) !void { } }; + config.sentry = b.option( + bool, + "sentry", + "Build with Sentry crash reporting. Default for macOS is true, false for any other system.", + ) orelse sentry: { + switch (target.result.os.tag) { + .macos, .ios => break :sentry true, + + // Note its false for linux because the crash reports on Linux + // don't have much useful information. + else => break :sentry false, + } + }; + const pie = b.option( bool, "pie", "Build a Position Independent Executable. Default true for system packages.", ) orelse system_package; + const strip = b.option( + bool, + "strip", + "Strip the final executable. Default true for fast and small releases", + ) orelse switch (optimize) { + .Debug => false, + .ReleaseSafe => false, + .ReleaseFast, .ReleaseSmall => true, + }; + const conformance = b.option( []const u8, "conformance", @@ -342,11 +366,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, - .strip = switch (optimize) { - .Debug => false, - .ReleaseSafe => false, - .ReleaseFast, .ReleaseSmall => true, - }, + .strip = strip, }) else null; // Exe @@ -669,7 +689,12 @@ pub fn build(b: *std.Build) !void { b.installFile("images/icons/icon_128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_1024.png", "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png"); + + // Flatpaks only support icons up to 512x512. + if (!config.flatpak) { + b.installFile("images/icons/icon_1024.png", "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png"); + } + b.installFile("images/icons/icon_16@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_32@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_128@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png"); @@ -685,6 +710,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main_c.zig"), .optimize = optimize, .target = target, + .strip = strip, }); _ = try addDeps(b, lib, config); @@ -702,6 +728,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main_c.zig"), .optimize = optimize, .target = target, + .strip = strip, }); _ = try addDeps(b, lib, config); @@ -1240,13 +1267,15 @@ fn addDeps( } // Sentry - const sentry_dep = b.dependency("sentry", .{ - .target = target, - .optimize = optimize, - .backend = .breakpad, - }); - step.root_module.addImport("sentry", sentry_dep.module("sentry")); - if (target.result.os.tag != .windows) { + if (config.sentry) { + const sentry_dep = b.dependency("sentry", .{ + .target = target, + .optimize = optimize, + .backend = .breakpad, + }); + + step.root_module.addImport("sentry", sentry_dep.module("sentry")); + // Sentry step.linkLibrary(sentry_dep.artifact("sentry")); try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin()); diff --git a/build.zig.zon b/build.zig.zon index 4152b6f2f..5c202e9cd 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = "ghostty", - .version = "1.0.1", + .version = "1.0.2", .paths = .{""}, .dependencies = .{ // Zig libs @@ -49,8 +49,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/08df2e8a72dde535f8d17d8115fe792dbcdc6f35.tar.gz", - .hash = "1220d1090ac2edf1e47059b592403deacd56e7289c7ad8744ed20dd2f297596744b8", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz", + .hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620", }, .vaxis = .{ .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", diff --git a/default.nix b/default.nix new file mode 100644 index 000000000..d6bf5743f --- /dev/null +++ b/default.nix @@ -0,0 +1,13 @@ +(import ( + let + lock = builtins.fromJSON (builtins.readFile ./flake.lock); + nodeName = lock.nodes.root.inputs.flake-compat; + in + fetchTarball { + url = + lock.nodes.${nodeName}.locked.url + or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz"; + sha256 = lock.nodes.${nodeName}.locked.narHash; + } +) {src = ./.;}) +.defaultNix diff --git a/include/ghostty.h b/include/ghostty.h index 61c3aad32..4b8d409e9 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -375,9 +375,9 @@ typedef enum { typedef enum { GHOSTTY_GOTO_SPLIT_PREVIOUS, GHOSTTY_GOTO_SPLIT_NEXT, - GHOSTTY_GOTO_SPLIT_TOP, + GHOSTTY_GOTO_SPLIT_UP, GHOSTTY_GOTO_SPLIT_LEFT, - GHOSTTY_GOTO_SPLIT_BOTTOM, + GHOSTTY_GOTO_SPLIT_DOWN, GHOSTTY_GOTO_SPLIT_RIGHT, } ghostty_action_goto_split_e; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 6d27bdf94..8564bbb1e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -358,8 +358,8 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) - syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove) - syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) + syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove) + syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow) syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) @@ -486,15 +486,16 @@ class AppDelegate: NSObject, // Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is // explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior - // defined by our "auto-update" configuration. - if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool != false { - updaterController.updater.automaticallyChecksForUpdates = - config.autoUpdate == .check || config.autoUpdate == .download - updaterController.updater.automaticallyDownloadsUpdates = - config.autoUpdate == .download - } else { + // defined by our "auto-update" configuration (if set) or fall back to Sparkle + // user-based defaults. + if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false { updaterController.updater.automaticallyChecksForUpdates = false updaterController.updater.automaticallyDownloadsUpdates = false + } else if let autoUpdate = config.autoUpdate { + updaterController.updater.automaticallyChecksForUpdates = + autoUpdate == .check || autoUpdate == .download + updaterController.updater.automaticallyDownloadsUpdates = + autoUpdate == .download } // Config could change keybindings, so update everything that depends on that diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index e4606f729..47ee2dfd9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -69,7 +69,7 @@ class QuickTerminalController: BaseTerminalController { window.isRestorable = false // Setup our configured appearance that we support. - syncAppearance(ghostty.config) + syncAppearance() // Setup our initial size based on our configured position position.setLoaded(window) @@ -214,6 +214,10 @@ class QuickTerminalController: BaseTerminalController { // If we canceled our animation in we do nothing guard self.visible else { return } + // Now that the window is visible, sync our appearance. This function + // requires the window is visible. + self.syncAppearance() + // Once our animation is done, we must grab focus since we can't grab // focus of a non-visible window. self.makeWindowKey(window) @@ -304,24 +308,18 @@ class QuickTerminalController: BaseTerminalController { }) } - private func syncAppearance(_ config: Ghostty.Config) { + private func syncAppearance() { guard let window else { return } - // If our window is not visible, then delay this. This is possible specifically - // during state restoration but probably in other scenarios as well. To delay, - // we just loop directly on the dispatch queue. We have to delay because some - // APIs such as window blur have no effect unless the window is visible. - guard window.isVisible else { - // Weak window so that if the window changes or is destroyed we aren't holding a ref - DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) } - return - } + // If our window is not visible, then no need to sync the appearance yet. + // 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 (config.windowColorspace) { + switch (self.derivedConfig.windowColorspace) { case "display-p3": window.colorSpace = .displayP3 case "srgb": @@ -331,7 +329,7 @@ class QuickTerminalController: BaseTerminalController { } // If we have window transparency then set it transparent. Otherwise set it opaque. - if (config.backgroundOpacity < 1) { + if (self.derivedConfig.backgroundOpacity < 1) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -391,24 +389,30 @@ class QuickTerminalController: BaseTerminalController { // Update our derived config self.derivedConfig = DerivedConfig(config) - syncAppearance(config) + syncAppearance() } private struct DerivedConfig { let quickTerminalScreen: QuickTerminalScreen let quickTerminalAnimationDuration: Double let quickTerminalAutoHide: Bool + let windowColorspace: String + let backgroundOpacity: Double init() { self.quickTerminalScreen = .main self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAutoHide = true + self.windowColorspace = "" + self.backgroundOpacity = 1.0 } init(_ config: Ghostty.Config) { self.quickTerminalScreen = config.quickTerminalScreen self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAutoHide = config.quickTerminalAutoHide + self.windowColorspace = config.windowColorspace + self.backgroundOpacity = config.backgroundOpacity } } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 68c243004..393c6ef4d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -45,6 +45,11 @@ class BaseTerminalController: NSWindowController, didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } + /// Whether the terminal surface should focus when the mouse is over it. + var focusFollowsMouse: Bool { + self.derivedConfig.focusFollowsMouse + } + /// Non-nil when an alert is active so we don't overlap multiple. private var alert: NSAlert? = nil @@ -106,8 +111,8 @@ class BaseTerminalController: NSWindowController, // Listen for local events that we need to know of outside of // single surface handlers. self.eventMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.flagsChanged], - handler: localEventHandler) + matching: [.flagsChanged] + ) { [weak self] event in self?.localEventHandler(event) } } deinit { @@ -155,7 +160,7 @@ class BaseTerminalController: NSWindowController, } // MARK: Notifications - + @objc private func didChangeScreenParametersNotification(_ notification: Notification) { // If we have a window that is visible and it is outside the bounds of the // screen then we clamp it back to within the screen. @@ -262,7 +267,6 @@ class BaseTerminalController: NSWindowController, // Set the main window title window.title = to - } func pwdDidChange(to: URL?) { @@ -309,11 +313,11 @@ class BaseTerminalController: NSWindowController, // We consider our mode changed if the types change (obvious) but // also if its nil (not obvious) because nil means that the style has // likely changed but we don't support it. - if newStyle == nil || type(of: newStyle) != type(of: oldStyle) { + if newStyle == nil || type(of: newStyle!) != type(of: oldStyle) { // Our mode changed. Exit fullscreen (since we're toggling anyways) - // and then unset the style so that we replace it next time. + // and then set the new style for future use oldStyle.exit() - self.fullscreenStyle = nil + self.fullscreenStyle = newStyle // We're done return @@ -536,11 +540,11 @@ class BaseTerminalController: NSWindowController, } @IBAction func splitMoveFocusAbove(_ sender: Any) { - splitMoveFocus(direction: .top) + splitMoveFocus(direction: .up) } @IBAction func splitMoveFocusBelow(_ sender: Any) { - splitMoveFocus(direction: .bottom) + splitMoveFocus(direction: .down) } @IBAction func splitMoveFocusLeft(_ sender: Any) { @@ -604,15 +608,18 @@ class BaseTerminalController: NSWindowController, private struct DerivedConfig { let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let windowStepResize: Bool + let focusFollowsMouse: Bool init() { self.macosTitlebarProxyIcon = .visible self.windowStepResize = false + self.focusFollowsMouse = false } init(_ config: Ghostty.Config) { self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon self.windowStepResize = config.windowStepResize + self.focusFollowsMouse = config.focusFollowsMouse } } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7fd1802dc..2da498e3a 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -101,6 +101,12 @@ class TerminalController: BaseTerminalController { // When our fullscreen state changes, we resync our appearance because some // properties change when fullscreen or not. guard let focusedSurface else { return } + if (!(fullscreenStyle?.isFullscreen ?? false) && + ghostty.config.macosTitlebarStyle == "hidden") + { + applyHiddenTitlebarStyle() + } + syncAppearance(focusedSurface.derivedConfig) } @@ -117,9 +123,6 @@ class TerminalController: BaseTerminalController { // Update our derived config self.derivedConfig = DerivedConfig(config) - guard let window = window as? TerminalWindow else { return } - window.focusFollowsMouse = config.focusFollowsMouse - // If we have no surfaces in our window (is that possible?) then we update // our window appearance based on the root config. If we have surfaces, we // don't call this because the TODO @@ -247,7 +250,9 @@ class TerminalController: BaseTerminalController { let backgroundColor: OSColor if let surfaceTree { if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { - backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor) + // Similar to above, an alpha component of "0" causes compositor issues, so + // we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308 + backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001) } else { // We don't have a focused surface or our surface doesn't border the // top. We choose to match the color of the top-left most surface. @@ -270,6 +275,28 @@ class TerminalController: BaseTerminalController { } } + private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { + guard let window else { return } + + // If we don't have both an X and Y we center. + guard let x, let y else { + window.center() + return + } + + // Prefer the screen our window is being placed on otherwise our primary screen. + guard let screen = window.screen ?? NSScreen.screens.first else { + window.center() + return + } + + // Orient based on the top left of the primary monitor + let frame = screen.visibleFrame + window.setFrameOrigin(.init( + x: frame.minX + CGFloat(x), + y: frame.maxY - (CGFloat(y) + window.frame.height))) + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -277,6 +304,43 @@ class TerminalController: BaseTerminalController { shouldCascadeWindows = false } + fileprivate func applyHiddenTitlebarStyle() { + guard let window else { return } + + 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, + .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. + if let themeFrame = window.contentView?.superview, + let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { + titleBarContainer.isHidden = true + } + } + override func windowDidLoad() { super.windowDidLoad() guard let window = window as? TerminalWindow else { return } @@ -328,9 +392,12 @@ class TerminalController: BaseTerminalController { } } - // Center the window to start, we'll move the window frame automatically - // when cascading. - window.center() + // Set our window positioning to coordinates if config value exists, otherwise + // fallback to original centering behavior + setInitialWindowPosition( + x: config.windowPositionX, + y: config.windowPositionY, + windowDecorations: config.windowDecorations) // Make sure our theme is set on the window so styling is correct. if let windowTheme = config.windowTheme { @@ -368,38 +435,7 @@ class TerminalController: BaseTerminalController { // If our titlebar style is "hidden" we adjust the style appropriately if (config.macosTitlebarStyle == "hidden") { - 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, - .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. - if let themeFrame = window.contentView?.superview, - let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { - titleBarContainer.isHidden = true - } + applyHiddenTitlebarStyle() } // In various situations, macOS automatically tabs new windows. Ghostty handles @@ -422,8 +458,6 @@ class TerminalController: BaseTerminalController { } } - window.focusFollowsMouse = config.focusFollowsMouse - // Apply any additional appearance-related properties to the new window. We // apply this based on the root config but change it later based on surface // config (see focused surface change callback). diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 503e76791..35f629bfd 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -414,8 +414,6 @@ class TerminalWindow: NSWindow { } } - var focusFollowsMouse: Bool = false - // Find the NSTextField responsible for displaying the titlebar's title. private var titlebarTextField: NSTextField? { guard let titlebarView = titlebarContainer?.subviews diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1e733c5e1..b6da07612 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -149,6 +149,20 @@ extension Ghostty { guard let ptr = v else { return "" } return String(cString: ptr) } + + var windowPositionX: Int16? { + guard let config = self.config else { return nil } + var v: Int16 = 0 + let key = "window-position-x" + return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + } + + var windowPositionY: Int16? { + guard let config = self.config else { return nil } + var v: Int16 = 0 + let key = "window-position-y" + return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + } var windowNewTabPosition: String { guard let config = self.config else { return "" } @@ -437,15 +451,14 @@ extension Ghostty { return v; } - var autoUpdate: AutoUpdate { - let defaultValue = AutoUpdate.check - guard let config = self.config else { return defaultValue } + var autoUpdate: AutoUpdate? { + guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "auto-update" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } - guard let ptr = v else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard let ptr = v else { return nil } let str = String(cString: ptr) - return AutoUpdate(rawValue: str) ?? defaultValue + return AutoUpdate(rawValue: str) } var autoUpdateChannel: AutoUpdateChannel { diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index f863eeada..899825d37 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -51,7 +51,7 @@ extension Ghostty { /// Returns the view that would prefer receiving focus in this tree. This is always the /// top-left-most view. This is used when creating a split or closing a split to find the /// next view to send focus to. - func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView { + func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView { let container: Container switch (self) { case .leaf(let leaf): @@ -64,10 +64,10 @@ extension Ghostty { let node: SplitNode switch (direction) { - case .previous, .top, .left: + case .previous, .up, .left: node = container.bottomRight - case .next, .bottom, .right: + case .next, .down, .right: node = container.topLeft } @@ -431,12 +431,12 @@ extension Ghostty { struct Neighbors { var left: SplitNode? var right: SplitNode? - var top: SplitNode? - var bottom: SplitNode? + var up: SplitNode? + var down: SplitNode? /// These are the previous/next nodes. It will certainly be one of the above as well /// but we keep track of these separately because depending on the split direction - /// of the containing node, previous may be left OR top (same for next). + /// of the containing node, previous may be left OR up (same for next). var previous: SplitNode? var next: SplitNode? @@ -448,8 +448,8 @@ extension Ghostty { let map: [SplitFocusDirection : KeyPath] = [ .previous: \.previous, .next: \.next, - .top: \.top, - .bottom: \.bottom, + .up: \.up, + .down: \.down, .left: \.left, .right: \.right, ] diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 272cdabdb..cc3bef149 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -308,7 +308,7 @@ extension Ghostty { resizeIncrements: .init(width: 1, height: 1), resizePublisher: container.resizeEvent, left: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.bottom + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.down TerminalSplitNested( node: closeableTopLeft(), @@ -318,7 +318,7 @@ extension Ghostty { ]) ) }, right: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.top + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.up TerminalSplitNested( node: closeableBottomRight(), diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 65f928443..d09100212 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -66,7 +66,7 @@ extension Ghostty { /// An enum that is used for the directions that a split focus event can change. enum SplitFocusDirection { - case previous, next, top, bottom, left, right + case previous, next, up, down, left, right /// Initialize from a Ghostty API enum. static func from(direction: ghostty_action_goto_split_e) -> Self? { @@ -77,11 +77,11 @@ extension Ghostty { case GHOSTTY_GOTO_SPLIT_NEXT: return .next - case GHOSTTY_GOTO_SPLIT_TOP: - return .top + case GHOSTTY_GOTO_SPLIT_UP: + return .up - case GHOSTTY_GOTO_SPLIT_BOTTOM: - return .bottom + case GHOSTTY_GOTO_SPLIT_DOWN: + return .down case GHOSTTY_GOTO_SPLIT_LEFT: return .left @@ -102,11 +102,11 @@ extension Ghostty { case .next: return GHOSTTY_GOTO_SPLIT_NEXT - case .top: - return GHOSTTY_GOTO_SPLIT_TOP + case .up: + return GHOSTTY_GOTO_SPLIT_UP - case .bottom: - return GHOSTTY_GOTO_SPLIT_BOTTOM + case .down: + return GHOSTTY_GOTO_SPLIT_DOWN case .left: return GHOSTTY_GOTO_SPLIT_LEFT diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 60de024d3..2cac4a0dd 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -617,11 +617,12 @@ extension Ghostty { let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) - // If focus follows mouse is enabled then move focus to this surface. - if let window = self.window as? TerminalWindow, - window.isKeyWindow && - window.focusFollowsMouse && - !self.focused + // Handle focus-follows-mouse + if let window, + let controller = window.windowController as? BaseTerminalController, + (window.isKeyWindow && + !self.focused && + controller.focusFollowsMouse) { Ghostty.moveFocus(to: self) } diff --git a/nix/package.nix b/nix/package.nix index 3c36661bf..78d2e2fdd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -111,7 +111,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.0.1"; + version = "1.0.2"; inherit src; nativeBuildInputs = [ diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 6a4c8e443..60e9e58a4 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-lfXkAoEd0lB25YV2cB4VVe+CI01DDY5PVOtvq//Qw/U=" +"sha256-njCce+r1DPTKLNrmrD2ObEoBS9nR7q03hqegQWe1UuY=" diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index b5c5c3c1e..983ec9ffc 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -14,7 +14,6 @@ pub fn build(b: *std.Build) !void { .@"enable-libpng" = true, }); const macos = b.dependency("macos", .{ .target = target, .optimize = optimize }); - const upstream = b.dependency("harfbuzz", .{}); const module = b.addModule("harfbuzz", .{ .root_source_file = b.path("main.zig"), @@ -26,6 +25,62 @@ pub fn build(b: *std.Build) !void { }, }); + // For dynamic linking, we prefer dynamic linking and to search by + // mode first. Mode first will search all paths for a dynamic library + // before falling back to static. + const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, + }; + + const test_exe = b.addTest(.{ + .name = "test", + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + + { + var it = module.import_table.iterator(); + while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*); + test_exe.linkLibrary(freetype.artifact("freetype")); + const tests_run = b.addRunArtifact(test_exe); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&tests_run.step); + } + + if (b.systemIntegrationOption("harfbuzz", .{})) { + module.linkSystemLibrary("harfbuzz", dynamic_link_opts); + test_exe.linkSystemLibrary2("harfbuzz", dynamic_link_opts); + } else { + const lib = try buildLib(b, module, .{ + .target = target, + .optimize = optimize, + + .coretext_enabled = coretext_enabled, + .freetype_enabled = freetype_enabled, + + .dynamic_link_opts = dynamic_link_opts, + }); + + test_exe.linkLibrary(lib); + } +} + +pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const target = options.target; + const optimize = options.optimize; + + const coretext_enabled = options.coretext_enabled; + const freetype_enabled = options.freetype_enabled; + + const freetype = b.dependency("freetype", .{ + .target = target, + .optimize = optimize, + .@"enable-libpng" = true, + }); + + const upstream = b.dependency("harfbuzz", .{}); const lib = b.addStaticLibrary(.{ .name = "harfbuzz", .target = target, @@ -41,13 +96,7 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, module); } - // For dynamic linking, we prefer dynamic linking and to search by - // mode first. Mode first will search all paths for a dynamic library - // before falling back to static. - const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ - .preferred_link_mode = .dynamic, - .search_strategy = .mode_first, - }; + const dynamic_link_opts = options.dynamic_link_opts; var flags = std.ArrayList([]const u8).init(b.allocator); defer flags.deinit(); @@ -102,20 +151,5 @@ pub fn build(b: *std.Build) !void { b.installArtifact(lib); - { - const test_exe = b.addTest(.{ - .name = "test", - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - }); - test_exe.linkLibrary(lib); - - var it = module.import_table.iterator(); - while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*); - test_exe.linkLibrary(freetype.artifact("freetype")); - const tests_run = b.addRunArtifact(test_exe); - const test_step = b.step("test", "Run tests"); - test_step.dependOn(&tests_run.step); - } + return lib; } diff --git a/pkg/wuffs/build.zig b/pkg/wuffs/build.zig index 36bb5a07c..438f714d3 100644 --- a/pkg/wuffs/build.zig +++ b/pkg/wuffs/build.zig @@ -30,4 +30,36 @@ pub fn build(b: *std.Build) !void { .file = wuffs.path("release/c/wuffs-v0.4.c"), .flags = flags.items, }); + + const unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + unit_tests.linkLibC(); + unit_tests.addIncludePath(wuffs.path("release/c")); + unit_tests.addCSourceFile(.{ + .file = wuffs.path("release/c/wuffs-v0.4.c"), + .flags = flags.items, + }); + + const pixels = b.dependency("pixels", .{}); + + inline for (.{ "000000", "FFFFFF" }) |color| { + inline for (.{ "gif", "jpg", "png", "ppm" }) |extension| { + const filename = std.fmt.comptimePrint("1x1#{s}.{s}", .{ color, extension }); + unit_tests.root_module.addAnonymousImport( + filename, + .{ + .root_source_file = pixels.path(filename), + }, + ); + } + } + + const run_unit_tests = b.addRunArtifact(unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); } diff --git a/pkg/wuffs/build.zig.zon b/pkg/wuffs/build.zig.zon index 126e43aba..d84d6957e 100644 --- a/pkg/wuffs/build.zig.zon +++ b/pkg/wuffs/build.zig.zon @@ -3,8 +3,13 @@ .version = "0.0.0", .dependencies = .{ .wuffs = .{ - .url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.8.tar.gz", - .hash = "12200984439edc817fbcbbaff564020e5104a0d04a2d0f53080700827052de700462", + .url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.9.tar.gz", + .hash = "122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd", + }, + + .pixels = .{ + .url = "git+https://github.com/make-github-pseudonymous-again/pixels?ref=main#d843c2714d32e15b48b8d7eeb480295af537f877", + .hash = "12207ff340169c7d40c570b4b6a97db614fe47e0d83b5801a932dcd44917424c8806", }, .apple_sdk = .{ .path = "../apple-sdk" }, diff --git a/pkg/wuffs/src/error.zig b/pkg/wuffs/src/error.zig index 609deec9c..c75188718 100644 --- a/pkg/wuffs/src/error.zig +++ b/pkg/wuffs/src/error.zig @@ -1,3 +1,13 @@ const std = @import("std"); +const c = @import("c.zig").c; + pub const Error = std.mem.Allocator.Error || error{WuffsError}; + +pub fn check(log: anytype, status: *const c.struct_wuffs_base__status__struct) error{WuffsError}!void { + if (!c.wuffs_base__status__is_ok(status)) { + const e = c.wuffs_base__status__message(status); + log.warn("decode err={s}", .{e}); + return error.WuffsError; + } +} diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig new file mode 100644 index 000000000..69628f582 --- /dev/null +++ b/pkg/wuffs/src/jpeg.zig @@ -0,0 +1,143 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const c = @import("c.zig").c; +const Error = @import("error.zig").Error; +const check = @import("error.zig").check; +const ImageData = @import("main.zig").ImageData; + +const log = std.log.scoped(.wuffs_jpeg); + +/// Decode a JPEG image. +pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { + // Work around some weirdness in WUFFS/Zig, there are some structs that + // are defined as "extern" by the Zig compiler which means that Zig won't + // allocate them on the stack at compile time. WUFFS has functions for + // dynamically allocating these structs but they use the C malloc/free. This + // gets around that by using the Zig allocator to allocate enough memory for + // the struct and then casts it to the appropriate pointer. + + const decoder_buf = try alloc.alloc(u8, c.sizeof__wuffs_jpeg__decoder()); + defer alloc.free(decoder_buf); + + const decoder: ?*c.wuffs_jpeg__decoder = @ptrCast(decoder_buf); + { + const status = c.wuffs_jpeg__decoder__initialize( + decoder, + c.sizeof__wuffs_jpeg__decoder(), + c.WUFFS_VERSION, + 0, + ); + try check(log, &status); + } + + var source_buffer: c.wuffs_base__io_buffer = .{ + .data = .{ .ptr = @constCast(@ptrCast(data.ptr)), .len = data.len }, + .meta = .{ + .wi = data.len, + .ri = 0, + .pos = 0, + .closed = true, + }, + }; + + var image_config: c.wuffs_base__image_config = undefined; + { + const status = c.wuffs_jpeg__decoder__decode_image_config( + decoder, + &image_config, + &source_buffer, + ); + try check(log, &status); + } + + const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg); + const height = c.wuffs_base__pixel_config__height(&image_config.pixcfg); + + c.wuffs_base__pixel_config__set( + &image_config.pixcfg, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE, + width, + height, + ); + + const destination = try alloc.alloc( + u8, + width * height * @sizeOf(c.wuffs_base__color_u32_argb_premul), + ); + errdefer alloc.free(destination); + + // temporary buffer for intermediate processing of image + const work_buffer = try alloc.alloc( + u8, + + // The type of this is a u64 on all systems but our allocator + // uses a usize which is a u32 on 32-bit systems. + std.math.cast( + usize, + c.wuffs_jpeg__decoder__workbuf_len(decoder).max_incl, + ) orelse return error.OutOfMemory, + ); + defer alloc.free(work_buffer); + + const work_slice = c.wuffs_base__make_slice_u8( + work_buffer.ptr, + work_buffer.len, + ); + + var pixel_buffer: c.wuffs_base__pixel_buffer = undefined; + { + const status = c.wuffs_base__pixel_buffer__set_from_slice( + &pixel_buffer, + &image_config.pixcfg, + c.wuffs_base__make_slice_u8(destination.ptr, destination.len), + ); + 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, + &pixel_buffer, + &source_buffer, + c.WUFFS_BASE__PIXEL_BLEND__SRC, + work_slice, + null, + ); + try check(log, &status); + } + + return .{ + .width = width, + .height = height, + .data = destination, + }; +} + +test "jpeg_decode_000000" { + const data = try decode(std.testing.allocator, @embedFile("1x1#000000.jpg")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data); +} + +test "jpeg_decode_FFFFFF" { + const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.jpg")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); +} diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index 3f03a4158..f282261c2 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -1,2 +1,15 @@ +const std = @import("std"); + pub const png = @import("png.zig"); +pub const jpeg = @import("jpeg.zig"); pub const swizzle = @import("swizzle.zig"); + +pub const ImageData = struct { + width: u32, + height: u32, + data: []const u8, +}; + +test { + std.testing.refAllDeclsRecursive(@This()); +} diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig index 3a3ac9a35..b85e4d747 100644 --- a/pkg/wuffs/src/png.zig +++ b/pkg/wuffs/src/png.zig @@ -2,15 +2,13 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const c = @import("c.zig").c; const Error = @import("error.zig").Error; +const check = @import("error.zig").check; +const ImageData = @import("main.zig").ImageData; const log = std.log.scoped(.wuffs_png); /// Decode a PNG image. -pub fn decode(alloc: Allocator, data: []const u8) Error!struct { - width: u32, - height: u32, - data: []const u8, -} { +pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { // Work around some weirdness in WUFFS/Zig, there are some structs that // are defined as "extern" by the Zig compiler which means that Zig won't // allocate them on the stack at compile time. WUFFS has functions for @@ -29,11 +27,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { c.WUFFS_VERSION, 0, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } var source_buffer: c.wuffs_base__io_buffer = .{ @@ -53,11 +47,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { &image_config, &source_buffer, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg); @@ -102,11 +92,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { &image_config.pixcfg, c.wuffs_base__make_slice_u8(destination.ptr, destination.len), ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } var frame_config: c.wuffs_base__frame_config = undefined; @@ -116,11 +102,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { &frame_config, &source_buffer, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } { @@ -132,11 +114,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { work_slice, null, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } return .{ @@ -145,3 +123,21 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { .data = destination, }; } + +test "png_decode_000000" { + const data = try decode(std.testing.allocator, @embedFile("1x1#000000.png")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data); +} + +test "png_decode_FFFFFF" { + const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.png")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); +} diff --git a/src/Command.zig b/src/Command.zig index 82b48fa18..6e30eae13 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -18,6 +18,7 @@ const Command = @This(); const std = @import("std"); const builtin = @import("builtin"); +const global_state = &@import("global.zig").state; const internal_os = @import("os/main.zig"); const windows = internal_os.windows; const TempDir = internal_os.TempDir; @@ -175,6 +176,10 @@ fn startPosix(self: *Command, arena: Allocator) !void { // We don't log because that'll show up in the output. }; + // Restore any rlimits that were set by Ghostty. This might fail but + // any failures are ignored (its best effort). + global_state.rlimits.restore(); + // If the user requested a pre exec callback, call it now. if (self.pre_exec) |f| f(self); diff --git a/src/Surface.zig b/src/Surface.zig index 053dec3fd..389e7f7e4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -853,11 +853,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }, .color_change => |change| { - // On any color change, we have to report for mode 2031 - // if it is enabled. - self.reportColorScheme(false); - - // Notify our apprt + // Notify our apprt, but don't send a mode 2031 DSR report + // because VT sequences were used to change the color. try self.rt_app.performAction( .{ .surface = self }, .color_change, @@ -1159,7 +1156,6 @@ pub fn updateConfig( } // If we are in the middle of a key sequence, clear it. - self.keyboard.bindings = null; self.endKeySequence(.drop, .free); // Before sending any other config changes, we give the renderer a new font @@ -1710,16 +1706,37 @@ pub fn keyCallback( // Update our modifiers, this will update mouse mods too self.modsChanged(event.mods); - // Refresh our link state - const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; - self.mouseRefreshLinks( - pos, - self.posToViewport(pos.x, pos.y), - self.mouse.over_link, - ) catch |err| { - log.warn("failed to refresh links err={}", .{err}); - break :mouse_mods; - }; + // We only refresh links if + // 1. mouse reporting is off + // OR + // 2. mouse reporting is on and we are not reporting shift to the terminal + if (self.io.terminal.flags.mouse_event == .none or + (self.mouse.mods.shift and !self.mouseShiftCapture(false))) + { + // Refresh our link state + const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; + self.mouseRefreshLinks( + pos, + self.posToViewport(pos.x, pos.y), + self.mouse.over_link, + ) catch |err| { + log.warn("failed to refresh links err={}", .{err}); + break :mouse_mods; + }; + } else if (self.io.terminal.flags.mouse_event != .none and !self.mouse.mods.shift) { + // If we have mouse reports on and we don't have shift pressed, we reset state + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + self.io.terminal.mouse_shape, + ); + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = "" }, + ); + try self.queueRender(); + } } // Process the cursor state logic. This will update the cursor shape if @@ -1835,9 +1852,6 @@ fn maybeHandleBinding( if (self.keyboard.bindings != null and !event.key.modifier()) { - // Reset to the root set - self.keyboard.bindings = null; - // Encode everything up to this point self.endKeySequence(.flush, .retain); } @@ -1923,10 +1937,21 @@ fn maybeHandleBinding( return .closed; } + // If we have the performable flag and the action was not performed, + // then we act as though a binding didn't exist. + if (leaf.flags.performable and !performed) { + // If we're in a sequence, we treat this as if we pressed a key + // that doesn't exist in the sequence. Reset our sequence and flush + // any queued events. + self.endKeySequence(.flush, .retain); + + return null; + } + // If we consume this event, then we are done. If we don't consume // it, we processed the action but we still want to process our // encodings, too. - if (performed and consumed) { + if (consumed) { // If we had queued events, we deinit them since we consumed self.endKeySequence(.drop, .retain); @@ -1968,6 +1993,10 @@ fn endKeySequence( ); }; + // No matter what we clear our current binding set. This restores + // the set we look at to the root set. + self.keyboard.bindings = null; + if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { @@ -3195,7 +3224,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { .trim = false, }); defer self.alloc.free(str); - try internal_os.open(self.alloc, str); + try internal_os.open(self.alloc, .unknown, str); }, ._open_osc8 => { @@ -3203,7 +3232,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; - try internal_os.open(self.alloc, uri); + try internal_os.open(self.alloc, .unknown, uri); }, } @@ -3350,6 +3379,27 @@ pub fn cursorPosCallback( try self.queueRender(); } + // Handle link hovering + // We refresh links when + // 1. we were previously over a link + // OR + // 2. the cursor position has changed (either we have no previous state, or the state has + // changed) + // AND + // 1. mouse reporting is off + // OR + // 2. mouse reporting is on and we are not reporting shift to the terminal + if ((over_link or + self.mouse.link_point == null or + (self.mouse.link_point != null and !self.mouse.link_point.?.eql(pos_vp))) and + (self.io.terminal.flags.mouse_event == .none or + (self.mouse.mods.shift and !self.mouseShiftCapture(false)))) + { + // If we were previously over a link, we always update. We do this so that if the text + // changed underneath us, even if the mouse didn't move, we update the URL hints and state + try self.mouseRefreshLinks(pos, pos_vp, over_link); + } + // Do a mouse report if (self.io.terminal.flags.mouse_event != .none) report: { // Shift overrides mouse "grabbing" in the window, taken from Kitty. @@ -3370,18 +3420,6 @@ pub fn cursorPosCallback( try self.mouseReport(button, .motion, self.mouse.mods, pos); - // If we were previously over a link, we need to undo the link state. - // We also queue a render so the renderer can undo the rendered link - // state. - if (over_link) { - try self.rt_app.performAction( - .{ .surface = self }, - .mouse_over_link, - .{ .url = "" }, - ); - try self.queueRender(); - } - // If we're doing mouse motion tracking, we do not support text // selection. return; @@ -3437,30 +3475,6 @@ pub fn cursorPosCallback( return; } - - // Handle link hovering - if (self.mouse.link_point) |last_vp| { - // Mark the link's row as dirty. - if (over_link) { - self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; - } - - // If our last link viewport point is unchanged, then don't process - // links. This avoids constantly reprocessing regular expressions - // for every pixel change. - if (last_vp.eql(pos_vp)) { - // We have to restore old values that are always cleared - if (over_link) { - self.mouse.over_link = over_link; - self.renderer_state.mouse.point = pos_vp; - } - - return; - } - } - - // We can process new links. - try self.mouseRefreshLinks(pos, pos_vp, over_link); } /// Double-click dragging moves the selection one "word" at a time. @@ -3886,7 +3900,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool log.err("error setting clipboard string err={}", .{err}); return true; }; + + return true; } + + return false; }, .paste_from_clipboard => try self.startClipboardRequest( @@ -4239,7 +4257,13 @@ fn writeScreenFile( const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)}); // Open our scrollback file - var file = try tmp_dir.dir.createFile(filename, .{}); + var file = try tmp_dir.dir.createFile( + filename, + switch (builtin.os.tag) { + .windows => .{}, + else => .{ .mode = 0o600 }, + }, + ); defer file.close(); // Screen.dumpString writes byte-by-byte, so buffer it @@ -4287,11 +4311,16 @@ fn writeScreenFile( tmp_dir.deinit(); return; }; + + // Use topLeft and bottomRight to ensure correct coordinate ordering + const tl = sel.topLeft(&self.io.terminal.screen); + const br = sel.bottomRight(&self.io.terminal.screen); + try self.io.terminal.screen.dumpString( buf_writer.writer(), .{ - .tl = sel.start(), - .br = sel.end(), + .tl = tl, + .br = br, .unwrap = true, }, ); @@ -4303,7 +4332,7 @@ fn writeScreenFile( const path = try tmp_dir.dir.realpath(filename, &path_buf); switch (write_action) { - .open => try internal_os.open(self.alloc, path), + .open => try internal_os.open(self.alloc, .text, path), .paste => self.io.queueMessage(try termio.Message.writeReq( self.alloc, path, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 527535ffa..de6758d6c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -332,9 +332,9 @@ pub const GotoSplit = enum(c_int) { previous, next, - top, + up, left, - bottom, + down, right, }; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 64b0cbe81..3fbef0f22 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -510,6 +510,13 @@ pub const Surface = struct { ) orelse return glfw.mustGetErrorCode(); errdefer win.destroy(); + // Setup our + setInitialWindowPosition( + win, + app.config.@"window-position-x", + app.config.@"window-position-y", + ); + // Get our physical DPI - debug only because we don't have a use for // this but the logging of it may be useful if (builtin.mode == .Debug) { @@ -663,6 +670,17 @@ pub const Surface = struct { }); } + /// Set the initial window position. This is called exactly once at + /// surface initialization time. This may be called before "self" + /// is fully initialized. + fn setInitialWindowPosition(win: glfw.Window, x: ?i16, y: ?i16) void { + const start_position_x = x orelse return; + const start_position_y = y orelse return; + + log.debug("setting initial window position ({},{})", .{ start_position_x, start_position_y }); + win.setPos(.{ .x = start_position_x, .y = start_position_y }); + } + /// Set the size limits of the window. /// Note: this interface is not good, we should redo it if we plan /// to use this more. i.e. you can't set max width but no max height, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c6e2b4d08..d74b07570 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -81,6 +81,9 @@ transient_cgroup_base: ?[]const u8 = null, /// CSS Provider for any styles based on ghostty configuration values css_provider: *c.GtkCssProvider, +/// Providers for loading custom stylesheets defined by user +custom_css_providers: std.ArrayListUnmanaged(*c.GtkCssProvider) = .{}, + /// The timer used to quit the application after the last window is closed. quit_timer: union(enum) { off: void, @@ -108,7 +111,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE. // For the remainder of "why" see the 4.14 comment below. _ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan"); - _ = internal_os.setenv("GDK_DEBUG", "opengl"); + _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-no-fractional"); } else if (version.atLeast(4, 14, 0)) { // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. // Older versions of GTK do not support these values so it is safe @@ -123,7 +126,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // - "vulkan-disable" - disable Vulkan, Ghostty can't use Vulkan // and initializing a Vulkan context was causing a longer delay // on some systems. - _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable"); + _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable,gl-no-fractional"); } else { // Versions prior to 4.14 are a bit of an unknown for Ghostty. It // is an environment that isn't tested well and we don't have a @@ -441,6 +444,11 @@ pub fn terminate(self: *App) void { if (self.context_menu) |context_menu| c.g_object_unref(context_menu); if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path); + for (self.custom_css_providers.items) |provider| { + c.g_object_unref(provider); + } + self.custom_css_providers.deinit(self.core_app.alloc); + self.config.deinit(); } @@ -786,6 +794,7 @@ fn setInitialSize( ), } } + fn showDesktopNotification( self: *App, target: apprt.Target, @@ -892,7 +901,7 @@ fn syncConfigChanges(self: *App) !void { try self.updateConfigErrors(); try self.syncActionAccelerators(); - // Load our runtime CSS. If this fails then our window is just stuck + // Load our runtime and custom CSS. If this fails then our window is just stuck // with the old CSS but we don't want to fail the entire sync operation. self.loadRuntimeCss() catch |err| switch (err) { error.OutOfMemory => log.warn( @@ -900,6 +909,9 @@ fn syncConfigChanges(self: *App) !void { .{}, ), }; + self.loadCustomCss() catch |err| { + log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err}); + }; } /// This should be called whenever the configuration changes to update @@ -972,9 +984,6 @@ fn loadRuntimeCss( const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground; try writer.print( - \\window.without-window-decoration-and-with-titlebar {{ - \\ border-radius: 0 0; - \\}} \\widget.unfocused-split {{ \\ opacity: {d:.2}; \\ background-color: rgb({d},{d},{d}); @@ -1036,11 +1045,68 @@ fn loadRuntimeCss( } // Clears any previously loaded CSS from this provider - c.gtk_css_provider_load_from_data( - self.css_provider, - buf.items.ptr, - @intCast(buf.items.len), - ); + loadCssProviderFromData(self.css_provider, buf.items); +} + +fn loadCustomCss(self: *App) !void { + const display = c.gdk_display_get_default(); + + // unload the previously loaded style providers + for (self.custom_css_providers.items) |provider| { + c.gtk_style_context_remove_provider_for_display( + display, + @ptrCast(provider), + ); + c.g_object_unref(provider); + } + self.custom_css_providers.clearRetainingCapacity(); + + for (self.config.@"gtk-custom-css".value.items) |p| { + const path, const optional = switch (p) { + .optional => |path| .{ path, true }, + .required => |path| .{ path, false }, + }; + const file = std.fs.openFileAbsolute(path, .{}) catch |err| { + if (err != error.FileNotFound or !optional) { + log.err("error opening gtk-custom-css file {s}: {}", .{ path, err }); + } + continue; + }; + defer file.close(); + + log.info("loading gtk-custom-css path={s}", .{path}); + const contents = try file.reader().readAllAlloc( + self.core_app.alloc, + 5 * 1024 * 1024 // 5MB + ); + defer self.core_app.alloc.free(contents); + + const provider = c.gtk_css_provider_new(); + c.gtk_style_context_add_provider_for_display( + display, + @ptrCast(provider), + c.GTK_STYLE_PROVIDER_PRIORITY_USER, + ); + + loadCssProviderFromData(provider, contents); + + try self.custom_css_providers.append(self.core_app.alloc, provider); + } +} + +fn loadCssProviderFromData(provider: *c.GtkCssProvider, data: []const u8) void { + if (version.atLeast(4, 12, 0)) { + const g_bytes = c.g_bytes_new(data.ptr, data.len); + defer c.g_bytes_unref(g_bytes); + + c.gtk_css_provider_load_from_bytes(provider, g_bytes); + } else { + c.gtk_css_provider_load_from_data( + provider, + data.ptr, + @intCast(data.len), + ); + } } /// Called by CoreApp to wake up the event loop. @@ -1403,7 +1469,15 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme { null, &err, ) orelse { - if (err) |e| log.err("unable to get current color scheme: {s}", .{e.message}); + if (err) |e| { + // If ReadOne is not yet implemented, fall back to deprecated "Read" method + // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne” + if (e.code == 19) { + return self.getColorSchemeDeprecated(); + } + // Otherwise, log the error and return .light + log.err("unable to get current color scheme: {s}", .{e.message}); + } return .light; }; defer c.g_variant_unref(value); @@ -1420,6 +1494,49 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme { 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; +} + /// This will be called by D-Bus when the style changes between light & dark. fn gtkNotifyColorScheme( _: ?*c.GDBusConnection, diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index 30b38f1d4..f0b60a2c6 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -131,6 +131,7 @@ const PrimaryView = struct { c.gtk_text_view_set_bottom_margin(@ptrCast(text), 8); c.gtk_text_view_set_left_margin(@ptrCast(text), 8); c.gtk_text_view_set_right_margin(@ptrCast(text), 8); + c.gtk_text_view_set_monospace(@ptrCast(text), 1); return .{ .root = view.root, .text = @ptrCast(text) }; } @@ -238,7 +239,7 @@ fn promptText(req: apprt.ClipboardRequest) [:0]const u8 { \\Pasting this text into the terminal may be dangerous as it looks like some commands may be executed. , .osc_52_read => - \\An appliclication is attempting to read from the clipboard. + \\An application is attempting to read from the clipboard. \\The current clipboard contents are shown below. , .osc_52_write => diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 61c2edece..2d428acb2 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -111,16 +111,6 @@ pub fn init( // Keep a long-lived reference, which we unref in destroy. _ = c.g_object_ref(paned); - // Clicks - const gesture_click = c.gtk_gesture_click_new(); - errdefer c.g_object_unref(gesture_click); - c.gtk_event_controller_set_propagation_phase(@ptrCast(gesture_click), c.GTK_PHASE_CAPTURE); - c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 1); - c.gtk_widget_add_controller(paned, @ptrCast(gesture_click)); - - // Signals - _ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(>kMouseDown), self, null, c.G_CONNECT_DEFAULT); - // Update all of our containers to point to the right place. // The split has to point to where the sibling pointed to because // we're inheriting its parent. The sibling points to its location @@ -246,19 +236,6 @@ pub fn equalize(self: *Split) f64 { return weight; } -fn gtkMouseDown( - _: *c.GtkGestureClick, - n_press: c.gint, - _: c.gdouble, - _: c.gdouble, - ud: ?*anyopaque, -) callconv(.C) void { - if (n_press == 2) { - const self: *Split = @ptrCast(@alignCast(ud)); - _ = equalize(self); - } -} - // maxPosition returns the maximum position of the GtkPaned, which is the // "max-position" attribute. fn maxPosition(self: *Split) f64 { @@ -339,7 +316,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap { // This behavior matches the behavior of macOS at the time of writing // this. There is an open issue (#524) to make this depend on the // actual physical location of the current split. - result.put(.top, prev.surface); + result.put(.up, prev.surface); result.put(.left, prev.surface); } } @@ -347,7 +324,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap { if (self.directionNext(from)) |next| { result.put(.next, next.surface); if (!next.wrapped) { - result.put(.bottom, next.surface); + result.put(.down, next.surface); result.put(.right, next.surface); } } diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index f61e34a07..c53190ccc 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -794,10 +794,11 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { // can support fractional scaling. const gtk_scale: f32 = @floatFromInt(c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area))); - // If we are on X11, we also have to scale using Xft.dpi - const xft_dpi_scale = if (!x11.is_current_display_server()) 1.0 else xft_scale: { - // Here we use GTK to retrieve gtk-xft-dpi, which is Xft.dpi multiplied - // by 1024. See https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html + // Also scale using font-specific DPI, which is often exposed to the user + // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html). + const xft_dpi_scale = xft_scale: { + // gtk-xft-dpi is font DPI multiplied by 1024. See + // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html const settings = c.gtk_settings_get_default(); var value: c.GValue = std.mem.zeroes(c.GValue); @@ -806,10 +807,9 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value); const gtk_xft_dpi = c.g_value_get_int(&value); - // As noted above Xft.dpi is multiplied by 1024, so we divide by 1024, - // then divide by the default value of Xft.dpi (96) to derive a scale. - // Note that gtk-xft-dpi can be fractional, so we use floating point - // math here. + // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by + // 1024, then divide by the default value (96) to derive a scale. Note + // gtk-xft-dpi can be fractional, so we use floating point math here. const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024; break :xft_scale xft_dpi / 96; }; @@ -1426,15 +1426,23 @@ fn gtkMouseMotion( .y = @floatCast(scaled.y), }; - // Our pos changed, update - self.cursor_pos = pos; + // 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. + 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 (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") { + if (!is_cursor_still and 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); diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 82384a44a..ed0804fd3 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -76,7 +76,7 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void { // Set the userdata of the box to point to this tab. c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self); - try window.notebook.addTab(self, "Ghostty"); + window.notebook.addTab(self, "Ghostty"); // Attach all events _ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 6bfcb0750..516ea7fc5 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -83,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void { // Create the window const window: *c.GtkWidget = window: { - if (self.isAdwWindow()) { + if ((comptime adwaita.versionAtLeast(0, 0, 0)) and adwaita.enabled(&self.app.config)) { const window = c.adw_application_window_new(app.app); c.gtk_widget_add_css_class(@ptrCast(window), "adw"); break :window window; @@ -99,6 +99,7 @@ pub fn init(self: *Window, app: *App) !void { self.window = gtk_window; c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_default_size(gtk_window, 1000, 600); + c.gtk_widget_add_css_class(@ptrCast(gtk_window), "terminal-window"); // GTK4 grabs F10 input by default to focus the menubar icon. We want // to disable this so that terminal programs can capture F10 (such as htop) @@ -122,12 +123,12 @@ pub fn init(self: *Window, app: *App) !void { const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); // Setup our notebook - self.notebook = Notebook.create(self); + self.notebook.init(); // If we are using Adwaita, then we can support the tab overview. - self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 3, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 3, 0)) overview: { + self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 4, 0)) overview: { const tab_overview = c.adw_tab_overview_new(); - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view); c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1); _ = c.g_signal_connect_data( tab_overview, @@ -156,6 +157,9 @@ pub fn init(self: *Window, app: *App) !void { if (app.config.@"gtk-titlebar") { const header = HeaderBar.init(self); + // If we are not decorated then we hide the titlebar. + header.setVisible(app.config.@"window-decoration"); + { const btn = c.gtk_menu_button_new(); c.gtk_widget_set_tooltip_text(btn, "Main Menu"); @@ -167,7 +171,7 @@ pub fn init(self: *Window, app: *App) !void { // If we're using an AdwWindow then we can support the tab overview. if (self.tab_overview) |tab_overview| { if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; - assert(self.isAdwWindow()); + assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0)); const btn = switch (app.config.@"gtk-tabs-location") { .top, .bottom, .left, .right => btn: { const btn = c.gtk_toggle_button_new(); @@ -186,7 +190,7 @@ pub fn init(self: *Window, app: *App) !void { .hidden => btn: { const btn = c.adw_tab_button_new(); - c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw_tab_view); + c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view); c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open"); break :btn btn; }, @@ -216,6 +220,14 @@ pub fn init(self: *Window, app: *App) !void { } } + // 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. + if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { + if (self.header) |h| { + c.gtk_box_append(@ptrCast(box), h.asWidget()); + } + } + // In debug we show a warning and apply the 'devel' class to the window. // This is a really common issue where people build from source in debug and performance is really bad. if (comptime std.debug.runtime_safety) { @@ -256,8 +268,8 @@ pub fn init(self: *Window, app: *App) !void { // If we have a tab overview then we can set it on our notebook. if (self.tab_overview) |tab_overview| { if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable; - assert(self.notebook == .adw_tab_view); - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); + assert(self.notebook == .adw); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view); } self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); @@ -284,16 +296,17 @@ pub fn init(self: *Window, app: *App) !void { // Our actions for the menu initActions(self); - if (self.isAdwWindow()) { - if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; + if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new()); - const header_widget: *c.GtkWidget = self.header.?.asWidget(); - c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); + if (self.header) |header| { + const header_widget = header.asWidget(); + c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); + } if (self.app.config.@"gtk-tabs-location" != .hidden) { const tab_bar = c.adw_tab_bar_new(); - c.adw_tab_bar_set_view(tab_bar, self.notebook.adw_tab_view); + c.adw_tab_bar_set_view(tab_bar, self.notebook.adw.tab_view); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); @@ -315,33 +328,19 @@ pub fn init(self: *Window, app: *App) !void { c.adw_toolbar_view_set_top_bar_style(toolbar_view, toolbar_style); c.adw_toolbar_view_set_bottom_bar_style(toolbar_view, toolbar_style); - // If we are not decorated then we hide the titlebar. - if (!app.config.@"window-decoration") { - c.gtk_widget_set_visible(header_widget, 0); - } - - // Set our application window content. The content depends on if - // we're using an AdwTabOverview or not. - if (self.tab_overview) |tab_overview| { - c.adw_tab_overview_set_child( - @ptrCast(tab_overview), - @ptrCast(@alignCast(toolbar_view)), - ); - c.adw_application_window_set_content( - @ptrCast(gtk_window), - @ptrCast(@alignCast(tab_overview)), - ); - } else { - c.adw_application_window_set_content( - @ptrCast(gtk_window), - @ptrCast(@alignCast(toolbar_view)), - ); - } + // Set our application window content. + c.adw_tab_overview_set_child( + @ptrCast(self.tab_overview), + @ptrCast(@alignCast(toolbar_view)), + ); + c.adw_application_window_set_content( + @ptrCast(gtk_window), + @ptrCast(@alignCast(self.tab_overview)), + ); } else tab_bar: { switch (self.notebook) { - .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) { + .adw => |*adw| if (comptime adwaita.versionAtLeast(0, 0, 0)) { if (app.config.@"gtk-tabs-location" == .hidden) break :tab_bar; - // In earlier adwaita versions, we need to add the tabbar manually since we do not use // an AdwToolbarView. const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?; @@ -361,17 +360,26 @@ pub fn init(self: *Window, app: *App) !void { ), .hidden => unreachable, } - c.adw_tab_bar_set_view(tab_bar, tab_view); + c.adw_tab_bar_set_view(tab_bar, adw.tab_view); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); }, - .gtk_notebook => {}, + .gtk => {}, } // The box is our main child - c.gtk_window_set_child(gtk_window, box); - if (self.header) |h| c.gtk_window_set_titlebar(gtk_window, h.asWidget()); + if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { + c.adw_application_window_set_content( + @ptrCast(gtk_window), + box, + ); + } else { + c.gtk_window_set_child(gtk_window, box); + if (self.header) |h| { + c.gtk_window_set_titlebar(gtk_window, h.asWidget()); + } + } } // Show the window @@ -420,17 +428,6 @@ pub fn deinit(self: *Window) void { } } -/// Returns true if this window should use an Adwaita window. -/// -/// This must be `inline` so that the comptime check noops conditional -/// paths that are not enabled. -inline fn isAdwWindow(self: *Window) bool { - return (comptime adwaita.versionAtLeast(1, 4, 0)) and - adwaita.enabled(&self.app.config) and - adwaita.versionAtLeast(1, 4, 0) and - self.app.config.@"gtk-titlebar"; -} - /// Add a new tab to this window. pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { const alloc = self.app.core_app.alloc; @@ -517,13 +514,19 @@ pub fn toggleWindowDecorations(self: *Window) void { const new_decorated = !old_decorated; c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated)); + // Fix any artifacting that may occur in window corners. + if (new_decorated) { + c.gtk_widget_add_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); + } else { + c.gtk_widget_remove_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); + } + // 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. - if (self.header) |v| { - const widget = v.asWidget(); - c.gtk_widget_set_visible(widget, @intFromBool(new_decorated)); + if (self.header) |headerbar| { + headerbar.setVisible(new_decorated); } } @@ -562,12 +565,12 @@ fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { /// because we need to return an AdwTabPage from this function. fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwTabPage { const self: *Window = userdataSelf(ud.?); - assert(self.isAdwWindow()); + assert((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)); const alloc = self.app.core_app.alloc; const surface = self.actionSurface(); const tab = Tab.create(alloc, self, surface) catch return null; - return c.adw_tab_view_get_page(self.notebook.adw_tab_view, @ptrCast(@alignCast(tab.box))); + return c.adw_tab_view_get_page(self.notebook.adw.tab_view, @ptrCast(@alignCast(tab.box))); } fn adwTabOverviewOpen( @@ -744,7 +747,7 @@ fn gtkActionAbout( if ((comptime adwaita.versionAtLeast(1, 5, 0)) and adwaita.versionAtLeast(1, 5, 0) and - self.isAdwWindow()) + adwaita.enabled(&self.app.config)) { c.adw_show_about_dialog( @ptrCast(self.window), @@ -892,7 +895,9 @@ fn gtkActionCopy( return; }; - self.sendToast("Copied to clipboard"); + if (self.app.config.@"adw-toast".@"clipboard-copy") { + self.sendToast("Copied to clipboard"); + } } fn gtkActionPaste( diff --git a/src/apprt/gtk/adwaita.zig b/src/apprt/gtk/adwaita.zig index 2c28bc39b..075055586 100644 --- a/src/apprt/gtk/adwaita.zig +++ b/src/apprt/gtk/adwaita.zig @@ -25,7 +25,10 @@ pub inline fn enabled(config: *const Config) bool { /// in the headers. If it is run in a runtime context, it will /// check the actual version of the library we are linked against. /// So generally you probably want to do both checks! -pub fn versionAtLeast( +/// +/// This is inlined so that the comptime checks will disable the +/// runtime checks if the comptime checks fail. +pub inline fn versionAtLeast( comptime major: u16, comptime minor: u16, comptime micro: u16, @@ -37,8 +40,9 @@ pub fn versionAtLeast( // compiling against unknown symbols and makes runtime checks // very slightly faster. if (comptime c.ADW_MAJOR_VERSION < major or - c.ADW_MINOR_VERSION < minor or - c.ADW_MICRO_VERSION < micro) return false; + (c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION < minor) or + (c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION == minor and c.ADW_MICRO_VERSION < micro)) + return false; // If we're in comptime then we can't check the runtime version. if (@inComptime()) return true; @@ -56,3 +60,16 @@ pub fn versionAtLeast( return false; } + +test "versionAtLeast" { + const testing = std.testing; + + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); + try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); + try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); + try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1)); +} diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig index b1567ce27..5bb92aca2 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -30,6 +30,10 @@ pub const HeaderBar = union(enum) { return .{ .gtk = @ptrCast(headerbar) }; } + pub fn setVisible(self: HeaderBar, visible: bool) void { + c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); + } + pub fn asWidget(self: HeaderBar) *c.GtkWidget { return switch (self) { .adw => |headerbar| @ptrCast(@alignCast(headerbar)), diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index 9d5f07f05..4676c2529 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -4,161 +4,76 @@ const c = @import("c.zig").c; const Window = @import("Window.zig"); const Tab = @import("Tab.zig"); +const NotebookAdw = @import("notebook_adw.zig").NotebookAdw; +const NotebookGtk = @import("notebook_gtk.zig").NotebookGtk; const adwaita = @import("adwaita.zig"); const log = std.log.scoped(.gtk); const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; +/// An abstraction over the GTK notebook and Adwaita tab view to manage +/// all the terminal tabs in a window. /// An abstraction over the GTK notebook and Adwaita tab view to manage /// all the terminal tabs in a window. pub const Notebook = union(enum) { - adw_tab_view: *AdwTabView, - gtk_notebook: *c.GtkNotebook, + adw: NotebookAdw, + gtk: NotebookGtk, - pub fn create(window: *Window) Notebook { + pub fn init(self: *Notebook) void { + const window: *Window = @fieldParentPtr("notebook", self); const app = window.app; - if (adwaita.enabled(&app.config)) return initAdw(window); - return initGtk(window); + if (adwaita.enabled(&app.config)) return NotebookAdw.init(self); + + return NotebookGtk.init(self); } - fn initGtk(window: *Window) Notebook { - const app = window.app; - - // Create a notebook to hold our tabs. - const notebook_widget: *c.GtkWidget = c.gtk_notebook_new(); - const notebook: *c.GtkNotebook = @ptrCast(notebook_widget); - const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { - .top, .hidden => c.GTK_POS_TOP, - .bottom => c.GTK_POS_BOTTOM, - .left => c.GTK_POS_LEFT, - .right => c.GTK_POS_RIGHT, - }; - c.gtk_notebook_set_tab_pos(notebook, notebook_tab_pos); - c.gtk_notebook_set_scrollable(notebook, 1); - c.gtk_notebook_set_show_tabs(notebook, 0); - c.gtk_notebook_set_show_border(notebook, 0); - - // This enables all Ghostty terminal tabs to be exchanged across windows. - c.gtk_notebook_set_group_name(notebook, "ghostty-terminal-tabs"); - - // This is important so the notebook expands to fit available space. - // Otherwise, it will be zero/zero in the box below. - c.gtk_widget_set_vexpand(notebook_widget, 1); - c.gtk_widget_set_hexpand(notebook_widget, 1); - - // Remove the background from the stack widget - const stack = c.gtk_widget_get_last_child(notebook_widget); - c.gtk_widget_add_css_class(stack, "transparent"); - - // All of our events - _ = c.g_signal_connect_data(notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT); - - return .{ .gtk_notebook = notebook }; - } - - fn initAdw(window: *Window) Notebook { - const app = window.app; - assert(adwaita.enabled(&app.config)); - - const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; - - if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) { - // Adwaita enables all of the shortcuts by default. - // We want to manage keybindings ourselves. - c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); - } - - _ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT); - - return .{ .adw_tab_view = tab_view }; - } - - pub fn asWidget(self: Notebook) *c.GtkWidget { - return switch (self) { - .adw_tab_view => |tab_view| @ptrCast(@alignCast(tab_view)), - .gtk_notebook => |notebook| @ptrCast(@alignCast(notebook)), + pub fn asWidget(self: *Notebook) *c.GtkWidget { + return switch (self.*) { + .adw => |*adw| adw.asWidget(), + .gtk => |*gtk| gtk.asWidget(), }; } - pub fn nPages(self: Notebook) c_int { - return switch (self) { - .gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook), - .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) - c.adw_tab_view_get_n_pages(tab_view) - else - unreachable, + pub fn nPages(self: *Notebook) c_int { + return switch (self.*) { + .adw => |*adw| adw.nPages(), + .gtk => |*gtk| gtk.nPages(), }; } /// Returns the index of the currently selected page. /// Returns null if the notebook has no pages. - fn currentPage(self: Notebook) ?c_int { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null; - return c.adw_tab_view_get_page_position(tab_view, page); - }, - - .gtk_notebook => |notebook| { - const current = c.gtk_notebook_get_current_page(notebook); - return if (current == -1) null else current; - }, - } + fn currentPage(self: *Notebook) ?c_int { + return switch (self.*) { + .adw => |*adw| adw.currentPage(), + .gtk => |*gtk| gtk.currentPage(), + }; } /// Returns the currently selected tab or null if there are none. - pub fn currentTab(self: Notebook) ?*Tab { - const child = switch (self) { - .adw_tab_view => |tab_view| child: { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null; - const child = c.adw_tab_page_get_child(page); - break :child child; - }, - - .gtk_notebook => |notebook| child: { - const page = self.currentPage() orelse return null; - break :child c.gtk_notebook_get_nth_page(notebook, page); - }, + pub fn currentTab(self: *Notebook) ?*Tab { + return switch (self.*) { + .adw => |*adw| adw.currentTab(), + .gtk => |*gtk| gtk.currentTab(), }; - return @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, - )); } - pub fn gotoNthTab(self: Notebook, position: c_int) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page_to_select = c.adw_tab_view_get_nth_page(tab_view, position); - c.adw_tab_view_set_selected_page(tab_view, page_to_select); - }, - .gtk_notebook => |notebook| c.gtk_notebook_set_current_page(notebook, position), + pub fn gotoNthTab(self: *Notebook, position: c_int) void { + switch (self.*) { + .adw => |*adw| adw.gotoNthTab(position), + .gtk => |*gtk| gtk.gotoNthTab(position), } } - pub fn getTabPosition(self: Notebook, tab: *Tab) ?c_int { - return switch (self) { - .adw_tab_view => |tab_view| page_idx: { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return null; - break :page_idx c.adw_tab_view_get_page_position(tab_view, page); - }, - .gtk_notebook => |notebook| page_idx: { - const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return null; - break :page_idx getNotebookPageIndex(page); - }, + pub fn getTabPosition(self: *Notebook, tab: *Tab) ?c_int { + return switch (self.*) { + .adw => |*adw| adw.getTabPosition(tab), + .gtk => |*gtk| gtk.getTabPosition(tab), }; } - pub fn gotoPreviousTab(self: Notebook, tab: *Tab) void { + pub fn gotoPreviousTab(self: *Notebook, tab: *Tab) void { const page_idx = self.getTabPosition(tab) orelse return; // The next index is the previous or we wrap around. @@ -173,7 +88,7 @@ pub const Notebook = union(enum) { self.gotoNthTab(next_idx); } - pub fn gotoNextTab(self: Notebook, tab: *Tab) void { + pub fn gotoNextTab(self: *Notebook, tab: *Tab) void { const page_idx = self.getTabPosition(tab) orelse return; const max = self.nPages() -| 1; @@ -183,7 +98,7 @@ pub const Notebook = union(enum) { self.gotoNthTab(next_idx); } - pub fn moveTab(self: Notebook, tab: *Tab, position: c_int) void { + pub fn moveTab(self: *Notebook, tab: *Tab, position: c_int) void { const page_idx = self.getTabPosition(tab) orelse return; const max = self.nPages() -| 1; @@ -199,42 +114,28 @@ pub const Notebook = union(enum) { self.reorderPage(tab, new_position); } - pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void { - switch (self) { - .gtk_notebook => |notebook| { - c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position); - }, - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - _ = c.adw_tab_view_reorder_page(tab_view, page, position); - }, + pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void { + switch (self.*) { + .adw => |*adw| adw.reorderPage(tab, position), + .gtk => |*gtk| gtk.reorderPage(tab, position), } } - pub fn setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - c.adw_tab_page_set_title(page, title.ptr); - }, - .gtk_notebook => c.gtk_label_set_text(tab.label_text, title.ptr), + pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + switch (self.*) { + .adw => |*adw| adw.setTabLabel(tab, title), + .gtk => |*gtk| gtk.setTabLabel(tab, title), } } - pub fn setTabTooltip(self: Notebook, tab: *Tab, tooltip: [:0]const u8) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - c.adw_tab_page_set_tooltip(page, tooltip.ptr); - }, - .gtk_notebook => c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr), + pub fn setTabTooltip(self: *Notebook, tab: *Tab, tooltip: [:0]const u8) void { + switch (self.*) { + .adw => |*adw| adw.setTabTooltip(tab, tooltip), + .gtk => |*gtk| gtk.setTabTooltip(tab, tooltip), } } - fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int { + fn newTabInsertPosition(self: *Notebook, tab: *Tab) c_int { const numPages = self.nPages(); return switch (tab.window.app.config.@"window-new-tab-position") { .current => if (self.currentPage()) |page| page + 1 else numPages, @@ -243,249 +144,23 @@ pub const Notebook = union(enum) { } /// Adds a new tab with the given title to the notebook. - pub fn addTab(self: Notebook, tab: *Tab, title: [:0]const u8) !void { - const box_widget: *c.GtkWidget = @ptrCast(tab.box); - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - - const page = c.adw_tab_view_insert(tab_view, box_widget, self.newTabInsertPosition(tab)); - c.adw_tab_page_set_title(page, title.ptr); - - // Switch to the new tab - c.adw_tab_view_set_selected_page(tab_view, page); - }, - .gtk_notebook => |notebook| { - // Build the tab label - const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0); - const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget)); - const label_text_widget = c.gtk_label_new(title.ptr); - const label_text: *c.GtkLabel = @ptrCast(label_text_widget); - c.gtk_box_append(label_box, label_text_widget); - tab.label_text = label_text; - - const window = tab.window; - if (window.app.config.@"gtk-wide-tabs") { - c.gtk_widget_set_hexpand(label_box_widget, 1); - c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL); - c.gtk_widget_set_hexpand(label_text_widget, 1); - c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL); - - // This ensures that tabs are always equal width. If they're too - // long, they'll be truncated with an ellipsis. - c.gtk_label_set_max_width_chars(label_text, 1); - c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END); - - // We need to set a minimum width so that at a certain point - // the notebook will have an arrow button rather than shrinking tabs - // to an unreadably small size. - c.gtk_widget_set_size_request(label_text_widget, 100, 1); - } - - // Build the close button for the tab - const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic"); - const label_close: *c.GtkButton = @ptrCast(label_close_widget); - c.gtk_button_set_has_frame(label_close, 0); - c.gtk_box_append(label_box, label_close_widget); - - const page_idx = c.gtk_notebook_insert_page( - notebook, - box_widget, - label_box_widget, - self.newTabInsertPosition(tab), - ); - - // Clicks - const gesture_tab_click = c.gtk_gesture_click_new(); - c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0); - c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click)); - - _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); - - // Tab settings - c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1); - c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1); - - if (self.nPages() > 1) { - c.gtk_notebook_set_show_tabs(notebook, 1); - } - - // Switch to the new tab - c.gtk_notebook_set_current_page(notebook, page_idx); - }, + pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + const position = self.newTabInsertPosition(tab); + switch (self.*) { + .adw => |*adw| adw.addTab(tab, position, title), + .gtk => |*gtk| gtk.addTab(tab, position, title), } } - pub fn closeTab(self: Notebook, tab: *Tab) void { - const window = tab.window; - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return; - c.adw_tab_view_close_page(tab_view, page); - - // If we have no more tabs we close the window - if (self.nPages() == 0) { - // 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 - // warning from GTK, but I don't know any other workaround. - // Note: I'm not actually sure if 1.4.0 contains the fix, - // I just know that 1.3.x is broken and 1.5.1 is fixed. - // If we know that 1.4.0 is fixed, we can change this. - if (!adwaita.versionAtLeast(1, 4, 0)) { - c.g_object_unref(tab.box); - } - - c.gtk_window_destroy(window.window); - } - }, - .gtk_notebook => |notebook| { - const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return; - - // Find page and tab which we're closing - const page_idx = getNotebookPageIndex(page); - - // Remove the page. This will destroy the GTK widgets in the page which - // will trigger Tab cleanup. The `tab` variable is therefore unusable past that point. - c.gtk_notebook_remove_page(notebook, page_idx); - - const remaining = self.nPages(); - switch (remaining) { - // If we have no more tabs we close the window - 0 => c.gtk_window_destroy(tab.window.window), - - // If we have one more tab we hide the tab bar - 1 => c.gtk_notebook_set_show_tabs(notebook, 0), - - else => {}, - } - - // If we have remaining tabs, we need to make sure we grab focus. - if (remaining > 0) window.focusCurrentTab(); - }, + pub fn closeTab(self: *Notebook, tab: *Tab) void { + switch (self.*) { + .adw => |*adw| adw.closeTab(tab), + .gtk => |*gtk| gtk.closeTab(tab), } } - - fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int { - var value: c.GValue = std.mem.zeroes(c.GValue); - defer c.g_value_unset(&value); - _ = c.g_value_init(&value, c.G_TYPE_INT); - c.g_object_get_property( - @ptrCast(@alignCast(page)), - "position", - &value, - ); - - return c.g_value_get_int(&value); - } }; -fn gtkPageRemoved( - _: *c.GtkNotebook, - _: *c.GtkWidget, - _: c.guint, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud.?)); - - const notebook: *c.GtkNotebook = self.notebook.gtk_notebook; - - // Hide the tab bar if we only have one tab after removal - const remaining = c.gtk_notebook_get_n_pages(notebook); - if (remaining == 1) { - c.gtk_notebook_set_show_tabs(notebook, 0); - } -} - -fn adwPageAttached(tab_view: *AdwTabView, page: *c.AdwTabPage, position: c_int, ud: ?*anyopaque) callconv(.C) void { - _ = position; - _ = tab_view; - const self: *Window = @ptrCast(@alignCast(ud.?)); - - const child = c.adw_tab_page_get_child(page); - const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return)); - tab.window = self; - - self.focusCurrentTab(); -} - -fn gtkPageAdded( - notebook: *c.GtkNotebook, - _: *c.GtkWidget, - page_idx: c.guint, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud.?)); - - // The added page can come from another window with drag and drop, thus we migrate the tab - // window to be self. - const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx)); - const tab: *Tab = @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return, - )); - tab.window = self; - - // Whenever a new page is added, we always grab focus of the - // currently selected page. This was added specifically so that when - // we drag a tab out to create a new window ("create-window" event) - // we grab focus in the new window. Without this, the terminal didn't - // have focus. - self.focusCurrentTab(); -} - -fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void { - const window: *Window = @ptrCast(@alignCast(ud.?)); - const page = c.adw_tab_view_get_selected_page(window.notebook.adw_tab_view) orelse return; - const title = c.adw_tab_page_get_title(page); - c.gtk_window_set_title(window.window, title); -} - -fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { - const window: *Window = @ptrCast(@alignCast(ud.?)); - const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(window.notebook.gtk_notebook, page))); - const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); - const label_text = c.gtk_label_get_text(gtk_label); - c.gtk_window_set_title(window.window, label_text); -} - -fn adwTabViewCreateWindow( - _: *AdwTabView, - ud: ?*anyopaque, -) callconv(.C) ?*AdwTabView { - const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); - const window = createWindow(currentWindow) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - return window.notebook.adw_tab_view; -} - -fn gtkNotebookCreateWindow( - _: *c.GtkNotebook, - page: *c.GtkWidget, - ud: ?*anyopaque, -) callconv(.C) ?*c.GtkNotebook { - // The tab for the page is stored in the widget data. - const tab: *Tab = @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null, - )); - - const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); - const window = createWindow(currentWindow) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - - // And add it to the new window. - tab.window = window; - - return window.notebook.gtk_notebook; -} - -fn createWindow(currentWindow: *Window) !*Window { +pub fn createWindow(currentWindow: *Window) !*Window { const alloc = currentWindow.app.core_app.alloc; const app = currentWindow.app; diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig new file mode 100644 index 000000000..85083a97e --- /dev/null +++ b/src/apprt/gtk/notebook_adw.zig @@ -0,0 +1,163 @@ +const std = @import("std"); +const assert = std.debug.assert; +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const Notebook = @import("notebook.zig").Notebook; +const createWindow = @import("notebook.zig").createWindow; +const adwaita = @import("adwaita.zig"); + +const log = std.log.scoped(.gtk); + +const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; +const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopaque; + +pub const NotebookAdw = struct { + /// the tab view + tab_view: *AdwTabView, + + pub fn init(notebook: *Notebook) void { + const window: *Window = @fieldParentPtr("notebook", notebook); + const app = window.app; + assert(adwaita.enabled(&app.config)); + + const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; + c.gtk_widget_add_css_class(@ptrCast(@alignCast(tab_view)), "notebook"); + + if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) { + // Adwaita enables all of the shortcuts by default. + // We want to manage keybindings ourselves. + c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); + } + + notebook.* = .{ + .adw = .{ + .tab_view = tab_view, + }, + }; + + _ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT); + } + + pub fn asWidget(self: *NotebookAdw) *c.GtkWidget { + return @ptrCast(@alignCast(self.tab_view)); + } + + pub fn nPages(self: *NotebookAdw) c_int { + if (comptime adwaita.versionAtLeast(0, 0, 0)) + return c.adw_tab_view_get_n_pages(self.tab_view) + else + unreachable; + } + + /// Returns the index of the currently selected page. + /// Returns null if the notebook has no pages. + pub fn currentPage(self: *NotebookAdw) ?c_int { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null; + return c.adw_tab_view_get_page_position(self.tab_view, page); + } + + /// Returns the currently selected tab or null if there are none. + pub fn currentTab(self: *NotebookAdw) ?*Tab { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null; + const child = c.adw_tab_page_get_child(page); + return @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, + )); + } + + pub fn gotoNthTab(self: *NotebookAdw, position: c_int) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page_to_select = c.adw_tab_view_get_nth_page(self.tab_view, position); + c.adw_tab_view_set_selected_page(self.tab_view, page_to_select); + } + + pub fn getTabPosition(self: *NotebookAdw, tab: *Tab) ?c_int { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return null; + return c.adw_tab_view_get_page_position(self.tab_view, page); + } + + pub fn reorderPage(self: *NotebookAdw, tab: *Tab, position: c_int) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); + _ = c.adw_tab_view_reorder_page(self.tab_view, page, position); + } + + pub fn setTabLabel(self: *NotebookAdw, tab: *Tab, title: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); + c.adw_tab_page_set_title(page, title.ptr); + } + + pub fn setTabTooltip(self: *NotebookAdw, tab: *Tab, tooltip: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); + c.adw_tab_page_set_tooltip(page, tooltip.ptr); + } + + pub fn addTab(self: *NotebookAdw, tab: *Tab, position: c_int, title: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const box_widget: *c.GtkWidget = @ptrCast(tab.box); + const page = c.adw_tab_view_insert(self.tab_view, box_widget, position); + c.adw_tab_page_set_title(page, title.ptr); + c.adw_tab_view_set_selected_page(self.tab_view, page); + } + + pub fn closeTab(self: *NotebookAdw, tab: *Tab) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return; + c.adw_tab_view_close_page(self.tab_view, page); + + // If we have no more tabs we close the window + if (self.nPages() == 0) { + // 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 + // warning from GTK, but I don't know any other workaround. + // Note: I'm not actually sure if 1.4.0 contains the fix, + // I just know that 1.3.x is broken and 1.5.1 is fixed. + // If we know that 1.4.0 is fixed, we can change this. + if (!adwaita.versionAtLeast(1, 4, 0)) { + c.g_object_unref(tab.box); + } + + c.gtk_window_destroy(tab.window.window); + } + } +}; + +fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaque) callconv(.C) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + + const child = c.adw_tab_page_get_child(page); + const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return)); + tab.window = window; + + window.focusCurrentTab(); +} + +fn adwTabViewCreateWindow( + _: *AdwTabView, + ud: ?*anyopaque, +) callconv(.C) ?*AdwTabView { + const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); + const window = createWindow(currentWindow) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + return window.notebook.adw.tab_view; +} + +fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + const page = c.adw_tab_view_get_selected_page(window.notebook.adw.tab_view) orelse return; + const title = c.adw_tab_page_get_title(page); + c.gtk_window_set_title(window.window, title); +} diff --git a/src/apprt/gtk/notebook_gtk.zig b/src/apprt/gtk/notebook_gtk.zig new file mode 100644 index 000000000..6e8b016ba --- /dev/null +++ b/src/apprt/gtk/notebook_gtk.zig @@ -0,0 +1,285 @@ +const std = @import("std"); +const assert = std.debug.assert; +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const Notebook = @import("notebook.zig").Notebook; +const createWindow = @import("notebook.zig").createWindow; + +const log = std.log.scoped(.gtk); + +/// An abstraction over the GTK notebook and Adwaita tab view to manage +/// all the terminal tabs in a window. +pub const NotebookGtk = struct { + notebook: *c.GtkNotebook, + + pub fn init(notebook: *Notebook) void { + const window: *Window = @fieldParentPtr("notebook", notebook); + const app = window.app; + + // Create a notebook to hold our tabs. + const notebook_widget: *c.GtkWidget = c.gtk_notebook_new(); + c.gtk_widget_add_css_class(notebook_widget, "notebook"); + + const gtk_notebook: *c.GtkNotebook = @ptrCast(notebook_widget); + const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { + .top, .hidden => c.GTK_POS_TOP, + .bottom => c.GTK_POS_BOTTOM, + .left => c.GTK_POS_LEFT, + .right => c.GTK_POS_RIGHT, + }; + c.gtk_notebook_set_tab_pos(gtk_notebook, notebook_tab_pos); + c.gtk_notebook_set_scrollable(gtk_notebook, 1); + c.gtk_notebook_set_show_tabs(gtk_notebook, 0); + c.gtk_notebook_set_show_border(gtk_notebook, 0); + + // This enables all Ghostty terminal tabs to be exchanged across windows. + c.gtk_notebook_set_group_name(gtk_notebook, "ghostty-terminal-tabs"); + + // This is important so the notebook expands to fit available space. + // Otherwise, it will be zero/zero in the box below. + c.gtk_widget_set_vexpand(notebook_widget, 1); + c.gtk_widget_set_hexpand(notebook_widget, 1); + + // Remove the background from the stack widget + const stack = c.gtk_widget_get_last_child(notebook_widget); + c.gtk_widget_add_css_class(stack, "transparent"); + + notebook.* = .{ + .gtk = .{ + .notebook = gtk_notebook, + }, + }; + + // All of our events + _ = c.g_signal_connect_data(gtk_notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT); + } + + /// return the underlying widget as a generic GtkWidget + pub fn asWidget(self: *NotebookGtk) *c.GtkWidget { + return @ptrCast(@alignCast(self.notebook)); + } + + /// returns the number of pages in the notebook + pub fn nPages(self: *NotebookGtk) c_int { + return c.gtk_notebook_get_n_pages(self.notebook); + } + + /// Returns the index of the currently selected page. + /// Returns null if the notebook has no pages. + pub fn currentPage(self: *NotebookGtk) ?c_int { + const current = c.gtk_notebook_get_current_page(self.notebook); + return if (current == -1) null else current; + } + + /// Returns the currently selected tab or null if there are none. + pub fn currentTab(self: *NotebookGtk) ?*Tab { + log.warn("currentTab", .{}); + const page = self.currentPage() orelse return null; + const child = c.gtk_notebook_get_nth_page(self.notebook, page); + return @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, + )); + } + + /// focus the nth tab + pub fn gotoNthTab(self: *NotebookGtk, position: c_int) void { + c.gtk_notebook_set_current_page(self.notebook, position); + } + + /// get the position of the current tab + pub fn getTabPosition(self: *NotebookGtk, tab: *Tab) ?c_int { + const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return null; + return getNotebookPageIndex(page); + } + + pub fn reorderPage(self: *NotebookGtk, tab: *Tab, position: c_int) void { + c.gtk_notebook_reorder_child(self.notebook, @ptrCast(tab.box), position); + } + + pub fn setTabLabel(_: *NotebookGtk, tab: *Tab, title: [:0]const u8) void { + c.gtk_label_set_text(tab.label_text, title.ptr); + } + + pub fn setTabTooltip(_: *NotebookGtk, tab: *Tab, tooltip: [:0]const u8) void { + c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr); + } + + /// Adds a new tab with the given title to the notebook. + pub fn addTab(self: *NotebookGtk, tab: *Tab, position: c_int, title: [:0]const u8) void { + const box_widget: *c.GtkWidget = @ptrCast(tab.box); + + // Build the tab label + const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0); + const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget)); + const label_text_widget = c.gtk_label_new(title.ptr); + const label_text: *c.GtkLabel = @ptrCast(label_text_widget); + c.gtk_box_append(label_box, label_text_widget); + tab.label_text = label_text; + + const window = tab.window; + if (window.app.config.@"gtk-wide-tabs") { + c.gtk_widget_set_hexpand(label_box_widget, 1); + c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL); + c.gtk_widget_set_hexpand(label_text_widget, 1); + c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL); + + // This ensures that tabs are always equal width. If they're too + // long, they'll be truncated with an ellipsis. + c.gtk_label_set_max_width_chars(label_text, 1); + c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END); + + // We need to set a minimum width so that at a certain point + // the notebook will have an arrow button rather than shrinking tabs + // to an unreadably small size. + c.gtk_widget_set_size_request(label_text_widget, 100, 1); + } + + // Build the close button for the tab + const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic"); + const label_close: *c.GtkButton = @ptrCast(label_close_widget); + c.gtk_button_set_has_frame(label_close, 0); + c.gtk_box_append(label_box, label_close_widget); + + const page_idx = c.gtk_notebook_insert_page( + self.notebook, + box_widget, + label_box_widget, + position, + ); + + // Clicks + const gesture_tab_click = c.gtk_gesture_click_new(); + c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0); + c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click)); + + _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); + + // Tab settings + c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1); + c.gtk_notebook_set_tab_detachable(self.notebook, box_widget, 1); + + if (self.nPages() > 1) { + c.gtk_notebook_set_show_tabs(self.notebook, 1); + } + + // Switch to the new tab + c.gtk_notebook_set_current_page(self.notebook, page_idx); + } + + pub fn closeTab(self: *NotebookGtk, tab: *Tab) void { + const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return; + + // Find page and tab which we're closing + const page_idx = getNotebookPageIndex(page); + + // Remove the page. This will destroy the GTK widgets in the page which + // will trigger Tab cleanup. The `tab` variable is therefore unusable past that point. + c.gtk_notebook_remove_page(self.notebook, page_idx); + + const remaining = self.nPages(); + switch (remaining) { + // If we have no more tabs we close the window + 0 => c.gtk_window_destroy(tab.window.window), + + // If we have one more tab we hide the tab bar + 1 => c.gtk_notebook_set_show_tabs(self.notebook, 0), + + else => {}, + } + + // If we have remaining tabs, we need to make sure we grab focus. + if (remaining > 0) + (self.currentTab() orelse return).window.focusCurrentTab(); + } +}; + +fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int { + var value: c.GValue = std.mem.zeroes(c.GValue); + defer c.g_value_unset(&value); + _ = c.g_value_init(&value, c.G_TYPE_INT); + c.g_object_get_property( + @ptrCast(@alignCast(page)), + "position", + &value, + ); + + return c.g_value_get_int(&value); +} + +fn gtkPageAdded( + notebook: *c.GtkNotebook, + _: *c.GtkWidget, + page_idx: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Window = @ptrCast(@alignCast(ud.?)); + + // The added page can come from another window with drag and drop, thus we migrate the tab + // window to be self. + const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx)); + const tab: *Tab = @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return, + )); + tab.window = self; + + // Whenever a new page is added, we always grab focus of the + // currently selected page. This was added specifically so that when + // we drag a tab out to create a new window ("create-window" event) + // we grab focus in the new window. Without this, the terminal didn't + // have focus. + self.focusCurrentTab(); +} + +fn gtkPageRemoved( + _: *c.GtkNotebook, + _: *c.GtkWidget, + _: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + log.warn("gtkPageRemoved", .{}); + const window: *Window = @ptrCast(@alignCast(ud.?)); + + // Hide the tab bar if we only have one tab after removal + const remaining = c.gtk_notebook_get_n_pages(window.notebook.gtk.notebook); + + if (remaining == 1) { + c.gtk_notebook_set_show_tabs(window.notebook.gtk.notebook, 0); + } +} + +fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + const self = &window.notebook.gtk; + const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(self.notebook, page))); + const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); + const label_text = c.gtk_label_get_text(gtk_label); + c.gtk_window_set_title(window.window, label_text); +} + +fn gtkNotebookCreateWindow( + _: *c.GtkNotebook, + page: *c.GtkWidget, + ud: ?*anyopaque, +) callconv(.C) ?*c.GtkNotebook { + // The tab for the page is stored in the widget data. + const tab: *Tab = @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null, + )); + + const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); + const newWindow = createWindow(currentWindow) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + + // And add it to the new window. + tab.window = newWindow; + + return newWindow.notebook.gtk.notebook; +} diff --git a/src/apprt/gtk/style-dark.css b/src/apprt/gtk/style-dark.css index b56fa14f2..dcd4bcab9 100644 --- a/src/apprt/gtk/style-dark.css +++ b/src/apprt/gtk/style-dark.css @@ -2,7 +2,7 @@ background-color: transparent; } -separator { +.terminal-window .notebook separator { background-color: rgba(36, 36, 36, 1); background-clip: content-box; } diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index 65dc0c075..bf0ee62f6 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -33,11 +33,15 @@ label.size-overlay.hidden { opacity: 0; } +window.without-window-decoration-and-with-titlebar { + border-radius: 0 0; +} + .transparent { background-color: transparent; } -separator { +.terminal-window .notebook separator { background-color: rgba(250, 250, 250, 1); background-clip: content-box; } diff --git a/src/apprt/gtk/version.zig b/src/apprt/gtk/version.zig index c61e940fb..af7ad12ea 100644 --- a/src/apprt/gtk/version.zig +++ b/src/apprt/gtk/version.zig @@ -19,8 +19,9 @@ pub inline fn atLeast( // compiling against unknown symbols and makes runtime checks // very slightly faster. if (comptime c.GTK_MAJOR_VERSION < major or - c.GTK_MINOR_VERSION < minor or - c.GTK_MICRO_VERSION < micro) return false; + (c.GTK_MAJOR_VERSION == major and c.GTK_MINOR_VERSION < minor) or + (c.GTK_MAJOR_VERSION == major and c.GTK_MINOR_VERSION == minor and c.GTK_MICRO_VERSION < micro)) + return false; // If we're in comptime then we can't check the runtime version. if (@inComptime()) return true; @@ -38,3 +39,20 @@ pub inline fn atLeast( return false; } + +test "atLeast" { + const std = @import("std"); + const testing = std.testing; + + try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(!atLeast(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + + try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); +} diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index a06199256..b75c4dd16 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -56,7 +56,7 @@ fn writeFishCompletions(writer: anytype) !void { else { try writer.writeAll(if (field.type != Config.RepeatablePath) " -f" else " -F"); switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), + .Bool => {}, .Enum => |info| { try writer.writeAll(" -a \""); for (info.fields, 0..) |f, i| { @@ -114,7 +114,7 @@ fn writeFishCompletions(writer: anytype) !void { } else try writer.writeAll(" -f"); switch (@typeInfo(opt.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), + .Bool => {}, .Enum => |info| { try writer.writeAll(" -a \""); for (info.fields, 0..) |f, i| { diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig index a451c7175..5c42ea5ab 100644 --- a/src/build/zsh_completions.zig +++ b/src/build/zsh_completions.zig @@ -7,6 +7,8 @@ const Action = @import("../cli/action.zig").Action; /// and options. pub const zsh_completions = comptimeGenerateZshCompletions(); +const equals_required = "=-:::"; + fn comptimeGenerateZshCompletions() []const u8 { comptime { @setEvalBranchQuota(50000); @@ -47,34 +49,42 @@ fn writeZshCompletions(writer: anytype) !void { if (field.name[0] == '_') continue; try writer.writeAll(" \"--"); try writer.writeAll(field.name); - try writer.writeAll("=-:::"); - if (std.mem.startsWith(u8, field.name, "font-family")) - try writer.writeAll("_fonts") - else if (std.mem.eql(u8, "theme", field.name)) - try writer.writeAll("_themes") - else if (std.mem.eql(u8, "working-directory", field.name)) - try writer.writeAll("{_files -/}") - else if (field.type == Config.RepeatablePath) - try writer.writeAll("_files") // todo check if this is needed - else { - try writer.writeAll("("); + if (std.mem.startsWith(u8, field.name, "font-family")) { + try writer.writeAll(equals_required); + try writer.writeAll("_fonts"); + } else if (std.mem.eql(u8, "theme", field.name)) { + try writer.writeAll(equals_required); + try writer.writeAll("_themes"); + } else if (std.mem.eql(u8, "working-directory", field.name)) { + try writer.writeAll(equals_required); + try writer.writeAll("{_files -/}"); + } else if (field.type == Config.RepeatablePath) { + try writer.writeAll(equals_required); + try writer.writeAll("_files"); // todo check if this is needed + } else { switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll("true false"), + .Bool => {}, .Enum => |info| { + try writer.writeAll(equals_required); + try writer.writeAll("("); for (info.fields, 0..) |f, i| { if (i > 0) try writer.writeAll(" "); try writer.writeAll(f.name); } + try writer.writeAll(")"); }, .Struct => |info| { + try writer.writeAll(equals_required); if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { + try writer.writeAll("("); for (info.fields, 0..) |f, i| { if (i > 0) try writer.writeAll(" "); try writer.writeAll(f.name); try writer.writeAll(" no-"); try writer.writeAll(f.name); } + try writer.writeAll(")"); } else { //resize-overlay-duration //keybind @@ -85,12 +95,14 @@ fn writeZshCompletions(writer: anytype) !void { //foreground //font-variation* //font-feature - try writer.writeAll(" "); + try writer.writeAll("( )"); } }, - else => try writer.writeAll(" "), + else => { + try writer.writeAll(equals_required); + try writer.writeAll("( )"); + }, } - try writer.writeAll(")"); } try writer.writeAll("\" \\\n"); @@ -170,10 +182,11 @@ fn writeZshCompletions(writer: anytype) !void { try writer.writeAll(padding ++ " '--"); try writer.writeAll(opt.name); - try writer.writeAll("=-:::"); + switch (@typeInfo(opt.type)) { - .Bool => try writer.writeAll("(true false)"), + .Bool => {}, .Enum => |info| { + try writer.writeAll(equals_required); try writer.writeAll("("); for (info.fields, 0..) |f, i| { if (i > 0) try writer.writeAll(" "); @@ -182,6 +195,7 @@ fn writeZshCompletions(writer: anytype) !void { try writer.writeAll(")"); }, .Optional => |optional| { + try writer.writeAll(equals_required); switch (@typeInfo(optional.child)) { .Enum => |info| { try writer.writeAll("("); @@ -199,11 +213,13 @@ fn writeZshCompletions(writer: anytype) !void { } }, else => { + try writer.writeAll(equals_required); if (std.mem.eql(u8, "config-file", opt.name)) { try writer.writeAll("_files"); } else try writer.writeAll("( )"); }, } + try writer.writeAll("' \\\n"); } try writer.writeAll(padding ++ ";;\n"); diff --git a/src/build_config.zig b/src/build_config.zig index 35c429564..c70615144 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -23,6 +23,7 @@ pub const BuildConfig = struct { flatpak: bool = false, adwaita: bool = false, x11: bool = false, + sentry: bool = true, app_runtime: apprt.Runtime = .none, renderer: rendererpkg.Impl = .opengl, font_backend: font.Backend = .freetype, @@ -43,6 +44,7 @@ pub const BuildConfig = struct { step.addOption(bool, "flatpak", self.flatpak); step.addOption(bool, "adwaita", self.adwaita); step.addOption(bool, "x11", self.x11); + step.addOption(bool, "sentry", self.sentry); step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); step.addOption(font.Backend, "font_backend", self.font_backend); step.addOption(rendererpkg.Impl, "renderer", self.renderer); diff --git a/src/cli/help.zig b/src/cli/help.zig index e9e449550..daadc37cc 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -63,7 +63,7 @@ pub fn run(alloc: Allocator) !u8 { try stdout.writeAll( \\ \\Specify `+ --help` to see the help for a specific action, - \\where `` is one of actions listed below. + \\where `` is one of actions listed above. \\ ); diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig index 8dbadc65a..65b9dcdad 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -20,8 +20,9 @@ pub const Options = struct { } }; -/// The `list-actions` command is used to list all the available keybind actions -/// for Ghostty. +/// The `list-actions` command is used to list all the available keybind +/// 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. pub fn run(alloc: Allocator) !u8 { diff --git a/src/cli/version.zig b/src/cli/version.zig index 29ab7f63f..99f03384b 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -3,6 +3,7 @@ const build_options = @import("build_options"); const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); +const internal_os = @import("../os/main.zig"); const xev = @import("xev"); const renderer = @import("../renderer.zig"); const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void; @@ -37,6 +38,7 @@ pub fn run(alloc: Allocator) !u8 { try stdout.print(" - renderer : {}\n", .{renderer.Renderer}); try stdout.print(" - libxev : {}\n", .{xev.backend}); if (comptime build_config.app_runtime == .gtk) { + try stdout.print(" - desktop env: {s}\n", .{@tagName(internal_os.desktopEnvironment())}); try stdout.print(" - GTK version:\n", .{}); try stdout.print(" build : {d}.{d}.{d}\n", .{ gtk.GTK_MAJOR_VERSION, diff --git a/src/config/Config.zig b/src/config/Config.zig index a2f71c0c0..e1a7483ea 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -147,23 +147,28 @@ const c = @cImport({ /// By default, synthetic styles are enabled. @"font-synthetic-style": FontSyntheticStyle = .{}, -/// Apply a font feature. This can be repeated multiple times to enable multiple -/// font features. You can NOT set multiple font features with a single value -/// (yet). +/// Apply a font feature. To enable multiple font features you can repeat +/// this multiple times or use a comma-separated list of feature settings. +/// +/// The syntax for feature settings is as follows, where `feat` is a feature: +/// +/// * Enable features with e.g. `feat`, `+feat`, `feat on`, `feat=1`. +/// * Disabled features with e.g. `-feat`, `feat off`, `feat=0`. +/// * Set a feature value with e.g. `feat=2`, `feat = 3`, `feat 4`. +/// * Feature names may be wrapped in quotes, meaning this config should be +/// syntactically compatible with the `font-feature-settings` CSS property. +/// +/// The syntax is fairly loose, but invalid settings will be silently ignored. /// /// The font feature will apply to all fonts rendered by Ghostty. A future /// enhancement will allow targeting specific faces. /// -/// A valid value is the name of a feature. Prefix the feature with a `-` to -/// explicitly disable it. Example: `ss20` or `-ss20`. -/// /// To disable programming ligatures, use `-calt` since this is the typical /// feature name for programming ligatures. To look into what font features /// your font has and what they do, use a font inspection tool such as /// [fontdrop.info](https://fontdrop.info). /// -/// To generally disable most ligatures, use `-calt`, `-liga`, and `-dlig` (as -/// separate repetitive entries in your config). +/// To generally disable most ligatures, use `-calt, -liga, -dlig`. @"font-feature": RepeatableString = .{}, /// Font size in points. This value can be a non-integer and the nearest integer @@ -177,6 +182,10 @@ const c = @cImport({ /// depending on your `window-inherit-font-size` setting. If that setting is /// true, only the first window will be affected by this change since all /// subsequent windows will inherit the font size of the previous window. +/// +/// On Linux with GTK, font size is scaled according to both display-wide and +/// text-specific scaling factors, which are often managed by your desktop +/// environment (e.g. the GNOME display scale and large text settings). @"font-size": f32 = switch (builtin.os.tag) { // On macOS we default a little bigger since this tends to look better. This // is purely subjective but this is easy to modify. @@ -320,7 +329,7 @@ const c = @cImport({ /// FreeType load flags to enable. The format of this is a list of flags to /// enable separated by commas. If you prefix a flag with `no-` then it is -/// disabled. If you omit a flag, it's default value is used, so you must +/// disabled. If you omit a flag, its default value is used, so you must /// explicitly disable flags you don't want. You can also use `true` or `false` /// to turn all flags on or off. /// @@ -398,14 +407,17 @@ const c = @cImport({ theme: ?Theme = null, /// Background color for the window. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, /// Foreground color for the window. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// The foreground and background color for selection. If this is not set, then /// the selection color is just the inverted window background and foreground /// (note: not to be confused with the cell bg/fg). +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"selection-foreground": ?Color = null, @"selection-background": ?Color = null, @@ -431,15 +443,20 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, @"minimum-contrast": f64 = 1, /// Color palette for the 256 color form that many terminal applications use. -/// The syntax of this configuration is `N=HEXCODE` where `N` is 0 to 255 (for -/// the 256 colors in the terminal color table) and `HEXCODE` is a typical RGB -/// color code such as `#AABBCC`. +/// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for +/// the 256 colors in the terminal color table) and `COLOR` is a typical RGB +/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. /// -/// For definitions on all the codes [see this cheat -/// sheet](https://www.ditig.com/256-colors-cheat-sheet). +/// The palette index can be in decimal, binary, octal, or hexadecimal. +/// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal, +/// and `0x` for hexadecimal. +/// +/// For definitions on the color indices and what they canonically map to, +/// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet). palette: Palette = .{}, /// The color of the cursor. If this is not set, a default will be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"cursor-color": ?Color = null, /// Swap the foreground and background colors of the cell under the cursor. This @@ -493,6 +510,7 @@ palette: Palette = .{}, /// The color of the text under the cursor. If this is not set, a default will /// be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"cursor-text": ?Color = null, /// Enables the ability to move the cursor at prompts by using `alt+click` on @@ -548,7 +566,7 @@ palette: Palette = .{}, /// than 0.01 or greater than 10,000 will be clamped to the nearest valid /// value. /// -/// A value of "1" (default) scrolls te default amount. A value of "2" scrolls +/// A value of "1" (default) scrolls the default amount. A value of "2" scrolls /// double the default amount. A value of "0.5" scrolls half the default amount. /// Et cetera. @"mouse-scroll-multiplier": f64 = 1.0, @@ -560,6 +578,8 @@ palette: Palette = .{}, /// On macOS, background opacity is disabled when the terminal enters native /// fullscreen. This is because the background becomes gray and it can cause /// widgets to show through which isn't generally desirable. +/// +/// On macOS, changing this configuration requires restarting Ghostty completely. @"background-opacity": f64 = 1.0, /// A positive value enables blurring of the background when background-opacity @@ -586,6 +606,8 @@ palette: Palette = .{}, /// that rectangle and can be used to carefully control the dimming effect. /// /// This will default to the background color. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"unfocused-split-fill": ?Color = null, /// The command to run, usually a shell. If this is not an absolute path, it'll @@ -724,7 +746,7 @@ fullscreen: bool = false, /// This configuration can be reloaded at runtime. If it is set, the title /// will update for all windows. If it is unset, the next title change escape /// sequence will be honored but previous changes will not retroactively -/// be set. This latter case may require you restart programs such as neovim +/// be set. This latter case may require you to restart programs such as Neovim /// to get the new title. title: ?[:0]const u8 = null, @@ -907,6 +929,15 @@ class: ?[:0]const u8 = null, /// Since they are not associated with a specific terminal surface, /// they're never encoded. /// +/// * `performable:` - Only consume the input if the action is able to be +/// performed. For example, the `copy_to_clipboard` action will only +/// consume the input if there is a selection to copy. If there is no +/// selection, Ghostty behaves as if the keybind was not set. This has +/// no effect with `global:` or `all:`-prefixed keybinds. For key +/// sequences, this will reset the sequence if the action is not +/// performable (acting identically to not having a keybind set at +/// all). +/// /// Keybind triggers are not unique per prefix combination. For example, /// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind /// set later will overwrite the keybind set earlier. In this case, the @@ -1104,6 +1135,32 @@ keybind: Keybinds = .{}, @"window-height": u32 = 0, @"window-width": u32 = 0, +/// The starting window position. This position is in pixels and is relative +/// to the top-left corner of the primary monitor. Both values must be set to take +/// effect. If only one value is set, it is ignored. +/// +/// Note that the window manager may put limits on the position or override +/// the position. For example, a tiling window manager may force the window +/// to be a certain position to fit within the grid. There is nothing Ghostty +/// will do about this, but it will make an effort. +/// +/// Also note that negative values are also up to the operating system and +/// window manager. Some window managers may not allow windows to be placed +/// off-screen. +/// +/// Invalid positions are runtime-specific, but generally the positions are +/// clamped to the nearest valid position. +/// +/// On macOS, the window position is relative to the top-left corner of +/// the visible screen area. This means that if the menu bar is visible, the +/// window will be placed below the menu bar. +/// +/// Note: this is only supported on macOS and Linux GLFW builds. The GTK +/// runtime does not support setting the window position (this is a limitation +/// of GTK 4.0). +@"window-position-x": ?i16 = null, +@"window-position-y": ?i16 = null, + /// Whether to enable saving and restoring window state. Window state includes /// their position, size, tabs, splits, etc. Some window state requires shell /// integration, such as preserving working directories. See `shell-integration` @@ -1152,11 +1209,15 @@ keybind: Keybinds = .{}, /// Background color for the window titlebar. This only takes effect if /// window-theme is set to ghostty. Currently only supported in the GTK app /// runtime. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"window-titlebar-background": ?Color = null, /// Foreground color for the window titlebar. This only takes effect if /// window-theme is set to ghostty. Currently only supported in the GTK app /// runtime. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"window-titlebar-foreground": ?Color = null, /// This controls when resize overlays are shown. Resize overlays are a @@ -1772,21 +1833,19 @@ keybind: Keybinds = .{}, /// The color of the ghost in the macOS app icon. /// -/// The format of the color is the same as the `background` configuration; -/// see that for more information. -/// /// Note: This configuration is required when `macos-icon` is set to /// `custom-style`. /// /// This only has an effect when `macos-icon` is set to `custom-style`. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"macos-icon-ghost-color": ?Color = null, /// The color of the screen in the macOS app icon. /// /// The screen is a gradient so you can specify multiple colors that -/// make up the gradient. Colors should be separated by commas. The -/// format of the color is the same as the `background` configuration; -/// see that for more information. +/// make up the gradient. Comma-separated colors may be specified as +/// as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 colors. /// /// Note: This configuration is required when `macos-icon` is set to /// `custom-style`. @@ -1905,6 +1964,29 @@ 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, @@ -1925,6 +2007,15 @@ keybind: Keybinds = .{}, /// Adwaita support. @"gtk-adwaita": bool = true, +/// Custom CSS files to be loaded. +/// +/// This configuration can be repeated multiple times to load multiple files. +/// Prepend a ? character to the file path to suppress errors if the file does +/// not exist. If you want to include a file that begins with a literal ? +/// character, surround the file path in double quotes ("). +/// The file size limit for a single stylesheet is 5MiB. +@"gtk-custom-css": RepeatablePath = .{}, + /// If `true` (default), applications running in the terminal can show desktop /// notifications using certain escape sequences such as OSC 9 or OSC 777. @"desktop-notifications": bool = true, @@ -1963,10 +2054,11 @@ term: []const u8 = "xterm-ghostty", /// * `download` - Check for updates, automatically download the update, /// notify the user, but do not automatically install the update. /// -/// The default value is `check`. +/// If unset, we defer to Sparkle's default behavior, which respects the +/// preference stored in the standard user defaults (`defaults(1)`). /// /// Changing this value at runtime works after a small delay. -@"auto-update": AutoUpdate = .check, +@"auto-update": ?AutoUpdate = null, /// The release channel to use for auto-updates. /// @@ -2083,6 +2175,20 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .v }, .mods = mods }, .{ .paste_from_clipboard = {} }, ); + // On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an + // alt keybinding for Copy and shift+ins is an alt keybinding for Paste + if (!builtin.target.isDarwin()) { + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } }, + .{ .copy_to_clipboard = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, + .{ .paste_from_clipboard = {} }, + ); + } } // Increase font size mapping for keyboards with dedicated plus keys (like german) @@ -2124,45 +2230,53 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { ); // Expand Selection - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .left }, .mods = .{ .shift = true } }, .{ .adjust_selection = .left }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .right }, .mods = .{ .shift = true } }, .{ .adjust_selection = .right }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .up }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .down }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_up }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_down }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, .{ .adjust_selection = .home }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, .{ .adjust_selection = .end }, + .{ .performable = true }, ); // Tabs common to all platforms @@ -2247,12 +2361,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .top }, + .{ .goto_split = .up }, ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .bottom }, + .{ .goto_split = .down }, ); try result.keybind.set.put( alloc, @@ -2412,10 +2526,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .q }, .mods = .{ .super = true } }, .{ .quit = {} }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .k }, .mods = .{ .super = true } }, .{ .clear_screen = {} }, + .{ .performable = true }, ); try result.keybind.set.put( alloc, @@ -2516,12 +2631,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .top }, + .{ .goto_split = .up }, ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .bottom }, + .{ .goto_split = .down }, ); try result.keybind.set.put( alloc, @@ -2668,18 +2783,43 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { try self.expandPaths(std.fs.path.dirname(path).?); } +pub const OptionalFileAction = enum { loaded, not_found, @"error" }; + /// Load optional configuration file from `path`. All errors are ignored. -pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void { - self.loadFile(alloc, path) catch |err| switch (err) { - error.FileNotFound => std.log.info( - "optional config file not found, not loading path={s}", - .{path}, - ), - else => std.log.warn( - "error reading optional config file, not loading err={} path={s}", - .{ err, path }, - ), - }; +/// +/// Returns the action that was taken. +pub fn loadOptionalFile( + self: *Config, + alloc: Allocator, + path: []const u8, +) OptionalFileAction { + if (self.loadFile(alloc, path)) { + return .loaded; + } else |err| switch (err) { + error.FileNotFound => return .not_found, + else => { + std.log.warn( + "error reading optional config file, not loading err={} path={s}", + .{ err, path }, + ); + + return .@"error"; + }, + } +} + +fn writeConfigTemplate(path: []const u8) !void { + log.info("creating template config file: path={s}", .{path}); + if (std.fs.path.dirname(path)) |dir_path| { + try std.fs.makeDirAbsolute(dir_path); + } + const file = try std.fs.createFileAbsolute(path, .{}); + defer file.close(); + try std.fmt.format( + file.writer(), + @embedFile("./config-template"), + .{ .path = path }, + ); } /// Load configurations from the default configuration files. The default @@ -2688,14 +2828,30 @@ pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void /// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config` /// is also loaded. pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { + // Load XDG first const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); defer alloc.free(xdg_path); - self.loadOptionalFile(alloc, xdg_path); + const xdg_action = self.loadOptionalFile(alloc, xdg_path); + // On macOS load the app support directory as well if (comptime builtin.os.tag == .macos) { const app_support_path = try internal_os.macos.appSupportDir(alloc, "config"); defer alloc.free(app_support_path); - self.loadOptionalFile(alloc, app_support_path); + const app_support_action = self.loadOptionalFile(alloc, app_support_path); + + // If both files are not found, then we create a template file. + // For macOS, we only create the template file in the app support + if (app_support_action == .not_found and xdg_action == .not_found) { + writeConfigTemplate(app_support_path) catch |err| { + log.warn("error creating template config file err={}", .{err}); + }; + } + } else { + if (xdg_action == .not_found) { + writeConfigTemplate(xdg_path) catch |err| { + log.warn("error creating template config file err={}", .{err}); + }; + } } } @@ -2805,17 +2961,21 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // replace the entire list with the new list. inline for (fields, 0..) |field, i| { const v = &@field(self, field); - const len = v.list.items.len - counter[i]; - if (len > 0) { - // Note: we don't have to worry about freeing the memory - // that we overwrite or cut off here because its all in - // an arena. - v.list.replaceRangeAssumeCapacity( - 0, - len, - v.list.items[counter[i]..], - ); - v.list.items.len = len; + + // The list can be empty if it was reset, i.e. --font-family="" + if (v.list.items.len > 0) { + const len = v.list.items.len - counter[i]; + if (len > 0) { + // Note: we don't have to worry about freeing the memory + // that we overwrite or cut off here because its all in + // an arena. + v.list.replaceRangeAssumeCapacity( + 0, + len, + v.list.items[counter[i]..], + ); + v.list.items.len = len; + } } } } @@ -3797,17 +3957,22 @@ pub const Color = struct { pub fn fromHex(input: []const u8) !Color { // Trim the beginning '#' if it exists const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input; + if (trimmed.len != 6 and trimmed.len != 3) return error.InvalidValue; - // We expect exactly 6 for RRGGBB - if (trimmed.len != 6) return error.InvalidValue; + // Expand short hex values to full hex values + const rgb: []const u8 = if (trimmed.len == 3) &.{ + trimmed[0], trimmed[0], + trimmed[1], trimmed[1], + trimmed[2], trimmed[2], + } else trimmed; // Parse the colors two at a time. var result: Color = undefined; comptime var i: usize = 0; inline while (i < 6) : (i += 2) { const v: u8 = - ((try std.fmt.charToDigit(trimmed[i], 16)) * 16) + - try std.fmt.charToDigit(trimmed[i + 1], 16); + ((try std.fmt.charToDigit(rgb[i], 16)) * 16) + + try std.fmt.charToDigit(rgb[i + 1], 16); @field(result, switch (i) { 0 => "r", @@ -3827,6 +3992,8 @@ pub const Color = struct { try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C")); try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C")); try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF")); + try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFF")); + try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.fromHex("#345")); } test "parseCLI from name" { @@ -3987,7 +4154,7 @@ pub const Palette = struct { const eqlIdx = std.mem.indexOf(u8, value, "=") orelse return error.InvalidValue; - const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 10); + const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 0); const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]); self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; } @@ -4027,6 +4194,28 @@ pub const Palette = struct { try testing.expect(p.value[0].b == 0xCC); } + test "parseCLI base" { + const testing = std.testing; + + var p: Self = .{}; + + try p.parseCLI("0b1=#014589"); + try p.parseCLI("0o7=#234567"); + try p.parseCLI("0xF=#ABCDEF"); + + try testing.expect(p.value[0b1].r == 0x01); + try testing.expect(p.value[0b1].g == 0x45); + try testing.expect(p.value[0b1].b == 0x89); + + try testing.expect(p.value[0o7].r == 0x23); + try testing.expect(p.value[0o7].g == 0x45); + try testing.expect(p.value[0o7].b == 0x67); + + try testing.expect(p.value[0xF].r == 0xAB); + try testing.expect(p.value[0xF].g == 0xCD); + try testing.expect(p.value[0xF].b == 0xEF); + } + test "parseCLI overflow" { const testing = std.testing; @@ -4291,6 +4480,45 @@ pub const RepeatablePath = struct { // If it isn't absolute, we need to make it absolute relative // to the base. var buf: [std.fs.max_path_bytes]u8 = undefined; + + // Check if the path starts with a tilde and expand it to the + // home directory on Linux/macOS. We explicitly look for "~/" + // because we don't support alternate users such as "~alice/" + if (std.mem.startsWith(u8, path, "~/")) expand: { + // Windows isn't supported yet + if (comptime builtin.os.tag == .windows) break :expand; + + const expanded: []const u8 = internal_os.expandHome( + path, + &buf, + ) catch |err| { + try diags.append(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "error expanding home directory for path {s}: {}", + .{ path, err }, + ), + }); + + // Blank this path so that we don't attempt to resolve it + // again + self.value.items[i] = .{ .required = "" }; + + continue; + }; + + log.debug( + "expanding file path from home directory: path={s}", + .{expanded}, + ); + + switch (self.value.items[i]) { + .optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded), + } + + continue; + } + const abs = dir.realpath(path, &buf) catch |err| abs: { if (err == error.FileNotFound) { // The file doesn't exist. Try to resolve the relative path @@ -4701,9 +4929,11 @@ pub const Keybinds = struct { try list.parseCLI(alloc, "ctrl+z>2=goto_tab:2"); try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer())); + // Note they turn into translated keys because they match + // their ASCII mapping. const want = - \\keybind = ctrl+z>1=goto_tab:1 - \\keybind = ctrl+z>2=goto_tab:2 + \\keybind = ctrl+z>two=goto_tab:2 + \\keybind = ctrl+z>one=goto_tab:1 \\ ; try std.testing.expectEqualStrings(want, buf.items); @@ -5297,6 +5527,11 @@ pub const AdwToolbarStyle = enum { @"raised-border", }; +/// See adw-toast +pub const AdwToast = packed struct { + @"clipboard-copy": bool = true, +}; + /// See mouse-shift-capture pub const MouseShiftCapture = enum { false, diff --git a/src/config/c_get.zig b/src/config/c_get.zig index dd7c7cce8..d3f38415e 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -42,6 +42,11 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { ptr.* = @intCast(value); }, + i16 => { + const ptr: *c_short = @ptrCast(@alignCast(ptr_raw)); + ptr.* = @intCast(value); + }, + f32, f64 => |Float| { const ptr: *Float = @ptrCast(@alignCast(ptr_raw)); ptr.* = @floatCast(value); diff --git a/src/config/config-template b/src/config/config-template new file mode 100644 index 000000000..4645e60aa --- /dev/null +++ b/src/config/config-template @@ -0,0 +1,43 @@ +# This is the configuration file for Ghostty. +# +# This template file has been automatically created at the following +# path since Ghostty couldn't find any existing config files on your system: +# +# {[path]s} +# +# The template does not set any default options, since Ghostty ships +# with sensible defaults for all options. Users should only need to set +# options that they want to change from the default. +# +# Run `ghostty +show-config --default --docs` to view a list of +# all available config options and their default values. +# +# Additionally, each config option is also explained in detail +# on Ghostty's website, at https://ghostty.org/docs/config. + +# Config syntax crash course +# ========================== +# # The config file consists of simple key-value pairs, +# # separated by equals signs. +# font-family = Iosevka +# window-padding-x = 2 +# +# # Spacing around the equals sign does not matter. +# # All of these are identical: +# key=value +# key= value +# key =value +# key = value +# +# # Any line beginning with a # is a comment. It's not possible to put +# # a comment after a config option, since it would be interpreted as a +# # part of the value. For example, this will have a value of "#123abc": +# background = #123abc +# +# # Empty values are used to reset config keys to default. +# key = +# +# # Some config options have unique syntaxes for their value, +# # which is explained in the docs for that config option. +# # Just for example: +# resize-overlay-duration = 4s 200ms diff --git a/src/config/edit.zig b/src/config/edit.zig index 692447594..871a1a755 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -1,31 +1,29 @@ const std = @import("std"); const builtin = @import("builtin"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const internal_os = @import("../os/main.zig"); /// Open the configuration in the OS default editor according to the default /// paths the main config file could be in. +/// +/// On Linux, this will open the file at the XDG config path. This is the +/// only valid path for Linux so we don't need to check for other paths. +/// +/// On macOS, both XDG and AppSupport paths are valid. Because Ghostty +/// prioritizes AppSupport over XDG, we will open AppSupport if it exists, +/// followed by XDG if it exists, and finally AppSupport if neither exist. +/// For the existence check, we also prefer non-empty files over empty +/// files. pub fn open(alloc_gpa: Allocator) !void { - // default location - const config_path = config_path: { - const xdg_config_path = try internal_os.xdg.config(alloc_gpa, .{ .subdir = "ghostty/config" }); + // Use an arena to make memory management easier in here. + var arena = ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); - if (comptime builtin.os.tag == .macos) macos: { - // On macOS, use the application support path if the XDG path doesn't exists. - if (std.fs.accessAbsolute(xdg_config_path, .{})) { - break :macos; - } else |err| switch (err) { - error.BadPathName, error.FileNotFound => {}, - else => break :macos, - } - - alloc_gpa.free(xdg_config_path); - break :config_path try internal_os.macos.appSupportDir(alloc_gpa, "config"); - } - - break :config_path xdg_config_path; - }; - defer alloc_gpa.free(config_path); + // Get the path we should open + const config_path = try configPath(alloc); // Create config directory recursively. if (std.fs.path.dirname(config_path)) |config_dir| { @@ -43,5 +41,67 @@ pub fn open(alloc_gpa: Allocator) !void { } }; - try internal_os.open(alloc_gpa, config_path); + try internal_os.open(alloc, .text, config_path); +} + +/// Returns the config path to use for open for the current OS. +/// +/// The allocator must be an arena allocator. No memory is freed by this +/// function and the resulting path is not all the memory that is allocated. +fn configPath(alloc_arena: Allocator) ![]const u8 { + const paths: []const []const u8 = try configPathCandidates(alloc_arena); + assert(paths.len > 0); + + // Find the first path that exists and is non-empty. If no paths are + // non-empty but at least one exists, we will return the first path that + // exists. + var exists: ?[]const u8 = null; + for (paths) |path| { + const f = std.fs.openFileAbsolute(path, .{}) catch |err| { + switch (err) { + // File doesn't exist, continue. + error.BadPathName, error.FileNotFound => continue, + + // Some other error, assume it exists and return it. + else => return err, + } + }; + defer f.close(); + + // We expect stat to succeed because we just opened the file. + const stat = try f.stat(); + + // If the file is non-empty, return it. + if (stat.size > 0) return path; + + // If the file is empty, remember it exists. + if (exists == null) exists = path; + } + + // No paths are non-empty, return the first path that exists. + if (exists) |v| return v; + + // No paths are non-empty or exist, return the first path. + return paths[0]; +} + +/// Returns a const list of possible paths the main config file could be +/// in for the current OS. +fn configPathCandidates(alloc_arena: Allocator) ![]const []const u8 { + var paths = try std.ArrayList([]const u8).initCapacity(alloc_arena, 2); + errdefer paths.deinit(); + + if (comptime builtin.os.tag == .macos) { + paths.appendAssumeCapacity(try internal_os.macos.appSupportDir( + alloc_arena, + "config", + )); + } + + paths.appendAssumeCapacity(try internal_os.xdg.config( + alloc_arena, + .{ .subdir = "ghostty/config" }, + )); + + return paths.items; } diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index 14f2e484c..e9c49048c 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -3,7 +3,8 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); -const sentry = @import("sentry"); +const build_options = @import("build_options"); +const sentry = if (build_options.sentry) @import("sentry"); const internal_os = @import("../os/main.zig"); const crash = @import("main.zig"); const state = &@import("../global.zig").state; @@ -47,6 +48,8 @@ pub threadlocal var thread_state: ?ThreadState = null; /// It is up to the user to grab the logs and manually send them to us /// (or they own Sentry instance) if they want to. pub fn init(gpa: Allocator) !void { + if (comptime !build_options.sentry) return; + // Not supported on Windows currently, doesn't build. if (comptime builtin.os.tag == .windows) return; @@ -76,6 +79,8 @@ pub fn init(gpa: Allocator) !void { } fn initThread(gpa: Allocator) !void { + if (comptime !build_options.sentry) return; + var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const alloc = arena.allocator(); @@ -101,7 +106,23 @@ fn initThread(gpa: Allocator) !void { sentry.c.sentry_options_set_before_send(opts, beforeSend, null); // Determine the Sentry cache directory. - const cache_dir = try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" }); + const cache_dir = cache_dir: { + // On macOS, we prefer to use the NSCachesDirectory value to be + // a more idiomatic macOS application. But if XDG env vars are set + // we will respect them. + if (comptime builtin.os.tag == .macos) macos: { + if (std.posix.getenv("XDG_CACHE_HOME") != null) break :macos; + break :cache_dir try internal_os.macos.cacheDir( + alloc, + "sentry", + ); + } + + break :cache_dir try internal_os.xdg.cache( + alloc, + .{ .subdir = "ghostty/sentry" }, + ); + }; sentry.c.sentry_options_set_database_path_n( opts, cache_dir.ptr, @@ -129,6 +150,8 @@ fn initThread(gpa: Allocator) !void { /// Process-wide deinitialization of our Sentry client. This ensures all /// our data is flushed. pub fn deinit() void { + if (comptime !build_options.sentry) return; + if (comptime builtin.os.tag == .windows) return; // If we're still initializing then wait for init to finish. This diff --git a/src/font/discovery.zig b/src/font/discovery.zig index a42055d5a..071407d92 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -362,16 +362,9 @@ pub const CoreText = struct { const list = set.createMatchingFontDescriptors(); defer list.release(); - // Bring the list of descriptors in to zig land - var zig_list = try copyMatchingDescriptors(alloc, list); - errdefer alloc.free(zig_list); - - // Filter them. We don't use `CTFontCollectionSetExclusionDescriptors` - // to do this because that requires a mutable collection. This way is - // much more straight forward. - zig_list = try alloc.realloc(zig_list, filterDescriptors(zig_list)); - // Sort our descriptors + const zig_list = try copyMatchingDescriptors(alloc, list); + errdefer alloc.free(zig_list); sortMatchingDescriptors(&desc, zig_list); return DiscoverIterator{ @@ -558,47 +551,13 @@ pub const CoreText = struct { for (0..result.len) |i| { result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i); - // We need to retain because once the list - // is freed it will release all its members. + // We need to retain because once the list is freed it will + // release all its members. result[i].retain(); } return result; } - /// Filter any descriptors out of the list that aren't acceptable for - /// some reason or another (e.g. the font isn't in a format we can handle). - /// - /// Invalid descriptors are filled in from the end of - /// the list and the new length for the list is returned. - fn filterDescriptors(list: []*macos.text.FontDescriptor) usize { - var end = list.len; - var i: usize = 0; - while (i < end) { - if (validDescriptor(list[i])) { - i += 1; - } else { - list[i].release(); - end -= 1; - list[i] = list[end]; - } - } - return end; - } - - /// Used by `filterDescriptors` to decide whether a descriptor is valid. - fn validDescriptor(desc: *macos.text.FontDescriptor) bool { - if (desc.copyAttribute(macos.text.FontAttribute.format)) |format| { - defer format.release(); - var value: c_int = undefined; - assert(format.getValue(.int, &value)); - - // Bitmap fonts are not currently supported. - if (value == macos.text.c.kCTFontFormatBitmap) return false; - } - - return true; - } - fn sortMatchingDescriptors( desc: *const Descriptor, list: []*macos.text.FontDescriptor, diff --git a/src/font/embedded.zig b/src/font/embedded.zig index 098aa3eb4..31b07ff31 100644 --- a/src/font/embedded.zig +++ b/src/font/embedded.zig @@ -34,3 +34,6 @@ pub const cozette = @embedFile("res/CozetteVector.ttf"); /// Monaspace has weird ligature behaviors we want to test in our shapers /// so we embed it here. pub const monaspace_neon = @embedFile("res/MonaspaceNeon-Regular.otf"); + +/// Terminus TTF is a scalable font with bitmap glyphs at various sizes. +pub const terminus_ttf = @embedFile("res/TerminusTTF-Regular.ttf"); diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 92ab4d396..dd4f6432e 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -515,8 +515,17 @@ pub const Face = struct { fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics { // Read the 'head' table out of the font data. const head: opentype.Head = head: { - const tag = macos.text.FontTableTag.init("head"); - const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but + // the table format is byte-identical to the 'head' table, so if we + // can't find 'head' we try 'bhed' instead before failing. + // + // ref: https://fontforge.org/docs/techref/bitmaponlysfnt.html + const head_tag = macos.text.FontTableTag.init("head"); + const bhed_tag = macos.text.FontTableTag.init("bhed"); + const data = + ct_font.copyTable(head_tag) orelse + ct_font.copyTable(bhed_tag) orelse + return error.CopyTableError; defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index bc503a3af..630eaee25 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -288,7 +288,6 @@ pub const Face = struct { self.face.loadGlyph(glyph_id, .{ .render = true, .color = self.face.hasColor(), - .no_bitmap = !self.face.hasColor(), }) catch return false; // If the glyph is SVG we assume colorized @@ -323,14 +322,6 @@ pub const Face = struct { // glyph properties before render so we don't render here. .render = !self.synthetic.bold, - // Disable bitmap strikes for now since it causes issues with - // our cell metrics and rasterization. In the future, this is - // all fixable so we can enable it. - // - // This must be enabled for color faces though because those are - // often colored bitmaps, which we support. - .no_bitmap = !self.face.hasColor(), - // use options from config .no_hinting = !self.load_flags.hinting, .force_autohint = !self.load_flags.@"force-autohint", @@ -385,7 +376,7 @@ pub const Face = struct { return error.UnsupportedPixelMode; }; - log.warn("converting from pixel_mode={} to atlas_format={}", .{ + log.debug("converting from pixel_mode={} to atlas_format={}", .{ bitmap_ft.pixel_mode, atlas.format, }); @@ -1005,3 +996,59 @@ test "svg font table" { try testing.expectEqual(430, table.len); } + +const terminus_i = + \\........ + \\........ + \\...#.... + \\...#.... + \\........ + \\..##.... + \\...#.... + \\...#.... + \\...#.... + \\...#.... + \\...#.... + \\..###... + \\........ + \\........ + \\........ + \\........ +; +// Including the newline +const terminus_i_pitch = 9; + +test "bitmap glyph" { + const alloc = testing.allocator; + const testFont = font.embedded.terminus_ttf; + + var lib = try Library.init(); + defer lib.deinit(); + + var atlas = try font.Atlas.init(alloc, 512, .grayscale); + defer atlas.deinit(alloc); + + // Any glyph at 12pt @ 96 DPI is a bitmap + var ft_font = try Face.init(lib, testFont, .{ .size = .{ + .points = 12, + .xdpi = 96, + .ydpi = 96, + } }); + defer ft_font.deinit(); + + // glyph 77 = 'i' + const glyph = try ft_font.renderGlyph(alloc, &atlas, 77, .{}); + + // should render crisp + try testing.expectEqual(8, glyph.width); + try testing.expectEqual(16, glyph.height); + for (0..glyph.height) |y| { + for (0..glyph.width) |x| { + const pixel = terminus_i[y * terminus_i_pitch + x]; + try testing.expectEqual( + @as(u8, if (pixel == '#') 255 else 0), + atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)], + ); + } + } +} diff --git a/src/font/face/freetype_convert.zig b/src/font/face/freetype_convert.zig index 298aad8a0..6df350bfa 100644 --- a/src/font/face/freetype_convert.zig +++ b/src/font/face/freetype_convert.zig @@ -43,26 +43,14 @@ pub fn monoToGrayscale(alloc: Allocator, bm: Bitmap) Allocator.Error!Bitmap { var buf = try alloc.alloc(u8, bm.width * bm.rows); errdefer alloc.free(buf); - // width divided by 8 because each byte has 8 pixels. This is therefore - // the number of bytes in each row. - const bytes_per_row = bm.width >> 3; - - var source_i: usize = 0; - var target_i: usize = 0; - var i: usize = bm.rows; - while (i > 0) : (i -= 1) { - var j: usize = bytes_per_row; - while (j > 0) : (j -= 1) { - var bit: u4 = 8; - while (bit > 0) : (bit -= 1) { - const mask = @as(u8, 1) << @as(u3, @intCast(bit - 1)); - const bitval: u8 = if (bm.buffer[source_i + (j - 1)] & mask > 0) 0xFF else 0; - buf[target_i] = bitval; - target_i += 1; - } + for (0..bm.rows) |y| { + const row_offset = y * @as(usize, @intCast(bm.pitch)); + for (0..bm.width) |x| { + const byte_offset = row_offset + @divTrunc(x, 8); + const mask = @as(u8, 1) << @intCast(7 - (x % 8)); + const bit: u8 = @intFromBool((bm.buffer[byte_offset] & mask) != 0); + buf[y * bm.width + x] = bit * 255; } - - source_i += @intCast(bm.pitch); } var copy = bm; diff --git a/src/font/res/README.md b/src/font/res/README.md index 3195a8916..5ad4b274f 100644 --- a/src/font/res/README.md +++ b/src/font/res/README.md @@ -25,6 +25,9 @@ This project uses several fonts which fall under the SIL Open Font License (OFL- - [Copyright 2013 Google LLC](https://github.com/googlefonts/noto-emoji/blob/main/LICENSE) - Cozette (MIT) - [Copyright (c) 2020, Slavfox](https://github.com/slavfox/Cozette/blob/main/LICENSE) +- Terminus TTF (OFL-1.1) + - [Copyright (c) 2010-2020 Dimitar Toshkov Zhekov with Reserved Font Name "Terminus Font"](https://sourceforge.net/projects/terminus-font/) + - [Copyright (c) 2011-2023 Tilman Blumenbach with Reserved Font Name "Terminus (TTF)"](https://files.ax86.net/terminus-ttf/) A full copy of the OFL license can be found at [OFL.txt](./OFL.txt). An accompanying FAQ is also available at . diff --git a/src/font/res/TerminusTTF-Regular.ttf b/src/font/res/TerminusTTF-Regular.ttf new file mode 100644 index 000000000..d125e6347 Binary files /dev/null and b/src/font/res/TerminusTTF-Regular.ttf differ diff --git a/src/font/shape.zig b/src/font/shape.zig index 3721c63a6..cc67fc7a0 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -1,6 +1,7 @@ const builtin = @import("builtin"); const options = @import("main.zig").options; const run = @import("shaper/run.zig"); +const feature = @import("shaper/feature.zig"); pub const noop = @import("shaper/noop.zig"); pub const harfbuzz = @import("shaper/harfbuzz.zig"); pub const coretext = @import("shaper/coretext.zig"); @@ -8,6 +9,9 @@ pub const web_canvas = @import("shaper/web_canvas.zig"); pub const Cache = @import("shaper/Cache.zig"); pub const TextRun = run.TextRun; pub const RunIterator = run.RunIterator; +pub const Feature = feature.Feature; +pub const FeatureList = feature.FeatureList; +pub const default_features = feature.default_features; /// Shaper implementation for our compile options. pub const Shaper = switch (options.backend) { @@ -49,10 +53,7 @@ pub const Cell = struct { /// Options for shapers. pub const Options = struct { - /// Font features to use when shaping. These can be in the following - /// formats: "-feat" "+feat" "feat". A "-"-prefix is used to disable - /// a feature and the others are used to enable a feature. If a feature - /// isn't supported or is invalid, it will be ignored. + /// Font features to use when shaping. /// /// Note: eventually, this will move to font.Face probably as we may /// want to support per-face feature configuration. For now, we only diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index dbc9809e3..e084a68c9 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -7,6 +7,9 @@ const trace = @import("tracy").trace; const font = @import("../main.zig"); const os = @import("../../os/main.zig"); const terminal = @import("../../terminal/main.zig"); +const Feature = font.shape.Feature; +const FeatureList = font.shape.FeatureList; +const default_features = font.shape.default_features; const Face = font.Face; const Collection = font.Collection; const DeferredFace = font.DeferredFace; @@ -40,9 +43,10 @@ pub const Shaper = struct { /// The string used for shaping the current run. run_state: RunState, - /// The font features we want to use. The hardcoded features are always - /// set first. - features: FeatureList, + /// CoreFoundation Dictionary which represents our font feature settings. + features: *macos.foundation.Dictionary, + /// A version of the features dictionary with the default features excluded. + features_no_default: *macos.foundation.Dictionary, /// The shared memory used for shaping results. cell_buf: CellBuf, @@ -100,51 +104,17 @@ pub const Shaper = struct { } }; - /// List of font features, parsed into the data structures used by - /// the CoreText API. The CoreText API requires a pretty annoying wrapping - /// to setup font features: - /// - /// - The key parsed into a CFString - /// - The value parsed into a CFNumber - /// - The key and value are then put into a CFDictionary - /// - The CFDictionary is then put into a CFArray - /// - The CFArray is then put into another CFDictionary - /// - The CFDictionary is then passed to the CoreText API to create - /// a new font with the features set. - /// - /// This structure handles up to the point that we have a CFArray of - /// CFDictionary objects representing the font features and provides - /// functions for creating the dictionary to init the font. - const FeatureList = struct { - list: *macos.foundation.MutableArray, + /// Create a CoreFoundation Dictionary suitable for + /// settings the font features of a CoreText font. + fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary { + const list = try macos.foundation.MutableArray.create(); + errdefer list.release(); - pub fn init() !FeatureList { - var list = try macos.foundation.MutableArray.create(); - errdefer list.release(); - return .{ .list = list }; - } - - pub fn deinit(self: FeatureList) void { - self.list.release(); - } - - /// Append the given feature to the list. The feature syntax is - /// the same as Harfbuzz: "feat" enables it and "-feat" disables it. - pub fn append(self: *FeatureList, name_raw: []const u8) !void { - // If the name is `-name` then we are disabling the feature, - // otherwise we are enabling it, so we need to parse this out. - const name = if (name_raw[0] == '-') name_raw[1..] else name_raw; - const dict = try featureDict(name, name_raw[0] != '-'); - defer dict.release(); - self.list.appendValue(macos.foundation.Dictionary, dict); - } - - /// Create the dictionary for the given feature and value. - fn featureDict(name: []const u8, v: bool) !*macos.foundation.Dictionary { - const value_num: c_int = @intFromBool(v); + for (feats) |feat| { + const value_num: c_int = @intCast(feat.value); // Keys can only be ASCII. - var key = try macos.foundation.String.createWithBytes(name, .ascii, false); + var key = try macos.foundation.String.createWithBytes(&feat.tag, .ascii, false); defer key.release(); var value = try macos.foundation.Number.create(.int, &value_num); defer value.release(); @@ -154,50 +124,44 @@ pub const Shaper = struct { macos.text.c.kCTFontOpenTypeFeatureTag, macos.text.c.kCTFontOpenTypeFeatureValue, }, - &[_]?*const anyopaque{ - key, - value, - }, + &[_]?*const anyopaque{ key, value }, ); - errdefer dict.release(); - return dict; + defer dict.release(); + + list.appendValue(macos.foundation.Dictionary, dict); } - /// Returns the dictionary to use with the font API to set the - /// features. This should be released by the caller. - pub fn attrsDict( - self: FeatureList, - omit_defaults: bool, - ) !*macos.foundation.Dictionary { - // Get our feature list. If we're omitting defaults then we - // slice off the hardcoded features. - const list = if (!omit_defaults) self.list else list: { - const list = try macos.foundation.MutableArray.createCopy(@ptrCast(self.list)); - for (hardcoded_features) |_| list.removeValue(0); - break :list list; - }; - defer if (omit_defaults) list.release(); + var dict = try macos.foundation.Dictionary.create( + &[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute}, + &[_]?*const anyopaque{list}, + ); + errdefer dict.release(); - var dict = try macos.foundation.Dictionary.create( - &[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute}, - &[_]?*const anyopaque{list}, - ); - errdefer dict.release(); - return dict; - } - }; - - // These features are hardcoded to always be on by default. Users - // can turn them off by setting the features to "-liga" for example. - const hardcoded_features = [_][]const u8{ "dlig", "liga" }; + return dict; + } /// The cell_buf argument is the buffer to use for storing shaped results. /// This should be at least the number of columns in the terminal. pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { - var feats = try FeatureList.init(); - errdefer feats.deinit(); - for (hardcoded_features) |name| try feats.append(name); - for (opts.features) |name| try feats.append(name); + var feature_list: FeatureList = .{}; + defer feature_list.deinit(alloc); + for (opts.features) |feature_str| { + try feature_list.appendFromString(alloc, feature_str); + } + + // We need to construct two attrs dictionaries for font features; + // one without the default features included, and one with them. + const feats = feature_list.features.items; + const feats_df = try alloc.alloc(Feature, feats.len + default_features.len); + defer alloc.free(feats_df); + + @memcpy(feats_df[0..default_features.len], &default_features); + @memcpy(feats_df[default_features.len..], feats); + + const features = try makeFeaturesDict(feats_df); + errdefer features.release(); + const features_no_default = try makeFeaturesDict(feats); + errdefer features_no_default.release(); var run_state = RunState.init(); errdefer run_state.deinit(alloc); @@ -242,7 +206,8 @@ pub const Shaper = struct { .alloc = alloc, .cell_buf = .{}, .run_state = run_state, - .features = feats, + .features = features, + .features_no_default = features_no_default, .writing_direction = writing_direction, .cached_fonts = .{}, .cached_font_grid = 0, @@ -255,7 +220,8 @@ pub const Shaper = struct { pub fn deinit(self: *Shaper) void { self.cell_buf.deinit(self.alloc); self.run_state.deinit(self.alloc); - self.features.deinit(); + self.features.release(); + self.features_no_default.release(); self.writing_direction.release(); { @@ -509,8 +475,8 @@ pub const Shaper = struct { // If we have it, return the cached attr dict. if (self.cached_fonts.items[index_int]) |cached| return cached; - // Features dictionary, font descriptor, font - try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3); + // Font descriptor, font + try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 2); const run_font = font: { // The CoreText shaper relies on CoreText and CoreText claims @@ -533,8 +499,10 @@ pub const Shaper = struct { const face = try grid.resolver.collection.getFace(index); const original = face.font; - const attrs = try self.features.attrsDict(face.quirks_disable_default_font_features); - self.cf_release_pool.appendAssumeCapacity(attrs); + const attrs = if (face.quirks_disable_default_font_features) + self.features_no_default + else + self.features; const desc = try macos.text.FontDescriptor.createWithAttributes(attrs); self.cf_release_pool.appendAssumeCapacity(desc); diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig new file mode 100644 index 000000000..8e70d51da --- /dev/null +++ b/src/font/shaper/feature.zig @@ -0,0 +1,390 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const log = std.log.scoped(.font_shaper); + +/// Represents an OpenType font feature setting, which consists of a tag and +/// a numeric parameter >= 0. Most features are boolean, so only parameters +/// of 0 and 1 make sense for them, but some (e.g. 'cv01'..'cv99') can take +/// parameters to choose between multiple variants of a given character or +/// characters. +/// +/// Ref: +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/chapter2#features-and-lookups +/// - https://harfbuzz.github.io/shaping-opentype-features.html +pub const Feature = struct { + tag: [4]u8, + value: u32, + + pub fn fromString(str: []const u8) ?Feature { + var fbs = std.io.fixedBufferStream(str); + const reader = fbs.reader(); + return Feature.fromReader(reader); + } + + /// Parse a single font feature setting from a std.io.Reader, with a version + /// of the syntax of HarfBuzz's font feature strings. Stops at end of stream + /// or when a ',' is encountered. + /// + /// This parsing aims to be as error-tolerant as possible while avoiding any + /// assumptions in ambiguous scenarios. When invalid syntax is encountered, + /// the reader is advanced to the next boundary (end-of-stream or ',') so + /// that further features may be read. + /// + /// Ref: https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string + pub fn fromReader(reader: anytype) ?Feature { + var tag: [4]u8 = undefined; + var value: ?u32 = null; + + // TODO: when we move to Zig 0.14 this can be replaced with a + // labeled switch continue pattern rather than this loop. + var state: union(enum) { + /// Initial state. + start: void, + /// Parsing the tag, data is index. + tag: u2, + /// In the space between the tag and the value. + space: void, + /// Parsing an integer parameter directly in to `value`. + int: void, + /// Parsing a boolean keyword parameter ("on"/"off"). + bool: void, + /// Encountered an unrecoverable syntax error, advancing to boundary. + err: void, + /// Done parsing feature. + done: void, + } = .start; + while (true) { + // If we hit the end of the stream we just pretend it's a comma. + const byte = reader.readByte() catch ','; + switch (state) { + // If we're done then we skip whitespace until we see a ','. + .done => switch (byte) { + ' ', '\t' => continue, + ',' => break, + // If we see something other than whitespace or a ',' + // then this is an error since the intent is unclear. + else => { + state = .err; + continue; + }, + }, + + // If we're fast-forwarding from an error we just wanna + // stop at the first boundary and ignore all other bytes. + .err => if (byte == ',') return null, + + .start => switch (byte) { + // Ignore leading whitespace. + ' ', '\t' => continue, + // Empty feature string. + ',' => return null, + // '+' prefix to explicitly enable feature. + '+' => { + value = 1; + state = .{ .tag = 0 }; + continue; + }, + // '-' prefix to explicitly disable feature. + '-' => { + value = 0; + state = .{ .tag = 0 }; + continue; + }, + // Quote mark introducing a tag. + '"', '\'' => { + state = .{ .tag = 0 }; + continue; + }, + // First letter of tag. + else => { + tag[0] = byte; + state = .{ .tag = 1 }; + continue; + }, + }, + + .tag => |*i| switch (byte) { + // If the tag is interrupted by a comma it's invalid. + ',' => return null, + // Ignore quote marks. + '"', '\'' => continue, + // A prefix of '+' or '-' + // In all other cases we add the byte to our tag. + else => { + tag[i.*] = byte; + if (i.* == 3) { + state = .space; + continue; + } + i.* += 1; + }, + }, + + .space => switch (byte) { + ' ', '\t' => continue, + // Ignore quote marks since we might have a + // closing quote from the tag still ahead. + '"', '\'' => continue, + // Allow an '=' (which we can safely ignore) + // only if we don't already have a value due + // to a '+' or '-' prefix. + '=' => if (value != null) { + state = .err; + continue; + }, + ',' => { + // Specifying only a tag turns a feature on. + if (value == null) value = 1; + break; + }, + '0'...'9' => { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) { + state = .err; + continue; + } + value = byte - '0'; + state = .int; + continue; + }, + 'o', 'O' => { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) { + state = .err; + continue; + } + state = .bool; + continue; + }, + else => { + state = .err; + continue; + }, + }, + + .int => switch (byte) { + ',' => break, + '0'...'9' => { + // If our value gets too big while + // parsing we consider it an error. + value = std.math.mul(u32, value.?, 10) catch { + state = .err; + continue; + }; + value.? += byte - '0'; + }, + else => { + state = .err; + continue; + }, + }, + + .bool => switch (byte) { + ',' => return null, + 'n', 'N' => { + // "ofn" + if (value != null) { + assert(value == 0); + state = .err; + continue; + } + value = 1; + state = .done; + continue; + }, + 'f', 'F' => { + // To make sure we consume two 'f's. + if (value == null) { + value = 0; + } else { + assert(value == 0); + state = .done; + continue; + } + }, + else => { + state = .err; + continue; + }, + }, + } + } + + assert(value != null); + + return .{ + .tag = tag, + .value = value.?, + }; + } + + /// Serialize this feature to the provided buffer. + /// The string that this produces should be valid to parse. + pub fn toString(self: *const Feature, buf: []u8) !void { + var fbs = std.io.fixedBufferStream(buf); + try self.format("", .{}, fbs.writer()); + } + + /// Formatter for logging + pub fn format( + self: Feature, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + _ = opts; + if (self.value <= 1) { + // Format boolean options as "+tag" for on and "-tag" for off. + try std.fmt.format(writer, "{c}{s}", .{ + "-+"[self.value], + self.tag, + }); + } else { + // Format non-boolean tags as "tag=value". + try std.fmt.format(writer, "{s}={d}", .{ + self.tag, + self.value, + }); + } + } +}; + +/// A list of font feature settings (see `Feature` for more documentation). +pub const FeatureList = struct { + features: std.ArrayListUnmanaged(Feature) = .{}, + + pub fn deinit(self: *FeatureList, alloc: Allocator) void { + self.features.deinit(alloc); + } + + /// Parse a comma separated list of features. + /// See `Feature.fromReader` for more docs. + pub fn fromString(alloc: Allocator, str: []const u8) !FeatureList { + var self: FeatureList = .{}; + try self.appendFromString(alloc, str); + return self; + } + + /// Append features to this list from a string with a comma separated list. + /// See `Feature.fromReader` for more docs. + pub fn appendFromString( + self: *FeatureList, + alloc: Allocator, + str: []const u8, + ) !void { + var fbs = std.io.fixedBufferStream(str); + const reader = fbs.reader(); + while (fbs.pos < fbs.buffer.len) { + const i = fbs.pos; + if (Feature.fromReader(reader)) |feature| { + try self.features.append(alloc, feature); + } else log.warn( + "failed to parse font feature setting: \"{s}\"", + .{fbs.buffer[i..fbs.pos]}, + ); + } + } + + /// Formatter for logging + pub fn format( + self: FeatureList, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + for (self.features.items, 0..) |feature, i| { + try feature.format(layout, opts, writer); + if (i != std.features.items.len - 1) try writer.writeAll(", "); + } + if (self.value <= 1) { + // Format boolean options as "+tag" for on and "-tag" for off. + try std.fmt.format(writer, "{c}{s}", .{ + "-+"[self.value], + self.tag, + }); + } else { + // Format non-boolean tags as "tag=value". + try std.fmt.format(writer, "{s}={d}", .{ + self.tag, + self.value, + }); + } + } +}; + +/// These features are hardcoded to always be on by default. Users +/// can turn them off by setting the features to "-liga" for example. +pub const default_features = [_]Feature{ + .{ .tag = "dlig".*, .value = 1 }, + .{ .tag = "liga".*, .value = 1 }, +}; + +test "Feature.fromString" { + const testing = std.testing; + + // This is not *complete* coverage of every possible + // combination of syntax, but it covers quite a few. + + // Boolean settings (on) + const kern_on = Feature{ .tag = "kern".*, .value = 1 }; + try testing.expectEqual(kern_on, Feature.fromString("kern")); + try testing.expectEqual(kern_on, Feature.fromString("kern, ")); + try testing.expectEqual(kern_on, Feature.fromString("kern on")); + try testing.expectEqual(kern_on, Feature.fromString("kern on, ")); + try testing.expectEqual(kern_on, Feature.fromString("+kern")); + try testing.expectEqual(kern_on, Feature.fromString("+kern, ")); + try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1")); + try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1, ")); + + // Boolean settings (off) + const kern_off = Feature{ .tag = "kern".*, .value = 0 }; + try testing.expectEqual(kern_off, Feature.fromString("kern off")); + try testing.expectEqual(kern_off, Feature.fromString("kern off, ")); + try testing.expectEqual(kern_off, Feature.fromString("-'kern'")); + try testing.expectEqual(kern_off, Feature.fromString("-'kern', ")); + try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0")); + try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0, ")); + + // Non-boolean settings + const aalt_2 = Feature{ .tag = "aalt".*, .value = 2 }; + try testing.expectEqual(aalt_2, Feature.fromString("aalt=2")); + try testing.expectEqual(aalt_2, Feature.fromString("aalt=2, ")); + try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2")); + try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2, ")); + + // Various ambiguous/error cases which should be null + try testing.expectEqual(null, Feature.fromString("aalt=2x")); // bad number + try testing.expectEqual(null, Feature.fromString("toolong")); // tag too long + try testing.expectEqual(null, Feature.fromString("sht")); // tag too short + try testing.expectEqual(null, Feature.fromString("-kern 1")); // redundant/conflicting + try testing.expectEqual(null, Feature.fromString("-kern on")); // redundant/conflicting + try testing.expectEqual(null, Feature.fromString("aalt=o,")); // bad keyword + try testing.expectEqual(null, Feature.fromString("aalt=ofn,")); // bad keyword +} + +test "FeatureList.fromString" { + const testing = std.testing; + + const str = + " kern, kern on , +kern, \"kern\" = 1," ++ // Boolean settings (on) + "kern off, -'kern' , \"kern\"=0," ++ // Boolean settings (off) + "aalt=2, 'aalt'\t2," ++ // Non-boolean settings + "aalt=2x, toolong, sht, -kern 1, -kern on, aalt=o, aalt=ofn," ++ // Invalid cases + "last"; // To ensure final element is included correctly. + var feats = try FeatureList.fromString(testing.allocator, str); + defer feats.deinit(testing.allocator); + try testing.expectEqualSlices( + Feature, + &(.{Feature{ .tag = "kern".*, .value = 1 }} ** 4 ++ + .{Feature{ .tag = "kern".*, .value = 0 }} ** 3 ++ + .{Feature{ .tag = "aalt".*, .value = 2 }} ** 2 ++ + .{Feature{ .tag = "last".*, .value = 1 }}), + feats.features.items, + ); +} diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index ccb422f20..97292b9b0 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -3,6 +3,10 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); +const terminal = @import("../../terminal/main.zig"); +const Feature = font.shape.Feature; +const FeatureList = font.shape.FeatureList; +const default_features = font.shape.default_features; const Face = font.Face; const Collection = font.Collection; const DeferredFace = font.DeferredFace; @@ -10,7 +14,6 @@ const Library = font.Library; const SharedGrid = font.SharedGrid; const Style = font.Style; const Presentation = font.Presentation; -const terminal = @import("../../terminal/main.zig"); const log = std.log.scoped(.font_shaper); @@ -27,38 +30,37 @@ pub const Shaper = struct { cell_buf: CellBuf, /// The features to use for shaping. - hb_feats: FeatureList, + hb_feats: []harfbuzz.Feature, const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); - const FeatureList = std.ArrayListUnmanaged(harfbuzz.Feature); - - // These features are hardcoded to always be on by default. Users - // can turn them off by setting the features to "-liga" for example. - const hardcoded_features = [_][]const u8{ "dlig", "liga" }; /// The cell_buf argument is the buffer to use for storing shaped results. /// This should be at least the number of columns in the terminal. pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { - // Parse all the features we want to use. We use - var hb_feats = hb_feats: { - var list = try FeatureList.initCapacity(alloc, opts.features.len + hardcoded_features.len); - errdefer list.deinit(alloc); - - for (hardcoded_features) |name| { - if (harfbuzz.Feature.fromString(name)) |feat| { - try list.append(alloc, feat); - } else log.warn("failed to parse font feature: {s}", .{name}); + // Parse all the features we want to use. + const hb_feats = hb_feats: { + var feature_list: FeatureList = .{}; + defer feature_list.deinit(alloc); + try feature_list.features.appendSlice(alloc, &default_features); + for (opts.features) |feature_str| { + try feature_list.appendFromString(alloc, feature_str); } - for (opts.features) |name| { - if (harfbuzz.Feature.fromString(name)) |feat| { - try list.append(alloc, feat); - } else log.warn("failed to parse font feature: {s}", .{name}); + var list = try alloc.alloc(harfbuzz.Feature, feature_list.features.items.len); + errdefer alloc.free(list); + + for (feature_list.features.items, 0..) |feature, i| { + list[i] = .{ + .tag = std.mem.nativeToBig(u32, @bitCast(feature.tag)), + .value = feature.value, + .start = harfbuzz.c.HB_FEATURE_GLOBAL_START, + .end = harfbuzz.c.HB_FEATURE_GLOBAL_END, + }; } break :hb_feats list; }; - errdefer hb_feats.deinit(alloc); + errdefer alloc.free(hb_feats); return Shaper{ .alloc = alloc, @@ -71,7 +73,7 @@ pub const Shaper = struct { pub fn deinit(self: *Shaper) void { self.hb_buf.destroy(); self.cell_buf.deinit(self.alloc); - self.hb_feats.deinit(self.alloc); + self.alloc.free(self.hb_feats); } pub fn endFrame(self: *const Shaper) void { @@ -125,10 +127,10 @@ pub const Shaper = struct { // If we are disabling default font features we just offset // our features by the hardcoded items because always // add those at the beginning. - break :i hardcoded_features.len; + break :i default_features.len; }; - harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats.items[i..]); + harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..]); } // If our buffer is empty, we short-circuit the rest of the work diff --git a/src/global.zig b/src/global.zig index 7e43a9184..c00ce27a4 100644 --- a/src/global.zig +++ b/src/global.zig @@ -27,6 +27,7 @@ pub const GlobalState = struct { alloc: std.mem.Allocator, action: ?cli.Action, logging: Logging, + rlimits: ResourceLimits = .{}, /// The app resources directory, equivalent to zig-out/share when we build /// from source. This is null if we can't detect it. @@ -56,6 +57,7 @@ pub const GlobalState = struct { .alloc = undefined, .action = null, .logging = .{ .stderr = {} }, + .rlimits = .{}, .resources_dir = null, }; errdefer self.deinit(); @@ -123,8 +125,8 @@ pub const GlobalState = struct { std.log.info("renderer={}", .{renderer.Renderer}); std.log.info("libxev backend={}", .{xev.backend}); - // First things first, we fix our file descriptors - internal_os.fixMaxFiles(); + // As early as possible, initialize our resource limits. + self.rlimits = ResourceLimits.init(); // Initialize our crash reporting. crash.init(self.alloc) catch |err| { @@ -174,3 +176,21 @@ pub const GlobalState = struct { } } }; + +/// Maintains the Unix resource limits that we set for our process. This +/// can be used to restore the limits to their original values. +pub const ResourceLimits = struct { + nofile: ?internal_os.rlimit = null, + + pub fn init() ResourceLimits { + return .{ + // Maximize the number of file descriptors we can have open + // because we can consume a lot of them if we make many terminals. + .nofile = internal_os.fixMaxFiles(), + }; + } + + pub fn restore(self: *const ResourceLimits) void { + if (self.nofile) |lim| internal_os.restoreMaxFiles(lim); + } +}; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 85721339d..3168e8c03 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -36,6 +36,11 @@ pub const Flags = packed struct { /// and not just while Ghostty is focused. This may not work on all platforms. /// See the keybind config documentation for more information. global: bool = false, + + /// True if this binding should only be triggered if the action can be + /// performed. If the action can't be performed then the binding acts as + /// if it doesn't exist. + performable: bool = false, }; /// Full binding parser. The binding parser is implemented as an iterator @@ -90,6 +95,9 @@ pub const Parser = struct { } else if (std.mem.eql(u8, prefix, "unconsumed")) { if (!flags.consumed) return Error.InvalidFormat; flags.consumed = false; + } else if (std.mem.eql(u8, prefix, "performable")) { + if (flags.performable) return Error.InvalidFormat; + flags.performable = true; } else { // If we don't recognize the prefix then we're done. // There are trigger-specific prefixes like "physical:" so @@ -185,10 +193,29 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { if (rhs.trigger.mods.alt) count += 1; break :blk count; }; - if (lhs_count == rhs_count) + + if (lhs_count != rhs_count) + return lhs_count > rhs_count; + + if (lhs.trigger.mods.int() != rhs.trigger.mods.int()) return lhs.trigger.mods.int() > rhs.trigger.mods.int(); - return lhs_count > rhs_count; + const lhs_key: c_int = blk: { + switch (lhs.trigger.key) { + .translated => break :blk @intFromEnum(lhs.trigger.key.translated), + .physical => break :blk @intFromEnum(lhs.trigger.key.physical), + .unicode => break :blk @intCast(lhs.trigger.key.unicode), + } + }; + const rhs_key: c_int = blk: { + switch (rhs.trigger.key) { + .translated => break :blk @intFromEnum(rhs.trigger.key.translated), + .physical => break :blk @intFromEnum(rhs.trigger.key.physical), + .unicode => break :blk @intCast(rhs.trigger.key.unicode), + } + }; + + return lhs_key < rhs_key; } /// The set of actions that a keybinding can take. @@ -311,17 +338,17 @@ pub const Action = union(enum) { toggle_tab_overview: void, /// Create a new split in the given direction. The new split will appear in - /// the direction given. + /// the direction given. For example `new_split:up`. Valid values are left, right, up, down and auto. new_split: SplitDirection, - /// Focus on a split in a given direction. + /// Focus on a split in a given direction. For example `goto_split:top`. Valid values are top, bottom, left, right, previous and next. goto_split: SplitFocusDirection, /// zoom/unzoom the current split. toggle_split_zoom: void, /// Resize the current split by moving the split divider in the given - /// direction + /// direction. For example `resize_split:left,10`. The valid directions are up, down, left and right. resize_split: SplitResizeParameter, /// Equalize all splits in the current window @@ -478,10 +505,42 @@ pub const Action = union(enum) { previous, next, - top, + up, left, - bottom, + down, right, + + pub fn parse(input: []const u8) !SplitFocusDirection { + return std.meta.stringToEnum(SplitFocusDirection, input) orelse { + // For backwards compatibility we map "top" and "bottom" onto the enum + // values "up" and "down" + if (std.mem.eql(u8, input, "top")) { + return .up; + } else if (std.mem.eql(u8, input, "bottom")) { + return .down; + } else { + return Error.InvalidFormat; + } + }; + } + + test "parse" { + const testing = std.testing; + + try testing.expectEqual(.previous, try SplitFocusDirection.parse("previous")); + try testing.expectEqual(.next, try SplitFocusDirection.parse("next")); + + try testing.expectEqual(.up, try SplitFocusDirection.parse("up")); + try testing.expectEqual(.left, try SplitFocusDirection.parse("left")); + try testing.expectEqual(.down, try SplitFocusDirection.parse("down")); + try testing.expectEqual(.right, try SplitFocusDirection.parse("right")); + + try testing.expectEqual(.up, try SplitFocusDirection.parse("top")); + try testing.expectEqual(.down, try SplitFocusDirection.parse("bottom")); + + try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("")); + try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("green")); + } }; pub const SplitResizeDirection = enum { @@ -524,7 +583,16 @@ pub const Action = union(enum) { comptime field: std.builtin.Type.UnionField, param: []const u8, ) !field.type { - return switch (@typeInfo(field.type)) { + const field_info = @typeInfo(field.type); + + // Fields can provide a custom "parse" function + if (field_info == .Struct or field_info == .Union or field_info == .Enum) { + if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .Fn) { + return field.type.parse(param); + } + } + + return switch (field_info) { .Enum => try parseEnum(field.type, param), .Int => try parseInt(field.type, param), .Float => try parseFloat(field.type, param), @@ -1019,6 +1087,14 @@ pub const Trigger = struct { const cp = it.nextCodepoint() orelse break :unicode; if (it.nextCodepoint() != null) break :unicode; + // If this is ASCII and we have a translated key, set that. + if (std.math.cast(u8, cp)) |ascii| { + if (key.Key.fromASCII(ascii)) |k| { + result.key = .{ .translated = k }; + continue :loop; + } + } + result.key = .{ .unicode = cp }; continue :loop; } @@ -1554,6 +1630,19 @@ test "parse: triggers" { try parseSingle("a=ignore"), ); + // unicode keys that map to translated + try testing.expectEqual(Binding{ + .trigger = .{ .key = .{ .translated = .one } }, + .action = .{ .ignore = {} }, + }, try parseSingle("1=ignore")); + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .super = true }, + .key = .{ .translated = .period }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("cmd+.=ignore")); + // single modifier try testing.expectEqual(Binding{ .trigger = .{ @@ -1626,6 +1715,16 @@ test "parse: triggers" { .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:physical:a+shift=ignore")); + // performable keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .performable = true }, + }, try parseSingle("performable:shift+a=ignore")); + // invalid key try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore")); diff --git a/src/input/key.zig b/src/input/key.zig index eb2526593..a875611d0 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -729,7 +729,9 @@ pub const Key = enum(c_int) { .{ '\t', .tab }, // Keypad entries. We just assume keypad with the kp_ prefix - // so that has some special meaning. These must also always be last. + // so that has some special meaning. These must also always be last, + // so that our `fromASCII` function doesn't accidentally map them + // over normal numerics and other keys. .{ '0', .kp_0 }, .{ '1', .kp_1 }, .{ '2', .kp_2 }, diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index eae881ec4..bcdef1b47 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -14,6 +14,7 @@ const input = @import("../input.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); +const utils = @import("utils.zig"); /// The window names. These are used with docking so we need to have access. const window_cell = "Cell"; @@ -285,10 +286,6 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { cimgui.c.igDockBuilderFinish(dock_id_main); } -fn bytesToKb(bytes: usize) usize { - return bytes / 1024; -} - fn renderScreenWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. defer cimgui.c.igEnd(); @@ -444,7 +441,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_bytes, bytesToKb(kitty_images.total_bytes)); + cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_bytes, utils.toKiloBytes(kitty_images.total_bytes)); } } @@ -456,7 +453,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_limit, bytesToKb(kitty_images.total_limit)); + cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_limit, utils.toKiloBytes(kitty_images.total_limit)); } } @@ -522,7 +519,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KB)", pages.page_size, bytesToKb(pages.page_size)); + cimgui.c.igText("%d bytes (%d KB)", pages.page_size, utils.toKiloBytes(pages.page_size)); } } @@ -534,7 +531,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KB)", pages.maxSize(), bytesToKb(pages.maxSize())); + cimgui.c.igText("%d bytes (%d KB)", pages.maxSize(), utils.toKiloBytes(pages.maxSize())); } } @@ -728,7 +725,7 @@ fn renderSizeWindow(self: *Inspector) void { { _ = cimgui.c.igTableSetColumnIndex(1); cimgui.c.igText( - "%d pt", + "%.2f pt", self.surface.font_size.points, ); } diff --git a/src/inspector/page.zig b/src/inspector/page.zig index d74f07b1c..2852b719e 100644 --- a/src/inspector/page.zig +++ b/src/inspector/page.zig @@ -3,6 +3,8 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); +const inspector = @import("main.zig"); +const utils = @import("utils.zig"); pub fn render(page: *const terminal.Page) void { cimgui.c.igPushID_Ptr(page); @@ -25,7 +27,7 @@ pub fn render(page: *const terminal.Page) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", page.memory.len); + cimgui.c.igText("%d bytes (%d Kb)", page.memory.len, utils.toKiloBytes(page.memory.len)); cimgui.c.igText("%d VM pages", page.memory.len / std.mem.page_size); } } diff --git a/src/inspector/utils.zig b/src/inspector/utils.zig new file mode 100644 index 000000000..87c617a23 --- /dev/null +++ b/src/inspector/utils.zig @@ -0,0 +1,3 @@ +pub fn toKiloBytes(bytes: usize) usize { + return bytes / 1024; +} diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index b3df80538..9efe8d9b0 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -49,7 +49,8 @@ pub fn main() !MainReturn { error.InvalidAction => try stderr.print( "Error: unknown CLI action specified. CLI actions are specified with\n" ++ - "the '+' character.\n", + "the '+' character.\n\n" ++ + "All valid CLI actions can be listed with `ghostty +help`\n", .{}, ), diff --git a/src/os/desktop.zig b/src/os/desktop.zig index 103127dfa..3a61e2eaa 100644 --- a/src/os/desktop.zig +++ b/src/os/desktop.zig @@ -59,3 +59,29 @@ pub fn launchedFromDesktop() bool { else => @compileError("unsupported platform"), }; } + +pub const DesktopEnvironment = enum { + gnome, + macos, + other, + windows, +}; + +/// Detect what desktop environment we are running under. This is mainly used on +/// Linux to enable or disable GTK client-side decorations but there may be more +/// uses in the future. +pub fn desktopEnvironment() DesktopEnvironment { + return switch (comptime builtin.os.tag) { + .macos => .macos, + .windows => .windows, + .linux => de: { + if (@inComptime()) @compileError("Checking for the desktop environment on Linux must be done at runtime."); + // use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux + // https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop= + const de = posix.getenv("XDG_SESSION_DESKTOP") orelse break :de .other; + if (std.ascii.eqlIgnoreCase("gnome", de)) break :de .gnome; + break :de .other; + }, + else => .other, + }; +} diff --git a/src/os/file.zig b/src/os/file.zig index e0ec2f52c..875dd2c25 100644 --- a/src/os/file.zig +++ b/src/os/file.zig @@ -4,24 +4,27 @@ const posix = std.posix; const log = std.log.scoped(.os); +pub const rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {}; + /// This maximizes the number of file descriptors we can have open. We /// need to do this because each window consumes at least a handful of fds. /// This is extracted from the Zig compiler source code. -pub fn fixMaxFiles() void { - if (!@hasDecl(posix.system, "rlimit")) return; +pub fn fixMaxFiles() ?rlimit { + if (!@hasDecl(posix.system, "rlimit")) return null; - var lim = posix.getrlimit(.NOFILE) catch { + const old = posix.getrlimit(.NOFILE) catch { log.warn("failed to query file handle limit, may limit max windows", .{}); - return; // Oh well; we tried. + return null; // Oh well; we tried. }; // If we're already at the max, we're done. - if (lim.cur >= lim.max) { - log.debug("file handle limit already maximized value={}", .{lim.cur}); - return; + if (old.cur >= old.max) { + log.debug("file handle limit already maximized value={}", .{old.cur}); + return old; } // Do a binary search for the limit. + var lim = old; var min: posix.rlim_t = lim.cur; var max: posix.rlim_t = 1 << 20; // But if there's a defined upper bound, don't search, just set it. @@ -41,6 +44,12 @@ pub fn fixMaxFiles() void { } log.debug("file handle limit raised value={}", .{lim.cur}); + return old; +} + +pub fn restoreMaxFiles(lim: rlimit) void { + if (!@hasDecl(posix.system, "rlimit")) return; + posix.setrlimit(.NOFILE, lim) catch {}; } /// Return the recommended path for temporary files. diff --git a/src/os/homedir.zig b/src/os/homedir.zig index cf6931f22..b5629fd65 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -12,7 +12,7 @@ const Error = error{ /// Determine the home directory for the currently executing user. This /// is generally an expensive process so the value should be cached. -pub inline fn home(buf: []u8) !?[]u8 { +pub inline fn home(buf: []u8) !?[]const u8 { return switch (builtin.os.tag) { inline .linux, .macos => try homeUnix(buf), .windows => try homeWindows(buf), @@ -24,7 +24,7 @@ pub inline fn home(buf: []u8) !?[]u8 { }; } -fn homeUnix(buf: []u8) !?[]u8 { +fn homeUnix(buf: []u8) !?[]const u8 { // First: if we have a HOME env var, then we use that. if (posix.getenv("HOME")) |result| { if (buf.len < result.len) return Error.BufferTooSmall; @@ -77,7 +77,7 @@ fn homeUnix(buf: []u8) !?[]u8 { return null; } -fn homeWindows(buf: []u8) !?[]u8 { +fn homeWindows(buf: []u8) !?[]const u8 { const drive_len = blk: { var fba_instance = std.heap.FixedBufferAllocator.init(buf); const fba = fba_instance.allocator(); @@ -110,6 +110,68 @@ fn trimSpace(input: []const u8) []const u8 { return std.mem.trim(u8, input, " \n\t"); } +pub const ExpandError = error{ + HomeDetectionFailed, + BufferTooSmall, +}; + +/// Expands a path that starts with a tilde (~) to the home directory of +/// the current user. +/// +/// Errors if `home` fails or if the size of the expanded path is larger +/// than `buf.len`. +pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 { + return switch (builtin.os.tag) { + .linux, .macos => try expandHomeUnix(path, buf), + .ios => return path, + else => @compileError("unimplemented"), + }; +} + +fn expandHomeUnix(path: []const u8, buf: []u8) ExpandError![]const u8 { + if (!std.mem.startsWith(u8, path, "~/")) return path; + const home_dir: []const u8 = if (home(buf)) |home_| + home_ orelse return error.HomeDetectionFailed + else |_| + return error.HomeDetectionFailed; + const rest = path[1..]; // Skip the ~ + const expanded_len = home_dir.len + rest.len; + + if (expanded_len > buf.len) return Error.BufferTooSmall; + @memcpy(buf[home_dir.len..expanded_len], rest); + + return buf[0..expanded_len]; +} + +test "expandHomeUnix" { + const testing = std.testing; + const allocator = testing.allocator; + var buf: [std.fs.max_path_bytes]u8 = undefined; + const home_dir = try expandHomeUnix("~/", &buf); + // Joining the home directory `~` with the path `/` + // the result should end with a separator here. (e.g. `/home/user/`) + try testing.expect(home_dir[home_dir.len - 1] == std.fs.path.sep); + + const downloads = try expandHomeUnix("~/Downloads/shader.glsl", &buf); + const expected_downloads = try std.mem.concat(allocator, u8, &[_][]const u8{ home_dir, "Downloads/shader.glsl" }); + defer allocator.free(expected_downloads); + try testing.expectEqualStrings(expected_downloads, downloads); + + try testing.expectEqualStrings("~", try expandHomeUnix("~", &buf)); + try testing.expectEqualStrings("~abc/", try expandHomeUnix("~abc/", &buf)); + try testing.expectEqualStrings("/home/user", try expandHomeUnix("/home/user", &buf)); + try testing.expectEqualStrings("", try expandHomeUnix("", &buf)); + + // Expect an error if the buffer is large enough to hold the home directory, + // but not the expanded path + var small_buf = try allocator.alloc(u8, home_dir.len); + defer allocator.free(small_buf); + try testing.expectError(error.BufferTooSmall, expandHomeUnix( + "~/Downloads", + small_buf[0..], + )); +} + test { const testing = std.testing; diff --git a/src/os/macos.zig b/src/os/macos.zig index 53dfd1719..a956d25e2 100644 --- a/src/os/macos.zig +++ b/src/os/macos.zig @@ -24,42 +24,27 @@ pub const AppSupportDirError = Allocator.Error || error{AppleAPIFailed}; pub fn appSupportDir( alloc: Allocator, sub_path: []const u8, -) AppSupportDirError![]u8 { - comptime assert(builtin.target.isDarwin()); - - const NSFileManager = objc.getClass("NSFileManager").?; - const manager = NSFileManager.msgSend( - objc.Object, - objc.sel("defaultManager"), - .{}, +) AppSupportDirError![]const u8 { + return try commonDir( + alloc, + .NSApplicationSupportDirectory, + &.{ build_config.bundle_id, sub_path }, ); +} - const url = manager.msgSend( - objc.Object, - objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), - .{ - NSSearchPathDirectory.NSApplicationSupportDirectory, - NSSearchPathDomainMask.NSUserDomainMask, - @as(?*anyopaque, null), - true, - @as(?*anyopaque, null), - }, +pub const CacheDirError = Allocator.Error || error{AppleAPIFailed}; + +/// Return the path to the system cache directory with the given sub path joined. +/// This allocates the result using the given allocator. +pub fn cacheDir( + alloc: Allocator, + sub_path: []const u8, +) CacheDirError![]const u8 { + return try commonDir( + alloc, + .NSCachesDirectory, + &.{ build_config.bundle_id, sub_path }, ); - - // I don't think this is possible but just in case. - if (url.value == null) return error.AppleAPIFailed; - - // Get the UTF-8 string from the URL. - const path = url.getProperty(objc.Object, "path"); - const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse - return error.AppleAPIFailed; - const app_support_dir = std.mem.sliceTo(c_str, 0); - - return try std.fs.path.join(alloc, &.{ - app_support_dir, - build_config.bundle_id, - sub_path, - }); } pub const SetQosClassError = error{ @@ -110,9 +95,79 @@ pub const NSOperatingSystemVersion = extern struct { }; pub const NSSearchPathDirectory = enum(c_ulong) { + NSCachesDirectory = 13, NSApplicationSupportDirectory = 14, }; pub const NSSearchPathDomainMask = enum(c_ulong) { NSUserDomainMask = 1, }; + +fn commonDir( + alloc: Allocator, + directory: NSSearchPathDirectory, + sub_paths: []const []const u8, +) (error{AppleAPIFailed} || Allocator.Error)![]const u8 { + comptime assert(builtin.target.isDarwin()); + + const NSFileManager = objc.getClass("NSFileManager").?; + const manager = NSFileManager.msgSend( + objc.Object, + objc.sel("defaultManager"), + .{}, + ); + + const url = manager.msgSend( + objc.Object, + objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), + .{ + directory, + NSSearchPathDomainMask.NSUserDomainMask, + @as(?*anyopaque, null), + true, + @as(?*anyopaque, null), + }, + ); + + if (url.value == null) return error.AppleAPIFailed; + + const path = url.getProperty(objc.Object, "path"); + const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse + return error.AppleAPIFailed; + const base_dir = std.mem.sliceTo(c_str, 0); + + // Create a new array with base_dir as the first element + var paths = try alloc.alloc([]const u8, sub_paths.len + 1); + paths[0] = base_dir; + @memcpy(paths[1..], sub_paths); + defer alloc.free(paths); + + return try std.fs.path.join(alloc, paths); +} + +test "cacheDir paths" { + if (!builtin.target.isDarwin()) return; + + const testing = std.testing; + const alloc = testing.allocator; + + // Test base path + { + const cache_path = try cacheDir(alloc, ""); + defer alloc.free(cache_path); + try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null); + try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null); + } + + // Test with subdir + { + const cache_path = try cacheDir(alloc, "test"); + defer alloc.free(cache_path); + try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null); + try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null); + + const bundle_path = try std.fmt.allocPrint(alloc, "{s}/test", .{build_config.bundle_id}); + defer alloc.free(bundle_path); + try testing.expect(std.mem.indexOf(u8, cache_path, bundle_path) != null); + } +} diff --git a/src/os/main.zig b/src/os/main.zig index 3b7007fcb..e652a7981 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -32,14 +32,19 @@ pub const getenv = env.getenv; pub const setenv = env.setenv; pub const unsetenv = env.unsetenv; pub const launchedFromDesktop = desktop.launchedFromDesktop; +pub const desktopEnvironment = desktop.desktopEnvironment; +pub const rlimit = file.rlimit; pub const fixMaxFiles = file.fixMaxFiles; +pub const restoreMaxFiles = file.restoreMaxFiles; pub const allocTmpDir = file.allocTmpDir; pub const freeTmpDir = file.freeTmpDir; pub const isFlatpak = flatpak.isFlatpak; pub const FlatpakHostCommand = flatpak.FlatpakHostCommand; pub const home = homedir.home; +pub const expandHome = homedir.expandHome; pub const ensureLocale = locale.ensureLocale; pub const clickInterval = mouse.clickInterval; pub const open = openpkg.open; +pub const OpenType = openpkg.Type; pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; diff --git a/src/os/open.zig b/src/os/open.zig index 8df059487..f6dc7ca2a 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -2,25 +2,50 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +/// The type of the data at the URL to open. This is used as a hint +/// to potentially open the URL in a different way. +pub const Type = enum { + text, + unknown, +}; + /// Open a URL in the default handling application. /// /// Any output on stderr is logged as a warning in the application logs. /// Output on stdout is ignored. -pub fn open(alloc: Allocator, url: []const u8) !void { - // Some opener commands terminate after opening (macOS open) and some do not - // (xdg-open). For those which do not terminate, we do not want to wait for - // the process to exit to collect stderr. - const argv, const wait = switch (builtin.os.tag) { - .linux => .{ &.{ "xdg-open", url }, false }, - .macos => .{ &.{ "open", url }, true }, - .windows => .{ &.{ "rundll32", "url.dll,FileProtocolHandler", url }, false }, +pub fn open( + alloc: Allocator, + typ: Type, + url: []const u8, +) !void { + const cmd: OpenCommand = switch (builtin.os.tag) { + .linux => .{ .child = std.process.Child.init( + &.{ "xdg-open", url }, + alloc, + ) }, + + .windows => .{ .child = std.process.Child.init( + &.{ "rundll32", "url.dll,FileProtocolHandler", url }, + alloc, + ) }, + + .macos => .{ + .child = std.process.Child.init( + switch (typ) { + .text => &.{ "open", "-t", url }, + .unknown => &.{ "open", url }, + }, + alloc, + ), + .wait = true, + }, + .ios => return error.Unimplemented, else => @compileError("unsupported OS"), }; - var exe = std.process.Child.init(argv, alloc); - - if (comptime wait) { + var exe = cmd.child; + if (cmd.wait) { // Pipe stdout/stderr so we can collect output from the command exe.stdout_behavior = .Pipe; exe.stderr_behavior = .Pipe; @@ -28,7 +53,7 @@ pub fn open(alloc: Allocator, url: []const u8) !void { try exe.spawn(); - if (comptime wait) { + if (cmd.wait) { // 50 KiB is the default value used by std.process.Child.run const output_max_size = 50 * 1024; @@ -47,3 +72,8 @@ pub fn open(alloc: Allocator, url: []const u8) !void { if (stderr.items.len > 0) std.log.err("open stderr={s}", .{stderr.items}); } } + +const OpenCommand = struct { + child: std.process.Child, + wait: bool = false, +}; diff --git a/src/os/xdg.zig b/src/os/xdg.zig index 6c7655c22..a5b29abe4 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -143,6 +143,32 @@ test { } } +test "cache directory paths" { + const testing = std.testing; + const alloc = testing.allocator; + const mock_home = "/Users/test"; + + // Test when XDG_CACHE_HOME is not set + { + // Test base path + { + const cache_path = try cache(alloc, .{ .home = mock_home }); + defer alloc.free(cache_path); + try testing.expectEqualStrings("/Users/test/.cache", cache_path); + } + + // Test with subdir + { + const cache_path = try cache(alloc, .{ + .home = mock_home, + .subdir = "ghostty", + }); + defer alloc.free(cache_path); + try testing.expectEqualStrings("/Users/test/.cache/ghostty", cache_path); + } + } +} + test parseTerminalExec { const testing = std.testing; diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 72e0457e9..6521226a3 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -146,7 +146,7 @@ image_bg_end: u32 = 0, image_text_end: u32 = 0, image_virtual: bool = false, -/// Defererred OpenGL operation to update the screen size. +/// Deferred OpenGL operation to update the screen size. const SetScreenSize = struct { size: renderer.Size, diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index fa1544fbf..72ae455df 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -68,6 +68,34 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then builtin unset ghostty_bash_inject rcfile fi +# Sudo +if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" && -n "$TERMINFO" ]]; then + # Wrap `sudo` command to ensure Ghostty terminfo is preserved. + # + # This approach supports wrapping a `sudo` alias, but the alias definition + # must come _after_ this function is defined. Otherwise, the alias expansion + # will take precedence over this function, and it won't be wrapped. + function sudo { + builtin local sudo_has_sudoedit_flags="no" + for arg in "$@"; do + # Check if argument is '-e' or '--edit' (sudoedit flags) + if [[ "$arg" == "-e" || $arg == "--edit" ]]; then + sudo_has_sudoedit_flags="yes" + builtin break + fi + # Check if argument is neither an option nor a key-value pair + if [[ "$arg" != -* && "$arg" != *=* ]]; then + builtin break + fi + done + if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then + builtin command sudo "$@"; + else + builtin command sudo TERMINFO="$TERMINFO" "$@"; + fi + } +fi + # Import bash-preexec, safe to do multiple times builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh" @@ -109,31 +137,6 @@ function __ghostty_precmd() { PS0=$PS0'\[\e[0 q\]' fi - # Sudo - if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" ]] && [[ -n "$TERMINFO" ]]; then - # Wrap `sudo` command to ensure Ghostty terminfo is preserved - # shellcheck disable=SC2317 - sudo() { - builtin local sudo_has_sudoedit_flags="no" - for arg in "$@"; do - # Check if argument is '-e' or '--edit' (sudoedit flags) - if [[ "$arg" == "-e" || $arg == "--edit" ]]; then - sudo_has_sudoedit_flags="yes" - builtin break - fi - # Check if argument is neither an option nor a key-value pair - if [[ "$arg" != -* && "$arg" != *=* ]]; then - builtin break - fi - done - if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then - builtin command sudo "$@"; - else - builtin command sudo TERMINFO="$TERMINFO" "$@"; - fi - } - fi - if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then # Command and working directory # shellcheck disable=SC2016 diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ca928fda6..5fb49ea66 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3413,6 +3413,16 @@ pub const Pin = struct { direction: Direction, limit: ?Pin, ) PageIterator { + if (build_config.slow_runtime_safety) { + if (limit) |l| { + // Check the order according to the iteration direction. + switch (direction) { + .right_down => assert(self.eql(l) or self.before(l)), + .left_up => assert(self.eql(l) or l.before(self)), + } + } + } + return .{ .row = self, .limit = if (limit) |p| .{ .row = p } else .{ .none = {} }, diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index cc87d6c9d..25c819b10 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -382,6 +382,7 @@ fn encodeError(r: *Response, err: EncodeableError) void { error.DecompressionFailed => r.message = "EINVAL: decompression failed", error.FilePathTooLong => r.message = "EINVAL: file path too long", error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir", + error.TemporaryFileNotNamedCorrectly => r.message = "EINVAL: temporary file not named correctly", error.UnsupportedFormat => r.message = "EINVAL: unsupported format", error.UnsupportedMedium => r.message = "EINVAL: unsupported medium", error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth", diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index ff498cbb8..094e1622b 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -220,6 +220,9 @@ pub const LoadingImage = struct { // Temporary file logic if (medium == .temporary_file) { if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir; + if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) { + return error.TemporaryFileNotNamedCorrectly; + } } defer if (medium == .temporary_file) { posix.unlink(path) catch |err| { @@ -469,6 +472,7 @@ pub const Image = struct { DimensionsTooLarge, FilePathTooLong, TemporaryFileNotInTempDir, + TemporaryFileNotNamedCorrectly, UnsupportedFormat, UnsupportedMedium, UnsupportedDepth, @@ -682,7 +686,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" try testing.expect(img.compression == .none); } -test "image load: rgb, not compressed, temporary file" { +test "image load: temporary file without correct path" { const testing = std.testing; const alloc = testing.allocator; @@ -697,6 +701,39 @@ test "image load: rgb, not compressed, temporary file" { var buf: [std.fs.max_path_bytes]u8 = undefined; const path = try tmp_dir.dir.realpath("image.data", &buf); + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd)); + + // Temporary file should still be there + try tmp_dir.dir.access(path, .{}); +} + +test "image load: rgb, not compressed, temporary file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try internal_os.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + var cmd: command.Command = .{ .control = .{ .transmit = .{ .format = .rgb, @@ -762,12 +799,12 @@ test "image load: png, not compressed, regular file" { defer tmp_dir.deinit(); const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); try tmp_dir.dir.writeFile(.{ - .sub_path = "image.data", + .sub_path = "tty-graphics-protocol-image.data", .data = data, }); var buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("image.data", &buf); + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); var cmd: command.Command = .{ .control = .{ .transmit = .{ diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index 7d602714c..cdf39657b 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -189,26 +189,39 @@ pub const Parser = struct { .@"8_fg" = @enumFromInt(slice[0] - 30), }, - 38 => if (slice.len >= 5 and slice[1] == 2) { - self.idx += 4; + 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]; + }; - // In the 6-len form, ignore the 3rd param. - const rgb = slice[2..5]; - - // 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]), - }, - }; - } else if (slice.len >= 3 and slice[1] == 5) { - self.idx += 2; - return Attribute{ - .@"256_fg" = @truncate(slice[2]), - }; + // 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{ + .@"256_fg" = @truncate(slice[2]), + }; + }, + else => {}, }, 39 => return Attribute{ .reset_fg = {} }, @@ -217,26 +230,39 @@ pub const Parser = struct { .@"8_bg" = @enumFromInt(slice[0] - 40), }, - 48 => if (slice.len >= 5 and slice[1] == 2) { - self.idx += 4; + 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]; + }; - // We only support the 5-len form. - const rgb = slice[2..5]; - - // 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]), - }, - }; - } else if (slice.len >= 3 and slice[1] == 5) { - self.idx += 2; - return Attribute{ - .@"256_bg" = @truncate(slice[2]), - }; + // 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{ + .@"256_bg" = @truncate(slice[2]), + }; + }, + else => {}, }, 49 => return Attribute{ .reset_bg = {} }, @@ -244,30 +270,39 @@ pub const Parser = struct { 53 => return Attribute{ .overline = {} }, 55 => return Attribute{ .reset_overline = {} }, - 58 => if (slice.len >= 5 and slice[1] == 2) { - self.idx += 4; + 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]; + }; - // In the 6-len form, ignore the 3rd param. Otherwise, use it. - const rgb = if (slice.len == 5) slice[2..5] else rgb: { - // Consume one more element - self.idx += 1; - break :rgb slice[3..6]; - }; - - // 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]), - }, - }; - } else if (slice.len >= 3 and slice[1] == 5) { - self.idx += 2; - return Attribute{ - .@"256_underline_color" = @truncate(slice[2]), - }; + // 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{ + .@"256_underline_color" = @truncate(slice[2]), + }; + }, + else => {}, }, 59 => return Attribute{ .reset_underline_color = {} }, @@ -566,3 +601,59 @@ test "sgr: direct color bg missing color" { var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false }; while (p.next()) |_| {} } + +test "sgr: direct fg/bg/underline ignore optional color space" { + // These behaviors have been verified against xterm. + + // 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 }); + 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); + } + { + // 4 8 : 2 : Pi : Pr : Pg : Pb + const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 }); + 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); + try testing.expectEqual(@as(u8, 3), v.direct_color_bg.b); + } + { + // 5 8 : 2 : Pi : Pr : Pg : Pb + const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 }); + 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); + try testing.expectEqual(@as(u8, 3), v.underline_color.b); + } + + // 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 }); + 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); + try testing.expectEqual(@as(u8, 2), v.direct_color_fg.b); + } + { + // 4 8 ; 2 ; Pr ; Pg ; Pb + const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 }); + 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); + try testing.expectEqual(@as(u8, 2), v.direct_color_bg.b); + } + { + // 5 8 ; 2 ; Pr ; Pg ; Pb + const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 }); + 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); + } +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 59a8e704d..a4a32e169 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -380,109 +380,172 @@ pub fn Stream(comptime Handler: type) type { fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { switch (input.final) { // CUU - Cursor Up - 'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {}", .{input}); - return; + 'A', 'k' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor up command: {}", .{input}); + return; + }, }, - }, - false, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + false, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI A with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUD - Cursor Down - 'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor down command: {}", .{input}); - return; + 'B' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor down command: {}", .{input}); + return; + }, }, - }, - false, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + false, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI B with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUF - Cursor Right - 'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor right command: {}", .{input}); - return; + 'C' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor right command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI C with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUB - Cursor Left - 'D', 'j' => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor left command: {}", .{input}); - return; + 'D', 'j' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor left command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI D with intermediates: {s}", + .{input.intermediates}, + ), + }, // CNL - Cursor Next Line - 'E' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {}", .{input}); - return; + 'E' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor up command: {}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + true, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI E with intermediates: {s}", + .{input.intermediates}, + ), + }, // CPL - Cursor Previous Line - 'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor down command: {}", .{input}); - return; + 'F' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor down command: {}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + true, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI F with intermediates: {s}", + .{input.intermediates}, + ), + }, // HPA - Cursor Horizontal Position Absolute // TODO: test - 'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) { - 0 => try self.handler.setCursorCol(1), - 1 => try self.handler.setCursorCol(input.params[0]), - else => log.warn("invalid HPA command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'G', '`' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) { + 0 => try self.handler.setCursorCol(1), + 1 => try self.handler.setCursorCol(input.params[0]), + else => log.warn("invalid HPA command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI G with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUP - Set Cursor Position. // TODO: test - 'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) { - 0 => try self.handler.setCursorPos(1, 1), - 1 => try self.handler.setCursorPos(input.params[0], 1), - 2 => try self.handler.setCursorPos(input.params[0], input.params[1]), - else => log.warn("invalid CUP command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'H', 'f' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) { + 0 => try self.handler.setCursorPos(1, 1), + 1 => try self.handler.setCursorPos(input.params[0], 1), + 2 => try self.handler.setCursorPos(input.params[0], input.params[1]), + else => log.warn("invalid CUP command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI H with intermediates: {s}", + .{input.intermediates}, + ), + }, // CHT - Cursor Horizontal Tabulation - 'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab command: {}", .{input}); - return; + 'I' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI I with intermediates: {s}", + .{input.intermediates}, + ), + }, // Erase Display 'J' => if (@hasDecl(T, "eraseDisplay")) { @@ -540,31 +603,52 @@ pub fn Stream(comptime Handler: type) type { // IL - Insert Lines // TODO: test - 'L' => if (@hasDecl(T, "insertLines")) switch (input.params.len) { - 0 => try self.handler.insertLines(1), - 1 => try self.handler.insertLines(input.params[0]), - else => log.warn("invalid IL command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'L' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "insertLines")) switch (input.params.len) { + 0 => try self.handler.insertLines(1), + 1 => try self.handler.insertLines(input.params[0]), + else => log.warn("invalid IL command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI L with intermediates: {s}", + .{input.intermediates}, + ), + }, // DL - Delete Lines // TODO: test - 'M' => if (@hasDecl(T, "deleteLines")) switch (input.params.len) { - 0 => try self.handler.deleteLines(1), - 1 => try self.handler.deleteLines(input.params[0]), - else => log.warn("invalid DL command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'M' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "deleteLines")) switch (input.params.len) { + 0 => try self.handler.deleteLines(1), + 1 => try self.handler.deleteLines(input.params[0]), + else => log.warn("invalid DL command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI M with intermediates: {s}", + .{input.intermediates}, + ), + }, // Delete Character (DCH) - 'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid delete characters command: {}", .{input}); - return; + 'P' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid delete characters command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI P with intermediates: {s}", + .{input.intermediates}, + ), + }, // Scroll Up (SD) @@ -587,38 +671,43 @@ pub fn Stream(comptime Handler: type) type { }, // Scroll Down (SD) - 'T' => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid scroll down command: {}", .{input}); - return; + 'T' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid scroll down command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI T with intermediates: {s}", + .{input.intermediates}, + ), + }, // Cursor Tabulation Control - 'W' => { - switch (input.params.len) { - 0 => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{input}), + 'W' => switch (input.intermediates.len) { + 0 => { + if (input.params.len == 0 or + (input.params.len == 1 and input.params[0] == 0)) + { + if (@hasDecl(T, "tabSet")) + try self.handler.tabSet() + else + log.warn("unimplemented tab set callback: {}", .{input}); - 1 => if (input.intermediates.len == 1 and input.intermediates[0] == '?') { - if (input.params[0] == 5) { - if (@hasDecl(T, "tabReset")) - try self.handler.tabReset() - else - log.warn("unimplemented tab reset callback: {}", .{input}); - } else log.warn("invalid cursor tabulation control: {}", .{input}); - } else { - switch (input.params[0]) { - 0 => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{input}), + return; + } + + switch (input.params.len) { + 0 => unreachable, + + 1 => switch (input.params[0]) { + 0 => unreachable, 2 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear(.current) @@ -631,63 +720,103 @@ pub fn Stream(comptime Handler: type) type { log.warn("unimplemented tab clear callback: {}", .{input}), else => {}, - } - }, + }, - else => {}, - } + else => {}, + } - log.warn("invalid cursor tabulation control: {}", .{input}); - return; + log.warn("invalid cursor tabulation control: {}", .{input}); + return; + }, + + 1 => if (input.intermediates[0] == '?' and input.params[0] == 5) { + if (@hasDecl(T, "tabReset")) + try self.handler.tabReset() + else + log.warn("unimplemented tab reset callback: {}", .{input}); + } else log.warn("invalid cursor tabulation control: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI W with intermediates: {s}", + .{input.intermediates}, + ), }, // Erase Characters (ECH) - 'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid erase characters command: {}", .{input}); - return; + 'X' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid erase characters command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI X with intermediates: {s}", + .{input.intermediates}, + ), + }, // CHT - Cursor Horizontal Tabulation Back - 'Z' => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab back command: {}", .{input}); - return; + 'Z' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab back command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI Z with intermediates: {s}", + .{input.intermediates}, + ), + }, // HPR - Cursor Horizontal Position Relative - 'a' => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid HPR command: {}", .{input}); - return; + 'a' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid HPR command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI a with intermediates: {s}", + .{input.intermediates}, + ), + }, // Repeat Previous Char (REP) - 'b' => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid print repeat command: {}", .{input}); - return; + 'b' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid print repeat command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI b with intermediates: {s}", + .{input.intermediates}, + ), + }, // c - Device Attributes (DA1) 'c' => if (@hasDecl(T, "deviceAttributes")) { @@ -708,40 +837,61 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented CSI callback: {}", .{input}), // VPA - Cursor Vertical Position Absolute - 'd' => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid VPA command: {}", .{input}); - return; + 'd' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid VPA command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI d with intermediates: {s}", + .{input.intermediates}, + ), + }, // VPR - Cursor Vertical Position Relative - 'e' => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid VPR command: {}", .{input}); - return; + 'e' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid VPR command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI e with intermediates: {s}", + .{input.intermediates}, + ), + }, // TBC - Tab Clear // TODO: test - 'g' => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( - switch (input.params.len) { - 1 => @enumFromInt(input.params[0]), - else => { - log.warn("invalid tab clear command: {}", .{input}); - return; + 'g' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( + switch (input.params.len) { + 1 => @enumFromInt(input.params[0]), + else => { + log.warn("invalid tab clear command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI g with intermediates: {s}", + .{input.intermediates}, + ), + }, // SM - Set Mode 'h' => if (@hasDecl(T, "setMode")) mode: { @@ -1564,10 +1714,13 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented ESC callback: {}", .{action}), // HTS - Horizontal Tab Set - 'H' => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{action}), + 'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) { + 0 => try self.handler.tabSet(), + else => { + log.warn("invalid tab set command: {}", .{action}); + return; + }, + } else log.warn("unimplemented tab set callback: {}", .{action}), // RI - Reverse Index 'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { @@ -1597,17 +1750,17 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented invokeCharset: {}", .{action}), // SPA - Start of Guarded Area - 'V' => if (@hasDecl(T, "setProtectedMode")) { + 'V' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { try self.handler.setProtectedMode(ansi.ProtectedMode.iso); } else log.warn("unimplemented ESC callback: {}", .{action}), // EPA - End of Guarded Area - 'W' => if (@hasDecl(T, "setProtectedMode")) { + 'W' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { try self.handler.setProtectedMode(ansi.ProtectedMode.off); } else log.warn("unimplemented ESC callback: {}", .{action}), // DECID - 'Z' => if (@hasDecl(T, "deviceAttributes")) { + 'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) { try self.handler.deviceAttributes(.primary, &.{}); } else log.warn("unimplemented ESC callback: {}", .{action}), @@ -1666,12 +1819,12 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented invokeCharset: {}", .{action}), // Set application keypad mode - '=' => if (@hasDecl(T, "setMode")) { + '=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { try self.handler.setMode(.keypad_keys, true); } else log.warn("unimplemented setMode: {}", .{action}), // Reset application keypad mode - '>' => if (@hasDecl(T, "setMode")) { + '>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { try self.handler.setMode(.keypad_keys, false); } else log.warn("unimplemented setMode: {}", .{action}), @@ -1753,6 +1906,10 @@ test "stream: cursor right (CUF)" { s.handler.amount = 0; try s.nextSlice("\x1B[5;4C"); try testing.expectEqual(@as(u16, 0), s.handler.amount); + + s.handler.amount = 0; + try s.nextSlice("\x1b[?3C"); + try testing.expectEqual(@as(u16, 0), s.handler.amount); } test "stream: dec set mode (SM) and reset mode (RM)" { @@ -1770,6 +1927,10 @@ test "stream: dec set mode (SM) and reset mode (RM)" { try s.nextSlice("\x1B[?6l"); try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); + + s.handler.mode = @as(modes.Mode, @enumFromInt(1)); + try s.nextSlice("\x1B[6 h"); + try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); } test "stream: ansi set mode (SM) and reset mode (RM)" { @@ -1788,6 +1949,10 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { try s.nextSlice("\x1B[4l"); try testing.expect(s.handler.mode == null); + + s.handler.mode = null; + try s.nextSlice("\x1B[>5h"); + try testing.expect(s.handler.mode == null); } test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { @@ -1937,6 +2102,12 @@ test "stream: DECED, DECSED" { try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } + { + // Invalid and ignored by the handler + for ("\x1B[>0J") |c| try s.next(c); + try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); + try testing.expect(!s.handler.protected.?); + } } test "stream: DECEL, DECSEL" { @@ -1997,6 +2168,12 @@ test "stream: DECEL, DECSEL" { try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } + { + // Invalid and ignored by the handler + for ("\x1B[<1K") |c| try s.next(c); + try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); + try testing.expect(!s.handler.protected.?); + } } test "stream: DECSCUSR" { @@ -2014,6 +2191,10 @@ test "stream: DECSCUSR" { try s.nextSlice("\x1B[1 q"); try testing.expect(s.handler.style.? == .blinking_block); + + // Invalid and ignored by the handler + try s.nextSlice("\x1B[?0 q"); + try testing.expect(s.handler.style.? == .blinking_block); } test "stream: DECSCUSR without space" { @@ -2054,6 +2235,10 @@ test "stream: XTSHIFTESCAPE" { try s.nextSlice("\x1B[>1s"); try testing.expect(s.handler.escape.? == true); + + // Invalid and ignored by the handler + try s.nextSlice("\x1B[1 s"); + try testing.expect(s.handler.escape.? == true); } test "stream: change window title with invalid utf-8" { @@ -2374,6 +2559,14 @@ test "stream CSI W tab set" { s.handler.called = false; try s.nextSlice("\x1b[0W"); try testing.expect(s.handler.called); + + s.handler.called = false; + try s.nextSlice("\x1b[>W"); + try testing.expect(!s.handler.called); + + s.handler.called = false; + try s.nextSlice("\x1b[99W"); + try testing.expect(!s.handler.called); } test "stream CSI ? W reset tab stops" { @@ -2392,4 +2585,8 @@ test "stream CSI ? W reset tab stops" { try s.nextSlice("\x1b[?5W"); try testing.expect(s.handler.reset); + + // Invalid and ignored by the handler + try s.nextSlice("\x1b[?1;2;3W"); + try testing.expect(s.handler.reset); } diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index bbcee7906..ab61ae4ca 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -466,6 +466,9 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // for alt screen, we do nothing. if (self.terminal.active_screen == .alternate) return; + // Clear our selection + self.terminal.screen.clearSelection(); + // Clear our scrollback if (history) self.terminal.eraseDisplay(.scrollback, false); diff --git a/typos.toml b/typos.toml index a72944e5f..87b41336b 100644 --- a/typos.toml +++ b/typos.toml @@ -42,6 +42,7 @@ wdth = "wdth" Strat = "Strat" grey = "gray" greyscale = "grayscale" +DECID = "DECID" [type.swift.extend-words] inout = "inout"