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

View File

@ -376,6 +376,41 @@ jobs:
-Dgtk-adwaita=${{ matrix.adwaita }} \
-Dgtk-x11=${{ matrix.x11 }}
test-sentry-linux:
strategy:
fail-fast: false
matrix:
sentry: ["true", "false"]
name: Build -Dsentry=${{ matrix.sentry }}
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.2.0
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@v30
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Test Sentry Build
run: |
nix develop -c zig build -Dsentry=${{ matrix.sentry }}
test-macos:
runs-on: namespace-profile-ghostty-macos
needs: test
@ -478,3 +513,38 @@ jobs:
useDaemon: false # sometimes fails on short jobs
- name: typos check
run: nix develop -c typos
test-pkg-linux:
strategy:
fail-fast: false
matrix:
pkg: ["wuffs"]
name: Test pkg/${{ matrix.pkg }}
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.2.0
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@v30
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Test ${{ matrix.pkg }} Build
run: |
nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test"

View File

@ -117,3 +117,11 @@ relevant to package maintainers:
often necessary for system packages to specify a specific minimum Linux
version, glibc, etc. Run `zig targets` to a get a full list of available
targets.
> [!WARNING]
>
> **The GLFW runtime is not meant for distribution.** The GLFW runtime
> (`-Dapp-runtime=glfw`) is meant for development and testing only. It is
> missing many features, has known memory leak scenarios, known crashes,
> and more. Please do not package the GLFW-based Ghostty runtime for
> distribution.

View File

