Merge branch 'main' of https://github.com/AlexJuca/ghostty into feature/display-memory-size-in-bytes-and-kb

This commit is contained in:
Alexandre Antonio Juca
2025-01-03 12:49:35 +01:00
87 changed files with 3588 additions and 1416 deletions

View File

@ -105,8 +105,8 @@ jobs:
with: with:
name: source-tarball name: source-tarball
path: |- path: |-
"ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz" ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz
"ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz.minisig" ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz.minisig
ghostty-source.tar.gz ghostty-source.tar.gz
ghostty-source.tar.gz.minisig ghostty-source.tar.gz.minisig
@ -360,8 +360,8 @@ jobs:
run: | run: |
mkdir blob mkdir blob
mkdir -p blob/${GHOSTTY_VERSION} 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" 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.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 blob/${GHOSTTY_VERSION}/ghostty-source.tar.gz
mv ghostty-source.tar.gz.minisig blob/${GHOSTTY_VERSION}/ghostty-source.tar.gz.minisig 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 mv ghostty-macos-universal.zip blob/${GHOSTTY_VERSION}/ghostty-macos-universal.zip

View File

@ -376,6 +376,41 @@ jobs:
-Dgtk-adwaita=${{ matrix.adwaita }} \ -Dgtk-adwaita=${{ matrix.adwaita }} \
-Dgtk-x11=${{ matrix.x11 }} -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: test-macos:
runs-on: namespace-profile-ghostty-macos runs-on: namespace-profile-ghostty-macos
needs: test needs: test
@ -478,3 +513,38 @@ jobs:
useDaemon: false # sometimes fails on short jobs useDaemon: false # sometimes fails on short jobs
- name: typos check - name: typos check
run: nix develop -c typos 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"

View File

@ -117,3 +117,11 @@ relevant to package maintainers:
often necessary for system packages to specify a specific minimum Linux 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 version, glibc, etc. Run `zig targets` to a get a full list of available
targets. 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.

View File

@ -43,7 +43,7 @@ comptime {
} }
/// The version of the next release. /// 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 { pub fn build(b: *std.Build) !void {
const optimize = b.standardOptimizeOption(.{}); 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( const pie = b.option(
bool, bool,
"pie", "pie",
"Build a Position Independent Executable. Default true for system packages.", "Build a Position Independent Executable. Default true for system packages.",
) orelse system_package; ) 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 conformance = b.option(
[]const u8, []const u8,
"conformance", "conformance",
@ -342,11 +366,7 @@ pub fn build(b: *std.Build) !void {
.root_source_file = b.path("src/main.zig"), .root_source_file = b.path("src/main.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
.strip = switch (optimize) { .strip = strip,
.Debug => false,
.ReleaseSafe => false,
.ReleaseFast, .ReleaseSmall => true,
},
}) else null; }) else null;
// Exe // 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_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_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_512.png", "share/icons/hicolor/512x512/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_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_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_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"); 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"), .root_source_file = b.path("src/main_c.zig"),
.optimize = optimize, .optimize = optimize,
.target = target, .target = target,
.strip = strip,
}); });
_ = try addDeps(b, lib, config); _ = 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"), .root_source_file = b.path("src/main_c.zig"),
.optimize = optimize, .optimize = optimize,
.target = target, .target = target,
.strip = strip,
}); });
_ = try addDeps(b, lib, config); _ = try addDeps(b, lib, config);
@ -1240,13 +1267,15 @@ fn addDeps(
} }
// Sentry // Sentry
if (config.sentry) {
const sentry_dep = b.dependency("sentry", .{ const sentry_dep = b.dependency("sentry", .{
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
.backend = .breakpad, .backend = .breakpad,
}); });
step.root_module.addImport("sentry", sentry_dep.module("sentry")); step.root_module.addImport("sentry", sentry_dep.module("sentry"));
if (target.result.os.tag != .windows) {
// Sentry // Sentry
step.linkLibrary(sentry_dep.artifact("sentry")); step.linkLibrary(sentry_dep.artifact("sentry"));
try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin()); try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin());

View File

@ -1,6 +1,6 @@
.{ .{
.name = "ghostty", .name = "ghostty",
.version = "1.0.1", .version = "1.0.2",
.paths = .{""}, .paths = .{""},
.dependencies = .{ .dependencies = .{
// Zig libs // Zig libs
@ -49,8 +49,8 @@
// Other // Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" }, .apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{ .iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/08df2e8a72dde535f8d17d8115fe792dbcdc6f35.tar.gz", .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz",
.hash = "1220d1090ac2edf1e47059b592403deacd56e7289c7ad8744ed20dd2f297596744b8", .hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620",
}, },
.vaxis = .{ .vaxis = .{
.url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b",

13
default.nix Normal file
View File

@ -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

View File

@ -375,9 +375,9 @@ typedef enum {
typedef enum { typedef enum {
GHOSTTY_GOTO_SPLIT_PREVIOUS, GHOSTTY_GOTO_SPLIT_PREVIOUS,
GHOSTTY_GOTO_SPLIT_NEXT, GHOSTTY_GOTO_SPLIT_NEXT,
GHOSTTY_GOTO_SPLIT_TOP, GHOSTTY_GOTO_SPLIT_UP,
GHOSTTY_GOTO_SPLIT_LEFT, GHOSTTY_GOTO_SPLIT_LEFT,
GHOSTTY_GOTO_SPLIT_BOTTOM, GHOSTTY_GOTO_SPLIT_DOWN,
GHOSTTY_GOTO_SPLIT_RIGHT, GHOSTTY_GOTO_SPLIT_RIGHT,
} ghostty_action_goto_split_e; } ghostty_action_goto_split_e;

View File

@ -358,8 +358,8 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit)
syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit)
syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove) syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove)
syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow)
syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) 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 // Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is
// explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior // explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior
// defined by our "auto-update" configuration. // defined by our "auto-update" configuration (if set) or fall back to Sparkle
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool != false { // user-based defaults.
updaterController.updater.automaticallyChecksForUpdates = if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
config.autoUpdate == .check || config.autoUpdate == .download
updaterController.updater.automaticallyDownloadsUpdates =
config.autoUpdate == .download
} else {
updaterController.updater.automaticallyChecksForUpdates = false updaterController.updater.automaticallyChecksForUpdates = false
updaterController.updater.automaticallyDownloadsUpdates = 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 // Config could change keybindings, so update everything that depends on that

View File

@ -69,7 +69,7 @@ class QuickTerminalController: BaseTerminalController {
window.isRestorable = false window.isRestorable = false
// Setup our configured appearance that we support. // Setup our configured appearance that we support.
syncAppearance(ghostty.config) syncAppearance()
// Setup our initial size based on our configured position // Setup our initial size based on our configured position
position.setLoaded(window) position.setLoaded(window)
@ -214,6 +214,10 @@ class QuickTerminalController: BaseTerminalController {
// If we canceled our animation in we do nothing // If we canceled our animation in we do nothing
guard self.visible else { return } 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 // Once our animation is done, we must grab focus since we can't grab
// focus of a non-visible window. // focus of a non-visible window.
self.makeWindowKey(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 } guard let window else { return }
// If our window is not visible, then delay this. This is possible specifically // If our window is not visible, then no need to sync the appearance yet.
// during state restoration but probably in other scenarios as well. To delay, // Some APIs such as window blur have no effect unless the window is visible.
// we just loop directly on the dispatch queue. We have to delay because some guard window.isVisible else { return }
// 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
}
// Terminals typically operate in sRGB color space and macOS defaults // Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources // to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden. // Ghostty defaults to sRGB but this can be overridden.
switch (config.windowColorspace) { switch (self.derivedConfig.windowColorspace) {
case "display-p3": case "display-p3":
window.colorSpace = .displayP3 window.colorSpace = .displayP3
case "srgb": case "srgb":
@ -331,7 +329,7 @@ class QuickTerminalController: BaseTerminalController {
} }
// If we have window transparency then set it transparent. Otherwise set it opaque. // 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 window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that // 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 // Update our derived config
self.derivedConfig = DerivedConfig(config) self.derivedConfig = DerivedConfig(config)
syncAppearance(config) syncAppearance()
} }
private struct DerivedConfig { private struct DerivedConfig {
let quickTerminalScreen: QuickTerminalScreen let quickTerminalScreen: QuickTerminalScreen
let quickTerminalAnimationDuration: Double let quickTerminalAnimationDuration: Double
let quickTerminalAutoHide: Bool let quickTerminalAutoHide: Bool
let windowColorspace: String
let backgroundOpacity: Double
init() { init() {
self.quickTerminalScreen = .main self.quickTerminalScreen = .main
self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAnimationDuration = 0.2
self.quickTerminalAutoHide = true self.quickTerminalAutoHide = true
self.windowColorspace = ""
self.backgroundOpacity = 1.0
} }
init(_ config: Ghostty.Config) { init(_ config: Ghostty.Config) {
self.quickTerminalScreen = config.quickTerminalScreen self.quickTerminalScreen = config.quickTerminalScreen
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
self.quickTerminalAutoHide = config.quickTerminalAutoHide self.quickTerminalAutoHide = config.quickTerminalAutoHide
self.windowColorspace = config.windowColorspace
self.backgroundOpacity = config.backgroundOpacity
} }
} }
} }

View File

@ -45,6 +45,11 @@ class BaseTerminalController: NSWindowController,
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } 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. /// Non-nil when an alert is active so we don't overlap multiple.
private var alert: NSAlert? = nil private var alert: NSAlert? = nil
@ -106,8 +111,8 @@ class BaseTerminalController: NSWindowController,
// Listen for local events that we need to know of outside of // Listen for local events that we need to know of outside of
// single surface handlers. // single surface handlers.
self.eventMonitor = NSEvent.addLocalMonitorForEvents( self.eventMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.flagsChanged], matching: [.flagsChanged]
handler: localEventHandler) ) { [weak self] event in self?.localEventHandler(event) }
} }
deinit { deinit {
@ -262,7 +267,6 @@ class BaseTerminalController: NSWindowController,
// Set the main window title // Set the main window title
window.title = to window.title = to
} }
func pwdDidChange(to: URL?) { func pwdDidChange(to: URL?) {
@ -309,11 +313,11 @@ class BaseTerminalController: NSWindowController,
// We consider our mode changed if the types change (obvious) but // We consider our mode changed if the types change (obvious) but
// also if its nil (not obvious) because nil means that the style has // also if its nil (not obvious) because nil means that the style has
// likely changed but we don't support it. // 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) // 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() oldStyle.exit()
self.fullscreenStyle = nil self.fullscreenStyle = newStyle
// We're done // We're done
return return
@ -536,11 +540,11 @@ class BaseTerminalController: NSWindowController,
} }
@IBAction func splitMoveFocusAbove(_ sender: Any) { @IBAction func splitMoveFocusAbove(_ sender: Any) {
splitMoveFocus(direction: .top) splitMoveFocus(direction: .up)
} }
@IBAction func splitMoveFocusBelow(_ sender: Any) { @IBAction func splitMoveFocusBelow(_ sender: Any) {
splitMoveFocus(direction: .bottom) splitMoveFocus(direction: .down)
} }
@IBAction func splitMoveFocusLeft(_ sender: Any) { @IBAction func splitMoveFocusLeft(_ sender: Any) {
@ -604,15 +608,18 @@ class BaseTerminalController: NSWindowController,
private struct DerivedConfig { private struct DerivedConfig {
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
let windowStepResize: Bool let windowStepResize: Bool
let focusFollowsMouse: Bool
init() { init() {
self.macosTitlebarProxyIcon = .visible self.macosTitlebarProxyIcon = .visible
self.windowStepResize = false self.windowStepResize = false
self.focusFollowsMouse = false
} }
init(_ config: Ghostty.Config) { init(_ config: Ghostty.Config) {
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
self.windowStepResize = config.windowStepResize self.windowStepResize = config.windowStepResize
self.focusFollowsMouse = config.focusFollowsMouse
} }
} }
} }

View File