@ -43,7 +43,7 @@ comptime {
}
/// The version of the next release.
const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 1 };
const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 2 };
pub fn build(b: *std.Build) !void {
const optimize = b.standardOptimizeOption(.{});
@ -152,12 +152,36 @@ pub fn build(b: *std.Build) !void {
}
};
config.sentry = b.option(
bool,
"sentry",
"Build with Sentry crash reporting. Default for macOS is true, false for any other system.",
) orelse sentry: {
switch (target.result.os.tag) {
.macos, .ios => break :sentry true,
// Note its false for linux because the crash reports on Linux
// don't have much useful information.
else => break :sentry false,
}
};
const pie = b.option(
bool,
"pie",
"Build a Position Independent Executable. Default true for system packages.",
) orelse system_package;
const strip = b.option(
bool,
"strip",
"Strip the final executable. Default true for fast and small releases",
) orelse switch (optimize) {
.Debug => false,
.ReleaseSafe => false,
.ReleaseFast, .ReleaseSmall => true,
};
const conformance = b.option(
[]const u8,
"conformance",
@ -342,11 +366,7 @@ pub fn build(b: *std.Build) !void {
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.strip = switch (optimize) {
.Debug => false,
.ReleaseSafe => false,
.ReleaseFast, .ReleaseSmall => true,
},
.strip = strip,
}) else null;
// Exe
@ -669,7 +689,12 @@ pub fn build(b: *std.Build) !void {
b.installFile("images/icons/icon_128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png");
// Flatpaks only support icons up to 512x512.
if (!config.flatpak) {
b.installFile("images/icons/icon_1024.png", "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png");
}
b.installFile("images/icons/icon_16@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_32@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_128@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png");
@ -685,6 +710,7 @@ pub fn build(b: *std.Build) !void {
.root_source_file = b.path("src/main_c.zig"),
.optimize = optimize,
.target = target,
.strip = strip,
});
_ = try addDeps(b, lib, config);
@ -702,6 +728,7 @@ pub fn build(b: *std.Build) !void {
.root_source_file = b.path("src/main_c.zig"),
.optimize = optimize,
.target = target,
.strip = strip,
});
_ = try addDeps(b, lib, config);
@ -1240,13 +1267,15 @@ fn addDeps(
}
// Sentry
if (config.sentry) {
const sentry_dep = b.dependency("sentry", .{
.target = target,
.optimize = optimize,
.backend = .breakpad,
});
step.root_module.addImport("sentry", sentry_dep.module("sentry"));
if (target.result.os.tag != .windows) {
// Sentry
step.linkLibrary(sentry_dep.artifact("sentry"));
try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin());

View File

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

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 {
GHOSTTY_GOTO_SPLIT_PREVIOUS,
GHOSTTY_GOTO_SPLIT_NEXT,
GHOSTTY_GOTO_SPLIT_TOP,
GHOSTTY_GOTO_SPLIT_UP,
GHOSTTY_GOTO_SPLIT_LEFT,
GHOSTTY_GOTO_SPLIT_BOTTOM,
GHOSTTY_GOTO_SPLIT_DOWN,
GHOSTTY_GOTO_SPLIT_RIGHT,
} ghostty_action_goto_split_e;

View File

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

View File

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

View File

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

View File

@ -101,6 +101,12 @@ class TerminalController: BaseTerminalController {
// When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not.
guard let focusedSurface else { return }
if (!(fullscreenStyle?.isFullscreen ?? false) &&
ghostty.config.macosTitlebarStyle == "hidden")
{
applyHiddenTitlebarStyle()
}
syncAppearance(focusedSurface.derivedConfig)
}
@ -117,9 +123,6 @@ class TerminalController: BaseTerminalController {
// Update our derived config
self.derivedConfig = DerivedConfig(config)
guard let window = window as? TerminalWindow else { return }
window.focusFollowsMouse = config.focusFollowsMouse
// If we have no surfaces in our window (is that possible?) then we update
// our window appearance based on the root config. If we have surfaces, we
// don't call this because the TODO
@ -247,7 +250,9 @@ class TerminalController: BaseTerminalController {
let backgroundColor: OSColor
if let surfaceTree {
if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) {
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor)
// Similar to above, an alpha component of "0" causes compositor issues, so
// we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001)
} else {
// We don't have a focused surface or our surface doesn't border the
// top. We choose to match the color of the top-left most surface.
@ -270,6 +275,28 @@ class TerminalController: BaseTerminalController {
}
}
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
guard let window else { return }
// If we don't have both an X and Y we center.
guard let x, let y else {
window.center()
return
}
// Prefer the screen our window is being placed on otherwise our primary screen.
guard let screen = window.screen ?? NSScreen.screens.first else {
window.center()
return
}
// Orient based on the top left of the primary monitor
let frame = screen.visibleFrame
window.setFrameOrigin(.init(
x: frame.minX + CGFloat(x),
y: frame.maxY - (CGFloat(y) + window.frame.height)))
}
//MARK: - NSWindowController
override func windowWillLoad() {
@ -277,6 +304,43 @@ class TerminalController: BaseTerminalController {
shouldCascadeWindows = false
}
fileprivate func applyHiddenTitlebarStyle() {
guard let window else { return }
window.styleMask = [
// We need `titled` in the mask to get the normal window frame
.titled,
// Full size content view so we can extend
// content in to the hidden titlebar's area
.fullSizeContentView,
.resizable,
.closable,
.miniaturizable,
]
// Hide the title
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
// Hide the traffic lights (window control buttons)
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
window.tabbingMode = .disallowed
// Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
// some operations that appear to bring back the titlebar visibility so this ensures
// it is gone forever.
if let themeFrame = window.contentView?.superview,
let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
titleBarContainer.isHidden = true
}
}
override func windowDidLoad() {
super.windowDidLoad()
guard let window = window as? TerminalWindow else { return }
@ -328,9 +392,12 @@ class TerminalController: BaseTerminalController {
}
}
// Center the window to start, we'll move the window frame automatically
// when cascading.
window.center()
// Set our window positioning to coordinates if config value exists, otherwise
// fallback to original centering behavior
setInitialWindowPosition(
x: config.windowPositionX,
y: config.windowPositionY,
windowDecorations: config.windowDecorations)
// Make sure our theme is set on the window so styling is correct.
if let windowTheme = config.windowTheme {
@ -368,38 +435,7 @@ class TerminalController: BaseTerminalController {
// If our titlebar style is "hidden" we adjust the style appropriately
if (config.macosTitlebarStyle == "hidden") {
window.styleMask = [
// We need `titled` in the mask to get the normal window frame
.titled,
// Full size content view so we can extend
// content in to the hidden titlebar's area
.fullSizeContentView,
.resizable,
.closable,
.miniaturizable,
]
// Hide the title
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
// Hide the traffic lights (window control buttons)
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
window.tabbingMode = .disallowed
// Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
// some operations that appear to bring back the titlebar visibility so this ensures
// it is gone forever.
if let themeFrame = window.contentView?.superview,
let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
titleBarContainer.isHidden = true
}
applyHiddenTitlebarStyle()
}
// In various situations, macOS automatically tabs new windows. Ghostty handles
@ -422,8 +458,6 @@ class TerminalController: BaseTerminalController {
}
}
window.focusFollowsMouse = config.focusFollowsMouse
// Apply any additional appearance-related properties to the new window. We
// apply this based on the root config but change it later based on surface
// config (see focused surface change callback).

View File

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

View File

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

View File

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

View File

@ -308,7 +308,7 @@ extension Ghostty {
resizeIncrements: .init(width: 1, height: 1),
resizePublisher: container.resizeEvent,
left: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.bottom
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.down
TerminalSplitNested(
node: closeableTopLeft(),
@ -318,7 +318,7 @@ extension Ghostty {
])
)
}, right: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.top
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.up
TerminalSplitNested(
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.
enum SplitFocusDirection {
case previous, next, top, bottom, left, right
case previous, next, up, down, left, right
/// Initialize from a Ghostty API enum.
static func from(direction: ghostty_action_goto_split_e) -> Self? {
@ -77,11 +77,11 @@ extension Ghostty {
case GHOSTTY_GOTO_SPLIT_NEXT:
return .next
case GHOSTTY_GOTO_SPLIT_TOP:
return .top
case GHOSTTY_GOTO_SPLIT_UP:
return .up
case GHOSTTY_GOTO_SPLIT_BOTTOM:
return .bottom
case GHOSTTY_GOTO_SPLIT_DOWN:
return .down
case GHOSTTY_GOTO_SPLIT_LEFT:
return .left
@ -102,11 +102,11 @@ extension Ghostty {
case .next:
return GHOSTTY_GOTO_SPLIT_NEXT
case .top:
return GHOSTTY_GOTO_SPLIT_TOP
case .up:
return GHOSTTY_GOTO_SPLIT_UP
case .bottom:
return GHOSTTY_GOTO_SPLIT_BOTTOM
case .down:
return GHOSTTY_GOTO_SPLIT_DOWN
case .left:
return GHOSTTY_GOTO_SPLIT_LEFT

View File

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

View File

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

View File

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

View File

@ -14,7 +14,6 @@ pub fn build(b: *std.Build) !void {
.@"enable-libpng" = true,
});
const macos = b.dependency("macos", .{ .target = target, .optimize = optimize });
const upstream = b.dependency("harfbuzz", .{});
const module = b.addModule("harfbuzz", .{
.root_source_file = b.path("main.zig"),
@ -26,6 +25,62 @@ pub fn build(b: *std.Build) !void {
},
});
// For dynamic linking, we prefer dynamic linking and to search by
// mode first. Mode first will search all paths for a dynamic library
// before falling back to static.
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
.preferred_link_mode = .dynamic,
.search_strategy = .mode_first,
};
const test_exe = b.addTest(.{
.name = "test",
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
{
var it = module.import_table.iterator();
while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*);
test_exe.linkLibrary(freetype.artifact("freetype"));
const tests_run = b.addRunArtifact(test_exe);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
}
if (b.systemIntegrationOption("harfbuzz", .{})) {
module.linkSystemLibrary("harfbuzz", dynamic_link_opts);
test_exe.linkSystemLibrary2("harfbuzz", dynamic_link_opts);
} else {
const lib = try buildLib(b, module, .{
.target = target,
.optimize = optimize,
.coretext_enabled = coretext_enabled,
.freetype_enabled = freetype_enabled,
.dynamic_link_opts = dynamic_link_opts,
});
test_exe.linkLibrary(lib);
}
}
pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile {
const target = options.target;
const optimize = options.optimize;
const coretext_enabled = options.coretext_enabled;
const freetype_enabled = options.freetype_enabled;
const freetype = b.dependency("freetype", .{
.target = target,
.optimize = optimize,
.@"enable-libpng" = true,
});
const upstream = b.dependency("harfbuzz", .{});
const lib = b.addStaticLibrary(.{
.name = "harfbuzz",
.target = target,
@ -41,13 +96,7 @@ pub fn build(b: *std.Build) !void {
try apple_sdk.addPaths(b, module);
}
// For dynamic linking, we prefer dynamic linking and to search by
// mode first. Mode first will search all paths for a dynamic library
// before falling back to static.
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
.preferred_link_mode = .dynamic,
.search_strategy = .mode_first,
};
const dynamic_link_opts = options.dynamic_link_opts;
var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit();
@ -102,20 +151,5 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(lib);
{
const test_exe = b.addTest(.{
.name = "test",
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
test_exe.linkLibrary(lib);
var it = module.import_table.iterator();
while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*);
test_exe.linkLibrary(freetype.artifact("freetype"));
const tests_run = b.addRunArtifact(test_exe);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
}
return lib;
}

View File

@ -30,4 +30,36 @@ pub fn build(b: *std.Build) !void {
.file = wuffs.path("release/c/wuffs-v0.4.c"),
.flags = flags.items,
});
const unit_tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
unit_tests.linkLibC();
unit_tests.addIncludePath(wuffs.path("release/c"));
unit_tests.addCSourceFile(.{
.file = wuffs.path("release/c/wuffs-v0.4.c"),
.flags = flags.items,
});
const pixels = b.dependency("pixels", .{});
inline for (.{ "000000", "FFFFFF" }) |color| {
inline for (.{ "gif", "jpg", "png", "ppm" }) |extension| {
const filename = std.fmt.comptimePrint("1x1#{s}.{s}", .{ color, extension });
unit_tests.root_module.addAnonymousImport(
filename,
.{
.root_source_file = pixels.path(filename),
},
);
}
}
const run_unit_tests = b.addRunArtifact(unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_unit_tests.step);
}

View File

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

View File

@ -1,3 +1,13 @@
const std = @import("std");
const c = @import("c.zig").c;
pub const Error = std.mem.Allocator.Error || error{WuffsError};
pub fn check(log: anytype, status: *const c.struct_wuffs_base__status__struct) error{WuffsError}!void {
if (!c.wuffs_base__status__is_ok(status)) {
const e = c.wuffs_base__status__message(status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
}

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 jpeg = @import("jpeg.zig");
pub const swizzle = @import("swizzle.zig");
pub const ImageData = struct {
width: u32,
height: u32,
data: []const u8,
};
test {
std.testing.refAllDeclsRecursive(@This());
}

View File

@ -2,15 +2,13 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const c = @import("c.zig").c;
const Error = @import("error.zig").Error;
const check = @import("error.zig").check;
const ImageData = @import("main.zig").ImageData;
const log = std.log.scoped(.wuffs_png);
/// Decode a PNG image.
pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
width: u32,
height: u32,
data: []const u8,
} {
pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
// Work around some weirdness in WUFFS/Zig, there are some structs that
// are defined as "extern" by the Zig compiler which means that Zig won't
// allocate them on the stack at compile time. WUFFS has functions for
@ -29,11 +27,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
c.WUFFS_VERSION,
0,
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
var source_buffer: c.wuffs_base__io_buffer = .{
@ -53,11 +47,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
&image_config,
&source_buffer,
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg);
@ -102,11 +92,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
&image_config.pixcfg,
c.wuffs_base__make_slice_u8(destination.ptr, destination.len),
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
var frame_config: c.wuffs_base__frame_config = undefined;
@ -116,11 +102,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
&frame_config,
&source_buffer,
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
{
@ -132,11 +114,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
work_slice,
null,
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
return .{
@ -145,3 +123,21 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
.data = destination,
};
}
test "png_decode_000000" {
const data = try decode(std.testing.allocator, @embedFile("1x1#000000.png"));
defer std.testing.allocator.free(data.data);
try std.testing.expectEqual(1, data.width);
try std.testing.expectEqual(1, data.height);
try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data);
}
test "png_decode_FFFFFF" {
const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.png"));
defer std.testing.allocator.free(data.data);
try std.testing.expectEqual(1, data.width);
try std.testing.expectEqual(1, data.height);
try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data);
}

View File

@ -18,6 +18,7 @@ const Command = @This();
const std = @import("std");
const builtin = @import("builtin");
const global_state = &@import("global.zig").state;
const internal_os = @import("os/main.zig");
const windows = internal_os.windows;
const TempDir = internal_os.TempDir;
@ -175,6 +176,10 @@ fn startPosix(self: *Command, arena: Allocator) !void {
// We don't log because that'll show up in the output.
};
// Restore any rlimits that were set by Ghostty. This might fail but
// any failures are ignored (its best effort).
global_state.rlimits.restore();
// If the user requested a pre exec callback, call it now.
if (self.pre_exec) |f| f(self);

View File

@ -853,11 +853,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
},
.color_change => |change| {
// On any color change, we have to report for mode 2031
// if it is enabled.
self.reportColorScheme(false);
// Notify our apprt
// Notify our apprt, but don't send a mode 2031 DSR report
// because VT sequences were used to change the color.
try self.rt_app.performAction(
.{ .surface = self },
.color_change,
@ -1159,7 +1156,6 @@ pub fn updateConfig(
}
// If we are in the middle of a key sequence, clear it.
self.keyboard.bindings = null;
self.endKeySequence(.drop, .free);
// Before sending any other config changes, we give the renderer a new font
@ -1710,6 +1706,13 @@ pub fn keyCallback(
// Update our modifiers, this will update mouse mods too
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
const pos = self.rt_surface.getCursorPos() catch break :mouse_mods;
self.mouseRefreshLinks(
@ -1720,6 +1723,20 @@ pub fn keyCallback(
log.warn("failed to refresh links err={}", .{err});
break :mouse_mods;
};
} else if (self.io.terminal.flags.mouse_event != .none and !self.mouse.mods.shift) {
// If we have mouse reports on and we don't have shift pressed, we reset state
try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
self.io.terminal.mouse_shape,
);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = "" },
);
try self.queueRender();
}
}
// Process the cursor state logic. This will update the cursor shape if
@ -1835,9 +1852,6 @@ fn maybeHandleBinding(
if (self.keyboard.bindings != null and
!event.key.modifier())
{
// Reset to the root set
self.keyboard.bindings = null;
// Encode everything up to this point
self.endKeySequence(.flush, .retain);
}
@ -1923,10 +1937,21 @@ fn maybeHandleBinding(
return .closed;
}
// If we have the performable flag and the action was not performed,
// then we act as though a binding didn't exist.
if (leaf.flags.performable and !performed) {
// If we're in a sequence, we treat this as if we pressed a key
// that doesn't exist in the sequence. Reset our sequence and flush
// any queued events.
self.endKeySequence(.flush, .retain);
return null;
}
// If we consume this event, then we are done. If we don't consume
// it, we processed the action but we still want to process our
// encodings, too.
if (performed and consumed) {
if (consumed) {
// If we had queued events, we deinit them since we consumed
self.endKeySequence(.drop, .retain);
@ -1968,6 +1993,10 @@ fn endKeySequence(
);
};
// No matter what we clear our current binding set. This restores
// the set we look at to the root set.
self.keyboard.bindings = null;
if (self.keyboard.queued.items.len > 0) {
switch (action) {
.flush => for (self.keyboard.queued.items) |write_req| {
@ -3195,7 +3224,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
.trim = false,
});
defer self.alloc.free(str);
try internal_os.open(self.alloc, str);
try internal_os.open(self.alloc, .unknown, str);
},
._open_osc8 => {
@ -3203,7 +3232,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
log.warn("failed to get URI for OSC8 hyperlink", .{});
return false;
};
try internal_os.open(self.alloc, uri);
try internal_os.open(self.alloc, .unknown, uri);
},
}
@ -3350,6 +3379,27 @@ pub fn cursorPosCallback(
try self.queueRender();
}
// Handle link hovering
// We refresh links when
// 1. we were previously over a link
// OR
// 2. the cursor position has changed (either we have no previous state, or the state has
// changed)
// AND
// 1. mouse reporting is off
// OR
// 2. mouse reporting is on and we are not reporting shift to the terminal
if ((over_link or
self.mouse.link_point == null or
(self.mouse.link_point != null and !self.mouse.link_point.?.eql(pos_vp))) and
(self.io.terminal.flags.mouse_event == .none or
(self.mouse.mods.shift and !self.mouseShiftCapture(false))))
{
// If we were previously over a link, we always update. We do this so that if the text
// changed underneath us, even if the mouse didn't move, we update the URL hints and state
try self.mouseRefreshLinks(pos, pos_vp, over_link);
}
// Do a mouse report
if (self.io.terminal.flags.mouse_event != .none) report: {
// Shift overrides mouse "grabbing" in the window, taken from Kitty.
@ -3370,18 +3420,6 @@ pub fn cursorPosCallback(
try self.mouseReport(button, .motion, self.mouse.mods, pos);
// If we were previously over a link, we need to undo the link state.
// We also queue a render so the renderer can undo the rendered link
// state.
if (over_link) {
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = "" },
);
try self.queueRender();
}
// If we're doing mouse motion tracking, we do not support text
// selection.
return;
@ -3437,30 +3475,6 @@ pub fn cursorPosCallback(
return;
}
// Handle link hovering
if (self.mouse.link_point) |last_vp| {
// Mark the link's row as dirty.
if (over_link) {
self.renderer_state.terminal.screen.dirty.hyperlink_hover = true;
}
// If our last link viewport point is unchanged, then don't process
// links. This avoids constantly reprocessing regular expressions
// for every pixel change.
if (last_vp.eql(pos_vp)) {
// We have to restore old values that are always cleared
if (over_link) {
self.mouse.over_link = over_link;
self.renderer_state.mouse.point = pos_vp;
}
return;
}
}
// We can process new links.
try self.mouseRefreshLinks(pos, pos_vp, over_link);
}
/// Double-click dragging moves the selection one "word" at a time.
@ -3886,7 +3900,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
log.err("error setting clipboard string err={}", .{err});
return true;
};
return true;
}
return false;
},
.paste_from_clipboard => try self.startClipboardRequest(
@ -4239,7 +4257,13 @@ fn writeScreenFile(
const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)});
// Open our scrollback file
var file = try tmp_dir.dir.createFile(filename, .{});
var file = try tmp_dir.dir.createFile(
filename,
switch (builtin.os.tag) {
.windows => .{},
else => .{ .mode = 0o600 },
},
);
defer file.close();
// Screen.dumpString writes byte-by-byte, so buffer it
@ -4287,11 +4311,16 @@ fn writeScreenFile(
tmp_dir.deinit();
return;
};
// Use topLeft and bottomRight to ensure correct coordinate ordering
const tl = sel.topLeft(&self.io.terminal.screen);
const br = sel.bottomRight(&self.io.terminal.screen);
try self.io.terminal.screen.dumpString(
buf_writer.writer(),
.{
.tl = sel.start(),
.br = sel.end(),
.tl = tl,
.br = br,
.unwrap = true,
},
);
@ -4303,7 +4332,7 @@ fn writeScreenFile(
const path = try tmp_dir.dir.realpath(filename, &path_buf);
switch (write_action) {
.open => try internal_os.open(self.alloc, path),
.open => try internal_os.open(self.alloc, .text, path),
.paste => self.io.queueMessage(try termio.Message.writeReq(
self.alloc,
path,

View File

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

View File

@ -510,6 +510,13 @@ pub const Surface = struct {
) orelse return glfw.mustGetErrorCode();
errdefer win.destroy();
// Setup our
setInitialWindowPosition(
win,
app.config.@"window-position-x",
app.config.@"window-position-y",
);
// Get our physical DPI - debug only because we don't have a use for
// this but the logging of it may be useful
if (builtin.mode == .Debug) {
@ -663,6 +670,17 @@ pub const Surface = struct {
});
}
/// Set the initial window position. This is called exactly once at
/// surface initialization time. This may be called before "self"
/// is fully initialized.
fn setInitialWindowPosition(win: glfw.Window, x: ?i16, y: ?i16) void {
const start_position_x = x orelse return;
const start_position_y = y orelse return;
log.debug("setting initial window position ({},{})", .{ start_position_x, start_position_y });
win.setPos(.{ .x = start_position_x, .y = start_position_y });
}
/// Set the size limits of the window.
/// Note: this interface is not good, we should redo it if we plan
/// to use this more. i.e. you can't set max width but no max height,

View File

@ -81,6 +81,9 @@ transient_cgroup_base: ?[]const u8 = null,
/// CSS Provider for any styles based on ghostty configuration values
css_provider: *c.GtkCssProvider,
/// Providers for loading custom stylesheets defined by user
custom_css_providers: std.ArrayListUnmanaged(*c.GtkCssProvider) = .{},
/// The timer used to quit the application after the last window is closed.
quit_timer: union(enum) {
off: void,
@ -108,7 +111,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
// For the remainder of "why" see the 4.14 comment below.
_ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan");
_ = internal_os.setenv("GDK_DEBUG", "opengl");
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-no-fractional");
} else if (version.atLeast(4, 14, 0)) {
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14.
// Older versions of GTK do not support these values so it is safe
@ -123,7 +126,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// - "vulkan-disable" - disable Vulkan, Ghostty can't use Vulkan
// and initializing a Vulkan context was causing a longer delay
// on some systems.
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable");
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable,gl-no-fractional");
} else {
// Versions prior to 4.14 are a bit of an unknown for Ghostty. It
// is an environment that isn't tested well and we don't have a
@ -441,6 +444,11 @@ pub fn terminate(self: *App) void {
if (self.context_menu) |context_menu| c.g_object_unref(context_menu);
if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path);
for (self.custom_css_providers.items) |provider| {
c.g_object_unref(provider);
}
self.custom_css_providers.deinit(self.core_app.alloc);
self.config.deinit();
}
@ -786,6 +794,7 @@ fn setInitialSize(
),
}
}
fn showDesktopNotification(
self: *App,
target: apprt.Target,
@ -892,7 +901,7 @@ fn syncConfigChanges(self: *App) !void {
try self.updateConfigErrors();
try self.syncActionAccelerators();
// Load our runtime CSS. If this fails then our window is just stuck
// Load our runtime and custom CSS. If this fails then our window is just stuck
// with the old CSS but we don't want to fail the entire sync operation.
self.loadRuntimeCss() catch |err| switch (err) {
error.OutOfMemory => log.warn(
@ -900,6 +909,9 @@ fn syncConfigChanges(self: *App) !void {
.{},
),
};
self.loadCustomCss() catch |err| {
log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err});
};
}
/// This should be called whenever the configuration changes to update
@ -972,9 +984,6 @@ fn loadRuntimeCss(
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
try writer.print(
\\window.without-window-decoration-and-with-titlebar {{
\\ border-radius: 0 0;
\\}}
\\widget.unfocused-split {{
\\ opacity: {d:.2};
\\ background-color: rgb({d},{d},{d});
@ -1036,11 +1045,68 @@ fn loadRuntimeCss(
}
// Clears any previously loaded CSS from this provider
c.gtk_css_provider_load_from_data(
self.css_provider,
buf.items.ptr,
@intCast(buf.items.len),
loadCssProviderFromData(self.css_provider, buf.items);
}
fn loadCustomCss(self: *App) !void {
const display = c.gdk_display_get_default();
// unload the previously loaded style providers
for (self.custom_css_providers.items) |provider| {
c.gtk_style_context_remove_provider_for_display(
display,
@ptrCast(provider),
);
c.g_object_unref(provider);
}
self.custom_css_providers.clearRetainingCapacity();
for (self.config.@"gtk-custom-css".value.items) |p| {
const path, const optional = switch (p) {
.optional => |path| .{ path, true },
.required => |path| .{ path, false },
};
const file = std.fs.openFileAbsolute(path, .{}) catch |err| {
if (err != error.FileNotFound or !optional) {
log.err("error opening gtk-custom-css file {s}: {}", .{ path, err });
}
continue;
};
defer file.close();
log.info("loading gtk-custom-css path={s}", .{path});
const contents = try file.reader().readAllAlloc(
self.core_app.alloc,
5 * 1024 * 1024 // 5MB
);
defer self.core_app.alloc.free(contents);
const provider = c.gtk_css_provider_new();
c.gtk_style_context_add_provider_for_display(
display,
@ptrCast(provider),
c.GTK_STYLE_PROVIDER_PRIORITY_USER,
);
loadCssProviderFromData(provider, contents);
try self.custom_css_providers.append(self.core_app.alloc, provider);
}
}
fn loadCssProviderFromData(provider: *c.GtkCssProvider, data: []const u8) void {
if (version.atLeast(4, 12, 0)) {
const g_bytes = c.g_bytes_new(data.ptr, data.len);
defer c.g_bytes_unref(g_bytes);
c.gtk_css_provider_load_from_bytes(provider, g_bytes);
} else {
c.gtk_css_provider_load_from_data(
provider,
data.ptr,
@intCast(data.len),
);
}
}
/// Called by CoreApp to wake up the event loop.
@ -1403,7 +1469,15 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme {
null,
&err,
) orelse {
if (err) |e| log.err("unable to get current color scheme: {s}", .{e.message});
if (err) |e| {
// If ReadOne is not yet implemented, fall back to deprecated "Read" method
// Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method ReadOne
if (e.code == 19) {
return self.getColorSchemeDeprecated();
}
// Otherwise, log the error and return .light
log.err("unable to get current color scheme: {s}", .{e.message});
}
return .light;
};
defer c.g_variant_unref(value);
@ -1420,6 +1494,49 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme {
return .light;
}
/// Call the deprecated D-Bus "Read" method to determine the current color scheme. If
/// there is any error at any point we'll log the error and return "light"
fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme {
const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app));
var err: ?*c.GError = null;
defer if (err) |e| c.g_error_free(e);
const value = c.g_dbus_connection_call_sync(
dbus_connection,
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
"Read",
c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
c.G_VARIANT_TYPE("(v)"),
c.G_DBUS_CALL_FLAGS_NONE,
-1,
null,
&err,
) orelse {
if (err) |e| log.err("Read method failed: {s}", .{e.message});
return .light;
};
defer c.g_variant_unref(value);
if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) {
var inner: ?*c.GVariant = null;
c.g_variant_get(value, "(v)", &inner);
defer if (inner) |i| c.g_variant_unref(i);
if (inner) |i| {
const child = c.g_variant_get_child_value(i, 0) orelse {
return .light;
};
defer c.g_variant_unref(child);
const val = c.g_variant_get_uint32(child);
return if (val == 1) .dark else .light;
}
}
return .light;
}
/// This will be called by D-Bus when the style changes between light & dark.
fn gtkNotifyColorScheme(
_: ?*c.GDBusConnection,

View File

@ -131,6 +131,7 @@ const PrimaryView = struct {
c.gtk_text_view_set_bottom_margin(@ptrCast(text), 8);
c.gtk_text_view_set_left_margin(@ptrCast(text), 8);
c.gtk_text_view_set_right_margin(@ptrCast(text), 8);
c.gtk_text_view_set_monospace(@ptrCast(text), 1);
return .{ .root = view.root, .text = @ptrCast(text) };
}
@ -238,7 +239,7 @@ fn promptText(req: apprt.ClipboardRequest) [:0]const u8 {
\\Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.
,
.osc_52_read =>
\\An appliclication is attempting to read from the clipboard.
\\An application is attempting to read from the clipboard.
\\The current clipboard contents are shown below.
,
.osc_52_write =>

View File

@ -111,16 +111,6 @@ pub fn init(
// Keep a long-lived reference, which we unref in destroy.
_ = c.g_object_ref(paned);
// Clicks
const gesture_click = c.gtk_gesture_click_new();
errdefer c.g_object_unref(gesture_click);
c.gtk_event_controller_set_propagation_phase(@ptrCast(gesture_click), c.GTK_PHASE_CAPTURE);
c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 1);
c.gtk_widget_add_controller(paned, @ptrCast(gesture_click));
// Signals
_ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(&gtkMouseDown), self, null, c.G_CONNECT_DEFAULT);
// Update all of our containers to point to the right place.
// The split has to point to where the sibling pointed to because
// we're inheriting its parent. The sibling points to its location
@ -246,19 +236,6 @@ pub fn equalize(self: *Split) f64 {
return weight;
}
fn gtkMouseDown(
_: *c.GtkGestureClick,
n_press: c.gint,
_: c.gdouble,
_: c.gdouble,
ud: ?*anyopaque,
) callconv(.C) void {
if (n_press == 2) {
const self: *Split = @ptrCast(@alignCast(ud));
_ = equalize(self);
}
}
// maxPosition returns the maximum position of the GtkPaned, which is the
// "max-position" attribute.
fn maxPosition(self: *Split) f64 {
@ -339,7 +316,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap {
// This behavior matches the behavior of macOS at the time of writing
// this. There is an open issue (#524) to make this depend on the
// actual physical location of the current split.
result.put(.top, prev.surface);
result.put(.up, prev.surface);
result.put(.left, prev.surface);
}
}
@ -347,7 +324,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap {
if (self.directionNext(from)) |next| {
result.put(.next, next.surface);
if (!next.wrapped) {
result.put(.bottom, next.surface);
result.put(.down, next.surface);
result.put(.right, next.surface);
}
}

View File

@ -794,10 +794,11 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
// can support fractional scaling.
const gtk_scale: f32 = @floatFromInt(c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area)));
// If we are on X11, we also have to scale using Xft.dpi
const xft_dpi_scale = if (!x11.is_current_display_server()) 1.0 else xft_scale: {
// Here we use GTK to retrieve gtk-xft-dpi, which is Xft.dpi multiplied
// by 1024. See https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html
// Also scale using font-specific DPI, which is often exposed to the user
// via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html).
const xft_dpi_scale = xft_scale: {
// gtk-xft-dpi is font DPI multiplied by 1024. See
// https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html
const settings = c.gtk_settings_get_default();
var value: c.GValue = std.mem.zeroes(c.GValue);
@ -806,10 +807,9 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value);
const gtk_xft_dpi = c.g_value_get_int(&value);
// As noted above Xft.dpi is multiplied by 1024, so we divide by 1024,
// then divide by the default value of Xft.dpi (96) to derive a scale.
// Note that gtk-xft-dpi can be fractional, so we use floating point
// math here.
// As noted above gtk-xft-dpi is multiplied by 1024, so we divide by
// 1024, then divide by the default value (96) to derive a scale. Note
// gtk-xft-dpi can be fractional, so we use floating point math here.
const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024;
break :xft_scale xft_dpi / 96;
};
@ -1426,15 +1426,23 @@ fn gtkMouseMotion(
.y = @floatCast(scaled.y),
};
// Our pos changed, update
self.cursor_pos = pos;
// When the GLArea is resized under the mouse, GTK issues a mouse motion
// event. This has the unfortunate side effect of causing focus to potentially
// change when `focus-follows-mouse` is enabled. To prevent this, we check
// if the cursor is still in the same place as the last event and only grab
// focus if it has moved.
const is_cursor_still = @abs(self.cursor_pos.x - pos.x) < 1 and
@abs(self.cursor_pos.y - pos.y) < 1;
// If we don't have focus, and we want it, grab it.
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
if (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") {
if (!is_cursor_still and c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") {
self.grabFocus();
}
// Our pos changed, update
self.cursor_pos = pos;
// Get our modifiers
const gtk_mods = c.gdk_event_get_modifier_state(event);
const mods = gtk_key.translateMods(gtk_mods);

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.
c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self);
try window.notebook.addTab(self, "Ghostty");
window.notebook.addTab(self, "Ghostty");
// Attach all events
_ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);

View File

@ -83,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void {
// Create the window
const window: *c.GtkWidget = window: {
if (self.isAdwWindow()) {
if ((comptime adwaita.versionAtLeast(0, 0, 0)) and adwaita.enabled(&self.app.config)) {
const window = c.adw_application_window_new(app.app);
c.gtk_widget_add_css_class(@ptrCast(window), "adw");
break :window window;
@ -99,6 +99,7 @@ pub fn init(self: *Window, app: *App) !void {
self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty");
c.gtk_window_set_default_size(gtk_window, 1000, 600);
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "terminal-window");
// GTK4 grabs F10 input by default to focus the menubar icon. We want
// to disable this so that terminal programs can capture F10 (such as htop)
@ -122,12 +123,12 @@ pub fn init(self: *Window, app: *App) !void {
const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0);
// Setup our notebook
self.notebook = Notebook.create(self);
self.notebook.init();
// If we are using Adwaita, then we can support the tab overview.
self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 3, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 3, 0)) overview: {
self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 4, 0)) overview: {
const tab_overview = c.adw_tab_overview_new();
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view);
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view);
c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1);
_ = c.g_signal_connect_data(
tab_overview,
@ -156,6 +157,9 @@ pub fn init(self: *Window, app: *App) !void {
if (app.config.@"gtk-titlebar") {
const header = HeaderBar.init(self);
// If we are not decorated then we hide the titlebar.
header.setVisible(app.config.@"window-decoration");
{
const btn = c.gtk_menu_button_new();
c.gtk_widget_set_tooltip_text(btn, "Main Menu");
@ -167,7 +171,7 @@ pub fn init(self: *Window, app: *App) !void {
// If we're using an AdwWindow then we can support the tab overview.
if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
assert(self.isAdwWindow());
assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0));
const btn = switch (app.config.@"gtk-tabs-location") {
.top, .bottom, .left, .right => btn: {
const btn = c.gtk_toggle_button_new();
@ -186,7 +190,7 @@ pub fn init(self: *Window, app: *App) !void {
.hidden => btn: {
const btn = c.adw_tab_button_new();
c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw_tab_view);
c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view);
c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open");
break :btn btn;
},
@ -216,6 +220,14 @@ pub fn init(self: *Window, app: *App) !void {
}
}
// If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we
// need to stick the headerbar into the content box.
if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
if (self.header) |h| {
c.gtk_box_append(@ptrCast(box), h.asWidget());
}
}
// In debug we show a warning and apply the 'devel' class to the window.
// This is a really common issue where people build from source in debug and performance is really bad.
if (comptime std.debug.runtime_safety) {
@ -256,8 +268,8 @@ pub fn init(self: *Window, app: *App) !void {
// If we have a tab overview then we can set it on our notebook.
if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable;
assert(self.notebook == .adw_tab_view);
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view);
assert(self.notebook == .adw);
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view);
}
self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu)));
@ -284,16 +296,17 @@ pub fn init(self: *Window, app: *App) !void {
// Our actions for the menu
initActions(self);
if (self.isAdwWindow()) {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new());
const header_widget: *c.GtkWidget = self.header.?.asWidget();
if (self.header) |header| {
const header_widget = header.asWidget();
c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget);
}
if (self.app.config.@"gtk-tabs-location" != .hidden) {
const tab_bar = c.adw_tab_bar_new();
c.adw_tab_bar_set_view(tab_bar, self.notebook.adw_tab_view);
c.adw_tab_bar_set_view(tab_bar, self.notebook.adw.tab_view);
if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0);
@ -315,33 +328,19 @@ pub fn init(self: *Window, app: *App) !void {
c.adw_toolbar_view_set_top_bar_style(toolbar_view, toolbar_style);
c.adw_toolbar_view_set_bottom_bar_style(toolbar_view, toolbar_style);
// If we are not decorated then we hide the titlebar.
if (!app.config.@"window-decoration") {
c.gtk_widget_set_visible(header_widget, 0);
}
// Set our application window content. The content depends on if
// we're using an AdwTabOverview or not.
if (self.tab_overview) |tab_overview| {
// Set our application window content.
c.adw_tab_overview_set_child(
@ptrCast(tab_overview),
@ptrCast(self.tab_overview),
@ptrCast(@alignCast(toolbar_view)),
);
c.adw_application_window_set_content(
@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: {
switch (self.notebook) {
.adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) {
.adw => |*adw| if (comptime adwaita.versionAtLeast(0, 0, 0)) {
if (app.config.@"gtk-tabs-location" == .hidden) break :tab_bar;
// In earlier adwaita versions, we need to add the tabbar manually since we do not use
// an AdwToolbarView.
const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?;
@ -361,17 +360,26 @@ pub fn init(self: *Window, app: *App) !void {
),
.hidden => unreachable,
}
c.adw_tab_bar_set_view(tab_bar, tab_view);
c.adw_tab_bar_set_view(tab_bar, adw.tab_view);
if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0);
},
.gtk_notebook => {},
.gtk => {},
}
// The box is our main child
if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
c.adw_application_window_set_content(
@ptrCast(gtk_window),
box,
);
} else {
c.gtk_window_set_child(gtk_window, box);
if (self.header) |h| c.gtk_window_set_titlebar(gtk_window, h.asWidget());
if (self.header) |h| {
c.gtk_window_set_titlebar(gtk_window, h.asWidget());
}
}
}
// Show the window
@ -420,17 +428,6 @@ pub fn deinit(self: *Window) void {
}
}
/// Returns true if this window should use an Adwaita window.
///
/// This must be `inline` so that the comptime check noops conditional
/// paths that are not enabled.
inline fn isAdwWindow(self: *Window) bool {
return (comptime adwaita.versionAtLeast(1, 4, 0)) and
adwaita.enabled(&self.app.config) and
adwaita.versionAtLeast(1, 4, 0) and
self.app.config.@"gtk-titlebar";
}
/// Add a new tab to this window.
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {
const alloc = self.app.core_app.alloc;
@ -517,13 +514,19 @@ pub fn toggleWindowDecorations(self: *Window) void {
const new_decorated = !old_decorated;
c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated));
// Fix any artifacting that may occur in window corners.
if (new_decorated) {
c.gtk_widget_add_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar");
} else {
c.gtk_widget_remove_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar");
}
// If we have a titlebar, then we also show/hide it depending on the
// decorated state. GTK tends to consider the titlebar part of the frame
// and hides it with decorations, but libadwaita doesn't. This makes it
// explicit.
if (self.header) |v| {
const widget = v.asWidget();
c.gtk_widget_set_visible(widget, @intFromBool(new_decorated));
if (self.header) |headerbar| {
headerbar.setVisible(new_decorated);
}
}
@ -562,12 +565,12 @@ fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
/// because we need to return an AdwTabPage from this function.
fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwTabPage {
const self: *Window = userdataSelf(ud.?);
assert(self.isAdwWindow());
assert((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config));
const alloc = self.app.core_app.alloc;
const surface = self.actionSurface();
const tab = Tab.create(alloc, self, surface) catch return null;
return c.adw_tab_view_get_page(self.notebook.adw_tab_view, @ptrCast(@alignCast(tab.box)));
return c.adw_tab_view_get_page(self.notebook.adw.tab_view, @ptrCast(@alignCast(tab.box)));
}
fn adwTabOverviewOpen(
@ -744,7 +747,7 @@ fn gtkActionAbout(
if ((comptime adwaita.versionAtLeast(1, 5, 0)) and
adwaita.versionAtLeast(1, 5, 0) and
self.isAdwWindow())
adwaita.enabled(&self.app.config))
{
c.adw_show_about_dialog(
@ptrCast(self.window),
@ -892,7 +895,9 @@ fn gtkActionCopy(
return;
};
if (self.app.config.@"adw-toast".@"clipboard-copy") {
self.sendToast("Copied to clipboard");
}
}
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
/// check the actual version of the library we are linked against.
/// So generally you probably want to do both checks!
pub fn versionAtLeast(
///
/// This is inlined so that the comptime checks will disable the
/// runtime checks if the comptime checks fail.
pub inline fn versionAtLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
@ -37,8 +40,9 @@ pub fn versionAtLeast(
// compiling against unknown symbols and makes runtime checks
// very slightly faster.
if (comptime c.ADW_MAJOR_VERSION < major or
c.ADW_MINOR_VERSION < minor or
c.ADW_MICRO_VERSION < micro) return false;
(c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION < minor) or
(c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION == minor and c.ADW_MICRO_VERSION < micro))
return false;
// If we're in comptime then we can't check the runtime version.
if (@inComptime()) return true;
@ -56,3 +60,16 @@ pub fn versionAtLeast(
return false;
}
test "versionAtLeast" {
const testing = std.testing;
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1));
}

View File

@ -30,6 +30,10 @@ pub const HeaderBar = union(enum) {
return .{ .gtk = @ptrCast(headerbar) };
}
pub fn setVisible(self: HeaderBar, visible: bool) void {
c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible));
}
pub fn asWidget(self: HeaderBar) *c.GtkWidget {
return switch (self) {
.adw => |headerbar| @ptrCast(@alignCast(headerbar)),

View File

@ -4,161 +4,76 @@ const c = @import("c.zig").c;
const Window = @import("Window.zig");
const Tab = @import("Tab.zig");
const NotebookAdw = @import("notebook_adw.zig").NotebookAdw;
const NotebookGtk = @import("notebook_gtk.zig").NotebookGtk;
const adwaita = @import("adwaita.zig");
const log = std.log.scoped(.gtk);
const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque;
/// An abstraction over the GTK notebook and Adwaita tab view to manage
/// all the terminal tabs in a window.
/// An abstraction over the GTK notebook and Adwaita tab view to manage
/// all the terminal tabs in a window.
pub const Notebook = union(enum) {
adw_tab_view: *AdwTabView,
gtk_notebook: *c.GtkNotebook,
adw: NotebookAdw,
gtk: NotebookGtk,
pub fn create(window: *Window) Notebook {
pub fn init(self: *Notebook) void {
const window: *Window = @fieldParentPtr("notebook", self);
const app = window.app;
if (adwaita.enabled(&app.config)) return initAdw(window);
return initGtk(window);
if (adwaita.enabled(&app.config)) return NotebookAdw.init(self);
return NotebookGtk.init(self);
}
fn initGtk(window: *Window) Notebook {
const app = window.app;
// Create a notebook to hold our tabs.
const notebook_widget: *c.GtkWidget = c.gtk_notebook_new();
const notebook: *c.GtkNotebook = @ptrCast(notebook_widget);
const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") {
.top, .hidden => c.GTK_POS_TOP,
.bottom => c.GTK_POS_BOTTOM,
.left => c.GTK_POS_LEFT,
.right => c.GTK_POS_RIGHT,
};
c.gtk_notebook_set_tab_pos(notebook, notebook_tab_pos);
c.gtk_notebook_set_scrollable(notebook, 1);
c.gtk_notebook_set_show_tabs(notebook, 0);
c.gtk_notebook_set_show_border(notebook, 0);
// This enables all Ghostty terminal tabs to be exchanged across windows.
c.gtk_notebook_set_group_name(notebook, "ghostty-terminal-tabs");
// This is important so the notebook expands to fit available space.
// Otherwise, it will be zero/zero in the box below.
c.gtk_widget_set_vexpand(notebook_widget, 1);
c.gtk_widget_set_hexpand(notebook_widget, 1);
// Remove the background from the stack widget
const stack = c.gtk_widget_get_last_child(notebook_widget);
c.gtk_widget_add_css_class(stack, "transparent");
// All of our events
_ = c.g_signal_connect_data(notebook, "page-added", c.G_CALLBACK(&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 asWidget(self: *Notebook) *c.GtkWidget {
return switch (self.*) {
.adw => |*adw| adw.asWidget(),
.gtk => |*gtk| gtk.asWidget(),
};
}
pub fn nPages(self: Notebook) c_int {
return switch (self) {
.gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook),
.adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0))
c.adw_tab_view_get_n_pages(tab_view)
else
unreachable,
pub fn nPages(self: *Notebook) c_int {
return switch (self.*) {
.adw => |*adw| adw.nPages(),
.gtk => |*gtk| gtk.nPages(),
};
}
/// Returns the index of the currently selected page.
/// Returns null if the notebook has no pages.
fn currentPage(self: Notebook) ?c_int {
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null;
return c.adw_tab_view_get_page_position(tab_view, page);
},
.gtk_notebook => |notebook| {
const current = c.gtk_notebook_get_current_page(notebook);
return if (current == -1) null else current;
},
}
fn currentPage(self: *Notebook) ?c_int {
return switch (self.*) {
.adw => |*adw| adw.currentPage(),
.gtk => |*gtk| gtk.currentPage(),
};
}
/// Returns the currently selected tab or null if there are none.
pub fn currentTab(self: Notebook) ?*Tab {
const child = switch (self) {
.adw_tab_view => |tab_view| child: {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null;
const child = c.adw_tab_page_get_child(page);
break :child child;
},
.gtk_notebook => |notebook| child: {
const page = self.currentPage() orelse return null;
break :child c.gtk_notebook_get_nth_page(notebook, page);
},
};
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 currentTab(self: *Notebook) ?*Tab {
return switch (self.*) {
.adw => |*adw| adw.currentTab(),
.gtk => |*gtk| gtk.currentTab(),
};
}
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;
// The next index is the previous or we wrap around.
@ -173,7 +88,7 @@ pub const Notebook = union(enum) {
self.gotoNthTab(next_idx);
}
pub fn gotoNextTab(self: Notebook, tab: *Tab) void {
pub fn gotoNextTab(self: *Notebook, tab: *Tab) void {
const page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1;
@ -183,7 +98,7 @@ pub const Notebook = union(enum) {
self.gotoNthTab(next_idx);
}
pub fn moveTab(self: Notebook, tab: *Tab, position: c_int) void {
pub fn moveTab(self: *Notebook, tab: *Tab, position: c_int) void {
const page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1;
@ -199,42 +114,28 @@ pub const Notebook = union(enum) {
self.reorderPage(tab, new_position);
}
pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void {
switch (self) {
.gtk_notebook => |notebook| {
c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position);
},
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
_ = c.adw_tab_view_reorder_page(tab_view, page, position);
},
pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void {
switch (self.*) {
.adw => |*adw| adw.reorderPage(tab, position),
.gtk => |*gtk| gtk.reorderPage(tab, position),
}
}
pub fn setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void {
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
c.adw_tab_page_set_title(page, title.ptr);
},
.gtk_notebook => c.gtk_label_set_text(tab.label_text, title.ptr),
pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
switch (self.*) {
.adw => |*adw| adw.setTabLabel(tab, title),
.gtk => |*gtk| gtk.setTabLabel(tab, title),
}
}
pub fn setTabTooltip(self: Notebook, tab: *Tab, tooltip: [:0]const u8) void {
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
c.adw_tab_page_set_tooltip(page, tooltip.ptr);
},
.gtk_notebook => c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr),
pub fn setTabTooltip(self: *Notebook, tab: *Tab, tooltip: [:0]const u8) void {
switch (self.*) {
.adw => |*adw| adw.setTabTooltip(tab, tooltip),
.gtk => |*gtk| gtk.setTabTooltip(tab, tooltip),
}
}
fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int {
fn newTabInsertPosition(self: *Notebook, tab: *Tab) c_int {
const numPages = self.nPages();
return switch (tab.window.app.config.@"window-new-tab-position") {
.current => if (self.currentPage()) |page| page + 1 else numPages,
@ -243,249 +144,23 @@ pub const Notebook = union(enum) {
}
/// Adds a new tab with the given title to the notebook.
pub fn addTab(self: Notebook, tab: *Tab, title: [:0]const u8) !void {
const box_widget: *c.GtkWidget = @ptrCast(tab.box);
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_insert(tab_view, box_widget, self.newTabInsertPosition(tab));
c.adw_tab_page_set_title(page, title.ptr);
// Switch to the new tab
c.adw_tab_view_set_selected_page(tab_view, page);
},
.gtk_notebook => |notebook| {
// Build the tab label
const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0);
const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget));
const label_text_widget = c.gtk_label_new(title.ptr);
const label_text: *c.GtkLabel = @ptrCast(label_text_widget);
c.gtk_box_append(label_box, label_text_widget);
tab.label_text = label_text;
const window = tab.window;
if (window.app.config.@"gtk-wide-tabs") {
c.gtk_widget_set_hexpand(label_box_widget, 1);
c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL);
c.gtk_widget_set_hexpand(label_text_widget, 1);
c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL);
// This ensures that tabs are always equal width. If they're too
// long, they'll be truncated with an ellipsis.
c.gtk_label_set_max_width_chars(label_text, 1);
c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END);
// We need to set a minimum width so that at a certain point
// the notebook will have an arrow button rather than shrinking tabs
// to an unreadably small size.
c.gtk_widget_set_size_request(label_text_widget, 100, 1);
}
// Build the close button for the tab
const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic");
const label_close: *c.GtkButton = @ptrCast(label_close_widget);
c.gtk_button_set_has_frame(label_close, 0);
c.gtk_box_append(label_box, label_close_widget);
const page_idx = c.gtk_notebook_insert_page(
notebook,
box_widget,
label_box_widget,
self.newTabInsertPosition(tab),
);
// Clicks
const gesture_tab_click = c.gtk_gesture_click_new();
c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0);
c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click));
_ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT);
// Tab settings
c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1);
c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1);
if (self.nPages() > 1) {
c.gtk_notebook_set_show_tabs(notebook, 1);
}
// Switch to the new tab
c.gtk_notebook_set_current_page(notebook, page_idx);
},
pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
const position = self.newTabInsertPosition(tab);
switch (self.*) {
.adw => |*adw| adw.addTab(tab, position, title),
.gtk => |*gtk| gtk.addTab(tab, position, title),
}
}
pub fn closeTab(self: Notebook, tab: *Tab) void {
const window = tab.window;
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return;
c.adw_tab_view_close_page(tab_view, page);
// If we have no more tabs we close the window
if (self.nPages() == 0) {
// libadw versions <= 1.3.x leak the final page view
// which causes our surface to not properly cleanup. We
// unref to force the cleanup. This will trigger a critical
// warning from GTK, but I don't know any other workaround.
// Note: I'm not actually sure if 1.4.0 contains the fix,
// I just know that 1.3.x is broken and 1.5.1 is fixed.
// If we know that 1.4.0 is fixed, we can change this.
if (!adwaita.versionAtLeast(1, 4, 0)) {
c.g_object_unref(tab.box);
pub fn closeTab(self: *Notebook, tab: *Tab) void {
switch (self.*) {
.adw => |*adw| adw.closeTab(tab),
.gtk => |*gtk| gtk.closeTab(tab),
}
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(
_: *c.GtkNotebook,
_: *c.GtkWidget,
_: c.guint,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Window = @ptrCast(@alignCast(ud.?));
const notebook: *c.GtkNotebook = self.notebook.gtk_notebook;
// Hide the tab bar if we only have one tab after removal
const remaining = c.gtk_notebook_get_n_pages(notebook);
if (remaining == 1) {
c.gtk_notebook_set_show_tabs(notebook, 0);
}
}
fn adwPageAttached(tab_view: *AdwTabView, page: *c.AdwTabPage, position: c_int, ud: ?*anyopaque) callconv(.C) void {
_ = position;
_ = tab_view;
const self: *Window = @ptrCast(@alignCast(ud.?));
const child = c.adw_tab_page_get_child(page);
const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return));
tab.window = self;
self.focusCurrentTab();
}
fn gtkPageAdded(
notebook: *c.GtkNotebook,
_: *c.GtkWidget,
page_idx: c.guint,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Window = @ptrCast(@alignCast(ud.?));
// The added page can come from another window with drag and drop, thus we migrate the tab
// window to be self.
const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx));
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return,
));
tab.window = self;
// Whenever a new page is added, we always grab focus of the
// currently selected page. This was added specifically so that when
// we drag a tab out to create a new window ("create-window" event)
// we grab focus in the new window. Without this, the terminal didn't
// have focus.
self.focusCurrentTab();
}
fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void {
const window: *Window = @ptrCast(@alignCast(ud.?));
const page = c.adw_tab_view_get_selected_page(window.notebook.adw_tab_view) orelse return;
const title = c.adw_tab_page_get_title(page);
c.gtk_window_set_title(window.window, title);
}
fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void {
const window: *Window = @ptrCast(@alignCast(ud.?));
const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(window.notebook.gtk_notebook, page)));
const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box)));
const label_text = c.gtk_label_get_text(gtk_label);
c.gtk_window_set_title(window.window, label_text);
}
fn adwTabViewCreateWindow(
_: *AdwTabView,
ud: ?*anyopaque,
) callconv(.C) ?*AdwTabView {
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
const window = createWindow(currentWindow) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
};
return window.notebook.adw_tab_view;
}
fn gtkNotebookCreateWindow(
_: *c.GtkNotebook,
page: *c.GtkWidget,
ud: ?*anyopaque,
) callconv(.C) ?*c.GtkNotebook {
// The tab for the page is stored in the widget data.
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null,
));
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
const window = createWindow(currentWindow) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
};
// And add it to the new window.
tab.window = window;
return window.notebook.gtk_notebook;
}
fn createWindow(currentWindow: *Window) !*Window {
pub fn createWindow(currentWindow: *Window) !*Window {
const alloc = currentWindow.app.core_app.alloc;
const app = currentWindow.app;

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;
}
separator {
.terminal-window .notebook separator {
background-color: rgba(36, 36, 36, 1);
background-clip: content-box;
}

View File

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

View File

@ -19,8 +19,9 @@ pub inline fn atLeast(
// compiling against unknown symbols and makes runtime checks
// very slightly faster.
if (comptime c.GTK_MAJOR_VERSION < major or
c.GTK_MINOR_VERSION < minor or
c.GTK_MICRO_VERSION < micro) return false;
(c.GTK_MAJOR_VERSION == major and c.GTK_MINOR_VERSION < minor) or
(c.GTK_MAJOR_VERSION == major and c.GTK_MINOR_VERSION == minor and c.GTK_MICRO_VERSION < micro))
return false;
// If we're in comptime then we can't check the runtime version.
if (@inComptime()) return true;
@ -38,3 +39,20 @@ pub inline fn atLeast(
return false;
}
test "atLeast" {
const std = @import("std");
const testing = std.testing;
try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(!atLeast(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1));
}

View File

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

View File

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

View File

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

View File

@ -63,7 +63,7 @@ pub fn run(alloc: Allocator) !u8 {
try stdout.writeAll(
\\
\\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
/// for Ghostty.
/// The `list-actions` command is used to list all the available keybind
/// actions for Ghostty. These are distinct from the CLI Actions which can
/// be listed via `+help`
///
/// The `--docs` argument will print out the documentation for each action.
pub fn run(alloc: Allocator) !u8 {

View File

@ -3,6 +3,7 @@ const build_options = @import("build_options");
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const build_config = @import("../build_config.zig");
const internal_os = @import("../os/main.zig");
const xev = @import("xev");
const renderer = @import("../renderer.zig");
const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void;
@ -37,6 +38,7 @@ pub fn run(alloc: Allocator) !u8 {
try stdout.print(" - renderer : {}\n", .{renderer.Renderer});
try stdout.print(" - libxev : {}\n", .{xev.backend});
if (comptime build_config.app_runtime == .gtk) {
try stdout.print(" - desktop env: {s}\n", .{@tagName(internal_os.desktopEnvironment())});
try stdout.print(" - GTK version:\n", .{});
try stdout.print(" build : {d}.{d}.{d}\n", .{
gtk.GTK_MAJOR_VERSION,

View File

@ -147,23 +147,28 @@ const c = @cImport({
/// By default, synthetic styles are enabled.
@"font-synthetic-style": FontSyntheticStyle = .{},
/// Apply a font feature. This can be repeated multiple times to enable multiple
/// font features. You can NOT set multiple font features with a single value
/// (yet).
/// Apply a font feature. To enable multiple font features you can repeat
/// this multiple times or use a comma-separated list of feature settings.
///
/// The syntax for feature settings is as follows, where `feat` is a feature:
///
/// * Enable features with e.g. `feat`, `+feat`, `feat on`, `feat=1`.
/// * Disabled features with e.g. `-feat`, `feat off`, `feat=0`.
/// * Set a feature value with e.g. `feat=2`, `feat = 3`, `feat 4`.
/// * Feature names may be wrapped in quotes, meaning this config should be
/// syntactically compatible with the `font-feature-settings` CSS property.
///
/// The syntax is fairly loose, but invalid settings will be silently ignored.
///
/// The font feature will apply to all fonts rendered by Ghostty. A future
/// enhancement will allow targeting specific faces.
///
/// A valid value is the name of a feature. Prefix the feature with a `-` to
/// explicitly disable it. Example: `ss20` or `-ss20`.
///
/// To disable programming ligatures, use `-calt` since this is the typical
/// feature name for programming ligatures. To look into what font features
/// your font has and what they do, use a font inspection tool such as
/// [fontdrop.info](https://fontdrop.info).
///
/// To generally disable most ligatures, use `-calt`, `-liga`, and `-dlig` (as
/// separate repetitive entries in your config).
/// To generally disable most ligatures, use `-calt, -liga, -dlig`.
@"font-feature": RepeatableString = .{},
/// Font size in points. This value can be a non-integer and the nearest integer
@ -177,6 +182,10 @@ const c = @cImport({
/// depending on your `window-inherit-font-size` setting. If that setting is
/// true, only the first window will be affected by this change since all
/// subsequent windows will inherit the font size of the previous window.
///
/// On Linux with GTK, font size is scaled according to both display-wide and
/// text-specific scaling factors, which are often managed by your desktop
/// environment (e.g. the GNOME display scale and large text settings).
@"font-size": f32 = switch (builtin.os.tag) {
// On macOS we default a little bigger since this tends to look better. This
// is purely subjective but this is easy to modify.
@ -320,7 +329,7 @@ const c = @cImport({
/// FreeType load flags to enable. The format of this is a list of flags to
/// enable separated by commas. If you prefix a flag with `no-` then it is
/// disabled. If you omit a flag, it's default value is used, so you must
/// disabled. If you omit a flag, its default value is used, so you must
/// explicitly disable flags you don't want. You can also use `true` or `false`
/// to turn all flags on or off.
///
@ -398,14 +407,17 @@ const c = @cImport({
theme: ?Theme = null,
/// Background color for the window.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 },
/// Foreground color for the window.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
/// The foreground and background color for selection. If this is not set, then
/// the selection color is just the inverted window background and foreground
/// (note: not to be confused with the cell bg/fg).
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"selection-foreground": ?Color = null,
@"selection-background": ?Color = null,
@ -431,15 +443,20 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
@"minimum-contrast": f64 = 1,
/// Color palette for the 256 color form that many terminal applications use.
/// The syntax of this configuration is `N=HEXCODE` where `N` is 0 to 255 (for
/// the 256 colors in the terminal color table) and `HEXCODE` is a typical RGB
/// color code such as `#AABBCC`.
/// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for
/// the 256 colors in the terminal color table) and `COLOR` is a typical RGB
/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color.
///
/// For definitions on all the codes [see this cheat
/// sheet](https://www.ditig.com/256-colors-cheat-sheet).
/// The palette index can be in decimal, binary, octal, or hexadecimal.
/// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal,
/// and `0x` for hexadecimal.
///
/// For definitions on the color indices and what they canonically map to,
/// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet).
palette: Palette = .{},
/// The color of the cursor. If this is not set, a default will be chosen.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"cursor-color": ?Color = null,
/// Swap the foreground and background colors of the cell under the cursor. This
@ -493,6 +510,7 @@ palette: Palette = .{},
/// The color of the text under the cursor. If this is not set, a default will
/// be chosen.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"cursor-text": ?Color = null,
/// Enables the ability to move the cursor at prompts by using `alt+click` on
@ -548,7 +566,7 @@ palette: Palette = .{},
/// than 0.01 or greater than 10,000 will be clamped to the nearest valid
/// value.
///
/// A value of "1" (default) scrolls te default amount. A value of "2" scrolls
/// A value of "1" (default) scrolls the default amount. A value of "2" scrolls
/// double the default amount. A value of "0.5" scrolls half the default amount.
/// Et cetera.
@"mouse-scroll-multiplier": f64 = 1.0,
@ -560,6 +578,8 @@ palette: Palette = .{},
/// On macOS, background opacity is disabled when the terminal enters native
/// fullscreen. This is because the background becomes gray and it can cause
/// widgets to show through which isn't generally desirable.
///
/// On macOS, changing this configuration requires restarting Ghostty completely.
@"background-opacity": f64 = 1.0,
/// A positive value enables blurring of the background when background-opacity
@ -586,6 +606,8 @@ palette: Palette = .{},
/// that rectangle and can be used to carefully control the dimming effect.
///
/// This will default to the background color.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"unfocused-split-fill": ?Color = null,
/// The command to run, usually a shell. If this is not an absolute path, it'll
@ -724,7 +746,7 @@ fullscreen: bool = false,
/// This configuration can be reloaded at runtime. If it is set, the title
/// will update for all windows. If it is unset, the next title change escape
/// sequence will be honored but previous changes will not retroactively
/// be set. This latter case may require you restart programs such as neovim
/// be set. This latter case may require you to restart programs such as Neovim
/// to get the new title.
title: ?[:0]const u8 = null,
@ -907,6 +929,15 @@ class: ?[:0]const u8 = null,
/// Since they are not associated with a specific terminal surface,
/// they're never encoded.
///
/// * `performable:` - Only consume the input if the action is able to be
/// performed. For example, the `copy_to_clipboard` action will only
/// consume the input if there is a selection to copy. If there is no
/// selection, Ghostty behaves as if the keybind was not set. This has
/// no effect with `global:` or `all:`-prefixed keybinds. For key
/// sequences, this will reset the sequence if the action is not
/// performable (acting identically to not having a keybind set at
/// all).
///
/// Keybind triggers are not unique per prefix combination. For example,
/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind
/// set later will overwrite the keybind set earlier. In this case, the
@ -1104,6 +1135,32 @@ keybind: Keybinds = .{},
@"window-height": u32 = 0,
@"window-width": u32 = 0,
/// The starting window position. This position is in pixels and is relative
/// to the top-left corner of the primary monitor. Both values must be set to take
/// effect. If only one value is set, it is ignored.
///
/// Note that the window manager may put limits on the position or override
/// the position. For example, a tiling window manager may force the window
/// to be a certain position to fit within the grid. There is nothing Ghostty
/// will do about this, but it will make an effort.
///
/// Also note that negative values are also up to the operating system and
/// window manager. Some window managers may not allow windows to be placed
/// off-screen.
///
/// Invalid positions are runtime-specific, but generally the positions are
/// clamped to the nearest valid position.
///
/// On macOS, the window position is relative to the top-left corner of
/// the visible screen area. This means that if the menu bar is visible, the
/// window will be placed below the menu bar.
///
/// Note: this is only supported on macOS and Linux GLFW builds. The GTK
/// runtime does not support setting the window position (this is a limitation
/// of GTK 4.0).
@"window-position-x": ?i16 = null,
@"window-position-y": ?i16 = null,
/// Whether to enable saving and restoring window state. Window state includes
/// their position, size, tabs, splits, etc. Some window state requires shell
/// integration, such as preserving working directories. See `shell-integration`
@ -1152,11 +1209,15 @@ keybind: Keybinds = .{},
/// Background color for the window titlebar. This only takes effect if
/// window-theme is set to ghostty. Currently only supported in the GTK app
/// runtime.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"window-titlebar-background": ?Color = null,
/// Foreground color for the window titlebar. This only takes effect if
/// window-theme is set to ghostty. Currently only supported in the GTK app
/// runtime.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"window-titlebar-foreground": ?Color = null,
/// This controls when resize overlays are shown. Resize overlays are a
@ -1772,21 +1833,19 @@ keybind: Keybinds = .{},
/// The color of the ghost in the macOS app icon.
///
/// The format of the color is the same as the `background` configuration;
/// see that for more information.
///
/// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`.
///
/// This only has an effect when `macos-icon` is set to `custom-style`.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"macos-icon-ghost-color": ?Color = null,
/// The color of the screen in the macOS app icon.
///
/// The screen is a gradient so you can specify multiple colors that
/// make up the gradient. Colors should be separated by commas. The
/// format of the color is the same as the `background` configuration;
/// see that for more information.
/// make up the gradient. Comma-separated colors may be specified as
/// as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 colors.
///
/// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`.
@ -1905,6 +1964,29 @@ keybind: Keybinds = .{},
/// Changing this value at runtime will only affect new windows.
@"adw-toolbar-style": AdwToolbarStyle = .raised,
/// Control the toasts that Ghostty shows. Toasts are small notifications
/// that appear overlaid on top of the terminal window. They are used to
/// show information that is not critical but may be important.
///
/// Possible toasts are:
///
/// - `clipboard-copy` (default: true) - Show a toast when text is copied
/// to the clipboard.
///
/// To specify a toast to enable, specify the name of the toast. To specify
/// a toast to disable, prefix the name with `no-`. For example, to disable
/// the clipboard-copy toast, set this configuration to `no-clipboard-copy`.
/// To enable the clipboard-copy toast, set this configuration to
/// `clipboard-copy`.
///
/// Multiple toasts can be enabled or disabled by separating them with a comma.
///
/// A value of "false" will disable all toasts. A value of "true" will
/// enable all toasts.
///
/// This configuration only applies to GTK with Adwaita enabled.
@"adw-toast": AdwToast = .{},
/// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs
/// are the new typical Gnome style where tabs fill their available space.
/// If you set this to `false` then tabs will only take up space they need,
@ -1925,6 +2007,15 @@ keybind: Keybinds = .{},
/// Adwaita support.
@"gtk-adwaita": bool = true,
/// Custom CSS files to be loaded.
///
/// This configuration can be repeated multiple times to load multiple files.
/// Prepend a ? character to the file path to suppress errors if the file does
/// not exist. If you want to include a file that begins with a literal ?
/// character, surround the file path in double quotes (").
/// The file size limit for a single stylesheet is 5MiB.
@"gtk-custom-css": RepeatablePath = .{},
/// If `true` (default), applications running in the terminal can show desktop
/// notifications using certain escape sequences such as OSC 9 or OSC 777.
@"desktop-notifications": bool = true,
@ -1963,10 +2054,11 @@ term: []const u8 = "xterm-ghostty",
/// * `download` - Check for updates, automatically download the update,
/// notify the user, but do not automatically install the update.
///
/// The default value is `check`.
/// If unset, we defer to Sparkle's default behavior, which respects the
/// preference stored in the standard user defaults (`defaults(1)`).
///
/// Changing this value at runtime works after a small delay.
@"auto-update": AutoUpdate = .check,
@"auto-update": ?AutoUpdate = null,
/// The release channel to use for auto-updates.
///
@ -2083,6 +2175,20 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
.{ .key = .{ .translated = .v }, .mods = mods },
.{ .paste_from_clipboard = {} },
);
// On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an
// alt keybinding for Copy and shift+ins is an alt keybinding for Paste
if (!builtin.target.isDarwin()) {
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } },
.{ .copy_to_clipboard = {} },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } },
.{ .paste_from_clipboard = {} },
);
}
}
// Increase font size mapping for keyboards with dedicated plus keys (like german)
@ -2124,45 +2230,53 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
);
// Expand Selection
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .left }, .mods = .{ .shift = true } },
.{ .adjust_selection = .left },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .right }, .mods = .{ .shift = true } },
.{ .adjust_selection = .right },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .shift = true } },
.{ .adjust_selection = .up },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .shift = true } },
.{ .adjust_selection = .down },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } },
.{ .adjust_selection = .page_up },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } },
.{ .adjust_selection = .page_down },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .home }, .mods = .{ .shift = true } },
.{ .adjust_selection = .home },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .end }, .mods = .{ .shift = true } },
.{ .adjust_selection = .end },
.{ .performable = true },
);
// Tabs common to all platforms
@ -2247,12 +2361,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } },
.{ .goto_split = .top },
.{ .goto_split = .up },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } },
.{ .goto_split = .bottom },
.{ .goto_split = .down },
);
try result.keybind.set.put(
alloc,
@ -2412,10 +2526,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
.{ .key = .{ .translated = .q }, .mods = .{ .super = true } },
.{ .quit = {} },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .k }, .mods = .{ .super = true } },
.{ .clear_screen = {} },
.{ .performable = true },
);
try result.keybind.set.put(
alloc,
@ -2516,12 +2631,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } },
.{ .goto_split = .top },
.{ .goto_split = .up },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } },
.{ .goto_split = .bottom },
.{ .goto_split = .down },
);
try result.keybind.set.put(
alloc,
@ -2668,18 +2783,43 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void {
try self.expandPaths(std.fs.path.dirname(path).?);
}
pub const OptionalFileAction = enum { loaded, not_found, @"error" };
/// Load optional configuration file from `path`. All errors are ignored.
pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void {
self.loadFile(alloc, path) catch |err| switch (err) {
error.FileNotFound => std.log.info(
"optional config file not found, not loading path={s}",
.{path},
),
else => std.log.warn(
///
/// Returns the action that was taken.
pub fn loadOptionalFile(
self: *Config,
alloc: Allocator,
path: []const u8,
) OptionalFileAction {
if (self.loadFile(alloc, path)) {
return .loaded;
} else |err| switch (err) {
error.FileNotFound => return .not_found,
else => {
std.log.warn(
"error reading optional config file, not loading err={} path={s}",
.{ err, path },
),
};
);
return .@"error";
},
}
}
fn writeConfigTemplate(path: []const u8) !void {
log.info("creating template config file: path={s}", .{path});
if (std.fs.path.dirname(path)) |dir_path| {
try std.fs.makeDirAbsolute(dir_path);
}
const file = try std.fs.createFileAbsolute(path, .{});
defer file.close();
try std.fmt.format(
file.writer(),
@embedFile("./config-template"),
.{ .path = path },
);
}
/// Load configurations from the default configuration files. The default
@ -2688,14 +2828,30 @@ pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void
/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config`
/// is also loaded.
pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void {
// Load XDG first
const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" });
defer alloc.free(xdg_path);
self.loadOptionalFile(alloc, xdg_path);
const xdg_action = self.loadOptionalFile(alloc, xdg_path);
// On macOS load the app support directory as well
if (comptime builtin.os.tag == .macos) {
const app_support_path = try internal_os.macos.appSupportDir(alloc, "config");
defer alloc.free(app_support_path);
self.loadOptionalFile(alloc, app_support_path);
const app_support_action = self.loadOptionalFile(alloc, app_support_path);
// If both files are not found, then we create a template file.
// For macOS, we only create the template file in the app support
if (app_support_action == .not_found and xdg_action == .not_found) {
writeConfigTemplate(app_support_path) catch |err| {
log.warn("error creating template config file err={}", .{err});
};
}
} else {
if (xdg_action == .not_found) {
writeConfigTemplate(xdg_path) catch |err| {
log.warn("error creating template config file err={}", .{err});
};
}
}
}
@ -2805,6 +2961,9 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
// replace the entire list with the new list.
inline for (fields, 0..) |field, i| {
const v = &@field(self, field);
// The list can be empty if it was reset, i.e. --font-family=""
if (v.list.items.len > 0) {
const len = v.list.items.len - counter[i];
if (len > 0) {
// Note: we don't have to worry about freeing the memory
@ -2819,6 +2978,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
}
}
}
}
// Config files loaded from the CLI args are relative to pwd
if (self.@"config-file".value.items.len > 0) {
@ -3797,17 +3957,22 @@ pub const Color = struct {
pub fn fromHex(input: []const u8) !Color {
// Trim the beginning '#' if it exists
const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input;
if (trimmed.len != 6 and trimmed.len != 3) return error.InvalidValue;
// We expect exactly 6 for RRGGBB
if (trimmed.len != 6) return error.InvalidValue;
// Expand short hex values to full hex values
const rgb: []const u8 = if (trimmed.len == 3) &.{
trimmed[0], trimmed[0],
trimmed[1], trimmed[1],
trimmed[2], trimmed[2],
} else trimmed;
// Parse the colors two at a time.
var result: Color = undefined;
comptime var i: usize = 0;
inline while (i < 6) : (i += 2) {
const v: u8 =
((try std.fmt.charToDigit(trimmed[i], 16)) * 16) +
try std.fmt.charToDigit(trimmed[i + 1], 16);
((try std.fmt.charToDigit(rgb[i], 16)) * 16) +
try std.fmt.charToDigit(rgb[i + 1], 16);
@field(result, switch (i) {
0 => "r",
@ -3827,6 +3992,8 @@ pub const Color = struct {
try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C"));
try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C"));
try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF"));
try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFF"));
try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.fromHex("#345"));
}
test "parseCLI from name" {
@ -3987,7 +4154,7 @@ pub const Palette = struct {
const eqlIdx = std.mem.indexOf(u8, value, "=") orelse
return error.InvalidValue;
const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 10);
const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 0);
const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]);
self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b };
}
@ -4027,6 +4194,28 @@ pub const Palette = struct {
try testing.expect(p.value[0].b == 0xCC);
}
test "parseCLI base" {
const testing = std.testing;
var p: Self = .{};
try p.parseCLI("0b1=#014589");
try p.parseCLI("0o7=#234567");
try p.parseCLI("0xF=#ABCDEF");
try testing.expect(p.value[0b1].r == 0x01);
try testing.expect(p.value[0b1].g == 0x45);
try testing.expect(p.value[0b1].b == 0x89);
try testing.expect(p.value[0o7].r == 0x23);
try testing.expect(p.value[0o7].g == 0x45);
try testing.expect(p.value[0o7].b == 0x67);
try testing.expect(p.value[0xF].r == 0xAB);
try testing.expect(p.value[0xF].g == 0xCD);
try testing.expect(p.value[0xF].b == 0xEF);
}
test "parseCLI overflow" {
const testing = std.testing;
@ -4291,6 +4480,45 @@ pub const RepeatablePath = struct {
// If it isn't absolute, we need to make it absolute relative
// to the base.
var buf: [std.fs.max_path_bytes]u8 = undefined;
// Check if the path starts with a tilde and expand it to the
// home directory on Linux/macOS. We explicitly look for "~/"
// because we don't support alternate users such as "~alice/"
if (std.mem.startsWith(u8, path, "~/")) expand: {
// Windows isn't supported yet
if (comptime builtin.os.tag == .windows) break :expand;
const expanded: []const u8 = internal_os.expandHome(
path,
&buf,
) catch |err| {
try diags.append(alloc, .{
.message = try std.fmt.allocPrintZ(
alloc,
"error expanding home directory for path {s}: {}",
.{ path, err },
),
});
// Blank this path so that we don't attempt to resolve it
// again
self.value.items[i] = .{ .required = "" };
continue;
};
log.debug(
"expanding file path from home directory: path={s}",
.{expanded},
);
switch (self.value.items[i]) {
.optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded),
}
continue;
}
const abs = dir.realpath(path, &buf) catch |err| abs: {
if (err == error.FileNotFound) {
// The file doesn't exist. Try to resolve the relative path
@ -4701,9 +4929,11 @@ pub const Keybinds = struct {
try list.parseCLI(alloc, "ctrl+z>2=goto_tab:2");
try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer()));
// Note they turn into translated keys because they match
// their ASCII mapping.
const want =
\\keybind = ctrl+z>1=goto_tab:1
\\keybind = ctrl+z>2=goto_tab:2
\\keybind = ctrl+z>two=goto_tab:2
\\keybind = ctrl+z>one=goto_tab:1
\\
;
try std.testing.expectEqualStrings(want, buf.items);
@ -5297,6 +5527,11 @@ pub const AdwToolbarStyle = enum {
@"raised-border",
};
/// See adw-toast
pub const AdwToast = packed struct {
@"clipboard-copy": bool = true,
};
/// See mouse-shift-capture
pub const MouseShiftCapture = enum {
false,

View File

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

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 builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const internal_os = @import("../os/main.zig");
/// Open the configuration in the OS default editor according to the default
/// paths the main config file could be in.
///
/// On Linux, this will open the file at the XDG config path. This is the
/// only valid path for Linux so we don't need to check for other paths.
///
/// On macOS, both XDG and AppSupport paths are valid. Because Ghostty
/// prioritizes AppSupport over XDG, we will open AppSupport if it exists,
/// followed by XDG if it exists, and finally AppSupport if neither exist.
/// For the existence check, we also prefer non-empty files over empty
/// files.
pub fn open(alloc_gpa: Allocator) !void {
// default location
const config_path = config_path: {
const xdg_config_path = try internal_os.xdg.config(alloc_gpa, .{ .subdir = "ghostty/config" });
// Use an arena to make memory management easier in here.
var arena = ArenaAllocator.init(alloc_gpa);
defer arena.deinit();
const alloc = arena.allocator();
if (comptime builtin.os.tag == .macos) macos: {
// On macOS, use the application support path if the XDG path doesn't exists.
if (std.fs.accessAbsolute(xdg_config_path, .{})) {
break :macos;
} else |err| switch (err) {
error.BadPathName, error.FileNotFound => {},
else => break :macos,
}
alloc_gpa.free(xdg_config_path);
break :config_path try internal_os.macos.appSupportDir(alloc_gpa, "config");
}
break :config_path xdg_config_path;
};
defer alloc_gpa.free(config_path);
// Get the path we should open
const config_path = try configPath(alloc);
// Create config directory recursively.
if (std.fs.path.dirname(config_path)) |config_dir| {
@ -43,5 +41,67 @@ pub fn open(alloc_gpa: Allocator) !void {
}
};
try internal_os.open(alloc_gpa, config_path);
try internal_os.open(alloc, .text, config_path);
}
/// Returns the config path to use for open for the current OS.
///
/// The allocator must be an arena allocator. No memory is freed by this
/// function and the resulting path is not all the memory that is allocated.
fn configPath(alloc_arena: Allocator) ![]const u8 {
const paths: []const []const u8 = try configPathCandidates(alloc_arena);
assert(paths.len > 0);
// Find the first path that exists and is non-empty. If no paths are
// non-empty but at least one exists, we will return the first path that
// exists.
var exists: ?[]const u8 = null;
for (paths) |path| {
const f = std.fs.openFileAbsolute(path, .{}) catch |err| {
switch (err) {
// File doesn't exist, continue.
error.BadPathName, error.FileNotFound => continue,
// Some other error, assume it exists and return it.
else => return err,
}
};
defer f.close();
// We expect stat to succeed because we just opened the file.
const stat = try f.stat();
// If the file is non-empty, return it.
if (stat.size > 0) return path;
// If the file is empty, remember it exists.
if (exists == null) exists = path;
}
// No paths are non-empty, return the first path that exists.
if (exists) |v| return v;
// No paths are non-empty or exist, return the first path.
return paths[0];
}
/// Returns a const list of possible paths the main config file could be
/// in for the current OS.
fn configPathCandidates(alloc_arena: Allocator) ![]const []const u8 {
var paths = try std.ArrayList([]const u8).initCapacity(alloc_arena, 2);
errdefer paths.deinit();
if (comptime builtin.os.tag == .macos) {
paths.appendAssumeCapacity(try internal_os.macos.appSupportDir(
alloc_arena,
"config",
));
}
paths.appendAssumeCapacity(try internal_os.xdg.config(
alloc_arena,
.{ .subdir = "ghostty/config" },
));
return paths.items;
}

View File

@ -3,7 +3,8 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const build_config = @import("../build_config.zig");
const sentry = @import("sentry");
const build_options = @import("build_options");
const sentry = if (build_options.sentry) @import("sentry");
const internal_os = @import("../os/main.zig");
const crash = @import("main.zig");
const state = &@import("../global.zig").state;
@ -47,6 +48,8 @@ pub threadlocal var thread_state: ?ThreadState = null;
/// It is up to the user to grab the logs and manually send them to us
/// (or they own Sentry instance) if they want to.
pub fn init(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
// Not supported on Windows currently, doesn't build.
if (comptime builtin.os.tag == .windows) return;
@ -76,6 +79,8 @@ pub fn init(gpa: Allocator) !void {
}
fn initThread(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit();
const alloc = arena.allocator();
@ -101,7 +106,23 @@ fn initThread(gpa: Allocator) !void {
sentry.c.sentry_options_set_before_send(opts, beforeSend, null);
// Determine the Sentry cache directory.
const cache_dir = try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" });
const cache_dir = cache_dir: {
// On macOS, we prefer to use the NSCachesDirectory value to be
// a more idiomatic macOS application. But if XDG env vars are set
// we will respect them.
if (comptime builtin.os.tag == .macos) macos: {
if (std.posix.getenv("XDG_CACHE_HOME") != null) break :macos;
break :cache_dir try internal_os.macos.cacheDir(
alloc,
"sentry",
);
}
break :cache_dir try internal_os.xdg.cache(
alloc,
.{ .subdir = "ghostty/sentry" },
);
};
sentry.c.sentry_options_set_database_path_n(
opts,
cache_dir.ptr,
@ -129,6 +150,8 @@ fn initThread(gpa: Allocator) !void {
/// Process-wide deinitialization of our Sentry client. This ensures all
/// our data is flushed.
pub fn deinit() void {
if (comptime !build_options.sentry) return;
if (comptime builtin.os.tag == .windows) return;
// If we're still initializing then wait for init to finish. This

View File

@ -362,16 +362,9 @@ pub const CoreText = struct {
const list = set.createMatchingFontDescriptors();
defer list.release();
// Bring the list of descriptors in to zig land
var zig_list = try copyMatchingDescriptors(alloc, list);
errdefer alloc.free(zig_list);
// Filter them. We don't use `CTFontCollectionSetExclusionDescriptors`
// to do this because that requires a mutable collection. This way is
// much more straight forward.
zig_list = try alloc.realloc(zig_list, filterDescriptors(zig_list));
// Sort our descriptors
const zig_list = try copyMatchingDescriptors(alloc, list);
errdefer alloc.free(zig_list);
sortMatchingDescriptors(&desc, zig_list);
return DiscoverIterator{
@ -558,47 +551,13 @@ pub const CoreText = struct {
for (0..result.len) |i| {
result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i);
// We need to retain because once the list
// is freed it will release all its members.
// We need to retain because once the list is freed it will
// release all its members.
result[i].retain();
}
return result;
}
/// Filter any descriptors out of the list that aren't acceptable for
/// some reason or another (e.g. the font isn't in a format we can handle).
///
/// Invalid descriptors are filled in from the end of
/// the list and the new length for the list is returned.
fn filterDescriptors(list: []*macos.text.FontDescriptor) usize {
var end = list.len;
var i: usize = 0;
while (i < end) {
if (validDescriptor(list[i])) {
i += 1;
} else {
list[i].release();
end -= 1;
list[i] = list[end];
}
}
return end;
}
/// Used by `filterDescriptors` to decide whether a descriptor is valid.
fn validDescriptor(desc: *macos.text.FontDescriptor) bool {
if (desc.copyAttribute(macos.text.FontAttribute.format)) |format| {
defer format.release();
var value: c_int = undefined;
assert(format.getValue(.int, &value));
// Bitmap fonts are not currently supported.
if (value == macos.text.c.kCTFontFormatBitmap) return false;
}
return true;
}
fn sortMatchingDescriptors(
desc: *const Descriptor,
list: []*macos.text.FontDescriptor,

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
/// so we embed it here.
pub const monaspace_neon = @embedFile("res/MonaspaceNeon-Regular.otf");
/// Terminus TTF is a scalable font with bitmap glyphs at various sizes.
pub const terminus_ttf = @embedFile("res/TerminusTTF-Regular.ttf");

View File

@ -515,8 +515,17 @@ pub const Face = struct {
fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics {
// Read the 'head' table out of the font data.
const head: opentype.Head = head: {
const tag = macos.text.FontTableTag.init("head");
const data = ct_font.copyTable(tag) orelse return error.CopyTableError;
// macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but
// the table format is byte-identical to the 'head' table, so if we
// can't find 'head' we try 'bhed' instead before failing.
//
// ref: https://fontforge.org/docs/techref/bitmaponlysfnt.html
const head_tag = macos.text.FontTableTag.init("head");
const bhed_tag = macos.text.FontTableTag.init("bhed");
const data =
ct_font.copyTable(head_tag) orelse
ct_font.copyTable(bhed_tag) orelse
return error.CopyTableError;
defer data.release();
const ptr = data.getPointer();
const len = data.getLength();

View File

@ -288,7 +288,6 @@ pub const Face = struct {
self.face.loadGlyph(glyph_id, .{
.render = true,
.color = self.face.hasColor(),
.no_bitmap = !self.face.hasColor(),
}) catch return false;
// If the glyph is SVG we assume colorized
@ -323,14 +322,6 @@ pub const Face = struct {
// glyph properties before render so we don't render here.
.render = !self.synthetic.bold,
// Disable bitmap strikes for now since it causes issues with
// our cell metrics and rasterization. In the future, this is
// all fixable so we can enable it.
//
// This must be enabled for color faces though because those are
// often colored bitmaps, which we support.
.no_bitmap = !self.face.hasColor(),
// use options from config
.no_hinting = !self.load_flags.hinting,
.force_autohint = !self.load_flags.@"force-autohint",
@ -385,7 +376,7 @@ pub const Face = struct {
return error.UnsupportedPixelMode;
};
log.warn("converting from pixel_mode={} to atlas_format={}", .{
log.debug("converting from pixel_mode={} to atlas_format={}", .{
bitmap_ft.pixel_mode,
atlas.format,
});
@ -1005,3 +996,59 @@ test "svg font table" {
try testing.expectEqual(430, table.len);
}
const terminus_i =
\\........
\\........
\\...#....
\\...#....
\\........
\\..##....
\\...#....
\\...#....
\\...#....
\\...#....
\\...#....
\\..###...
\\........
\\........
\\........
\\........
;
// Including the newline
const terminus_i_pitch = 9;
test "bitmap glyph" {
const alloc = testing.allocator;
const testFont = font.embedded.terminus_ttf;
var lib = try Library.init();
defer lib.deinit();
var atlas = try font.Atlas.init(alloc, 512, .grayscale);
defer atlas.deinit(alloc);
// Any glyph at 12pt @ 96 DPI is a bitmap
var ft_font = try Face.init(lib, testFont, .{ .size = .{
.points = 12,
.xdpi = 96,
.ydpi = 96,
} });
defer ft_font.deinit();
// glyph 77 = 'i'
const glyph = try ft_font.renderGlyph(alloc, &atlas, 77, .{});
// should render crisp
try testing.expectEqual(8, glyph.width);
try testing.expectEqual(16, glyph.height);
for (0..glyph.height) |y| {
for (0..glyph.width) |x| {
const pixel = terminus_i[y * terminus_i_pitch + x];
try testing.expectEqual(
@as(u8, if (pixel == '#') 255 else 0),
atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)],
);
}
}
}

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);
errdefer alloc.free(buf);
// width divided by 8 because each byte has 8 pixels. This is therefore
// the number of bytes in each row.
const bytes_per_row = bm.width >> 3;
var source_i: usize = 0;
var target_i: usize = 0;
var i: usize = bm.rows;
while (i > 0) : (i -= 1) {
var j: usize = bytes_per_row;
while (j > 0) : (j -= 1) {
var bit: u4 = 8;
while (bit > 0) : (bit -= 1) {
const mask = @as(u8, 1) << @as(u3, @intCast(bit - 1));
const bitval: u8 = if (bm.buffer[source_i + (j - 1)] & mask > 0) 0xFF else 0;
buf[target_i] = bitval;
target_i += 1;
for (0..bm.rows) |y| {
const row_offset = y * @as(usize, @intCast(bm.pitch));
for (0..bm.width) |x| {
const byte_offset = row_offset + @divTrunc(x, 8);
const mask = @as(u8, 1) << @intCast(7 - (x % 8));
const bit: u8 = @intFromBool((bm.buffer[byte_offset] & mask) != 0);
buf[y * bm.width + x] = bit * 255;
}
}
source_i += @intCast(bm.pitch);
}
var copy = bm;
copy.buffer = buf.ptr;
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)
- Cozette (MIT)
- [Copyright (c) 2020, Slavfox](https://github.com/slavfox/Cozette/blob/main/LICENSE)
- Terminus TTF (OFL-1.1)
- [Copyright (c) 2010-2020 Dimitar Toshkov Zhekov with Reserved Font Name "Terminus Font"](https://sourceforge.net/projects/terminus-font/)
- [Copyright (c) 2011-2023 Tilman Blumenbach with Reserved Font Name "Terminus (TTF)"](https://files.ax86.net/terminus-ttf/)
A full copy of the OFL license can be found at [OFL.txt](./OFL.txt).
An accompanying FAQ is also available at <https://openfontlicense.org/>.

Binary file not shown.

View File

@ -1,6 +1,7 @@
const builtin = @import("builtin");
const options = @import("main.zig").options;
const run = @import("shaper/run.zig");
const feature = @import("shaper/feature.zig");
pub const noop = @import("shaper/noop.zig");
pub const harfbuzz = @import("shaper/harfbuzz.zig");
pub const coretext = @import("shaper/coretext.zig");
@ -8,6 +9,9 @@ pub const web_canvas = @import("shaper/web_canvas.zig");
pub const Cache = @import("shaper/Cache.zig");
pub const TextRun = run.TextRun;
pub const RunIterator = run.RunIterator;
pub const Feature = feature.Feature;
pub const FeatureList = feature.FeatureList;
pub const default_features = feature.default_features;
/// Shaper implementation for our compile options.
pub const Shaper = switch (options.backend) {
@ -49,10 +53,7 @@ pub const Cell = struct {
/// Options for shapers.
pub const Options = struct {
/// Font features to use when shaping. These can be in the following
/// formats: "-feat" "+feat" "feat". A "-"-prefix is used to disable
/// a feature and the others are used to enable a feature. If a feature
/// isn't supported or is invalid, it will be ignored.
/// Font features to use when shaping.
///
/// Note: eventually, this will move to font.Face probably as we may
/// want to support per-face feature configuration. For now, we only

View File

@ -7,6 +7,9 @@ const trace = @import("tracy").trace;
const font = @import("../main.zig");
const os = @import("../../os/main.zig");
const terminal = @import("../../terminal/main.zig");
const Feature = font.shape.Feature;
const FeatureList = font.shape.FeatureList;
const default_features = font.shape.default_features;
const Face = font.Face;
const Collection = font.Collection;
const DeferredFace = font.DeferredFace;
@ -40,9 +43,10 @@ pub const Shaper = struct {
/// The string used for shaping the current run.
run_state: RunState,
/// The font features we want to use. The hardcoded features are always
/// set first.
features: FeatureList,
/// CoreFoundation Dictionary which represents our font feature settings.
features: *macos.foundation.Dictionary,
/// A version of the features dictionary with the default features excluded.
features_no_default: *macos.foundation.Dictionary,
/// The shared memory used for shaping results.
cell_buf: CellBuf,
@ -100,51 +104,17 @@ pub const Shaper = struct {
}
};
/// List of font features, parsed into the data structures used by
/// the CoreText API. The CoreText API requires a pretty annoying wrapping
/// to setup font features:
///
/// - The key parsed into a CFString
/// - The value parsed into a CFNumber
/// - The key and value are then put into a CFDictionary
/// - The CFDictionary is then put into a CFArray
/// - The CFArray is then put into another CFDictionary
/// - The CFDictionary is then passed to the CoreText API to create
/// a new font with the features set.
///
/// This structure handles up to the point that we have a CFArray of
/// CFDictionary objects representing the font features and provides
/// functions for creating the dictionary to init the font.
const FeatureList = struct {
list: *macos.foundation.MutableArray,
pub fn init() !FeatureList {
var list = try macos.foundation.MutableArray.create();
/// Create a CoreFoundation Dictionary suitable for
/// settings the font features of a CoreText font.
fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary {
const list = try macos.foundation.MutableArray.create();
errdefer list.release();
return .{ .list = list };
}
pub fn deinit(self: FeatureList) void {
self.list.release();
}
/// Append the given feature to the list. The feature syntax is
/// the same as Harfbuzz: "feat" enables it and "-feat" disables it.
pub fn append(self: *FeatureList, name_raw: []const u8) !void {
// If the name is `-name` then we are disabling the feature,
// otherwise we are enabling it, so we need to parse this out.
const name = if (name_raw[0] == '-') name_raw[1..] else name_raw;
const dict = try featureDict(name, name_raw[0] != '-');
defer dict.release();
self.list.appendValue(macos.foundation.Dictionary, dict);
}
/// Create the dictionary for the given feature and value.
fn featureDict(name: []const u8, v: bool) !*macos.foundation.Dictionary {
const value_num: c_int = @intFromBool(v);
for (feats) |feat| {
const value_num: c_int = @intCast(feat.value);
// Keys can only be ASCII.
var key = try macos.foundation.String.createWithBytes(name, .ascii, false);
var key = try macos.foundation.String.createWithBytes(&feat.tag, .ascii, false);
defer key.release();
var value = try macos.foundation.Number.create(.int, &value_num);
defer value.release();
@ -154,50 +124,44 @@ pub const Shaper = struct {
macos.text.c.kCTFontOpenTypeFeatureTag,
macos.text.c.kCTFontOpenTypeFeatureValue,
},
&[_]?*const anyopaque{
key,
value,
},
&[_]?*const anyopaque{ key, value },
);
errdefer dict.release();
return dict;
}
defer dict.release();
/// Returns the dictionary to use with the font API to set the
/// features. This should be released by the caller.
pub fn attrsDict(
self: FeatureList,
omit_defaults: bool,
) !*macos.foundation.Dictionary {
// Get our feature list. If we're omitting defaults then we
// slice off the hardcoded features.
const list = if (!omit_defaults) self.list else list: {
const list = try macos.foundation.MutableArray.createCopy(@ptrCast(self.list));
for (hardcoded_features) |_| list.removeValue(0);
break :list list;
};
defer if (omit_defaults) list.release();
list.appendValue(macos.foundation.Dictionary, dict);
}
var dict = try macos.foundation.Dictionary.create(
&[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute},
&[_]?*const anyopaque{list},
);
errdefer dict.release();
return dict;
}
};
// These features are hardcoded to always be on by default. Users
// can turn them off by setting the features to "-liga" for example.
const hardcoded_features = [_][]const u8{ "dlig", "liga" };
/// The cell_buf argument is the buffer to use for storing shaped results.
/// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
var feats = try FeatureList.init();
errdefer feats.deinit();
for (hardcoded_features) |name| try feats.append(name);
for (opts.features) |name| try feats.append(name);
var feature_list: FeatureList = .{};
defer feature_list.deinit(alloc);
for (opts.features) |feature_str| {
try feature_list.appendFromString(alloc, feature_str);
}
// We need to construct two attrs dictionaries for font features;
// one without the default features included, and one with them.
const feats = feature_list.features.items;
const feats_df = try alloc.alloc(Feature, feats.len + default_features.len);
defer alloc.free(feats_df);
@memcpy(feats_df[0..default_features.len], &default_features);
@memcpy(feats_df[default_features.len..], feats);
const features = try makeFeaturesDict(feats_df);
errdefer features.release();
const features_no_default = try makeFeaturesDict(feats);
errdefer features_no_default.release();
var run_state = RunState.init();
errdefer run_state.deinit(alloc);
@ -242,7 +206,8 @@ pub const Shaper = struct {
.alloc = alloc,
.cell_buf = .{},
.run_state = run_state,
.features = feats,
.features = features,
.features_no_default = features_no_default,
.writing_direction = writing_direction,
.cached_fonts = .{},
.cached_font_grid = 0,
@ -255,7 +220,8 @@ pub const Shaper = struct {
pub fn deinit(self: *Shaper) void {
self.cell_buf.deinit(self.alloc);
self.run_state.deinit(self.alloc);
self.features.deinit();
self.features.release();
self.features_no_default.release();
self.writing_direction.release();
{
@ -509,8 +475,8 @@ pub const Shaper = struct {
// If we have it, return the cached attr dict.
if (self.cached_fonts.items[index_int]) |cached| return cached;
// Features dictionary, font descriptor, font
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3);
// Font descriptor, font
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 2);
const run_font = font: {
// The CoreText shaper relies on CoreText and CoreText claims
@ -533,8 +499,10 @@ pub const Shaper = struct {
const face = try grid.resolver.collection.getFace(index);
const original = face.font;
const attrs = try self.features.attrsDict(face.quirks_disable_default_font_features);
self.cf_release_pool.appendAssumeCapacity(attrs);
const attrs = if (face.quirks_disable_default_font_features)
self.features_no_default
else
self.features;
const desc = try macos.text.FontDescriptor.createWithAttributes(attrs);
self.cf_release_pool.appendAssumeCapacity(desc);

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 harfbuzz = @import("harfbuzz");
const font = @import("../main.zig");
const terminal = @import("../../terminal/main.zig");
const Feature = font.shape.Feature;
const FeatureList = font.shape.FeatureList;
const default_features = font.shape.default_features;
const Face = font.Face;
const Collection = font.Collection;
const DeferredFace = font.DeferredFace;
@ -10,7 +14,6 @@ const Library = font.Library;
const SharedGrid = font.SharedGrid;
const Style = font.Style;
const Presentation = font.Presentation;
const terminal = @import("../../terminal/main.zig");
const log = std.log.scoped(.font_shaper);
@ -27,38 +30,37 @@ pub const Shaper = struct {
cell_buf: CellBuf,
/// The features to use for shaping.
hb_feats: FeatureList,
hb_feats: []harfbuzz.Feature,
const CellBuf = std.ArrayListUnmanaged(font.shape.Cell);
const FeatureList = std.ArrayListUnmanaged(harfbuzz.Feature);
// These features are hardcoded to always be on by default. Users
// can turn them off by setting the features to "-liga" for example.
const hardcoded_features = [_][]const u8{ "dlig", "liga" };
/// The cell_buf argument is the buffer to use for storing shaped results.
/// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
// Parse all the features we want to use. We use
var hb_feats = hb_feats: {
var list = try FeatureList.initCapacity(alloc, opts.features.len + hardcoded_features.len);
errdefer list.deinit(alloc);
for (hardcoded_features) |name| {
if (harfbuzz.Feature.fromString(name)) |feat| {
try list.append(alloc, feat);
} else log.warn("failed to parse font feature: {s}", .{name});
// Parse all the features we want to use.
const hb_feats = hb_feats: {
var feature_list: FeatureList = .{};
defer feature_list.deinit(alloc);
try feature_list.features.appendSlice(alloc, &default_features);
for (opts.features) |feature_str| {
try feature_list.appendFromString(alloc, feature_str);
}
for (opts.features) |name| {
if (harfbuzz.Feature.fromString(name)) |feat| {
try list.append(alloc, feat);
} else log.warn("failed to parse font feature: {s}", .{name});
var list = try alloc.alloc(harfbuzz.Feature, feature_list.features.items.len);
errdefer alloc.free(list);
for (feature_list.features.items, 0..) |feature, i| {
list[i] = .{
.tag = std.mem.nativeToBig(u32, @bitCast(feature.tag)),
.value = feature.value,
.start = harfbuzz.c.HB_FEATURE_GLOBAL_START,
.end = harfbuzz.c.HB_FEATURE_GLOBAL_END,
};
}
break :hb_feats list;
};
errdefer hb_feats.deinit(alloc);
errdefer alloc.free(hb_feats);
return Shaper{
.alloc = alloc,
@ -71,7 +73,7 @@ pub const Shaper = struct {
pub fn deinit(self: *Shaper) void {
self.hb_buf.destroy();
self.cell_buf.deinit(self.alloc);
self.hb_feats.deinit(self.alloc);
self.alloc.free(self.hb_feats);
}
pub fn endFrame(self: *const Shaper) void {
@ -125,10 +127,10 @@ pub const Shaper = struct {
// If we are disabling default font features we just offset
// our features by the hardcoded items because always
// add those at the beginning.
break :i hardcoded_features.len;
break :i default_features.len;
};
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats.items[i..]);
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..]);
}
// If our buffer is empty, we short-circuit the rest of the work

View File

@ -27,6 +27,7 @@ pub const GlobalState = struct {
alloc: std.mem.Allocator,
action: ?cli.Action,
logging: Logging,
rlimits: ResourceLimits = .{},
/// The app resources directory, equivalent to zig-out/share when we build
/// from source. This is null if we can't detect it.
@ -56,6 +57,7 @@ pub const GlobalState = struct {
.alloc = undefined,
.action = null,
.logging = .{ .stderr = {} },
.rlimits = .{},
.resources_dir = null,
};
errdefer self.deinit();
@ -123,8 +125,8 @@ pub const GlobalState = struct {
std.log.info("renderer={}", .{renderer.Renderer});
std.log.info("libxev backend={}", .{xev.backend});
// First things first, we fix our file descriptors
internal_os.fixMaxFiles();
// As early as possible, initialize our resource limits.
self.rlimits = ResourceLimits.init();
// Initialize our crash reporting.
crash.init(self.alloc) catch |err| {
@ -174,3 +176,21 @@ pub const GlobalState = struct {
}
}
};
/// Maintains the Unix resource limits that we set for our process. This
/// can be used to restore the limits to their original values.
pub const ResourceLimits = struct {
nofile: ?internal_os.rlimit = null,
pub fn init() ResourceLimits {
return .{
// Maximize the number of file descriptors we can have open
// because we can consume a lot of them if we make many terminals.
.nofile = internal_os.fixMaxFiles(),
};
}
pub fn restore(self: *const ResourceLimits) void {
if (self.nofile) |lim| internal_os.restoreMaxFiles(lim);
}
};

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.
/// See the keybind config documentation for more information.
global: bool = false,
/// True if this binding should only be triggered if the action can be
/// performed. If the action can't be performed then the binding acts as
/// if it doesn't exist.
performable: bool = false,
};
/// Full binding parser. The binding parser is implemented as an iterator
@ -90,6 +95,9 @@ pub const Parser = struct {
} else if (std.mem.eql(u8, prefix, "unconsumed")) {
if (!flags.consumed) return Error.InvalidFormat;
flags.consumed = false;
} else if (std.mem.eql(u8, prefix, "performable")) {
if (flags.performable) return Error.InvalidFormat;
flags.performable = true;
} else {
// If we don't recognize the prefix then we're done.
// There are trigger-specific prefixes like "physical:" so
@ -185,10 +193,29 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool {
if (rhs.trigger.mods.alt) count += 1;
break :blk count;
};
if (lhs_count == rhs_count)
if (lhs_count != rhs_count)
return lhs_count > rhs_count;
if (lhs.trigger.mods.int() != rhs.trigger.mods.int())
return lhs.trigger.mods.int() > rhs.trigger.mods.int();
return lhs_count > rhs_count;
const lhs_key: c_int = blk: {
switch (lhs.trigger.key) {
.translated => break :blk @intFromEnum(lhs.trigger.key.translated),
.physical => break :blk @intFromEnum(lhs.trigger.key.physical),
.unicode => break :blk @intCast(lhs.trigger.key.unicode),
}
};
const rhs_key: c_int = blk: {
switch (rhs.trigger.key) {
.translated => break :blk @intFromEnum(rhs.trigger.key.translated),
.physical => break :blk @intFromEnum(rhs.trigger.key.physical),
.unicode => break :blk @intCast(rhs.trigger.key.unicode),
}
};
return lhs_key < rhs_key;
}
/// The set of actions that a keybinding can take.
@ -311,17 +338,17 @@ pub const Action = union(enum) {
toggle_tab_overview: void,
/// Create a new split in the given direction. The new split will appear in
/// the direction given.
/// the direction given. For example `new_split:up`. Valid values are left, right, up, down and auto.
new_split: SplitDirection,
/// Focus on a split in a given direction.
/// Focus on a split in a given direction. For example `goto_split:top`. Valid values are top, bottom, left, right, previous and next.
goto_split: SplitFocusDirection,
/// zoom/unzoom the current split.
toggle_split_zoom: void,
/// Resize the current split by moving the split divider in the given
/// direction
/// direction. For example `resize_split:left,10`. The valid directions are up, down, left and right.
resize_split: SplitResizeParameter,
/// Equalize all splits in the current window
@ -478,10 +505,42 @@ pub const Action = union(enum) {
previous,
next,
top,
up,
left,
bottom,
down,
right,
pub fn parse(input: []const u8) !SplitFocusDirection {
return std.meta.stringToEnum(SplitFocusDirection, input) orelse {
// For backwards compatibility we map "top" and "bottom" onto the enum
// values "up" and "down"
if (std.mem.eql(u8, input, "top")) {
return .up;
} else if (std.mem.eql(u8, input, "bottom")) {
return .down;
} else {
return Error.InvalidFormat;
}
};
}
test "parse" {
const testing = std.testing;
try testing.expectEqual(.previous, try SplitFocusDirection.parse("previous"));
try testing.expectEqual(.next, try SplitFocusDirection.parse("next"));
try testing.expectEqual(.up, try SplitFocusDirection.parse("up"));
try testing.expectEqual(.left, try SplitFocusDirection.parse("left"));
try testing.expectEqual(.down, try SplitFocusDirection.parse("down"));
try testing.expectEqual(.right, try SplitFocusDirection.parse("right"));
try testing.expectEqual(.up, try SplitFocusDirection.parse("top"));
try testing.expectEqual(.down, try SplitFocusDirection.parse("bottom"));
try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse(""));
try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("green"));
}
};
pub const SplitResizeDirection = enum {
@ -524,7 +583,16 @@ pub const Action = union(enum) {
comptime field: std.builtin.Type.UnionField,
param: []const u8,
) !field.type {
return switch (@typeInfo(field.type)) {
const field_info = @typeInfo(field.type);
// Fields can provide a custom "parse" function
if (field_info == .Struct or field_info == .Union or field_info == .Enum) {
if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .Fn) {
return field.type.parse(param);
}
}
return switch (field_info) {
.Enum => try parseEnum(field.type, param),
.Int => try parseInt(field.type, param),
.Float => try parseFloat(field.type, param),
@ -1019,6 +1087,14 @@ pub const Trigger = struct {
const cp = it.nextCodepoint() orelse break :unicode;
if (it.nextCodepoint() != null) break :unicode;
// If this is ASCII and we have a translated key, set that.
if (std.math.cast(u8, cp)) |ascii| {
if (key.Key.fromASCII(ascii)) |k| {
result.key = .{ .translated = k };
continue :loop;
}
}
result.key = .{ .unicode = cp };
continue :loop;
}
@ -1554,6 +1630,19 @@ test "parse: triggers" {
try parseSingle("a=ignore"),
);
// unicode keys that map to translated
try testing.expectEqual(Binding{
.trigger = .{ .key = .{ .translated = .one } },
.action = .{ .ignore = {} },
}, try parseSingle("1=ignore"));
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .super = true },
.key = .{ .translated = .period },
},
.action = .{ .ignore = {} },
}, try parseSingle("cmd+.=ignore"));
// single modifier
try testing.expectEqual(Binding{
.trigger = .{
@ -1626,6 +1715,16 @@ test "parse: triggers" {
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:physical:a+shift=ignore"));
// performable keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .performable = true },
}, try parseSingle("performable:shift+a=ignore"));
// invalid key
try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore"));

View File

@ -729,7 +729,9 @@ pub const Key = enum(c_int) {
.{ '\t', .tab },
// Keypad entries. We just assume keypad with the kp_ prefix
// so that has some special meaning. These must also always be last.
// so that has some special meaning. These must also always be last,
// so that our `fromASCII` function doesn't accidentally map them
// over normal numerics and other keys.
.{ '0', .kp_0 },
.{ '1', .kp_1 },
.{ '2', .kp_2 },

View File

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

View File

@ -49,7 +49,8 @@ pub fn main() !MainReturn {
error.InvalidAction => try stderr.print(
"Error: unknown CLI action specified. CLI actions are specified with\n" ++
"the '+' character.\n",
"the '+' character.\n\n" ++
"All valid CLI actions can be listed with `ghostty +help`\n",
.{},
),

View File

@ -59,3 +59,29 @@ pub fn launchedFromDesktop() bool {
else => @compileError("unsupported platform"),
};
}
pub const DesktopEnvironment = enum {
gnome,
macos,
other,
windows,
};
/// Detect what desktop environment we are running under. This is mainly used on
/// Linux to enable or disable GTK client-side decorations but there may be more
/// uses in the future.
pub fn desktopEnvironment() DesktopEnvironment {
return switch (comptime builtin.os.tag) {
.macos => .macos,
.windows => .windows,
.linux => de: {
if (@inComptime()) @compileError("Checking for the desktop environment on Linux must be done at runtime.");
// use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux
// https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop=
const de = posix.getenv("XDG_SESSION_DESKTOP") orelse break :de .other;
if (std.ascii.eqlIgnoreCase("gnome", de)) break :de .gnome;
break :de .other;
},
else => .other,
};
}

View File

@ -4,24 +4,27 @@ const posix = std.posix;
const log = std.log.scoped(.os);
pub const rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {};
/// This maximizes the number of file descriptors we can have open. We
/// need to do this because each window consumes at least a handful of fds.
/// This is extracted from the Zig compiler source code.
pub fn fixMaxFiles() void {
if (!@hasDecl(posix.system, "rlimit")) return;
pub fn fixMaxFiles() ?rlimit {
if (!@hasDecl(posix.system, "rlimit")) return null;
var lim = posix.getrlimit(.NOFILE) catch {
const old = posix.getrlimit(.NOFILE) catch {
log.warn("failed to query file handle limit, may limit max windows", .{});
return; // Oh well; we tried.
return null; // Oh well; we tried.
};
// If we're already at the max, we're done.
if (lim.cur >= lim.max) {
log.debug("file handle limit already maximized value={}", .{lim.cur});
return;
if (old.cur >= old.max) {
log.debug("file handle limit already maximized value={}", .{old.cur});
return old;
}
// Do a binary search for the limit.
var lim = old;
var min: posix.rlim_t = lim.cur;
var max: posix.rlim_t = 1 << 20;
// But if there's a defined upper bound, don't search, just set it.
@ -41,6 +44,12 @@ pub fn fixMaxFiles() void {
}
log.debug("file handle limit raised value={}", .{lim.cur});
return old;
}
pub fn restoreMaxFiles(lim: rlimit) void {
if (!@hasDecl(posix.system, "rlimit")) return;
posix.setrlimit(.NOFILE, lim) catch {};
}
/// Return the recommended path for temporary files.

View File

@ -12,7 +12,7 @@ const Error = error{
/// Determine the home directory for the currently executing user. This
/// is generally an expensive process so the value should be cached.
pub inline fn home(buf: []u8) !?[]u8 {
pub inline fn home(buf: []u8) !?[]const u8 {
return switch (builtin.os.tag) {
inline .linux, .macos => try homeUnix(buf),
.windows => try homeWindows(buf),
@ -24,7 +24,7 @@ pub inline fn home(buf: []u8) !?[]u8 {
};
}
fn homeUnix(buf: []u8) !?[]u8 {
fn homeUnix(buf: []u8) !?[]const u8 {
// First: if we have a HOME env var, then we use that.
if (posix.getenv("HOME")) |result| {
if (buf.len < result.len) return Error.BufferTooSmall;
@ -77,7 +77,7 @@ fn homeUnix(buf: []u8) !?[]u8 {
return null;
}
fn homeWindows(buf: []u8) !?[]u8 {
fn homeWindows(buf: []u8) !?[]const u8 {
const drive_len = blk: {
var fba_instance = std.heap.FixedBufferAllocator.init(buf);
const fba = fba_instance.allocator();
@ -110,6 +110,68 @@ fn trimSpace(input: []const u8) []const u8 {
return std.mem.trim(u8, input, " \n\t");
}
pub const ExpandError = error{
HomeDetectionFailed,
BufferTooSmall,
};
/// Expands a path that starts with a tilde (~) to the home directory of
/// the current user.
///
/// Errors if `home` fails or if the size of the expanded path is larger
/// than `buf.len`.
pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 {
return switch (builtin.os.tag) {
.linux, .macos => try expandHomeUnix(path, buf),
.ios => return path,
else => @compileError("unimplemented"),
};
}
fn expandHomeUnix(path: []const u8, buf: []u8) ExpandError![]const u8 {
if (!std.mem.startsWith(u8, path, "~/")) return path;
const home_dir: []const u8 = if (home(buf)) |home_|
home_ orelse return error.HomeDetectionFailed
else |_|
return error.HomeDetectionFailed;
const rest = path[1..]; // Skip the ~
const expanded_len = home_dir.len + rest.len;
if (expanded_len > buf.len) return Error.BufferTooSmall;
@memcpy(buf[home_dir.len..expanded_len], rest);
return buf[0..expanded_len];
}
test "expandHomeUnix" {
const testing = std.testing;
const allocator = testing.allocator;
var buf: [std.fs.max_path_bytes]u8 = undefined;
const home_dir = try expandHomeUnix("~/", &buf);
// Joining the home directory `~` with the path `/`
// the result should end with a separator here. (e.g. `/home/user/`)
try testing.expect(home_dir[home_dir.len - 1] == std.fs.path.sep);
const downloads = try expandHomeUnix("~/Downloads/shader.glsl", &buf);
const expected_downloads = try std.mem.concat(allocator, u8, &[_][]const u8{ home_dir, "Downloads/shader.glsl" });
defer allocator.free(expected_downloads);
try testing.expectEqualStrings(expected_downloads, downloads);
try testing.expectEqualStrings("~", try expandHomeUnix("~", &buf));
try testing.expectEqualStrings("~abc/", try expandHomeUnix("~abc/", &buf));
try testing.expectEqualStrings("/home/user", try expandHomeUnix("/home/user", &buf));
try testing.expectEqualStrings("", try expandHomeUnix("", &buf));
// Expect an error if the buffer is large enough to hold the home directory,
// but not the expanded path
var small_buf = try allocator.alloc(u8, home_dir.len);
defer allocator.free(small_buf);
try testing.expectError(error.BufferTooSmall, expandHomeUnix(
"~/Downloads",
small_buf[0..],
));
}
test {
const testing = std.testing;

View File

@ -24,42 +24,27 @@ pub const AppSupportDirError = Allocator.Error || error{AppleAPIFailed};
pub fn appSupportDir(
alloc: Allocator,
sub_path: []const u8,
) AppSupportDirError![]u8 {
comptime assert(builtin.target.isDarwin());
const NSFileManager = objc.getClass("NSFileManager").?;
const manager = NSFileManager.msgSend(
objc.Object,
objc.sel("defaultManager"),
.{},
) AppSupportDirError![]const u8 {
return try commonDir(
alloc,
.NSApplicationSupportDirectory,
&.{ build_config.bundle_id, sub_path },
);
}
const url = manager.msgSend(
objc.Object,
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
.{
NSSearchPathDirectory.NSApplicationSupportDirectory,
NSSearchPathDomainMask.NSUserDomainMask,
@as(?*anyopaque, null),
true,
@as(?*anyopaque, null),
},
pub const CacheDirError = Allocator.Error || error{AppleAPIFailed};
/// Return the path to the system cache directory with the given sub path joined.
/// This allocates the result using the given allocator.
pub fn cacheDir(
alloc: Allocator,
sub_path: []const u8,
) CacheDirError![]const u8 {
return try commonDir(
alloc,
.NSCachesDirectory,
&.{ build_config.bundle_id, sub_path },
);
// I don't think this is possible but just in case.
if (url.value == null) return error.AppleAPIFailed;
// Get the UTF-8 string from the URL.
const path = url.getProperty(objc.Object, "path");
const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse
return error.AppleAPIFailed;
const app_support_dir = std.mem.sliceTo(c_str, 0);
return try std.fs.path.join(alloc, &.{
app_support_dir,
build_config.bundle_id,
sub_path,
});
}
pub const SetQosClassError = error{
@ -110,9 +95,79 @@ pub const NSOperatingSystemVersion = extern struct {
};
pub const NSSearchPathDirectory = enum(c_ulong) {
NSCachesDirectory = 13,
NSApplicationSupportDirectory = 14,
};
pub const NSSearchPathDomainMask = enum(c_ulong) {
NSUserDomainMask = 1,
};
fn commonDir(
alloc: Allocator,
directory: NSSearchPathDirectory,
sub_paths: []const []const u8,
) (error{AppleAPIFailed} || Allocator.Error)![]const u8 {
comptime assert(builtin.target.isDarwin());
const NSFileManager = objc.getClass("NSFileManager").?;
const manager = NSFileManager.msgSend(
objc.Object,
objc.sel("defaultManager"),
.{},
);
const url = manager.msgSend(
objc.Object,
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
.{
directory,
NSSearchPathDomainMask.NSUserDomainMask,
@as(?*anyopaque, null),
true,
@as(?*anyopaque, null),
},
);
if (url.value == null) return error.AppleAPIFailed;
const path = url.getProperty(objc.Object, "path");
const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse
return error.AppleAPIFailed;
const base_dir = std.mem.sliceTo(c_str, 0);
// Create a new array with base_dir as the first element
var paths = try alloc.alloc([]const u8, sub_paths.len + 1);
paths[0] = base_dir;
@memcpy(paths[1..], sub_paths);
defer alloc.free(paths);
return try std.fs.path.join(alloc, paths);
}
test "cacheDir paths" {
if (!builtin.target.isDarwin()) return;
const testing = std.testing;
const alloc = testing.allocator;
// Test base path
{
const cache_path = try cacheDir(alloc, "");
defer alloc.free(cache_path);
try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null);
try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null);
}
// Test with subdir
{
const cache_path = try cacheDir(alloc, "test");
defer alloc.free(cache_path);
try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null);
try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null);
const bundle_path = try std.fmt.allocPrint(alloc, "{s}/test", .{build_config.bundle_id});
defer alloc.free(bundle_path);
try testing.expect(std.mem.indexOf(u8, cache_path, bundle_path) != null);
}
}

View File

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

View File

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

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

View File

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

View File

@ -68,6 +68,34 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
builtin unset ghostty_bash_inject rcfile
fi
# Sudo
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" && -n "$TERMINFO" ]]; then
# Wrap `sudo` command to ensure Ghostty terminfo is preserved.
#
# This approach supports wrapping a `sudo` alias, but the alias definition
# must come _after_ this function is defined. Otherwise, the alias expansion
# will take precedence over this function, and it won't be wrapped.
function sudo {
builtin local sudo_has_sudoedit_flags="no"
for arg in "$@"; do
# Check if argument is '-e' or '--edit' (sudoedit flags)
if [[ "$arg" == "-e" || $arg == "--edit" ]]; then
sudo_has_sudoedit_flags="yes"
builtin break
fi
# Check if argument is neither an option nor a key-value pair
if [[ "$arg" != -* && "$arg" != *=* ]]; then
builtin break
fi
done
if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then
builtin command sudo "$@";
else
builtin command sudo TERMINFO="$TERMINFO" "$@";
fi
}
fi
# Import bash-preexec, safe to do multiple times
builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh"
@ -109,31 +137,6 @@ function __ghostty_precmd() {
PS0=$PS0'\[\e[0 q\]'
fi
# Sudo
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" ]] && [[ -n "$TERMINFO" ]]; then
# Wrap `sudo` command to ensure Ghostty terminfo is preserved
# shellcheck disable=SC2317
sudo() {
builtin local sudo_has_sudoedit_flags="no"
for arg in "$@"; do
# Check if argument is '-e' or '--edit' (sudoedit flags)
if [[ "$arg" == "-e" || $arg == "--edit" ]]; then
sudo_has_sudoedit_flags="yes"
builtin break
fi
# Check if argument is neither an option nor a key-value pair
if [[ "$arg" != -* && "$arg" != *=* ]]; then
builtin break
fi
done
if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then
builtin command sudo "$@";
else
builtin command sudo TERMINFO="$TERMINFO" "$@";
fi
}
fi
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then
# Command and working directory
# shellcheck disable=SC2016

View File

@ -3413,6 +3413,16 @@ pub const Pin = struct {
direction: Direction,
limit: ?Pin,
) PageIterator {
if (build_config.slow_runtime_safety) {
if (limit) |l| {
// Check the order according to the iteration direction.
switch (direction) {
.right_down => assert(self.eql(l) or self.before(l)),
.left_up => assert(self.eql(l) or l.before(self)),
}
}
}
return .{
.row = self,
.limit = if (limit) |p| .{ .row = p } else .{ .none = {} },

View File

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

View File

@ -220,6 +220,9 @@ pub const LoadingImage = struct {
// Temporary file logic
if (medium == .temporary_file) {
if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir;
if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) {
return error.TemporaryFileNotNamedCorrectly;
}
}
defer if (medium == .temporary_file) {
posix.unlink(path) catch |err| {
@ -469,6 +472,7 @@ pub const Image = struct {
DimensionsTooLarge,
FilePathTooLong,
TemporaryFileNotInTempDir,
TemporaryFileNotNamedCorrectly,
UnsupportedFormat,
UnsupportedMedium,
UnsupportedDepth,
@ -682,7 +686,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk"
try testing.expect(img.compression == .none);
}
test "image load: rgb, not compressed, temporary file" {
test "image load: temporary file without correct path" {
const testing = std.testing;
const alloc = testing.allocator;
@ -697,6 +701,39 @@ test "image load: rgb, not compressed, temporary file" {
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try tmp_dir.dir.realpath("image.data", &buf);
var cmd: command.Command = .{
.control = .{ .transmit = .{
.format = .rgb,
.medium = .temporary_file,
.compression = .none,
.width = 20,
.height = 15,
.image_id = 31,
} },
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd));
// Temporary file should still be there
try tmp_dir.dir.access(path, .{});
}
test "image load: rgb, not compressed, temporary file" {
const testing = std.testing;
const alloc = testing.allocator;
var tmp_dir = try internal_os.TempDir.init();
defer tmp_dir.deinit();
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
try tmp_dir.dir.writeFile(.{
.sub_path = "tty-graphics-protocol-image.data",
.data = data,
});
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf);
var cmd: command.Command = .{
.control = .{ .transmit = .{
.format = .rgb,
@ -762,12 +799,12 @@ test "image load: png, not compressed, regular file" {
defer tmp_dir.deinit();
const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
try tmp_dir.dir.writeFile(.{
.sub_path = "image.data",
.sub_path = "tty-graphics-protocol-image.data",
.data = data,
});
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try tmp_dir.dir.realpath("image.data", &buf);
const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf);
var cmd: command.Command = .{
.control = .{ .transmit = .{

View File

@ -189,11 +189,20 @@ pub const Parser = struct {
.@"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;
// In the 6-len form, ignore the 3rd param.
const rgb = slice[2..5];
// When a colon separator is used, there may or may not be
// a color space identifier as the third param, which we
// need to ignore (it has no standardized behavior).
const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1;
break :rgb slice[3..6];
};
// We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
@ -204,12 +213,16 @@ pub const Parser = struct {
.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;
return Attribute{
.@"256_fg" = @truncate(slice[2]),
};
},
else => {},
},
39 => return Attribute{ .reset_fg = {} },
@ -217,11 +230,20 @@ pub const Parser = struct {
.@"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;
// We only support the 5-len form.
const rgb = slice[2..5];
// When a colon separator is used, there may or may not be
// a color space identifier as the third param, which we
// need to ignore (it has no standardized behavior).
const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1;
break :rgb slice[3..6];
};
// We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
@ -232,24 +254,33 @@ pub const Parser = struct {
.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;
return Attribute{
.@"256_bg" = @truncate(slice[2]),
};
},
else => {},
},
49 => return Attribute{ .reset_bg = {} },
53 => return Attribute{ .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;
// In the 6-len form, ignore the 3rd param. Otherwise, use it.
const rgb = if (slice.len == 5) slice[2..5] else rgb: {
// Consume one more element
// When a colon separator is used, there may or may not be
// a color space identifier as the third param, which we
// need to ignore (it has no standardized behavior).
const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1;
break :rgb slice[3..6];
};
@ -263,12 +294,16 @@ pub const Parser = struct {
.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;
return Attribute{
.@"256_underline_color" = @truncate(slice[2]),
};
},
else => {},
},
59 => return Attribute{ .reset_underline_color = {} },
@ -566,3 +601,59 @@ test "sgr: direct color bg missing color" {
var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false };
while (p.next()) |_| {}
}
test "sgr: direct fg/bg/underline ignore optional color space" {
// These behaviors have been verified against xterm.
// Colon version should skip the optional color space identifier
{
// 3 8 : 2 : Pi : Pr : Pg : Pb
const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b);
}
{
// 4 8 : 2 : Pi : Pr : Pg : Pb
const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
try testing.expect(v == .direct_color_bg);
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g);
try testing.expectEqual(@as(u8, 3), v.direct_color_bg.b);
}
{
// 5 8 : 2 : Pi : Pr : Pg : Pb
const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 1), v.underline_color.r);
try testing.expectEqual(@as(u8, 2), v.underline_color.g);
try testing.expectEqual(@as(u8, 3), v.underline_color.b);
}
// Semicolon version should not parse optional color space identifier
{
// 3 8 ; 2 ; Pr ; Pg ; Pb
const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 0), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.g);
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.b);
}
{
// 4 8 ; 2 ; Pr ; Pg ; Pb
const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
try testing.expect(v == .direct_color_bg);
try testing.expectEqual(@as(u8, 0), v.direct_color_bg.r);
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.g);
try testing.expectEqual(@as(u8, 2), v.direct_color_bg.b);
}
{
// 5 8 ; 2 ; Pr ; Pg ; Pb
const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 0), v.underline_color.r);
try testing.expectEqual(@as(u8, 1), v.underline_color.g);
try testing.expectEqual(@as(u8, 2), v.underline_color.b);
}
}

View File

@ -380,7 +380,8 @@ pub fn Stream(comptime Handler: type) type {
fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void {
switch (input.final) {
// CUU - Cursor Up
'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
'A', 'k' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -392,8 +393,15 @@ pub fn Stream(comptime Handler: type) type {
false,
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI A with intermediates: {s}",
.{input.intermediates},
),
},
// CUD - Cursor Down
'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
'B' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -405,8 +413,15 @@ pub fn Stream(comptime Handler: type) type {
false,
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI B with intermediates: {s}",
.{input.intermediates},
),
},
// CUF - Cursor Right
'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight(
'C' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight(
switch (input.params.len) {
0 => 1,
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(
"ignoring unimplemented CSI C with intermediates: {s}",
.{input.intermediates},
),
},
// 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) {
0 => 1,
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(
"ignoring unimplemented CSI D with intermediates: {s}",
.{input.intermediates},
),
},
// 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) {
0 => 1,
1 => input.params[0],
@ -442,8 +471,15 @@ pub fn Stream(comptime Handler: type) type {
true,
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI E with intermediates: {s}",
.{input.intermediates},
),
},
// CPL - Cursor Previous Line
'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
'F' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -455,25 +491,46 @@ pub fn Stream(comptime Handler: type) type {
true,
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI F with intermediates: {s}",
.{input.intermediates},
),
},
// HPA - Cursor Horizontal Position Absolute
// TODO: test
'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) {
'G', '`' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) {
0 => try self.handler.setCursorCol(1),
1 => try self.handler.setCursorCol(input.params[0]),
else => log.warn("invalid HPA command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI G with intermediates: {s}",
.{input.intermediates},
),
},
// CUP - Set Cursor Position.
// TODO: test
'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) {
'H', 'f' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) {
0 => try self.handler.setCursorPos(1, 1),
1 => try self.handler.setCursorPos(input.params[0], 1),
2 => try self.handler.setCursorPos(input.params[0], input.params[1]),
else => log.warn("invalid CUP command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI H with intermediates: {s}",
.{input.intermediates},
),
},
// CHT - Cursor Horizontal Tabulation
'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab(
'I' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab(
switch (input.params.len) {
0 => 1,
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(
"ignoring unimplemented CSI I with intermediates: {s}",
.{input.intermediates},
),
},
// Erase Display
'J' => if (@hasDecl(T, "eraseDisplay")) {
const protected_: ?bool = switch (input.intermediates.len) {
@ -540,22 +603,37 @@ pub fn Stream(comptime Handler: type) type {
// IL - Insert Lines
// 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),
1 => try self.handler.insertLines(input.params[0]),
else => log.warn("invalid IL command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI L with intermediates: {s}",
.{input.intermediates},
),
},
// DL - Delete Lines
// TODO: test
'M' => if (@hasDecl(T, "deleteLines")) switch (input.params.len) {
'M' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "deleteLines")) switch (input.params.len) {
0 => try self.handler.deleteLines(1),
1 => try self.handler.deleteLines(input.params[0]),
else => log.warn("invalid DL command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI M with intermediates: {s}",
.{input.intermediates},
),
},
// Delete Character (DCH)
'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars(
'P' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars(
switch (input.params.len) {
0 => 1,
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(
"ignoring unimplemented CSI P with intermediates: {s}",
.{input.intermediates},
),
},
// Scroll Up (SD)
'S' => switch (input.intermediates.len) {
@ -587,7 +671,8 @@ pub fn Stream(comptime Handler: type) type {
},
// 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) {
0 => 1,
1 => input.params[0],
@ -598,27 +683,31 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
// Cursor Tabulation Control
'W' => {
switch (input.params.len) {
0 => if (@hasDecl(T, "tabSet"))
try self.handler.tabSet()
else
log.warn("unimplemented tab set callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI T with intermediates: {s}",
.{input.intermediates},
),
},
1 => if (input.intermediates.len == 1 and input.intermediates[0] == '?') {
if (input.params[0] == 5) {
if (@hasDecl(T, "tabReset"))
try self.handler.tabReset()
else
log.warn("unimplemented tab reset callback: {}", .{input});
} else log.warn("invalid cursor tabulation control: {}", .{input});
} else {
switch (input.params[0]) {
0 => if (@hasDecl(T, "tabSet"))
// Cursor Tabulation Control
'W' => switch (input.intermediates.len) {
0 => {
if (input.params.len == 0 or
(input.params.len == 1 and input.params[0] == 0))
{
if (@hasDecl(T, "tabSet"))
try self.handler.tabSet()
else
log.warn("unimplemented tab set callback: {}", .{input}),
log.warn("unimplemented tab set callback: {}", .{input});
return;
}
switch (input.params.len) {
0 => unreachable,
1 => switch (input.params[0]) {
0 => unreachable,
2 => if (@hasDecl(T, "tabClear"))
try self.handler.tabClear(.current)
@ -631,7 +720,6 @@ pub fn Stream(comptime Handler: type) type {
log.warn("unimplemented tab clear callback: {}", .{input}),
else => {},
}
},
else => {},
@ -641,8 +729,22 @@ pub fn Stream(comptime Handler: type) type {
return;
},
1 => if (input.intermediates[0] == '?' and input.params[0] == 5) {
if (@hasDecl(T, "tabReset"))
try self.handler.tabReset()
else
log.warn("unimplemented tab reset callback: {}", .{input});
} else log.warn("invalid cursor tabulation control: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI W with intermediates: {s}",
.{input.intermediates},
),
},
// Erase Characters (ECH)
'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars(
'X' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars(
switch (input.params.len) {
0 => 1,
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(
"ignoring unimplemented CSI X with intermediates: {s}",
.{input.intermediates},
),
},
// 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) {
0 => 1,
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(
"ignoring unimplemented CSI Z with intermediates: {s}",
.{input.intermediates},
),
},
// 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) {
0 => 1,
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(
"ignoring unimplemented CSI a with intermediates: {s}",
.{input.intermediates},
),
},
// 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) {
0 => 1,
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(
"ignoring unimplemented CSI b with intermediates: {s}",
.{input.intermediates},
),
},
// c - Device Attributes (DA1)
'c' => if (@hasDecl(T, "deviceAttributes")) {
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}),
// 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) {
0 => 1,
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(
"ignoring unimplemented CSI d with intermediates: {s}",
.{input.intermediates},
),
},
// 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) {
0 => 1,
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(
"ignoring unimplemented CSI e with intermediates: {s}",
.{input.intermediates},
),
},
// TBC - Tab Clear
// 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) {
1 => @enumFromInt(input.params[0]),
else => {
@ -743,6 +887,12 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI g with intermediates: {s}",
.{input.intermediates},
),
},
// SM - Set Mode
'h' => if (@hasDecl(T, "setMode")) mode: {
const ansi_mode = ansi: {
@ -1564,10 +1714,13 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented ESC callback: {}", .{action}),
// HTS - Horizontal Tab Set
'H' => if (@hasDecl(T, "tabSet"))
try self.handler.tabSet()
else
log.warn("unimplemented tab set callback: {}", .{action}),
'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) {
0 => try self.handler.tabSet(),
else => {
log.warn("invalid tab set command: {}", .{action});
return;
},
} else log.warn("unimplemented tab set callback: {}", .{action}),
// RI - Reverse Index
'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) {
@ -1597,17 +1750,17 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented invokeCharset: {}", .{action}),
// SPA - Start of Guarded Area
'V' => if (@hasDecl(T, "setProtectedMode")) {
'V' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) {
try self.handler.setProtectedMode(ansi.ProtectedMode.iso);
} else log.warn("unimplemented ESC callback: {}", .{action}),
// EPA - End of Guarded Area
'W' => if (@hasDecl(T, "setProtectedMode")) {
'W' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) {
try self.handler.setProtectedMode(ansi.ProtectedMode.off);
} else log.warn("unimplemented ESC callback: {}", .{action}),
// DECID
'Z' => if (@hasDecl(T, "deviceAttributes")) {
'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) {
try self.handler.deviceAttributes(.primary, &.{});
} else log.warn("unimplemented ESC callback: {}", .{action}),
@ -1666,12 +1819,12 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented invokeCharset: {}", .{action}),
// Set application keypad mode
'=' => if (@hasDecl(T, "setMode")) {
'=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
try self.handler.setMode(.keypad_keys, true);
} else log.warn("unimplemented setMode: {}", .{action}),
// Reset application keypad mode
'>' => if (@hasDecl(T, "setMode")) {
'>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
try self.handler.setMode(.keypad_keys, false);
} else log.warn("unimplemented setMode: {}", .{action}),
@ -1753,6 +1906,10 @@ test "stream: cursor right (CUF)" {
s.handler.amount = 0;
try s.nextSlice("\x1B[5;4C");
try testing.expectEqual(@as(u16, 0), s.handler.amount);
s.handler.amount = 0;
try s.nextSlice("\x1b[?3C");
try testing.expectEqual(@as(u16, 0), s.handler.amount);
}
test "stream: dec set mode (SM) and reset mode (RM)" {
@ -1770,6 +1927,10 @@ test "stream: dec set mode (SM) and reset mode (RM)" {
try s.nextSlice("\x1B[?6l");
try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode);
s.handler.mode = @as(modes.Mode, @enumFromInt(1));
try s.nextSlice("\x1B[6 h");
try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode);
}
test "stream: ansi set mode (SM) and reset mode (RM)" {
@ -1788,6 +1949,10 @@ test "stream: ansi set mode (SM) and reset mode (RM)" {
try s.nextSlice("\x1B[4l");
try testing.expect(s.handler.mode == null);
s.handler.mode = null;
try s.nextSlice("\x1B[>5h");
try testing.expect(s.handler.mode == null);
}
test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" {
@ -1937,6 +2102,12 @@ test "stream: DECED, DECSED" {
try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?);
try testing.expect(!s.handler.protected.?);
}
{
// Invalid and ignored by the handler
for ("\x1B[>0J") |c| try s.next(c);
try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?);
try testing.expect(!s.handler.protected.?);
}
}
test "stream: DECEL, DECSEL" {
@ -1997,6 +2168,12 @@ test "stream: DECEL, DECSEL" {
try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?);
try testing.expect(!s.handler.protected.?);
}
{
// Invalid and ignored by the handler
for ("\x1B[<1K") |c| try s.next(c);
try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?);
try testing.expect(!s.handler.protected.?);
}
}
test "stream: DECSCUSR" {
@ -2014,6 +2191,10 @@ test "stream: DECSCUSR" {
try s.nextSlice("\x1B[1 q");
try testing.expect(s.handler.style.? == .blinking_block);
// Invalid and ignored by the handler
try s.nextSlice("\x1B[?0 q");
try testing.expect(s.handler.style.? == .blinking_block);
}
test "stream: DECSCUSR without space" {
@ -2054,6 +2235,10 @@ test "stream: XTSHIFTESCAPE" {
try s.nextSlice("\x1B[>1s");
try testing.expect(s.handler.escape.? == true);
// Invalid and ignored by the handler
try s.nextSlice("\x1B[1 s");
try testing.expect(s.handler.escape.? == true);
}
test "stream: change window title with invalid utf-8" {
@ -2374,6 +2559,14 @@ test "stream CSI W tab set" {
s.handler.called = false;
try s.nextSlice("\x1b[0W");
try testing.expect(s.handler.called);
s.handler.called = false;
try s.nextSlice("\x1b[>W");
try testing.expect(!s.handler.called);
s.handler.called = false;
try s.nextSlice("\x1b[99W");
try testing.expect(!s.handler.called);
}
test "stream CSI ? W reset tab stops" {
@ -2392,4 +2585,8 @@ test "stream CSI ? W reset tab stops" {
try s.nextSlice("\x1b[?5W");
try testing.expect(s.handler.reset);
// Invalid and ignored by the handler
try s.nextSlice("\x1b[?1;2;3W");
try testing.expect(s.handler.reset);
}

View File

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

View File

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