@ -101,6 +101,12 @@ class TerminalController: BaseTerminalController {
// When our fullscreen state changes, we resync our appearance because some // When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not. // properties change when fullscreen or not.
guard let focusedSurface else { return } guard let focusedSurface else { return }
if (!(fullscreenStyle?.isFullscreen ?? false) &&
ghostty.config.macosTitlebarStyle == "hidden")
{
applyHiddenTitlebarStyle()
}
syncAppearance(focusedSurface.derivedConfig) syncAppearance(focusedSurface.derivedConfig)
} }
@ -117,9 +123,6 @@ class TerminalController: BaseTerminalController {
// Update our derived config // Update our derived config
self.derivedConfig = DerivedConfig(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 // 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 // our window appearance based on the root config. If we have surfaces, we
// don't call this because the TODO // don't call this because the TODO
@ -247,7 +250,9 @@ class TerminalController: BaseTerminalController {
let backgroundColor: OSColor let backgroundColor: OSColor
if let surfaceTree { if let surfaceTree {
if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { 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 { } else {
// We don't have a focused surface or our surface doesn't border the // 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. // 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 //MARK: - NSWindowController
override func windowWillLoad() { override func windowWillLoad() {
@ -277,6 +304,43 @@ class TerminalController: BaseTerminalController {
shouldCascadeWindows = false 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() { override func windowDidLoad() {
super.windowDidLoad() super.windowDidLoad()
guard let window = window as? TerminalWindow else { return } 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 // Set our window positioning to coordinates if config value exists, otherwise
// when cascading. // fallback to original centering behavior
window.center() setInitialWindowPosition(
x: config.windowPositionX,
y: config.windowPositionY,
windowDecorations: config.windowDecorations)
// Make sure our theme is set on the window so styling is correct. // Make sure our theme is set on the window so styling is correct.
if let windowTheme = config.windowTheme { if let windowTheme = config.windowTheme {
@ -368,38 +435,7 @@ class TerminalController: BaseTerminalController {
// If our titlebar style is "hidden" we adjust the style appropriately // If our titlebar style is "hidden" we adjust the style appropriately
if (config.macosTitlebarStyle == "hidden") { if (config.macosTitlebarStyle == "hidden") {
window.styleMask = [ applyHiddenTitlebarStyle()
// 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
}
} }
// In various situations, macOS automatically tabs new windows. Ghostty handles // 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 any additional appearance-related properties to the new window. We
// apply this based on the root config but change it later based on surface // apply this based on the root config but change it later based on surface
// config (see focused surface change callback). // config (see focused surface change callback).

View File

@ -414,8 +414,6 @@ class TerminalWindow: NSWindow {
} }
} }
var focusFollowsMouse: Bool = false
// Find the NSTextField responsible for displaying the titlebar's title. // Find the NSTextField responsible for displaying the titlebar's title.
private var titlebarTextField: NSTextField? { private var titlebarTextField: NSTextField? {
guard let titlebarView = titlebarContainer?.subviews guard let titlebarView = titlebarContainer?.subviews

View File

@ -150,6 +150,20 @@ extension Ghostty {
return String(cString: ptr) 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 { var windowNewTabPosition: String {
guard let config = self.config else { return "" } guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil var v: UnsafePointer<Int8>? = nil
@ -437,15 +451,14 @@ extension Ghostty {
return v; return v;
} }
var autoUpdate: AutoUpdate { var autoUpdate: AutoUpdate? {
let defaultValue = AutoUpdate.check guard let config = self.config else { return nil }
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = nil var v: UnsafePointer<Int8>? = nil
let key = "auto-update" let key = "auto-update"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil }
guard let ptr = v else { return defaultValue } guard let ptr = v else { return nil }
let str = String(cString: ptr) let str = String(cString: ptr)
return AutoUpdate(rawValue: str) ?? defaultValue return AutoUpdate(rawValue: str)
} }
var autoUpdateChannel: AutoUpdateChannel { var autoUpdateChannel: AutoUpdateChannel {

View File

@ -51,7 +51,7 @@ extension Ghostty {
/// Returns the view that would prefer receiving focus in this tree. This is always the /// 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 /// top-left-most view. This is used when creating a split or closing a split to find the
/// next view to send focus to. /// next view to send focus to.
func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView { func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView {
let container: Container let container: Container
switch (self) { switch (self) {
case .leaf(let leaf): case .leaf(let leaf):
@ -64,10 +64,10 @@ extension Ghostty {
let node: SplitNode let node: SplitNode
switch (direction) { switch (direction) {
case .previous, .top, .left: case .previous, .up, .left:
node = container.bottomRight node = container.bottomRight
case .next, .bottom, .right: case .next, .down, .right:
node = container.topLeft node = container.topLeft
} }
@ -431,12 +431,12 @@ extension Ghostty {
struct Neighbors { struct Neighbors {
var left: SplitNode? var left: SplitNode?
var right: SplitNode? var right: SplitNode?
var top: SplitNode? var up: SplitNode?
var bottom: SplitNode? var down: SplitNode?
/// These are the previous/next nodes. It will certainly be one of the above as well /// 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 /// 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 previous: SplitNode?
var next: SplitNode? var next: SplitNode?
@ -448,8 +448,8 @@ extension Ghostty {
let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [ let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
.previous: \.previous, .previous: \.previous,
.next: \.next, .next: \.next,
.top: \.top, .up: \.up,
.bottom: \.bottom, .down: \.down,
.left: \.left, .left: \.left,
.right: \.right, .right: \.right,
] ]

View File

@ -308,7 +308,7 @@ extension Ghostty {
resizeIncrements: .init(width: 1, height: 1), resizeIncrements: .init(width: 1, height: 1),
resizePublisher: container.resizeEvent, resizePublisher: container.resizeEvent,
left: { left: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.bottom let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.down
TerminalSplitNested( TerminalSplitNested(
node: closeableTopLeft(), node: closeableTopLeft(),
@ -318,7 +318,7 @@ extension Ghostty {
]) ])
) )
}, right: { }, right: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.top let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.up
TerminalSplitNested( TerminalSplitNested(
node: closeableBottomRight(), node: closeableBottomRight(),

View File

@ -66,7 +66,7 @@ extension Ghostty {
/// An enum that is used for the directions that a split focus event can change. /// An enum that is used for the directions that a split focus event can change.
enum SplitFocusDirection { enum SplitFocusDirection {
case previous, next, top, bottom, left, right case previous, next, up, down, left, right
/// Initialize from a Ghostty API enum. /// Initialize from a Ghostty API enum.
static func from(direction: ghostty_action_goto_split_e) -> Self? { static func from(direction: ghostty_action_goto_split_e) -> Self? {
@ -77,11 +77,11 @@ extension Ghostty {
case GHOSTTY_GOTO_SPLIT_NEXT: case GHOSTTY_GOTO_SPLIT_NEXT:
return .next return .next
case GHOSTTY_GOTO_SPLIT_TOP: case GHOSTTY_GOTO_SPLIT_UP:
return .top return .up
case GHOSTTY_GOTO_SPLIT_BOTTOM: case GHOSTTY_GOTO_SPLIT_DOWN:
return .bottom return .down
case GHOSTTY_GOTO_SPLIT_LEFT: case GHOSTTY_GOTO_SPLIT_LEFT:
return .left return .left
@ -102,11 +102,11 @@ extension Ghostty {
case .next: case .next:
return GHOSTTY_GOTO_SPLIT_NEXT return GHOSTTY_GOTO_SPLIT_NEXT
case .top: case .up:
return GHOSTTY_GOTO_SPLIT_TOP return GHOSTTY_GOTO_SPLIT_UP
case .bottom: case .down:
return GHOSTTY_GOTO_SPLIT_BOTTOM return GHOSTTY_GOTO_SPLIT_DOWN
case .left: case .left:
return GHOSTTY_GOTO_SPLIT_LEFT return GHOSTTY_GOTO_SPLIT_LEFT

View File

@ -617,11 +617,12 @@ extension Ghostty {
let mods = Ghostty.ghosttyMods(event.modifierFlags) let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods)
// If focus follows mouse is enabled then move focus to this surface. // Handle focus-follows-mouse
if let window = self.window as? TerminalWindow, if let window,
window.isKeyWindow && let controller = window.windowController as? BaseTerminalController,
window.focusFollowsMouse && (window.isKeyWindow &&
!self.focused !self.focused &&
controller.focusFollowsMouse)
{ {
Ghostty.moveFocus(to: self) Ghostty.moveFocus(to: self)
} }

View File

@ -111,7 +111,7 @@
in in
stdenv.mkDerivation (finalAttrs: { stdenv.mkDerivation (finalAttrs: {
pname = "ghostty"; pname = "ghostty";
version = "1.0.1"; version = "1.0.2";
inherit src; inherit src;
nativeBuildInputs = [ nativeBuildInputs = [

View File

@ -1,3 +1,3 @@
# This file is auto-generated! check build-support/check-zig-cache-hash.sh for # This file is auto-generated! check build-support/check-zig-cache-hash.sh for
# more details. # more details.
"sha256-lfXkAoEd0lB25YV2cB4VVe+CI01DDY5PVOtvq//Qw/U=" "sha256-njCce+r1DPTKLNrmrD2ObEoBS9nR7q03hqegQWe1UuY="

View File

@ -14,7 +14,6 @@ pub fn build(b: *std.Build) !void {
.@"enable-libpng" = true, .@"enable-libpng" = true,
}); });
const macos = b.dependency("macos", .{ .target = target, .optimize = optimize }); const macos = b.dependency("macos", .{ .target = target, .optimize = optimize });
const upstream = b.dependency("harfbuzz", .{});
const module = b.addModule("harfbuzz", .{ const module = b.addModule("harfbuzz", .{
.root_source_file = b.path("main.zig"), .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(.{ const lib = b.addStaticLibrary(.{
.name = "harfbuzz", .name = "harfbuzz",
.target = target, .target = target,
@ -41,13 +96,7 @@ pub fn build(b: *std.Build) !void {
try apple_sdk.addPaths(b, module); try apple_sdk.addPaths(b, module);
} }
// For dynamic linking, we prefer dynamic linking and to search by const dynamic_link_opts = options.dynamic_link_opts;
// 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,
};
var flags = std.ArrayList([]const u8).init(b.allocator); var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit(); defer flags.deinit();
@ -102,20 +151,5 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(lib); b.installArtifact(lib);
{ return 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);
}
} }

View File

@ -30,4 +30,36 @@ pub fn build(b: *std.Build) !void {
.file = wuffs.path("release/c/wuffs-v0.4.c"), .file = wuffs.path("release/c/wuffs-v0.4.c"),
.flags = flags.items, .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);
} }

View File

@ -3,8 +3,13 @@
.version = "0.0.0", .version = "0.0.0",
.dependencies = .{ .dependencies = .{
.wuffs = .{ .wuffs = .{
.url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.8.tar.gz", .url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.9.tar.gz",
.hash = "12200984439edc817fbcbbaff564020e5104a0d04a2d0f53080700827052de700462", .hash = "122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd",
},
.pixels = .{
.url = "git+https://github.com/make-github-pseudonymous-again/pixels?ref=main#d843c2714d32e15b48b8d7eeb480295af537f877",
.hash = "12207ff340169c7d40c570b4b6a97db614fe47e0d83b5801a932dcd44917424c8806",
}, },
.apple_sdk = .{ .path = "../apple-sdk" }, .apple_sdk = .{ .path = "../apple-sdk" },

View File

@ -1,3 +1,13 @@
const std = @import("std"); const std = @import("std");
const c = @import("c.zig").c;
pub const Error = std.mem.Allocator.Error || error{WuffsError}; 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;
}
}

143
pkg/wuffs/src/jpeg.zig Normal file
View File

@ -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);
}

View File

@ -1,2 +1,15 @@
const std = @import("std");
pub const png = @import("png.zig"); pub const png = @import("png.zig");
pub const jpeg = @import("jpeg.zig");
pub const swizzle = @import("swizzle.zig"); pub const swizzle = @import("swizzle.zig");
pub const ImageData = struct {
width: u32,
height: u32,
data: []const u8,
};
test {
std.testing.refAllDeclsRecursive(@This());
}

View File

@ -2,15 +2,13 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const c = @import("c.zig").c; const c = @import("c.zig").c;
const Error = @import("error.zig").Error; 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); const log = std.log.scoped(.wuffs_png);
/// Decode a PNG image. /// Decode a PNG image.
pub fn decode(alloc: Allocator, data: []const u8) Error!struct { pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
width: u32,
height: u32,
data: []const u8,
} {
// Work around some weirdness in WUFFS/Zig, there are some structs that // 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 // 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 // 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, c.WUFFS_VERSION,
0, 0,
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
var source_buffer: c.wuffs_base__io_buffer = .{ var source_buffer: c.wuffs_base__io_buffer = .{
@ -53,11 +47,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
&image_config, &image_config,
&source_buffer, &source_buffer,
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg); 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, &image_config.pixcfg,
c.wuffs_base__make_slice_u8(destination.ptr, destination.len), c.wuffs_base__make_slice_u8(destination.ptr, destination.len),
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
var frame_config: c.wuffs_base__frame_config = undefined; 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, &frame_config,
&source_buffer, &source_buffer,
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
{ {
@ -132,11 +114,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
work_slice, work_slice,
null, null,
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
return .{ return .{
@ -145,3 +123,21 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
.data = destination, .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);
}

View File

@ -18,6 +18,7 @@ const Command = @This();
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const global_state = &@import("global.zig").state;
const internal_os = @import("os/main.zig"); const internal_os = @import("os/main.zig");
const windows = internal_os.windows; const windows = internal_os.windows;
const TempDir = internal_os.TempDir; 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. // 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 the user requested a pre exec callback, call it now.
if (self.pre_exec) |f| f(self); if (self.pre_exec) |f| f(self);

View File

@ -853,11 +853,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
}, },
.color_change => |change| { .color_change => |change| {
// On any color change, we have to report for mode 2031 // Notify our apprt, but don't send a mode 2031 DSR report
// if it is enabled. // because VT sequences were used to change the color.
self.reportColorScheme(false);
// Notify our apprt
try self.rt_app.performAction( try self.rt_app.performAction(
.{ .surface = self }, .{ .surface = self },
.color_change, .color_change,
@ -1159,7 +1156,6 @@ pub fn updateConfig(
} }
// If we are in the middle of a key sequence, clear it. // If we are in the middle of a key sequence, clear it.
self.keyboard.bindings = null;
self.endKeySequence(.drop, .free); self.endKeySequence(.drop, .free);
// Before sending any other config changes, we give the renderer a new font // Before sending any other config changes, we give the renderer a new font
@ -1710,6 +1706,13 @@ pub fn keyCallback(
// Update our modifiers, this will update mouse mods too // Update our modifiers, this will update mouse mods too
self.modsChanged(event.mods); self.modsChanged(event.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 // Refresh our link state
const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; const pos = self.rt_surface.getCursorPos() catch break :mouse_mods;
self.mouseRefreshLinks( self.mouseRefreshLinks(
@ -1720,6 +1723,20 @@ pub fn keyCallback(
log.warn("failed to refresh links err={}", .{err}); log.warn("failed to refresh links err={}", .{err});
break :mouse_mods; 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 // Process the cursor state logic. This will update the cursor shape if
@ -1835,9 +1852,6 @@ fn maybeHandleBinding(
if (self.keyboard.bindings != null and if (self.keyboard.bindings != null and
!event.key.modifier()) !event.key.modifier())
{ {
// Reset to the root set
self.keyboard.bindings = null;
// Encode everything up to this point // Encode everything up to this point
self.endKeySequence(.flush, .retain); self.endKeySequence(.flush, .retain);
} }
@ -1923,10 +1937,21 @@ fn maybeHandleBinding(
return .closed; 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 // 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 // it, we processed the action but we still want to process our
// encodings, too. // encodings, too.
if (performed and consumed) { if (consumed) {
// If we had queued events, we deinit them since we consumed // If we had queued events, we deinit them since we consumed
self.endKeySequence(.drop, .retain); 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) { if (self.keyboard.queued.items.len > 0) {
switch (action) { switch (action) {
.flush => for (self.keyboard.queued.items) |write_req| { .flush => for (self.keyboard.queued.items) |write_req| {
@ -3195,7 +3224,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
.trim = false, .trim = false,
}); });
defer self.alloc.free(str); defer self.alloc.free(str);
try internal_os.open(self.alloc, str); try internal_os.open(self.alloc, .unknown, str);
}, },
._open_osc8 => { ._open_osc8 => {
@ -3203,7 +3232,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
log.warn("failed to get URI for OSC8 hyperlink", .{}); log.warn("failed to get URI for OSC8 hyperlink", .{});
return false; 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(); 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 // Do a mouse report
if (self.io.terminal.flags.mouse_event != .none) report: { if (self.io.terminal.flags.mouse_event != .none) report: {
// Shift overrides mouse "grabbing" in the window, taken from Kitty. // 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); 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 // If we're doing mouse motion tracking, we do not support text
// selection. // selection.
return; return;
@ -3437,30 +3475,6 @@ pub fn cursorPosCallback(
return; 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. /// 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}); log.err("error setting clipboard string err={}", .{err});
return true; return true;
}; };
return true;
} }
return false;
}, },
.paste_from_clipboard => try self.startClipboardRequest( .paste_from_clipboard => try self.startClipboardRequest(
@ -4239,7 +4257,13 @@ fn writeScreenFile(
const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)}); const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)});
// Open our scrollback file // 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(); defer file.close();
// Screen.dumpString writes byte-by-byte, so buffer it // Screen.dumpString writes byte-by-byte, so buffer it
@ -4287,11 +4311,16 @@ fn writeScreenFile(
tmp_dir.deinit(); tmp_dir.deinit();
return; 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( try self.io.terminal.screen.dumpString(
buf_writer.writer(), buf_writer.writer(),
.{ .{
.tl = sel.start(), .tl = tl,
.br = sel.end(), .br = br,
.unwrap = true, .unwrap = true,
}, },
); );
@ -4303,7 +4332,7 @@ fn writeScreenFile(
const path = try tmp_dir.dir.realpath(filename, &path_buf); const path = try tmp_dir.dir.realpath(filename, &path_buf);
switch (write_action) { 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( .paste => self.io.queueMessage(try termio.Message.writeReq(
self.alloc, self.alloc,
path, path,

View File

@ -332,9 +332,9 @@ pub const GotoSplit = enum(c_int) {
previous, previous,
next, next,
top, up,
left, left,
bottom, down,
right, right,
}; };

View File

@ -510,6 +510,13 @@ pub const Surface = struct {
) orelse return glfw.mustGetErrorCode(); ) orelse return glfw.mustGetErrorCode();
errdefer win.destroy(); 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 // Get our physical DPI - debug only because we don't have a use for
// this but the logging of it may be useful // this but the logging of it may be useful
if (builtin.mode == .Debug) { 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. /// Set the size limits of the window.
/// Note: this interface is not good, we should redo it if we plan /// 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, /// to use this more. i.e. you can't set max width but no max height,

View File

@ -81,6 +81,9 @@ transient_cgroup_base: ?[]const u8 = null,
/// CSS Provider for any styles based on ghostty configuration values /// CSS Provider for any styles based on ghostty configuration values
css_provider: *c.GtkCssProvider, 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. /// The timer used to quit the application after the last window is closed.
quit_timer: union(enum) { quit_timer: union(enum) {
off: void, 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. // 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. // For the remainder of "why" see the 4.14 comment below.
_ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan"); _ = 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)) { } else if (version.atLeast(4, 14, 0)) {
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14. // 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 // 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 // - "vulkan-disable" - disable Vulkan, Ghostty can't use Vulkan
// and initializing a Vulkan context was causing a longer delay // and initializing a Vulkan context was causing a longer delay
// on some systems. // 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 { } else {
// Versions prior to 4.14 are a bit of an unknown for Ghostty. It // 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 // 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.context_menu) |context_menu| c.g_object_unref(context_menu);
if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path); 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(); self.config.deinit();
} }
@ -786,6 +794,7 @@ fn setInitialSize(
), ),
} }
} }
fn showDesktopNotification( fn showDesktopNotification(
self: *App, self: *App,
target: apprt.Target, target: apprt.Target,
@ -892,7 +901,7 @@ fn syncConfigChanges(self: *App) !void {
try self.updateConfigErrors(); try self.updateConfigErrors();
try self.syncActionAccelerators(); 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. // with the old CSS but we don't want to fail the entire sync operation.
self.loadRuntimeCss() catch |err| switch (err) { self.loadRuntimeCss() catch |err| switch (err) {
error.OutOfMemory => log.warn( 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 /// 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; const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
try writer.print( try writer.print(
\\window.without-window-decoration-and-with-titlebar {{
\\ border-radius: 0 0;
\\}}
\\widget.unfocused-split {{ \\widget.unfocused-split {{
\\ opacity: {d:.2}; \\ opacity: {d:.2};
\\ background-color: rgb({d},{d},{d}); \\ background-color: rgb({d},{d},{d});
@ -1036,11 +1045,68 @@ fn loadRuntimeCss(
} }
// Clears any previously loaded CSS from this provider // Clears any previously loaded CSS from this provider
c.gtk_css_provider_load_from_data( loadCssProviderFromData(self.css_provider, buf.items);
self.css_provider, }
buf.items.ptr,
@intCast(buf.items.len), 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. /// Called by CoreApp to wake up the event loop.
@ -1403,7 +1469,15 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme {
null, null,
&err, &err,
) orelse { ) 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; return .light;
}; };
defer c.g_variant_unref(value); defer c.g_variant_unref(value);
@ -1420,6 +1494,49 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme {
return .light; 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. /// This will be called by D-Bus when the style changes between light & dark.
fn gtkNotifyColorScheme( fn gtkNotifyColorScheme(
_: ?*c.GDBusConnection, _: ?*c.GDBusConnection,

View File

@ -131,6 +131,7 @@ const PrimaryView = struct {
c.gtk_text_view_set_bottom_margin(@ptrCast(text), 8); 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_left_margin(@ptrCast(text), 8);
c.gtk_text_view_set_right_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) }; 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. \\Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.
, ,
.osc_52_read => .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. \\The current clipboard contents are shown below.
, ,
.osc_52_write => .osc_52_write =>

View File

@ -111,16 +111,6 @@ pub fn init(
// Keep a long-lived reference, which we unref in destroy. // Keep a long-lived reference, which we unref in destroy.
_ = c.g_object_ref(paned); _ = 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(&gtkMouseDown), self, null, c.G_CONNECT_DEFAULT);
// Update all of our containers to point to the right place. // Update all of our containers to point to the right place.
// The split has to point to where the sibling pointed to because // The split has to point to where the sibling pointed to because
// we're inheriting its parent. The sibling points to its location // we're inheriting its parent. The sibling points to its location
@ -246,19 +236,6 @@ pub fn equalize(self: *Split) f64 {
return weight; 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 // maxPosition returns the maximum position of the GtkPaned, which is the
// "max-position" attribute. // "max-position" attribute.
fn maxPosition(self: *Split) f64 { 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 behavior matches the behavior of macOS at the time of writing
// this. There is an open issue (#524) to make this depend on the // this. There is an open issue (#524) to make this depend on the
// actual physical location of the current split. // actual physical location of the current split.
result.put(.top, prev.surface); result.put(.up, prev.surface);
result.put(.left, 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| { if (self.directionNext(from)) |next| {
result.put(.next, next.surface); result.put(.next, next.surface);
if (!next.wrapped) { if (!next.wrapped) {
result.put(.bottom, next.surface); result.put(.down, next.surface);
result.put(.right, next.surface); result.put(.right, next.surface);
} }
} }

View File

@ -794,10 +794,11 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
// can support fractional scaling. // can support fractional scaling.
const gtk_scale: f32 = @floatFromInt(c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area))); 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 // Also scale using font-specific DPI, which is often exposed to the user
const xft_dpi_scale = if (!x11.is_current_display_server()) 1.0 else xft_scale: { // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html).
// Here we use GTK to retrieve gtk-xft-dpi, which is Xft.dpi multiplied const xft_dpi_scale = xft_scale: {
// by 1024. See https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html // 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(); const settings = c.gtk_settings_get_default();
var value: c.GValue = std.mem.zeroes(c.GValue); 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); c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value);
const gtk_xft_dpi = c.g_value_get_int(&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, // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by
// then divide by the default value of Xft.dpi (96) to derive a scale. // 1024, then divide by the default value (96) to derive a scale. Note
// Note that gtk-xft-dpi can be fractional, so we use floating point // gtk-xft-dpi can be fractional, so we use floating point math here.
// math here.
const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024; const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024;
break :xft_scale xft_dpi / 96; break :xft_scale xft_dpi / 96;
}; };
@ -1426,15 +1426,23 @@ fn gtkMouseMotion(
.y = @floatCast(scaled.y), .y = @floatCast(scaled.y),
}; };
// Our pos changed, update // When the GLArea is resized under the mouse, GTK issues a mouse motion
self.cursor_pos = pos; // 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. // If we don't have focus, and we want it, grab it.
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area)); 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(); self.grabFocus();
} }
// Our pos changed, update
self.cursor_pos = pos;
// Get our modifiers // Get our modifiers
const gtk_mods = c.gdk_event_get_modifier_state(event); const gtk_mods = c.gdk_event_get_modifier_state(event);
const mods = gtk_key.translateMods(gtk_mods); const mods = gtk_key.translateMods(gtk_mods);

View File

@ -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. // Set the userdata of the box to point to this tab.
c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self); 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 // Attach all events
_ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);

View File

@ -83,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void {
// Create the window // Create the window
const window: *c.GtkWidget = 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); const window = c.adw_application_window_new(app.app);
c.gtk_widget_add_css_class(@ptrCast(window), "adw"); c.gtk_widget_add_css_class(@ptrCast(window), "adw");
break :window window; break :window window;
@ -99,6 +99,7 @@ pub fn init(self: *Window, app: *App) !void {
self.window = gtk_window; self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_title(gtk_window, "Ghostty");
c.gtk_window_set_default_size(gtk_window, 1000, 600); 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 // 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) // 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); const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0);
// Setup our notebook // Setup our notebook
self.notebook = Notebook.create(self); self.notebook.init();
// If we are using Adwaita, then we can support the tab overview. // 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(); 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.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1);
_ = c.g_signal_connect_data( _ = c.g_signal_connect_data(
tab_overview, tab_overview,
@ -156,6 +157,9 @@ pub fn init(self: *Window, app: *App) !void {
if (app.config.@"gtk-titlebar") { if (app.config.@"gtk-titlebar") {
const header = HeaderBar.init(self); 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(); const btn = c.gtk_menu_button_new();
c.gtk_widget_set_tooltip_text(btn, "Main Menu"); 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 we're using an AdwWindow then we can support the tab overview.
if (self.tab_overview) |tab_overview| { if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; 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") { const btn = switch (app.config.@"gtk-tabs-location") {
.top, .bottom, .left, .right => btn: { .top, .bottom, .left, .right => btn: {
const btn = c.gtk_toggle_button_new(); const btn = c.gtk_toggle_button_new();
@ -186,7 +190,7 @@ pub fn init(self: *Window, app: *App) !void {
.hidden => btn: { .hidden => btn: {
const btn = c.adw_tab_button_new(); 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"); c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open");
break :btn btn; 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. // 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. // This is a really common issue where people build from source in debug and performance is really bad.
if (comptime std.debug.runtime_safety) { 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 we have a tab overview then we can set it on our notebook.
if (self.tab_overview) |tab_overview| { if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable; if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable;
assert(self.notebook == .adw_tab_view); assert(self.notebook == .adw);
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);
} }
self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); 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 // Our actions for the menu
initActions(self); initActions(self);
if (self.isAdwWindow()) { if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new()); const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new());
const header_widget: *c.GtkWidget = self.header.?.asWidget(); if (self.header) |header| {
const header_widget = header.asWidget();
c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget);
}
if (self.app.config.@"gtk-tabs-location" != .hidden) { if (self.app.config.@"gtk-tabs-location" != .hidden) {
const tab_bar = c.adw_tab_bar_new(); 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); 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_top_bar_style(toolbar_view, toolbar_style);
c.adw_toolbar_view_set_bottom_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. // Set our application window content.
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( c.adw_tab_overview_set_child(
@ptrCast(tab_overview), @ptrCast(self.tab_overview),
@ptrCast(@alignCast(toolbar_view)), @ptrCast(@alignCast(toolbar_view)),
); );
c.adw_application_window_set_content( c.adw_application_window_set_content(
@ptrCast(gtk_window), @ptrCast(gtk_window),
@ptrCast(@alignCast(tab_overview)), @ptrCast(@alignCast(self.tab_overview)),
); );
} else {
c.adw_application_window_set_content(
@ptrCast(gtk_window),
@ptrCast(@alignCast(toolbar_view)),
);
}
} else tab_bar: { } else tab_bar: {
switch (self.notebook) { 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; 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 // In earlier adwaita versions, we need to add the tabbar manually since we do not use
// an AdwToolbarView. // an AdwToolbarView.
const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?; const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?;
@ -361,17 +360,26 @@ pub fn init(self: *Window, app: *App) !void {
), ),
.hidden => unreachable, .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); 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 // The box is our main child
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); c.gtk_window_set_child(gtk_window, box);
if (self.header) |h| c.gtk_window_set_titlebar(gtk_window, h.asWidget()); if (self.header) |h| {
c.gtk_window_set_titlebar(gtk_window, h.asWidget());
}
}
} }
// Show the window // 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. /// Add a new tab to this window.
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {
const alloc = self.app.core_app.alloc; const alloc = self.app.core_app.alloc;
@ -517,13 +514,19 @@ pub fn toggleWindowDecorations(self: *Window) void {
const new_decorated = !old_decorated; const new_decorated = !old_decorated;
c.gtk_window_set_decorated(self.window, @intFromBool(new_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 // 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 // decorated state. GTK tends to consider the titlebar part of the frame
// and hides it with decorations, but libadwaita doesn't. This makes it // and hides it with decorations, but libadwaita doesn't. This makes it
// explicit. // explicit.
if (self.header) |v| { if (self.header) |headerbar| {
const widget = v.asWidget(); headerbar.setVisible(new_decorated);
c.gtk_widget_set_visible(widget, @intFromBool(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. /// because we need to return an AdwTabPage from this function.
fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwTabPage { fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwTabPage {
const self: *Window = userdataSelf(ud.?); 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 alloc = self.app.core_app.alloc;
const surface = self.actionSurface(); const surface = self.actionSurface();
const tab = Tab.create(alloc, self, surface) catch return null; 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( fn adwTabOverviewOpen(
@ -744,7 +747,7 @@ fn gtkActionAbout(
if ((comptime adwaita.versionAtLeast(1, 5, 0)) and if ((comptime adwaita.versionAtLeast(1, 5, 0)) and
adwaita.versionAtLeast(1, 5, 0) and adwaita.versionAtLeast(1, 5, 0) and
self.isAdwWindow()) adwaita.enabled(&self.app.config))
{ {
c.adw_show_about_dialog( c.adw_show_about_dialog(
@ptrCast(self.window), @ptrCast(self.window),
@ -892,7 +895,9 @@ fn gtkActionCopy(
return; return;
}; };
if (self.app.config.@"adw-toast".@"clipboard-copy") {
self.sendToast("Copied to clipboard"); self.sendToast("Copied to clipboard");
}
} }
fn gtkActionPaste( fn gtkActionPaste(

View File

@ -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 /// in the headers. If it is run in a runtime context, it will
/// check the actual version of the library we are linked against. /// check the actual version of the library we are linked against.
/// So generally you probably want to do both checks! /// 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 major: u16,
comptime minor: u16, comptime minor: u16,
comptime micro: u16, comptime micro: u16,
@ -37,8 +40,9 @@ pub fn versionAtLeast(
// compiling against unknown symbols and makes runtime checks // compiling against unknown symbols and makes runtime checks
// very slightly faster. // very slightly faster.
if (comptime c.ADW_MAJOR_VERSION < major or if (comptime c.ADW_MAJOR_VERSION < major or
c.ADW_MINOR_VERSION < minor or (c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION < minor) or
c.ADW_MICRO_VERSION < micro) return false; (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 we're in comptime then we can't check the runtime version.
if (@inComptime()) return true; if (@inComptime()) return true;
@ -56,3 +60,16 @@ pub fn versionAtLeast(
return false; 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));
}

View File

@ -30,6 +30,10 @@ pub const HeaderBar = union(enum) {
return .{ .gtk = @ptrCast(headerbar) }; 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 { pub fn asWidget(self: HeaderBar) *c.GtkWidget {
return switch (self) { return switch (self) {
.adw => |headerbar| @ptrCast(@alignCast(headerbar)), .adw => |headerbar| @ptrCast(@alignCast(headerbar)),

View File

@ -4,161 +4,76 @@ const c = @import("c.zig").c;
const Window = @import("Window.zig"); const Window = @import("Window.zig");
const Tab = @import("Tab.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 adwaita = @import("adwaita.zig");
const log = std.log.scoped(.gtk); const log = std.log.scoped(.gtk);
const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; 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 /// An abstraction over the GTK notebook and Adwaita tab view to manage
/// all the terminal tabs in a window. /// all the terminal tabs in a window.
pub const Notebook = union(enum) { pub const Notebook = union(enum) {
adw_tab_view: *AdwTabView, adw: NotebookAdw,
gtk_notebook: *c.GtkNotebook, gtk: NotebookGtk,
pub fn create(window: *Window) Notebook { pub fn init(self: *Notebook) void {
const window: *Window = @fieldParentPtr("notebook", self);
const app = window.app; const app = window.app;
if (adwaita.enabled(&app.config)) return initAdw(window); if (adwaita.enabled(&app.config)) return NotebookAdw.init(self);
return initGtk(window);
return NotebookGtk.init(self);
} }
fn initGtk(window: *Window) Notebook { pub fn asWidget(self: *Notebook) *c.GtkWidget {
const app = window.app; return switch (self.*) {
.adw => |*adw| adw.asWidget(),
// Create a notebook to hold our tabs. .gtk => |*gtk| gtk.asWidget(),
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(&gtkPageAdded), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(notebook, "page-removed", c.G_CALLBACK(&gtkPageRemoved), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(&gtkSwitchPage), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(notebook, "create-window", c.G_CALLBACK(&gtkNotebookCreateWindow), 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 nPages(self: Notebook) c_int { pub fn nPages(self: *Notebook) c_int {
return switch (self) { return switch (self.*) {
.gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook), .adw => |*adw| adw.nPages(),
.adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) .gtk => |*gtk| gtk.nPages(),
c.adw_tab_view_get_n_pages(tab_view)
else
unreachable,
}; };
} }
/// Returns the index of the currently selected page. /// Returns the index of the currently selected page.
/// Returns null if the notebook has no pages. /// Returns null if the notebook has no pages.
fn currentPage(self: Notebook) ?c_int { fn currentPage(self: *Notebook) ?c_int {
switch (self) { return switch (self.*) {
.adw_tab_view => |tab_view| { .adw => |*adw| adw.currentPage(),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.currentPage(),
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;
},
}
} }
/// Returns the currently selected tab or null if there are none. /// Returns the currently selected tab or null if there are none.
pub fn currentTab(self: Notebook) ?*Tab { pub fn currentTab(self: *Notebook) ?*Tab {
const child = switch (self) { return switch (self.*) {
.adw_tab_view => |tab_view| child: { .adw => |*adw| adw.currentTab(),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.currentTab(),
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);
},
};
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 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 gotoPreviousTab(self: Notebook, tab: *Tab) void { 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 => |*adw| adw.getTabPosition(tab),
.gtk => |*gtk| gtk.getTabPosition(tab),
};
}
pub fn gotoPreviousTab(self: *Notebook, tab: *Tab) void {
const page_idx = self.getTabPosition(tab) orelse return; const page_idx = self.getTabPosition(tab) orelse return;
// The next index is the previous or we wrap around. // The next index is the previous or we wrap around.
@ -173,7 +88,7 @@ pub const Notebook = union(enum) {
self.gotoNthTab(next_idx); 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 page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1; const max = self.nPages() -| 1;
@ -183,7 +98,7 @@ pub const Notebook = union(enum) {
self.gotoNthTab(next_idx); 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 page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1; const max = self.nPages() -| 1;
@ -199,42 +114,28 @@ pub const Notebook = union(enum) {
self.reorderPage(tab, new_position); self.reorderPage(tab, new_position);
} }
pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void { pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void {
switch (self) { switch (self.*) {
.gtk_notebook => |notebook| { .adw => |*adw| adw.reorderPage(tab, position),
c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position); .gtk => |*gtk| gtk.reorderPage(tab, 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 setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void { pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
switch (self) { switch (self.*) {
.adw_tab_view => |tab_view| { .adw => |*adw| adw.setTabLabel(tab, title),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.setTabLabel(tab, title),
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 setTabTooltip(self: Notebook, tab: *Tab, tooltip: [:0]const u8) void { pub fn setTabTooltip(self: *Notebook, tab: *Tab, tooltip: [:0]const u8) void {
switch (self) { switch (self.*) {
.adw_tab_view => |tab_view| { .adw => |*adw| adw.setTabTooltip(tab, tooltip),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.setTabTooltip(tab, tooltip),
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),
} }
} }
fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int { fn newTabInsertPosition(self: *Notebook, tab: *Tab) c_int {
const numPages = self.nPages(); const numPages = self.nPages();
return switch (tab.window.app.config.@"window-new-tab-position") { return switch (tab.window.app.config.@"window-new-tab-position") {
.current => if (self.currentPage()) |page| page + 1 else numPages, .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. /// Adds a new tab with the given title to the notebook.
pub fn addTab(self: Notebook, tab: *Tab, title: [:0]const u8) !void { pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
const box_widget: *c.GtkWidget = @ptrCast(tab.box); const position = self.newTabInsertPosition(tab);
switch (self) { switch (self.*) {
.adw_tab_view => |tab_view| { .adw => |*adw| adw.addTab(tab, position, title),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.addTab(tab, position, title),
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 closeTab(self: Notebook, tab: *Tab) void { pub fn closeTab(self: *Notebook, tab: *Tab) void {
const window = tab.window; switch (self.*) {
switch (self) { .adw => |*adw| adw.closeTab(tab),
.adw_tab_view => |tab_view| { .gtk => |*gtk| gtk.closeTab(tab),
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();
},
}
}
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( pub fn createWindow(currentWindow: *Window) !*Window {
_: *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 {
const alloc = currentWindow.app.core_app.alloc; const alloc = currentWindow.app.core_app.alloc;
const app = currentWindow.app; const app = currentWindow.app;

View File

@ -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);
}

View File

@ -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(&gtkPageAdded), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gtk_notebook, "page-removed", c.G_CALLBACK(&gtkPageRemoved), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gtk_notebook, "switch-page", c.G_CALLBACK(&gtkSwitchPage), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gtk_notebook, "create-window", c.G_CALLBACK(&gtkNotebookCreateWindow), 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;
}

View File

@ -2,7 +2,7 @@
background-color: transparent; background-color: transparent;
} }
separator { .terminal-window .notebook separator {
background-color: rgba(36, 36, 36, 1); background-color: rgba(36, 36, 36, 1);
background-clip: content-box; background-clip: content-box;
} }

View File

@ -33,11 +33,15 @@ label.size-overlay.hidden {
opacity: 0; opacity: 0;
} }
window.without-window-decoration-and-with-titlebar {
border-radius: 0 0;
}
.transparent { .transparent {
background-color: transparent; background-color: transparent;
} }
separator { .terminal-window .notebook separator {
background-color: rgba(250, 250, 250, 1); background-color: rgba(250, 250, 250, 1);
background-clip: content-box; background-clip: content-box;
} }

View File

@ -19,8 +19,9 @@ pub inline fn atLeast(
// compiling against unknown symbols and makes runtime checks // compiling against unknown symbols and makes runtime checks
// very slightly faster. // very slightly faster.
if (comptime c.GTK_MAJOR_VERSION < major or if (comptime c.GTK_MAJOR_VERSION < major or
c.GTK_MINOR_VERSION < minor or (c.GTK_MAJOR_VERSION == major and c.GTK_MINOR_VERSION < minor) or
c.GTK_MICRO_VERSION < micro) return false; (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 we're in comptime then we can't check the runtime version.
if (@inComptime()) return true; if (@inComptime()) return true;
@ -38,3 +39,20 @@ pub inline fn atLeast(
return false; 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));
}

View File

@ -56,7 +56,7 @@ fn writeFishCompletions(writer: anytype) !void {
else { else {
try writer.writeAll(if (field.type != Config.RepeatablePath) " -f" else " -F"); try writer.writeAll(if (field.type != Config.RepeatablePath) " -f" else " -F");
switch (@typeInfo(field.type)) { switch (@typeInfo(field.type)) {
.Bool => try writer.writeAll(" -a \"true false\""), .Bool => {},
.Enum => |info| { .Enum => |info| {
try writer.writeAll(" -a \""); try writer.writeAll(" -a \"");
for (info.fields, 0..) |f, i| { for (info.fields, 0..) |f, i| {
@ -114,7 +114,7 @@ fn writeFishCompletions(writer: anytype) !void {
} else try writer.writeAll(" -f"); } else try writer.writeAll(" -f");
switch (@typeInfo(opt.type)) { switch (@typeInfo(opt.type)) {
.Bool => try writer.writeAll(" -a \"true false\""), .Bool => {},
.Enum => |info| { .Enum => |info| {
try writer.writeAll(" -a \""); try writer.writeAll(" -a \"");
for (info.fields, 0..) |f, i| { for (info.fields, 0..) |f, i| {

View File

@ -7,6 +7,8 @@ const Action = @import("../cli/action.zig").Action;
/// and options. /// and options.
pub const zsh_completions = comptimeGenerateZshCompletions(); pub const zsh_completions = comptimeGenerateZshCompletions();
const equals_required = "=-:::";
fn comptimeGenerateZshCompletions() []const u8 { fn comptimeGenerateZshCompletions() []const u8 {
comptime { comptime {
@setEvalBranchQuota(50000); @setEvalBranchQuota(50000);
@ -47,34 +49,42 @@ fn writeZshCompletions(writer: anytype) !void {
if (field.name[0] == '_') continue; if (field.name[0] == '_') continue;
try writer.writeAll(" \"--"); try writer.writeAll(" \"--");
try writer.writeAll(field.name); try writer.writeAll(field.name);
try writer.writeAll("=-:::");
if (std.mem.startsWith(u8, field.name, "font-family")) if (std.mem.startsWith(u8, field.name, "font-family")) {
try writer.writeAll("_fonts") try writer.writeAll(equals_required);
else if (std.mem.eql(u8, "theme", field.name)) try writer.writeAll("_fonts");
try writer.writeAll("_themes") } else if (std.mem.eql(u8, "theme", field.name)) {
else if (std.mem.eql(u8, "working-directory", field.name)) try writer.writeAll(equals_required);
try writer.writeAll("{_files -/}") try writer.writeAll("_themes");
else if (field.type == Config.RepeatablePath) } else if (std.mem.eql(u8, "working-directory", field.name)) {
try writer.writeAll("_files") // todo check if this is needed try writer.writeAll(equals_required);
else { try writer.writeAll("{_files -/}");
try writer.writeAll("("); } 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)) { switch (@typeInfo(field.type)) {
.Bool => try writer.writeAll("true false"), .Bool => {},
.Enum => |info| { .Enum => |info| {
try writer.writeAll(equals_required);
try writer.writeAll("(");
for (info.fields, 0..) |f, i| { for (info.fields, 0..) |f, i| {
if (i > 0) try writer.writeAll(" "); if (i > 0) try writer.writeAll(" ");
try writer.writeAll(f.name); try writer.writeAll(f.name);
} }
try writer.writeAll(")");
}, },
.Struct => |info| { .Struct => |info| {
try writer.writeAll(equals_required);
if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") {
try writer.writeAll("(");
for (info.fields, 0..) |f, i| { for (info.fields, 0..) |f, i| {
if (i > 0) try writer.writeAll(" "); if (i > 0) try writer.writeAll(" ");
try writer.writeAll(f.name); try writer.writeAll(f.name);
try writer.writeAll(" no-"); try writer.writeAll(" no-");
try writer.writeAll(f.name); try writer.writeAll(f.name);
} }
try writer.writeAll(")");
} else { } else {
//resize-overlay-duration //resize-overlay-duration
//keybind //keybind
@ -85,12 +95,14 @@ fn writeZshCompletions(writer: anytype) !void {
//foreground //foreground
//font-variation* //font-variation*
//font-feature //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"); try writer.writeAll("\" \\\n");
@ -170,10 +182,11 @@ fn writeZshCompletions(writer: anytype) !void {
try writer.writeAll(padding ++ " '--"); try writer.writeAll(padding ++ " '--");
try writer.writeAll(opt.name); try writer.writeAll(opt.name);
try writer.writeAll("=-:::");
switch (@typeInfo(opt.type)) { switch (@typeInfo(opt.type)) {
.Bool => try writer.writeAll("(true false)"), .Bool => {},
.Enum => |info| { .Enum => |info| {
try writer.writeAll(equals_required);
try writer.writeAll("("); try writer.writeAll("(");
for (info.fields, 0..) |f, i| { for (info.fields, 0..) |f, i| {
if (i > 0) try writer.writeAll(" "); if (i > 0) try writer.writeAll(" ");
@ -182,6 +195,7 @@ fn writeZshCompletions(writer: anytype) !void {
try writer.writeAll(")"); try writer.writeAll(")");
}, },
.Optional => |optional| { .Optional => |optional| {
try writer.writeAll(equals_required);
switch (@typeInfo(optional.child)) { switch (@typeInfo(optional.child)) {
.Enum => |info| { .Enum => |info| {
try writer.writeAll("("); try writer.writeAll("(");
@ -199,11 +213,13 @@ fn writeZshCompletions(writer: anytype) !void {
} }
}, },
else => { else => {
try writer.writeAll(equals_required);
if (std.mem.eql(u8, "config-file", opt.name)) { if (std.mem.eql(u8, "config-file", opt.name)) {
try writer.writeAll("_files"); try writer.writeAll("_files");
} else try writer.writeAll("( )"); } else try writer.writeAll("( )");
}, },
} }
try writer.writeAll("' \\\n"); try writer.writeAll("' \\\n");
} }
try writer.writeAll(padding ++ ";;\n"); try writer.writeAll(padding ++ ";;\n");

View File

@ -23,6 +23,7 @@ pub const BuildConfig = struct {
flatpak: bool = false, flatpak: bool = false,
adwaita: bool = false, adwaita: bool = false,
x11: bool = false, x11: bool = false,
sentry: bool = true,
app_runtime: apprt.Runtime = .none, app_runtime: apprt.Runtime = .none,
renderer: rendererpkg.Impl = .opengl, renderer: rendererpkg.Impl = .opengl,
font_backend: font.Backend = .freetype, font_backend: font.Backend = .freetype,
@ -43,6 +44,7 @@ pub const BuildConfig = struct {
step.addOption(bool, "flatpak", self.flatpak); step.addOption(bool, "flatpak", self.flatpak);
step.addOption(bool, "adwaita", self.adwaita); step.addOption(bool, "adwaita", self.adwaita);
step.addOption(bool, "x11", self.x11); step.addOption(bool, "x11", self.x11);
step.addOption(bool, "sentry", self.sentry);
step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); step.addOption(apprt.Runtime, "app_runtime", self.app_runtime);
step.addOption(font.Backend, "font_backend", self.font_backend); step.addOption(font.Backend, "font_backend", self.font_backend);
step.addOption(rendererpkg.Impl, "renderer", self.renderer); step.addOption(rendererpkg.Impl, "renderer", self.renderer);

View File

@ -63,7 +63,7 @@ pub fn run(alloc: Allocator) !u8 {
try stdout.writeAll( try stdout.writeAll(
\\ \\
\\Specify `+<action> --help` to see the help for a specific action, \\Specify `+<action> --help` to see the help for a specific action,
\\where `<action>` is one of actions listed below. \\where `<action>` is one of actions listed above.
\\ \\
); );

View File

@ -20,8 +20,9 @@ pub const Options = struct {
} }
}; };
/// The `list-actions` command is used to list all the available keybind actions /// The `list-actions` command is used to list all the available keybind
/// for Ghostty. /// 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. /// The `--docs` argument will print out the documentation for each action.
pub fn run(alloc: Allocator) !u8 { pub fn run(alloc: Allocator) !u8 {

View File

@ -3,6 +3,7 @@ const build_options = @import("build_options");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const builtin = @import("builtin"); const builtin = @import("builtin");
const build_config = @import("../build_config.zig"); const build_config = @import("../build_config.zig");
const internal_os = @import("../os/main.zig");
const xev = @import("xev"); const xev = @import("xev");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void; 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(" - renderer : {}\n", .{renderer.Renderer});
try stdout.print(" - libxev : {}\n", .{xev.backend}); try stdout.print(" - libxev : {}\n", .{xev.backend});
if (comptime build_config.app_runtime == .gtk) { 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(" - GTK version:\n", .{});
try stdout.print(" build : {d}.{d}.{d}\n", .{ try stdout.print(" build : {d}.{d}.{d}\n", .{
gtk.GTK_MAJOR_VERSION, gtk.GTK_MAJOR_VERSION,

View File

@ -147,23 +147,28 @@ const c = @cImport({
/// By default, synthetic styles are enabled. /// By default, synthetic styles are enabled.
@"font-synthetic-style": FontSyntheticStyle = .{}, @"font-synthetic-style": FontSyntheticStyle = .{},
/// Apply a font feature. This can be repeated multiple times to enable multiple /// Apply a font feature. To enable multiple font features you can repeat
/// font features. You can NOT set multiple font features with a single value /// this multiple times or use a comma-separated list of feature settings.
/// (yet). ///
/// 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 /// The font feature will apply to all fonts rendered by Ghostty. A future
/// enhancement will allow targeting specific faces. /// 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 /// To disable programming ligatures, use `-calt` since this is the typical
/// feature name for programming ligatures. To look into what font features /// 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 /// your font has and what they do, use a font inspection tool such as
/// [fontdrop.info](https://fontdrop.info). /// [fontdrop.info](https://fontdrop.info).
/// ///
/// To generally disable most ligatures, use `-calt`, `-liga`, and `-dlig` (as /// To generally disable most ligatures, use `-calt, -liga, -dlig`.
/// separate repetitive entries in your config).
@"font-feature": RepeatableString = .{}, @"font-feature": RepeatableString = .{},
/// Font size in points. This value can be a non-integer and the nearest integer /// 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 /// 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 /// true, only the first window will be affected by this change since all
/// subsequent windows will inherit the font size of the previous window. /// 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) { @"font-size": f32 = switch (builtin.os.tag) {
// On macOS we default a little bigger since this tends to look better. This // On macOS we default a little bigger since this tends to look better. This
// is purely subjective but this is easy to modify. // 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 /// 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 /// 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` /// explicitly disable flags you don't want. You can also use `true` or `false`
/// to turn all flags on or off. /// to turn all flags on or off.
/// ///
@ -398,14 +407,17 @@ const c = @cImport({
theme: ?Theme = null, theme: ?Theme = null,
/// Background color for the window. /// 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 }, background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 },
/// Foreground color for the window. /// 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 }, foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
/// The foreground and background color for selection. If this is not set, then /// The foreground and background color for selection. If this is not set, then
/// the selection color is just the inverted window background and foreground /// the selection color is just the inverted window background and foreground
/// (note: not to be confused with the cell bg/fg). /// (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-foreground": ?Color = null,
@"selection-background": ?Color = null, @"selection-background": ?Color = null,
@ -431,15 +443,20 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
@"minimum-contrast": f64 = 1, @"minimum-contrast": f64 = 1,
/// Color palette for the 256 color form that many terminal applications use. /// 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 syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for
/// the 256 colors in the terminal color table) and `HEXCODE` is a typical RGB /// the 256 colors in the terminal color table) and `COLOR` is a typical RGB
/// color code such as `#AABBCC`. /// color code such as `#AABBCC` or `AABBCC`, or a named X11 color.
/// ///
/// For definitions on all the codes [see this cheat /// The palette index can be in decimal, binary, octal, or hexadecimal.
/// sheet](https://www.ditig.com/256-colors-cheat-sheet). /// 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 = .{}, palette: Palette = .{},
/// The color of the cursor. If this is not set, a default will be chosen. /// 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, @"cursor-color": ?Color = null,
/// Swap the foreground and background colors of the cell under the cursor. This /// 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 /// The color of the text under the cursor. If this is not set, a default will
/// be chosen. /// be chosen.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"cursor-text": ?Color = null, @"cursor-text": ?Color = null,
/// Enables the ability to move the cursor at prompts by using `alt+click` on /// 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 /// than 0.01 or greater than 10,000 will be clamped to the nearest valid
/// value. /// 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. /// double the default amount. A value of "0.5" scrolls half the default amount.
/// Et cetera. /// Et cetera.
@"mouse-scroll-multiplier": f64 = 1.0, @"mouse-scroll-multiplier": f64 = 1.0,
@ -560,6 +578,8 @@ palette: Palette = .{},
/// On macOS, background opacity is disabled when the terminal enters native /// On macOS, background opacity is disabled when the terminal enters native
/// fullscreen. This is because the background becomes gray and it can cause /// fullscreen. This is because the background becomes gray and it can cause
/// widgets to show through which isn't generally desirable. /// widgets to show through which isn't generally desirable.
///
/// On macOS, changing this configuration requires restarting Ghostty completely.
@"background-opacity": f64 = 1.0, @"background-opacity": f64 = 1.0,
/// A positive value enables blurring of the background when background-opacity /// 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. /// that rectangle and can be used to carefully control the dimming effect.
/// ///
/// This will default to the background color. /// This will default to the background color.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"unfocused-split-fill": ?Color = null, @"unfocused-split-fill": ?Color = null,
/// The command to run, usually a shell. If this is not an absolute path, it'll /// 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 /// 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 /// will update for all windows. If it is unset, the next title change escape
/// sequence will be honored but previous changes will not retroactively /// 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. /// to get the new title.
title: ?[:0]const u8 = null, title: ?[:0]const u8 = null,
@ -907,6 +929,15 @@ class: ?[:0]const u8 = null,
/// Since they are not associated with a specific terminal surface, /// Since they are not associated with a specific terminal surface,
/// they're never encoded. /// 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, /// Keybind triggers are not unique per prefix combination. For example,
/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind /// `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 /// set later will overwrite the keybind set earlier. In this case, the
@ -1104,6 +1135,32 @@ keybind: Keybinds = .{},
@"window-height": u32 = 0, @"window-height": u32 = 0,
@"window-width": 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 /// Whether to enable saving and restoring window state. Window state includes
/// their position, size, tabs, splits, etc. Some window state requires shell /// their position, size, tabs, splits, etc. Some window state requires shell
/// integration, such as preserving working directories. See `shell-integration` /// 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 /// Background color for the window titlebar. This only takes effect if
/// window-theme is set to ghostty. Currently only supported in the GTK app /// window-theme is set to ghostty. Currently only supported in the GTK app
/// runtime. /// runtime.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"window-titlebar-background": ?Color = null, @"window-titlebar-background": ?Color = null,
/// Foreground color for the window titlebar. This only takes effect if /// Foreground color for the window titlebar. This only takes effect if
/// window-theme is set to ghostty. Currently only supported in the GTK app /// window-theme is set to ghostty. Currently only supported in the GTK app
/// runtime. /// runtime.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"window-titlebar-foreground": ?Color = null, @"window-titlebar-foreground": ?Color = null,
/// This controls when resize overlays are shown. Resize overlays are a /// 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 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 /// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`. /// `custom-style`.
/// ///
/// This only has an effect 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, @"macos-icon-ghost-color": ?Color = null,
/// The color of the screen in the macOS app icon. /// The color of the screen in the macOS app icon.
/// ///
/// The screen is a gradient so you can specify multiple colors that /// The screen is a gradient so you can specify multiple colors that
/// make up the gradient. Colors should be separated by commas. The /// make up the gradient. Comma-separated colors may be specified as
/// format of the color is the same as the `background` configuration; /// as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 colors.
/// see that for more information.
/// ///
/// Note: This configuration is required when `macos-icon` is set to /// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`. /// `custom-style`.
@ -1905,6 +1964,29 @@ keybind: Keybinds = .{},
/// Changing this value at runtime will only affect new windows. /// Changing this value at runtime will only affect new windows.
@"adw-toolbar-style": AdwToolbarStyle = .raised, @"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 /// 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. /// 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, /// If you set this to `false` then tabs will only take up space they need,
@ -1925,6 +2007,15 @@ keybind: Keybinds = .{},
/// Adwaita support. /// Adwaita support.
@"gtk-adwaita": bool = true, @"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 /// If `true` (default), applications running in the terminal can show desktop
/// notifications using certain escape sequences such as OSC 9 or OSC 777. /// notifications using certain escape sequences such as OSC 9 or OSC 777.
@"desktop-notifications": bool = true, @"desktop-notifications": bool = true,
@ -1963,10 +2054,11 @@ term: []const u8 = "xterm-ghostty",
/// * `download` - Check for updates, automatically download the update, /// * `download` - Check for updates, automatically download the update,
/// notify the user, but do not automatically install 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. /// 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. /// 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 }, .{ .key = .{ .translated = .v }, .mods = mods },
.{ .paste_from_clipboard = {} }, .{ .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) // 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 // Expand Selection
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .left }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .left }, .mods = .{ .shift = true } },
.{ .adjust_selection = .left }, .{ .adjust_selection = .left },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .right }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .right }, .mods = .{ .shift = true } },
.{ .adjust_selection = .right }, .{ .adjust_selection = .right },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .up }, .mods = .{ .shift = true } },
.{ .adjust_selection = .up }, .{ .adjust_selection = .up },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .down }, .mods = .{ .shift = true } },
.{ .adjust_selection = .down }, .{ .adjust_selection = .down },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } },
.{ .adjust_selection = .page_up }, .{ .adjust_selection = .page_up },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } },
.{ .adjust_selection = .page_down }, .{ .adjust_selection = .page_down },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } },
.{ .adjust_selection = .home }, .{ .adjust_selection = .home },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } },
.{ .adjust_selection = .end }, .{ .adjust_selection = .end },
.{ .performable = true },
); );
// Tabs common to all platforms // Tabs common to all platforms
@ -2247,12 +2361,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } },
.{ .goto_split = .top }, .{ .goto_split = .up },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } },
.{ .goto_split = .bottom }, .{ .goto_split = .down },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
@ -2412,10 +2526,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
.{ .key = .{ .translated = .q }, .mods = .{ .super = true } }, .{ .key = .{ .translated = .q }, .mods = .{ .super = true } },
.{ .quit = {} }, .{ .quit = {} },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .k }, .mods = .{ .super = true } }, .{ .key = .{ .translated = .k }, .mods = .{ .super = true } },
.{ .clear_screen = {} }, .{ .clear_screen = {} },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
@ -2516,12 +2631,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } }, .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } },
.{ .goto_split = .top }, .{ .goto_split = .up },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } }, .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } },
.{ .goto_split = .bottom }, .{ .goto_split = .down },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
@ -2668,18 +2783,43 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void {
try self.expandPaths(std.fs.path.dirname(path).?); 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. /// 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) { /// Returns the action that was taken.
error.FileNotFound => std.log.info( pub fn loadOptionalFile(
"optional config file not found, not loading path={s}", self: *Config,
.{path}, alloc: Allocator,
), path: []const u8,
else => std.log.warn( ) 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}", "error reading optional config file, not loading err={} path={s}",
.{ err, path }, .{ 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 /// 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` /// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config`
/// is also loaded. /// is also loaded.
pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void {
// Load XDG first
const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" });
defer alloc.free(xdg_path); 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) { if (comptime builtin.os.tag == .macos) {
const app_support_path = try internal_os.macos.appSupportDir(alloc, "config"); const app_support_path = try internal_os.macos.appSupportDir(alloc, "config");
defer alloc.free(app_support_path); 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,6 +2961,9 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
// replace the entire list with the new list. // replace the entire list with the new list.
inline for (fields, 0..) |field, i| { inline for (fields, 0..) |field, i| {
const v = &@field(self, field); const v = &@field(self, field);
// 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]; const len = v.list.items.len - counter[i];
if (len > 0) { if (len > 0) {
// Note: we don't have to worry about freeing the memory // Note: we don't have to worry about freeing the memory
@ -2819,6 +2978,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
} }
} }
} }
}
// Config files loaded from the CLI args are relative to pwd // Config files loaded from the CLI args are relative to pwd
if (self.@"config-file".value.items.len > 0) { if (self.@"config-file".value.items.len > 0) {
@ -3797,17 +3957,22 @@ pub const Color = struct {
pub fn fromHex(input: []const u8) !Color { pub fn fromHex(input: []const u8) !Color {
// Trim the beginning '#' if it exists // Trim the beginning '#' if it exists
const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input; 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 // Expand short hex values to full hex values
if (trimmed.len != 6) return error.InvalidValue; 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. // Parse the colors two at a time.
var result: Color = undefined; var result: Color = undefined;
comptime var i: usize = 0; comptime var i: usize = 0;
inline while (i < 6) : (i += 2) { inline while (i < 6) : (i += 2) {
const v: u8 = const v: u8 =
((try std.fmt.charToDigit(trimmed[i], 16)) * 16) + ((try std.fmt.charToDigit(rgb[i], 16)) * 16) +
try std.fmt.charToDigit(trimmed[i + 1], 16); try std.fmt.charToDigit(rgb[i + 1], 16);
@field(result, switch (i) { @field(result, switch (i) {
0 => "r", 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 = 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("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" { test "parseCLI from name" {
@ -3987,7 +4154,7 @@ pub const Palette = struct {
const eqlIdx = std.mem.indexOf(u8, value, "=") orelse const eqlIdx = std.mem.indexOf(u8, value, "=") orelse
return error.InvalidValue; 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 ..]); const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]);
self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; 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); 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" { test "parseCLI overflow" {
const testing = std.testing; const testing = std.testing;
@ -4291,6 +4480,45 @@ pub const RepeatablePath = struct {
// If it isn't absolute, we need to make it absolute relative // If it isn't absolute, we need to make it absolute relative
// to the base. // to the base.
var buf: [std.fs.max_path_bytes]u8 = undefined; 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: { const abs = dir.realpath(path, &buf) catch |err| abs: {
if (err == error.FileNotFound) { if (err == error.FileNotFound) {
// The file doesn't exist. Try to resolve the relative path // 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.parseCLI(alloc, "ctrl+z>2=goto_tab:2");
try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer())); try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer()));
// Note they turn into translated keys because they match
// their ASCII mapping.
const want = const want =
\\keybind = ctrl+z>1=goto_tab:1 \\keybind = ctrl+z>two=goto_tab:2
\\keybind = ctrl+z>2=goto_tab:2 \\keybind = ctrl+z>one=goto_tab:1
\\ \\
; ;
try std.testing.expectEqualStrings(want, buf.items); try std.testing.expectEqualStrings(want, buf.items);
@ -5297,6 +5527,11 @@ pub const AdwToolbarStyle = enum {
@"raised-border", @"raised-border",
}; };
/// See adw-toast
pub const AdwToast = packed struct {
@"clipboard-copy": bool = true,
};
/// See mouse-shift-capture /// See mouse-shift-capture
pub const MouseShiftCapture = enum { pub const MouseShiftCapture = enum {
false, false,

View File

@ -42,6 +42,11 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool {
ptr.* = @intCast(value); ptr.* = @intCast(value);
}, },
i16 => {
const ptr: *c_short = @ptrCast(@alignCast(ptr_raw));
ptr.* = @intCast(value);
},
f32, f64 => |Float| { f32, f64 => |Float| {
const ptr: *Float = @ptrCast(@alignCast(ptr_raw)); const ptr: *Float = @ptrCast(@alignCast(ptr_raw));
ptr.* = @floatCast(value); ptr.* = @floatCast(value);

View File

@ -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

View File

@ -1,31 +1,29 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const internal_os = @import("../os/main.zig"); const internal_os = @import("../os/main.zig");
/// Open the configuration in the OS default editor according to the default /// Open the configuration in the OS default editor according to the default
/// paths the main config file could be in. /// 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 { pub fn open(alloc_gpa: Allocator) !void {
// default location // Use an arena to make memory management easier in here.
const config_path = config_path: { var arena = ArenaAllocator.init(alloc_gpa);
const xdg_config_path = try internal_os.xdg.config(alloc_gpa, .{ .subdir = "ghostty/config" }); defer arena.deinit();
const alloc = arena.allocator();
if (comptime builtin.os.tag == .macos) macos: { // Get the path we should open
// On macOS, use the application support path if the XDG path doesn't exists. const config_path = try configPath(alloc);
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);
// Create config directory recursively. // Create config directory recursively.
if (std.fs.path.dirname(config_path)) |config_dir| { 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;
} }

View File

@ -3,7 +3,8 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const builtin = @import("builtin"); const builtin = @import("builtin");
const build_config = @import("../build_config.zig"); 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 internal_os = @import("../os/main.zig");
const crash = @import("main.zig"); const crash = @import("main.zig");
const state = &@import("../global.zig").state; 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 /// 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. /// (or they own Sentry instance) if they want to.
pub fn init(gpa: Allocator) !void { pub fn init(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
// Not supported on Windows currently, doesn't build. // Not supported on Windows currently, doesn't build.
if (comptime builtin.os.tag == .windows) return; if (comptime builtin.os.tag == .windows) return;
@ -76,6 +79,8 @@ pub fn init(gpa: Allocator) !void {
} }
fn initThread(gpa: Allocator) !void { fn initThread(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
var arena = std.heap.ArenaAllocator.init(gpa); var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit(); defer arena.deinit();
const alloc = arena.allocator(); const alloc = arena.allocator();
@ -101,7 +106,23 @@ fn initThread(gpa: Allocator) !void {
sentry.c.sentry_options_set_before_send(opts, beforeSend, null); sentry.c.sentry_options_set_before_send(opts, beforeSend, null);
// Determine the Sentry cache directory. // 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( sentry.c.sentry_options_set_database_path_n(
opts, opts,
cache_dir.ptr, cache_dir.ptr,
@ -129,6 +150,8 @@ fn initThread(gpa: Allocator) !void {
/// Process-wide deinitialization of our Sentry client. This ensures all /// Process-wide deinitialization of our Sentry client. This ensures all
/// our data is flushed. /// our data is flushed.
pub fn deinit() void { pub fn deinit() void {
if (comptime !build_options.sentry) return;
if (comptime builtin.os.tag == .windows) return; if (comptime builtin.os.tag == .windows) return;
// If we're still initializing then wait for init to finish. This // If we're still initializing then wait for init to finish. This

View File

@ -362,16 +362,9 @@ pub const CoreText = struct {
const list = set.createMatchingFontDescriptors(); const list = set.createMatchingFontDescriptors();
defer list.release(); 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 // Sort our descriptors
const zig_list = try copyMatchingDescriptors(alloc, list);
errdefer alloc.free(zig_list);
sortMatchingDescriptors(&desc, zig_list); sortMatchingDescriptors(&desc, zig_list);
return DiscoverIterator{ return DiscoverIterator{
@ -558,47 +551,13 @@ pub const CoreText = struct {
for (0..result.len) |i| { for (0..result.len) |i| {
result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i); result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i);
// We need to retain because once the list // We need to retain because once the list is freed it will
// is freed it will release all its members. // release all its members.
result[i].retain(); result[i].retain();
} }
return result; 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( fn sortMatchingDescriptors(
desc: *const Descriptor, desc: *const Descriptor,
list: []*macos.text.FontDescriptor, list: []*macos.text.FontDescriptor,

View File

@ -34,3 +34,6 @@ pub const cozette = @embedFile("res/CozetteVector.ttf");
/// Monaspace has weird ligature behaviors we want to test in our shapers /// Monaspace has weird ligature behaviors we want to test in our shapers
/// so we embed it here. /// so we embed it here.
pub const monaspace_neon = @embedFile("res/MonaspaceNeon-Regular.otf"); 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");

View File

@ -515,8 +515,17 @@ pub const Face = struct {
fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics { fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics {
// Read the 'head' table out of the font data. // Read the 'head' table out of the font data.
const head: opentype.Head = head: { const head: opentype.Head = head: {
const tag = macos.text.FontTableTag.init("head"); // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but
const data = ct_font.copyTable(tag) orelse return error.CopyTableError; // 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(); defer data.release();
const ptr = data.getPointer(); const ptr = data.getPointer();
const len = data.getLength(); const len = data.getLength();

View File

@ -288,7 +288,6 @@ pub const Face = struct {
self.face.loadGlyph(glyph_id, .{ self.face.loadGlyph(glyph_id, .{
.render = true, .render = true,
.color = self.face.hasColor(), .color = self.face.hasColor(),
.no_bitmap = !self.face.hasColor(),
}) catch return false; }) catch return false;
// If the glyph is SVG we assume colorized // 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. // glyph properties before render so we don't render here.
.render = !self.synthetic.bold, .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 // use options from config
.no_hinting = !self.load_flags.hinting, .no_hinting = !self.load_flags.hinting,
.force_autohint = !self.load_flags.@"force-autohint", .force_autohint = !self.load_flags.@"force-autohint",
@ -385,7 +376,7 @@ pub const Face = struct {
return error.UnsupportedPixelMode; 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, bitmap_ft.pixel_mode,
atlas.format, atlas.format,
}); });
@ -1005,3 +996,59 @@ test "svg font table" {
try testing.expectEqual(430, table.len); 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)],
);
}
}
}

View File

@ -43,28 +43,16 @@ pub fn monoToGrayscale(alloc: Allocator, bm: Bitmap) Allocator.Error!Bitmap {
var buf = try alloc.alloc(u8, bm.width * bm.rows); var buf = try alloc.alloc(u8, bm.width * bm.rows);
errdefer alloc.free(buf); errdefer alloc.free(buf);
// width divided by 8 because each byte has 8 pixels. This is therefore for (0..bm.rows) |y| {
// the number of bytes in each row. const row_offset = y * @as(usize, @intCast(bm.pitch));
const bytes_per_row = bm.width >> 3; for (0..bm.width) |x| {
const byte_offset = row_offset + @divTrunc(x, 8);
var source_i: usize = 0; const mask = @as(u8, 1) << @intCast(7 - (x % 8));
var target_i: usize = 0; const bit: u8 = @intFromBool((bm.buffer[byte_offset] & mask) != 0);
var i: usize = bm.rows; buf[y * bm.width + x] = bit * 255;
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;
} }
} }
source_i += @intCast(bm.pitch);
}
var copy = bm; var copy = bm;
copy.buffer = buf.ptr; copy.buffer = buf.ptr;
copy.pixel_mode = freetype.c.FT_PIXEL_MODE_GRAY; copy.pixel_mode = freetype.c.FT_PIXEL_MODE_GRAY;

View File

@ -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) - [Copyright 2013 Google LLC](https://github.com/googlefonts/noto-emoji/blob/main/LICENSE)
- Cozette (MIT) - Cozette (MIT)
- [Copyright (c) 2020, Slavfox](https://github.com/slavfox/Cozette/blob/main/LICENSE) - [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). A full copy of the OFL license can be found at [OFL.txt](./OFL.txt).
An accompanying FAQ is also available at <https://openfontlicense.org/>. An accompanying FAQ is also available at <https://openfontlicense.org/>.

Binary file not shown.

View File

@ -1,6 +1,7 @@
const builtin = @import("builtin"); const builtin = @import("builtin");
const options = @import("main.zig").options; const options = @import("main.zig").options;
const run = @import("shaper/run.zig"); const run = @import("shaper/run.zig");
const feature = @import("shaper/feature.zig");
pub const noop = @import("shaper/noop.zig"); pub const noop = @import("shaper/noop.zig");
pub const harfbuzz = @import("shaper/harfbuzz.zig"); pub const harfbuzz = @import("shaper/harfbuzz.zig");
pub const coretext = @import("shaper/coretext.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 Cache = @import("shaper/Cache.zig");
pub const TextRun = run.TextRun; pub const TextRun = run.TextRun;
pub const RunIterator = run.RunIterator; 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. /// Shaper implementation for our compile options.
pub const Shaper = switch (options.backend) { pub const Shaper = switch (options.backend) {
@ -49,10 +53,7 @@ pub const Cell = struct {
/// Options for shapers. /// Options for shapers.
pub const Options = struct { pub const Options = struct {
/// Font features to use when shaping. These can be in the following /// Font features to use when shaping.
/// 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.
/// ///
/// Note: eventually, this will move to font.Face probably as we may /// Note: eventually, this will move to font.Face probably as we may
/// want to support per-face feature configuration. For now, we only /// want to support per-face feature configuration. For now, we only

View File

@ -7,6 +7,9 @@ const trace = @import("tracy").trace;
const font = @import("../main.zig"); const font = @import("../main.zig");
const os = @import("../../os/main.zig"); const os = @import("../../os/main.zig");
const terminal = @import("../../terminal/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 Face = font.Face;
const Collection = font.Collection; const Collection = font.Collection;
const DeferredFace = font.DeferredFace; const DeferredFace = font.DeferredFace;
@ -40,9 +43,10 @@ pub const Shaper = struct {
/// The string used for shaping the current run. /// The string used for shaping the current run.
run_state: RunState, run_state: RunState,
/// The font features we want to use. The hardcoded features are always /// CoreFoundation Dictionary which represents our font feature settings.
/// set first. features: *macos.foundation.Dictionary,
features: FeatureList, /// A version of the features dictionary with the default features excluded.
features_no_default: *macos.foundation.Dictionary,
/// The shared memory used for shaping results. /// The shared memory used for shaping results.
cell_buf: CellBuf, cell_buf: CellBuf,
@ -100,51 +104,17 @@ pub const Shaper = struct {
} }
}; };
/// List of font features, parsed into the data structures used by /// Create a CoreFoundation Dictionary suitable for
/// the CoreText API. The CoreText API requires a pretty annoying wrapping /// settings the font features of a CoreText font.
/// to setup font features: fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary {
/// const list = try macos.foundation.MutableArray.create();
/// - 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,
pub fn init() !FeatureList {
var list = try macos.foundation.MutableArray.create();
errdefer list.release(); errdefer list.release();
return .{ .list = list };
}
pub fn deinit(self: FeatureList) void { for (feats) |feat| {
self.list.release(); const value_num: c_int = @intCast(feat.value);
}
/// 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);
// Keys can only be ASCII. // 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(); defer key.release();
var value = try macos.foundation.Number.create(.int, &value_num); var value = try macos.foundation.Number.create(.int, &value_num);
defer value.release(); defer value.release();
@ -154,50 +124,44 @@ pub const Shaper = struct {
macos.text.c.kCTFontOpenTypeFeatureTag, macos.text.c.kCTFontOpenTypeFeatureTag,
macos.text.c.kCTFontOpenTypeFeatureValue, macos.text.c.kCTFontOpenTypeFeatureValue,
}, },
&[_]?*const anyopaque{ &[_]?*const anyopaque{ key, value },
key,
value,
},
); );
errdefer dict.release(); defer dict.release();
return dict;
}
/// Returns the dictionary to use with the font API to set the list.appendValue(macos.foundation.Dictionary, dict);
/// 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( var dict = try macos.foundation.Dictionary.create(
&[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute}, &[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute},
&[_]?*const anyopaque{list}, &[_]?*const anyopaque{list},
); );
errdefer dict.release(); errdefer dict.release();
return dict; 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" };
/// The cell_buf argument is the buffer to use for storing shaped results. /// 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. /// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
var feats = try FeatureList.init(); var feature_list: FeatureList = .{};
errdefer feats.deinit(); defer feature_list.deinit(alloc);
for (hardcoded_features) |name| try feats.append(name); for (opts.features) |feature_str| {
for (opts.features) |name| try feats.append(name); 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(); var run_state = RunState.init();
errdefer run_state.deinit(alloc); errdefer run_state.deinit(alloc);
@ -242,7 +206,8 @@ pub const Shaper = struct {
.alloc = alloc, .alloc = alloc,
.cell_buf = .{}, .cell_buf = .{},
.run_state = run_state, .run_state = run_state,
.features = feats, .features = features,
.features_no_default = features_no_default,
.writing_direction = writing_direction, .writing_direction = writing_direction,
.cached_fonts = .{}, .cached_fonts = .{},
.cached_font_grid = 0, .cached_font_grid = 0,
@ -255,7 +220,8 @@ pub const Shaper = struct {
pub fn deinit(self: *Shaper) void { pub fn deinit(self: *Shaper) void {
self.cell_buf.deinit(self.alloc); self.cell_buf.deinit(self.alloc);
self.run_state.deinit(self.alloc); self.run_state.deinit(self.alloc);
self.features.deinit(); self.features.release();
self.features_no_default.release();
self.writing_direction.release(); self.writing_direction.release();
{ {
@ -509,8 +475,8 @@ pub const Shaper = struct {
// If we have it, return the cached attr dict. // If we have it, return the cached attr dict.
if (self.cached_fonts.items[index_int]) |cached| return cached; if (self.cached_fonts.items[index_int]) |cached| return cached;
// Features dictionary, font descriptor, font // Font descriptor, font
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3); try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 2);
const run_font = font: { const run_font = font: {
// The CoreText shaper relies on CoreText and CoreText claims // 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 face = try grid.resolver.collection.getFace(index);
const original = face.font; const original = face.font;
const attrs = try self.features.attrsDict(face.quirks_disable_default_font_features); const attrs = if (face.quirks_disable_default_font_features)
self.cf_release_pool.appendAssumeCapacity(attrs); self.features_no_default
else
self.features;
const desc = try macos.text.FontDescriptor.createWithAttributes(attrs); const desc = try macos.text.FontDescriptor.createWithAttributes(attrs);
self.cf_release_pool.appendAssumeCapacity(desc); self.cf_release_pool.appendAssumeCapacity(desc);

390
src/font/shaper/feature.zig Normal file
View File

@ -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,
);
}

View File

@ -3,6 +3,10 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const harfbuzz = @import("harfbuzz"); const harfbuzz = @import("harfbuzz");
const font = @import("../main.zig"); 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 Face = font.Face;
const Collection = font.Collection; const Collection = font.Collection;
const DeferredFace = font.DeferredFace; const DeferredFace = font.DeferredFace;
@ -10,7 +14,6 @@ const Library = font.Library;
const SharedGrid = font.SharedGrid; const SharedGrid = font.SharedGrid;
const Style = font.Style; const Style = font.Style;
const Presentation = font.Presentation; const Presentation = font.Presentation;
const terminal = @import("../../terminal/main.zig");
const log = std.log.scoped(.font_shaper); const log = std.log.scoped(.font_shaper);
@ -27,38 +30,37 @@ pub const Shaper = struct {
cell_buf: CellBuf, cell_buf: CellBuf,
/// The features to use for shaping. /// The features to use for shaping.
hb_feats: FeatureList, hb_feats: []harfbuzz.Feature,
const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); 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. /// 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. /// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
// Parse all the features we want to use. We use // Parse all the features we want to use.
var hb_feats = hb_feats: { const hb_feats = hb_feats: {
var list = try FeatureList.initCapacity(alloc, opts.features.len + hardcoded_features.len); var feature_list: FeatureList = .{};
errdefer list.deinit(alloc); defer feature_list.deinit(alloc);
try feature_list.features.appendSlice(alloc, &default_features);
for (hardcoded_features) |name| { for (opts.features) |feature_str| {
if (harfbuzz.Feature.fromString(name)) |feat| { try feature_list.appendFromString(alloc, feature_str);
try list.append(alloc, feat);
} else log.warn("failed to parse font feature: {s}", .{name});
} }
for (opts.features) |name| { var list = try alloc.alloc(harfbuzz.Feature, feature_list.features.items.len);
if (harfbuzz.Feature.fromString(name)) |feat| { errdefer alloc.free(list);
try list.append(alloc, feat);
} else log.warn("failed to parse font feature: {s}", .{name}); 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; break :hb_feats list;
}; };
errdefer hb_feats.deinit(alloc); errdefer alloc.free(hb_feats);
return Shaper{ return Shaper{
.alloc = alloc, .alloc = alloc,
@ -71,7 +73,7 @@ pub const Shaper = struct {
pub fn deinit(self: *Shaper) void { pub fn deinit(self: *Shaper) void {
self.hb_buf.destroy(); self.hb_buf.destroy();
self.cell_buf.deinit(self.alloc); 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 { 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 // If we are disabling default font features we just offset
// our features by the hardcoded items because always // our features by the hardcoded items because always
// add those at the beginning. // 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 // If our buffer is empty, we short-circuit the rest of the work

View File

@ -27,6 +27,7 @@ pub const GlobalState = struct {
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
action: ?cli.Action, action: ?cli.Action,
logging: Logging, logging: Logging,
rlimits: ResourceLimits = .{},
/// The app resources directory, equivalent to zig-out/share when we build /// The app resources directory, equivalent to zig-out/share when we build
/// from source. This is null if we can't detect it. /// from source. This is null if we can't detect it.
@ -56,6 +57,7 @@ pub const GlobalState = struct {
.alloc = undefined, .alloc = undefined,
.action = null, .action = null,
.logging = .{ .stderr = {} }, .logging = .{ .stderr = {} },
.rlimits = .{},
.resources_dir = null, .resources_dir = null,
}; };
errdefer self.deinit(); errdefer self.deinit();
@ -123,8 +125,8 @@ pub const GlobalState = struct {
std.log.info("renderer={}", .{renderer.Renderer}); std.log.info("renderer={}", .{renderer.Renderer});
std.log.info("libxev backend={}", .{xev.backend}); std.log.info("libxev backend={}", .{xev.backend});
// First things first, we fix our file descriptors // As early as possible, initialize our resource limits.
internal_os.fixMaxFiles(); self.rlimits = ResourceLimits.init();
// Initialize our crash reporting. // Initialize our crash reporting.
crash.init(self.alloc) catch |err| { 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);
}
};

View File

@ -36,6 +36,11 @@ pub const Flags = packed struct {
/// and not just while Ghostty is focused. This may not work on all platforms. /// and not just while Ghostty is focused. This may not work on all platforms.
/// See the keybind config documentation for more information. /// See the keybind config documentation for more information.
global: bool = false, 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 /// 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")) { } else if (std.mem.eql(u8, prefix, "unconsumed")) {
if (!flags.consumed) return Error.InvalidFormat; if (!flags.consumed) return Error.InvalidFormat;
flags.consumed = false; flags.consumed = false;
} else if (std.mem.eql(u8, prefix, "performable")) {
if (flags.performable) return Error.InvalidFormat;
flags.performable = true;
} else { } else {
// If we don't recognize the prefix then we're done. // If we don't recognize the prefix then we're done.
// There are trigger-specific prefixes like "physical:" so // 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; if (rhs.trigger.mods.alt) count += 1;
break :blk count; 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.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. /// The set of actions that a keybinding can take.
@ -311,17 +338,17 @@ pub const Action = union(enum) {
toggle_tab_overview: void, toggle_tab_overview: void,
/// Create a new split in the given direction. The new split will appear in /// 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, 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, goto_split: SplitFocusDirection,
/// zoom/unzoom the current split. /// zoom/unzoom the current split.
toggle_split_zoom: void, toggle_split_zoom: void,
/// Resize the current split by moving the split divider in the given /// 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, resize_split: SplitResizeParameter,
/// Equalize all splits in the current window /// Equalize all splits in the current window
@ -478,10 +505,42 @@ pub const Action = union(enum) {
previous, previous,
next, next,
top, up,
left, left,
bottom, down,
right, 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 { pub const SplitResizeDirection = enum {
@ -524,7 +583,16 @@ pub const Action = union(enum) {
comptime field: std.builtin.Type.UnionField, comptime field: std.builtin.Type.UnionField,
param: []const u8, param: []const u8,
) !field.type { ) !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), .Enum => try parseEnum(field.type, param),
.Int => try parseInt(field.type, param), .Int => try parseInt(field.type, param),
.Float => try parseFloat(field.type, param), .Float => try parseFloat(field.type, param),
@ -1019,6 +1087,14 @@ pub const Trigger = struct {
const cp = it.nextCodepoint() orelse break :unicode; const cp = it.nextCodepoint() orelse break :unicode;
if (it.nextCodepoint() != null) 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 }; result.key = .{ .unicode = cp };
continue :loop; continue :loop;
} }
@ -1554,6 +1630,19 @@ test "parse: triggers" {
try parseSingle("a=ignore"), 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 // single modifier
try testing.expectEqual(Binding{ try testing.expectEqual(Binding{
.trigger = .{ .trigger = .{
@ -1626,6 +1715,16 @@ test "parse: triggers" {
.flags = .{ .consumed = false }, .flags = .{ .consumed = false },
}, try parseSingle("unconsumed:physical:a+shift=ignore")); }, 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 // invalid key
try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore")); try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore"));

View File

@ -729,7 +729,9 @@ pub const Key = enum(c_int) {
.{ '\t', .tab }, .{ '\t', .tab },
// Keypad entries. We just assume keypad with the kp_ prefix // 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 }, .{ '0', .kp_0 },
.{ '1', .kp_1 }, .{ '1', .kp_1 },
.{ '2', .kp_2 }, .{ '2', .kp_2 },

View File

@ -728,7 +728,7 @@ fn renderSizeWindow(self: *Inspector) void {
{ {
_ = cimgui.c.igTableSetColumnIndex(1); _ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText( cimgui.c.igText(
"%d pt", "%.2f pt",
self.surface.font_size.points, self.surface.font_size.points,
); );
} }

View File

@ -49,7 +49,8 @@ pub fn main() !MainReturn {
error.InvalidAction => try stderr.print( error.InvalidAction => try stderr.print(
"Error: unknown CLI action specified. CLI actions are specified with\n" ++ "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",
.{}, .{},
), ),

View File

@ -59,3 +59,29 @@ pub fn launchedFromDesktop() bool {
else => @compileError("unsupported platform"), 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,
};
}

View File

@ -4,24 +4,27 @@ const posix = std.posix;
const log = std.log.scoped(.os); 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 /// 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. /// need to do this because each window consumes at least a handful of fds.
/// This is extracted from the Zig compiler source code. /// This is extracted from the Zig compiler source code.
pub fn fixMaxFiles() void { pub fn fixMaxFiles() ?rlimit {
if (!@hasDecl(posix.system, "rlimit")) return; 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", .{}); 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 we're already at the max, we're done.
if (lim.cur >= lim.max) { if (old.cur >= old.max) {
log.debug("file handle limit already maximized value={}", .{lim.cur}); log.debug("file handle limit already maximized value={}", .{old.cur});
return; return old;
} }
// Do a binary search for the limit. // Do a binary search for the limit.
var lim = old;
var min: posix.rlim_t = lim.cur; var min: posix.rlim_t = lim.cur;
var max: posix.rlim_t = 1 << 20; var max: posix.rlim_t = 1 << 20;
// But if there's a defined upper bound, don't search, just set it. // 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}); 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. /// Return the recommended path for temporary files.

View File

@ -12,7 +12,7 @@ const Error = error{
/// Determine the home directory for the currently executing user. This /// Determine the home directory for the currently executing user. This
/// is generally an expensive process so the value should be cached. /// 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) { return switch (builtin.os.tag) {
inline .linux, .macos => try homeUnix(buf), inline .linux, .macos => try homeUnix(buf),
.windows => try homeWindows(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. // First: if we have a HOME env var, then we use that.
if (posix.getenv("HOME")) |result| { if (posix.getenv("HOME")) |result| {
if (buf.len < result.len) return Error.BufferTooSmall; if (buf.len < result.len) return Error.BufferTooSmall;
@ -77,7 +77,7 @@ fn homeUnix(buf: []u8) !?[]u8 {
return null; return null;
} }
fn homeWindows(buf: []u8) !?[]u8 { fn homeWindows(buf: []u8) !?[]const u8 {
const drive_len = blk: { const drive_len = blk: {
var fba_instance = std.heap.FixedBufferAllocator.init(buf); var fba_instance = std.heap.FixedBufferAllocator.init(buf);
const fba = fba_instance.allocator(); const fba = fba_instance.allocator();
@ -110,6 +110,68 @@ fn trimSpace(input: []const u8) []const u8 {
return std.mem.trim(u8, input, " \n\t"); 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 { test {
const testing = std.testing; const testing = std.testing;

View File

@ -24,42 +24,27 @@ pub const AppSupportDirError = Allocator.Error || error{AppleAPIFailed};
pub fn appSupportDir( pub fn appSupportDir(
alloc: Allocator, alloc: Allocator,
sub_path: []const u8, sub_path: []const u8,
) AppSupportDirError![]u8 { ) AppSupportDirError![]const u8 {
comptime assert(builtin.target.isDarwin()); return try commonDir(
alloc,
const NSFileManager = objc.getClass("NSFileManager").?; .NSApplicationSupportDirectory,
const manager = NSFileManager.msgSend( &.{ build_config.bundle_id, sub_path },
objc.Object,
objc.sel("defaultManager"),
.{},
); );
}
const url = manager.msgSend( pub const CacheDirError = Allocator.Error || error{AppleAPIFailed};
objc.Object,
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), /// Return the path to the system cache directory with the given sub path joined.
.{ /// This allocates the result using the given allocator.
NSSearchPathDirectory.NSApplicationSupportDirectory, pub fn cacheDir(
NSSearchPathDomainMask.NSUserDomainMask, alloc: Allocator,
@as(?*anyopaque, null), sub_path: []const u8,
true, ) CacheDirError![]const u8 {
@as(?*anyopaque, null), 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{ pub const SetQosClassError = error{
@ -110,9 +95,79 @@ pub const NSOperatingSystemVersion = extern struct {
}; };
pub const NSSearchPathDirectory = enum(c_ulong) { pub const NSSearchPathDirectory = enum(c_ulong) {
NSCachesDirectory = 13,
NSApplicationSupportDirectory = 14, NSApplicationSupportDirectory = 14,
}; };
pub const NSSearchPathDomainMask = enum(c_ulong) { pub const NSSearchPathDomainMask = enum(c_ulong) {
NSUserDomainMask = 1, 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);
}
}

View File

@ -32,14 +32,19 @@ pub const getenv = env.getenv;
pub const setenv = env.setenv; pub const setenv = env.setenv;
pub const unsetenv = env.unsetenv; pub const unsetenv = env.unsetenv;
pub const launchedFromDesktop = desktop.launchedFromDesktop; pub const launchedFromDesktop = desktop.launchedFromDesktop;
pub const desktopEnvironment = desktop.desktopEnvironment;
pub const rlimit = file.rlimit;
pub const fixMaxFiles = file.fixMaxFiles; pub const fixMaxFiles = file.fixMaxFiles;
pub const restoreMaxFiles = file.restoreMaxFiles;
pub const allocTmpDir = file.allocTmpDir; pub const allocTmpDir = file.allocTmpDir;
pub const freeTmpDir = file.freeTmpDir; pub const freeTmpDir = file.freeTmpDir;
pub const isFlatpak = flatpak.isFlatpak; pub const isFlatpak = flatpak.isFlatpak;
pub const FlatpakHostCommand = flatpak.FlatpakHostCommand; pub const FlatpakHostCommand = flatpak.FlatpakHostCommand;
pub const home = homedir.home; pub const home = homedir.home;
pub const expandHome = homedir.expandHome;
pub const ensureLocale = locale.ensureLocale; pub const ensureLocale = locale.ensureLocale;
pub const clickInterval = mouse.clickInterval; pub const clickInterval = mouse.clickInterval;
pub const open = openpkg.open; pub const open = openpkg.open;
pub const OpenType = openpkg.Type;
pub const pipe = pipepkg.pipe; pub const pipe = pipepkg.pipe;
pub const resourcesDir = resourcesdir.resourcesDir; pub const resourcesDir = resourcesdir.resourcesDir;

View File

@ -2,25 +2,50 @@ const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const Allocator = std.mem.Allocator; 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. /// Open a URL in the default handling application.
/// ///
/// Any output on stderr is logged as a warning in the application logs. /// Any output on stderr is logged as a warning in the application logs.
/// Output on stdout is ignored. /// Output on stdout is ignored.
pub fn open(alloc: Allocator, url: []const u8) !void { pub fn open(
// Some opener commands terminate after opening (macOS open) and some do not alloc: Allocator,
// (xdg-open). For those which do not terminate, we do not want to wait for typ: Type,
// the process to exit to collect stderr. url: []const u8,
const argv, const wait = switch (builtin.os.tag) { ) !void {
.linux => .{ &.{ "xdg-open", url }, false }, const cmd: OpenCommand = switch (builtin.os.tag) {
.macos => .{ &.{ "open", url }, true }, .linux => .{ .child = std.process.Child.init(
.windows => .{ &.{ "rundll32", "url.dll,FileProtocolHandler", url }, false }, &.{ "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, .ios => return error.Unimplemented,
else => @compileError("unsupported OS"), else => @compileError("unsupported OS"),
}; };
var exe = std.process.Child.init(argv, alloc); var exe = cmd.child;
if (cmd.wait) {
if (comptime wait) {
// Pipe stdout/stderr so we can collect output from the command // Pipe stdout/stderr so we can collect output from the command
exe.stdout_behavior = .Pipe; exe.stdout_behavior = .Pipe;
exe.stderr_behavior = .Pipe; exe.stderr_behavior = .Pipe;
@ -28,7 +53,7 @@ pub fn open(alloc: Allocator, url: []const u8) !void {
try exe.spawn(); try exe.spawn();
if (comptime wait) { if (cmd.wait) {
// 50 KiB is the default value used by std.process.Child.run // 50 KiB is the default value used by std.process.Child.run
const output_max_size = 50 * 1024; 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}); if (stderr.items.len > 0) std.log.err("open stderr={s}", .{stderr.items});
} }
} }
const OpenCommand = struct {
child: std.process.Child,
wait: bool = false,
};

View File

@ -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 { test parseTerminalExec {
const testing = std.testing; const testing = std.testing;

View File

@ -146,7 +146,7 @@ image_bg_end: u32 = 0,
image_text_end: u32 = 0, image_text_end: u32 = 0,
image_virtual: bool = false, image_virtual: bool = false,
/// Defererred OpenGL operation to update the screen size. /// Deferred OpenGL operation to update the screen size.
const SetScreenSize = struct { const SetScreenSize = struct {
size: renderer.Size, size: renderer.Size,

View File

@ -68,6 +68,34 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
builtin unset ghostty_bash_inject rcfile builtin unset ghostty_bash_inject rcfile
fi 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 # Import bash-preexec, safe to do multiple times
builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh" builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh"
@ -109,31 +137,6 @@ function __ghostty_precmd() {
PS0=$PS0'\[\e[0 q\]' PS0=$PS0'\[\e[0 q\]'
fi 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 if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then
# Command and working directory # Command and working directory
# shellcheck disable=SC2016 # shellcheck disable=SC2016

View File

@ -3413,6 +3413,16 @@ pub const Pin = struct {
direction: Direction, direction: Direction,
limit: ?Pin, limit: ?Pin,
) PageIterator { ) 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 .{ return .{
.row = self, .row = self,
.limit = if (limit) |p| .{ .row = p } else .{ .none = {} }, .limit = if (limit) |p| .{ .row = p } else .{ .none = {} },

View File

@ -382,6 +382,7 @@ fn encodeError(r: *Response, err: EncodeableError) void {
error.DecompressionFailed => r.message = "EINVAL: decompression failed", error.DecompressionFailed => r.message = "EINVAL: decompression failed",
error.FilePathTooLong => r.message = "EINVAL: file path too long", error.FilePathTooLong => r.message = "EINVAL: file path too long",
error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir", 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.UnsupportedFormat => r.message = "EINVAL: unsupported format",
error.UnsupportedMedium => r.message = "EINVAL: unsupported medium", error.UnsupportedMedium => r.message = "EINVAL: unsupported medium",
error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth", error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth",

View File

@ -220,6 +220,9 @@ pub const LoadingImage = struct {
// Temporary file logic // Temporary file logic
if (medium == .temporary_file) { if (medium == .temporary_file) {
if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir; if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir;
if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) {
return error.TemporaryFileNotNamedCorrectly;
}
} }
defer if (medium == .temporary_file) { defer if (medium == .temporary_file) {
posix.unlink(path) catch |err| { posix.unlink(path) catch |err| {
@ -469,6 +472,7 @@ pub const Image = struct {
DimensionsTooLarge, DimensionsTooLarge,
FilePathTooLong, FilePathTooLong,
TemporaryFileNotInTempDir, TemporaryFileNotInTempDir,
TemporaryFileNotNamedCorrectly,
UnsupportedFormat, UnsupportedFormat,
UnsupportedMedium, UnsupportedMedium,
UnsupportedDepth, UnsupportedDepth,
@ -682,7 +686,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk"
try testing.expect(img.compression == .none); 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 testing = std.testing;
const alloc = testing.allocator; 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; 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("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 = .{ var cmd: command.Command = .{
.control = .{ .transmit = .{ .control = .{ .transmit = .{
.format = .rgb, .format = .rgb,
@ -762,12 +799,12 @@ test "image load: png, not compressed, regular file" {
defer tmp_dir.deinit(); defer tmp_dir.deinit();
const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
try tmp_dir.dir.writeFile(.{ try tmp_dir.dir.writeFile(.{
.sub_path = "image.data", .sub_path = "tty-graphics-protocol-image.data",
.data = data, .data = data,
}); });
var buf: [std.fs.max_path_bytes]u8 = undefined; 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 = .{ var cmd: command.Command = .{
.control = .{ .transmit = .{ .control = .{ .transmit = .{

View File

@ -189,11 +189,20 @@ pub const Parser = struct {
.@"8_fg" = @enumFromInt(slice[0] - 30), .@"8_fg" = @enumFromInt(slice[0] - 30),
}, },
38 => if (slice.len >= 5 and slice[1] == 2) { 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; self.idx += 4;
// When a colon separator is used, there may or may not be
// In the 6-len form, ignore the 3rd param. // a color space identifier as the third param, which we
const rgb = slice[2..5]; // 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 use @truncate because the value should be 0 to 255. If // We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it. // it isn't, the behavior is undefined so we just... truncate it.
@ -204,12 +213,16 @@ pub const Parser = struct {
.b = @truncate(rgb[2]), .b = @truncate(rgb[2]),
}, },
}; };
} else if (slice.len >= 3 and slice[1] == 5) { },
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2; self.idx += 2;
return Attribute{ return Attribute{
.@"256_fg" = @truncate(slice[2]), .@"256_fg" = @truncate(slice[2]),
}; };
}, },
else => {},
},
39 => return Attribute{ .reset_fg = {} }, 39 => return Attribute{ .reset_fg = {} },
@ -217,11 +230,20 @@ pub const Parser = struct {
.@"8_bg" = @enumFromInt(slice[0] - 40), .@"8_bg" = @enumFromInt(slice[0] - 40),
}, },
48 => if (slice.len >= 5 and slice[1] == 2) { 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; self.idx += 4;
// When a colon separator is used, there may or may not be
// We only support the 5-len form. // a color space identifier as the third param, which we
const rgb = slice[2..5]; // 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 use @truncate because the value should be 0 to 255. If // We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it. // it isn't, the behavior is undefined so we just... truncate it.
@ -232,24 +254,33 @@ pub const Parser = struct {
.b = @truncate(rgb[2]), .b = @truncate(rgb[2]),
}, },
}; };
} else if (slice.len >= 3 and slice[1] == 5) { },
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2; self.idx += 2;
return Attribute{ return Attribute{
.@"256_bg" = @truncate(slice[2]), .@"256_bg" = @truncate(slice[2]),
}; };
}, },
else => {},
},
49 => return Attribute{ .reset_bg = {} }, 49 => return Attribute{ .reset_bg = {} },
53 => return Attribute{ .overline = {} }, 53 => return Attribute{ .overline = {} },
55 => return Attribute{ .reset_overline = {} }, 55 => return Attribute{ .reset_overline = {} },
58 => if (slice.len >= 5 and slice[1] == 2) { 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; self.idx += 4;
// When a colon separator is used, there may or may not be
// In the 6-len form, ignore the 3rd param. Otherwise, use it. // a color space identifier as the third param, which we
const rgb = if (slice.len == 5) slice[2..5] else rgb: { // need to ignore (it has no standardized behavior).
// Consume one more element const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1; self.idx += 1;
break :rgb slice[3..6]; break :rgb slice[3..6];
}; };
@ -263,12 +294,16 @@ pub const Parser = struct {
.b = @truncate(rgb[2]), .b = @truncate(rgb[2]),
}, },
}; };
} else if (slice.len >= 3 and slice[1] == 5) { },
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2; self.idx += 2;
return Attribute{ return Attribute{
.@"256_underline_color" = @truncate(slice[2]), .@"256_underline_color" = @truncate(slice[2]),
}; };
}, },
else => {},
},
59 => return Attribute{ .reset_underline_color = {} }, 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 }; var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false };
while (p.next()) |_| {} 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);
}
}

View File

@ -380,7 +380,8 @@ pub fn Stream(comptime Handler: type) type {
fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void {
switch (input.final) { switch (input.final) {
// CUU - Cursor Up // CUU - Cursor Up
'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( 'A', 'k' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -392,8 +393,15 @@ pub fn Stream(comptime Handler: type) type {
false, false,
) 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},
),
},
// CUD - Cursor Down // CUD - Cursor Down
'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( 'B' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -405,8 +413,15 @@ pub fn Stream(comptime Handler: type) type {
false, false,
) 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},
),
},
// CUF - Cursor Right // CUF - Cursor Right
'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( 'C' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -417,8 +432,15 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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 // CUB - Cursor Left
'D', 'j' => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( 'D', 'j' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -429,8 +451,15 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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 // CNL - Cursor Next Line
'E' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( 'E' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -442,8 +471,15 @@ pub fn Stream(comptime Handler: type) type {
true, true,
) 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},
),
},
// CPL - Cursor Previous Line // CPL - Cursor Previous Line
'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( 'F' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -455,25 +491,46 @@ pub fn Stream(comptime Handler: type) type {
true, true,
) else log.warn("unimplemented CSI callback: {}", .{input}), ) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI F with intermediates: {s}",
.{input.intermediates},
),
},
// HPA - Cursor Horizontal Position Absolute // HPA - Cursor Horizontal Position Absolute
// TODO: test // TODO: test
'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) { 'G', '`' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) {
0 => try self.handler.setCursorCol(1), 0 => try self.handler.setCursorCol(1),
1 => try self.handler.setCursorCol(input.params[0]), 1 => try self.handler.setCursorCol(input.params[0]),
else => log.warn("invalid HPA command: {}", .{input}), else => log.warn("invalid HPA command: {}", .{input}),
} 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},
),
},
// CUP - Set Cursor Position. // CUP - Set Cursor Position.
// TODO: test // TODO: test
'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) { 'H', 'f' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) {
0 => try self.handler.setCursorPos(1, 1), 0 => try self.handler.setCursorPos(1, 1),
1 => try self.handler.setCursorPos(input.params[0], 1), 1 => try self.handler.setCursorPos(input.params[0], 1),
2 => try self.handler.setCursorPos(input.params[0], input.params[1]), 2 => try self.handler.setCursorPos(input.params[0], input.params[1]),
else => log.warn("invalid CUP command: {}", .{input}), else => log.warn("invalid CUP command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}), } else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI H with intermediates: {s}",
.{input.intermediates},
),
},
// CHT - Cursor Horizontal Tabulation // CHT - Cursor Horizontal Tabulation
'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( 'I' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -484,6 +541,12 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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 // Erase Display
'J' => if (@hasDecl(T, "eraseDisplay")) { 'J' => if (@hasDecl(T, "eraseDisplay")) {
const protected_: ?bool = switch (input.intermediates.len) { const protected_: ?bool = switch (input.intermediates.len) {
@ -540,22 +603,37 @@ pub fn Stream(comptime Handler: type) type {
// IL - Insert Lines // IL - Insert Lines
// TODO: test // TODO: test
'L' => if (@hasDecl(T, "insertLines")) switch (input.params.len) { 'L' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "insertLines")) switch (input.params.len) {
0 => try self.handler.insertLines(1), 0 => try self.handler.insertLines(1),
1 => try self.handler.insertLines(input.params[0]), 1 => try self.handler.insertLines(input.params[0]),
else => log.warn("invalid IL command: {}", .{input}), else => log.warn("invalid IL command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}), } else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI L with intermediates: {s}",
.{input.intermediates},
),
},
// DL - Delete Lines // DL - Delete Lines
// TODO: test // TODO: test
'M' => if (@hasDecl(T, "deleteLines")) switch (input.params.len) { 'M' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "deleteLines")) switch (input.params.len) {
0 => try self.handler.deleteLines(1), 0 => try self.handler.deleteLines(1),
1 => try self.handler.deleteLines(input.params[0]), 1 => try self.handler.deleteLines(input.params[0]),
else => log.warn("invalid DL command: {}", .{input}), else => log.warn("invalid DL command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}), } else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI M with intermediates: {s}",
.{input.intermediates},
),
},
// Delete Character (DCH) // Delete Character (DCH)
'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( 'P' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -566,6 +644,12 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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) // Scroll Up (SD)
'S' => switch (input.intermediates.len) { 'S' => switch (input.intermediates.len) {
@ -587,7 +671,8 @@ pub fn Stream(comptime Handler: type) type {
}, },
// Scroll Down (SD) // Scroll Down (SD)
'T' => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( 'T' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -598,27 +683,31 @@ pub fn Stream(comptime Handler: type) type {
}, },
) else log.warn("unimplemented CSI callback: {}", .{input}), ) else log.warn("unimplemented CSI callback: {}", .{input}),
// Cursor Tabulation Control else => log.warn(
'W' => { "ignoring unimplemented CSI T with intermediates: {s}",
switch (input.params.len) { .{input.intermediates},
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] == '?') { // Cursor Tabulation Control
if (input.params[0] == 5) { 'W' => switch (input.intermediates.len) {
if (@hasDecl(T, "tabReset")) 0 => {
try self.handler.tabReset() if (input.params.len == 0 or
else (input.params.len == 1 and input.params[0] == 0))
log.warn("unimplemented tab reset callback: {}", .{input}); {
} else log.warn("invalid cursor tabulation control: {}", .{input}); if (@hasDecl(T, "tabSet"))
} else {
switch (input.params[0]) {
0 => if (@hasDecl(T, "tabSet"))
try self.handler.tabSet() try self.handler.tabSet()
else else
log.warn("unimplemented tab set callback: {}", .{input}), 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")) 2 => if (@hasDecl(T, "tabClear"))
try self.handler.tabClear(.current) try self.handler.tabClear(.current)
@ -631,7 +720,6 @@ pub fn Stream(comptime Handler: type) type {
log.warn("unimplemented tab clear callback: {}", .{input}), log.warn("unimplemented tab clear callback: {}", .{input}),
else => {}, else => {},
}
}, },
else => {}, else => {},
@ -641,8 +729,22 @@ pub fn Stream(comptime Handler: type) type {
return; 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) // Erase Characters (ECH)
'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( 'X' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -653,8 +755,15 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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 // CHT - Cursor Horizontal Tabulation Back
'Z' => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( 'Z' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -665,8 +774,15 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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 // HPR - Cursor Horizontal Position Relative
'a' => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( 'a' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -677,8 +793,15 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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) // Repeat Previous Char (REP)
'b' => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( 'b' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -689,6 +812,12 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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 - Device Attributes (DA1)
'c' => if (@hasDecl(T, "deviceAttributes")) { 'c' => if (@hasDecl(T, "deviceAttributes")) {
const req: ansi.DeviceAttributeReq = switch (input.intermediates.len) { const req: ansi.DeviceAttributeReq = switch (input.intermediates.len) {
@ -708,7 +837,8 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented CSI callback: {}", .{input}), } else log.warn("unimplemented CSI callback: {}", .{input}),
// VPA - Cursor Vertical Position Absolute // VPA - Cursor Vertical Position Absolute
'd' => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( 'd' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -719,8 +849,15 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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 // VPR - Cursor Vertical Position Relative
'e' => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( 'e' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative(
switch (input.params.len) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -731,9 +868,16 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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 // TBC - Tab Clear
// TODO: test // TODO: test
'g' => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( 'g' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear(
switch (input.params.len) { switch (input.params.len) {
1 => @enumFromInt(input.params[0]), 1 => @enumFromInt(input.params[0]),
else => { else => {
@ -743,6 +887,12 @@ pub fn Stream(comptime Handler: type) type {
}, },
) 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 // SM - Set Mode
'h' => if (@hasDecl(T, "setMode")) mode: { 'h' => if (@hasDecl(T, "setMode")) mode: {
const ansi_mode = ansi: { const ansi_mode = ansi: {
@ -1564,10 +1714,13 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented ESC callback: {}", .{action}), } else log.warn("unimplemented ESC callback: {}", .{action}),
// HTS - Horizontal Tab Set // HTS - Horizontal Tab Set
'H' => if (@hasDecl(T, "tabSet")) 'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) {
try self.handler.tabSet() 0 => try self.handler.tabSet(),
else else => {
log.warn("unimplemented tab set callback: {}", .{action}), log.warn("invalid tab set command: {}", .{action});
return;
},
} else log.warn("unimplemented tab set callback: {}", .{action}),
// RI - Reverse Index // RI - Reverse Index
'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { '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}), } else log.warn("unimplemented invokeCharset: {}", .{action}),
// SPA - Start of Guarded Area // 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); try self.handler.setProtectedMode(ansi.ProtectedMode.iso);
} else log.warn("unimplemented ESC callback: {}", .{action}), } else log.warn("unimplemented ESC callback: {}", .{action}),
// EPA - End of Guarded Area // 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); try self.handler.setProtectedMode(ansi.ProtectedMode.off);
} else log.warn("unimplemented ESC callback: {}", .{action}), } else log.warn("unimplemented ESC callback: {}", .{action}),
// DECID // DECID
'Z' => if (@hasDecl(T, "deviceAttributes")) { 'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) {
try self.handler.deviceAttributes(.primary, &.{}); try self.handler.deviceAttributes(.primary, &.{});
} else log.warn("unimplemented ESC callback: {}", .{action}), } else log.warn("unimplemented ESC callback: {}", .{action}),
@ -1666,12 +1819,12 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented invokeCharset: {}", .{action}), } else log.warn("unimplemented invokeCharset: {}", .{action}),
// Set application keypad mode // Set application keypad mode
'=' => if (@hasDecl(T, "setMode")) { '=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
try self.handler.setMode(.keypad_keys, true); try self.handler.setMode(.keypad_keys, true);
} else log.warn("unimplemented setMode: {}", .{action}), } else log.warn("unimplemented setMode: {}", .{action}),
// Reset application keypad mode // Reset application keypad mode
'>' => if (@hasDecl(T, "setMode")) { '>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
try self.handler.setMode(.keypad_keys, false); try self.handler.setMode(.keypad_keys, false);
} else log.warn("unimplemented setMode: {}", .{action}), } else log.warn("unimplemented setMode: {}", .{action}),
@ -1753,6 +1906,10 @@ test "stream: cursor right (CUF)" {
s.handler.amount = 0; s.handler.amount = 0;
try s.nextSlice("\x1B[5;4C"); try s.nextSlice("\x1B[5;4C");
try testing.expectEqual(@as(u16, 0), s.handler.amount); 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)" { 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 s.nextSlice("\x1B[?6l");
try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); 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)" { 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 s.nextSlice("\x1B[4l");
try testing.expect(s.handler.mode == null); 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" { 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.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?);
try testing.expect(!s.handler.protected.?); 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" { test "stream: DECEL, DECSEL" {
@ -1997,6 +2168,12 @@ test "stream: DECEL, DECSEL" {
try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?);
try testing.expect(!s.handler.protected.?); 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" { test "stream: DECSCUSR" {
@ -2014,6 +2191,10 @@ test "stream: DECSCUSR" {
try s.nextSlice("\x1B[1 q"); try s.nextSlice("\x1B[1 q");
try testing.expect(s.handler.style.? == .blinking_block); 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" { test "stream: DECSCUSR without space" {
@ -2054,6 +2235,10 @@ test "stream: XTSHIFTESCAPE" {
try s.nextSlice("\x1B[>1s"); try s.nextSlice("\x1B[>1s");
try testing.expect(s.handler.escape.? == true); 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" { test "stream: change window title with invalid utf-8" {
@ -2374,6 +2559,14 @@ test "stream CSI W tab set" {
s.handler.called = false; s.handler.called = false;
try s.nextSlice("\x1b[0W"); try s.nextSlice("\x1b[0W");
try testing.expect(s.handler.called); 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" { test "stream CSI ? W reset tab stops" {
@ -2392,4 +2585,8 @@ test "stream CSI ? W reset tab stops" {
try s.nextSlice("\x1b[?5W"); try s.nextSlice("\x1b[?5W");
try testing.expect(s.handler.reset); 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);
} }

View File

@ -466,6 +466,9 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void {
// for alt screen, we do nothing. // for alt screen, we do nothing.
if (self.terminal.active_screen == .alternate) return; if (self.terminal.active_screen == .alternate) return;
// Clear our selection
self.terminal.screen.clearSelection();
// Clear our scrollback // Clear our scrollback
if (history) self.terminal.eraseDisplay(.scrollback, false); if (history) self.terminal.eraseDisplay(.scrollback, false);

View File

@ -42,6 +42,7 @@ wdth = "wdth"
Strat = "Strat" Strat = "Strat"
grey = "gray" grey = "gray"
greyscale = "grayscale" greyscale = "grayscale"
DECID = "DECID"
[type.swift.extend-words] [type.swift.extend-words]
inout = "inout" inout = "inout"