Merge branch 'ghostty-org:main' into main

This commit is contained in:
plyght
2024-12-11 08:55:27 -05:00
committed by GitHub
104 changed files with 2774 additions and 490 deletions

View File

@ -1,7 +1,7 @@
<!-- LOGO --> <!-- LOGO -->
<h1> <h1>
<p align="center"> <p align="center">
<img src="https://user-images.githubusercontent.com/1299/199110421-9ff5fc30-a244-441e-9882-26070662adf9.png" alt="Logo" width="100"> <img src="https://github.com/user-attachments/assets/fe853809-ba8b-400b-83ab-a9a0da25be8a" alt="Logo" width="128">
<br>Ghostty <br>Ghostty
</h1> </h1>
<p align="center"> <p align="center">
@ -107,25 +107,40 @@ palette = 7=#a89984
palette = 15=#fbf1c7 palette = 15=#fbf1c7
``` ```
You can view all available configuration options and their documentation #### Configuration Documentation
by executing the command `ghostty +show-config --default --docs`. Note that
this will output the full default configuration with docs to stdout, so There are multiple places to find documentation on the configuration options.
you may want to pipe that through a pager, an editor, etc. All locations are identical (they're all generated from the same source):
1. There are HTML and Markdown formatted docs in the
`$prefix/share/ghostty/docs` directory. This directory is created
when you build or install Ghostty. The `$prefix` is `zig-out` if you're
building from source (or the specified `--prefix` flag). On macOS,
`$prefix` is the `Contents/Resources` subdirectory of the `.app` bundle.
2. There are man pages in the `$prefix/share/man` directory. This directory
is created when you build or install Ghostty.
3. In the CLI, you can run `ghostty +show-config --default --docs`.
Note that this will output the full default configuration with docs to
stdout, so you may want to pipe that through a pager, an editor, etc.
4. In the source code, you can find the configuration structure in the
[Config structure](https://github.com/ghostty-org/ghostty/blob/main/src/config/Config.zig).
The available keys are the keys verbatim, and their possible values are typically
documented in the comments.
5. Not documentation per se, but you can search for the
[public config files](https://github.com/search?q=path%3Aghostty%2Fconfig&type=code)
of many Ghostty users for examples and inspiration.
> [!NOTE] > [!NOTE]
> >
> You'll see a lot of weird blank configurations like `font-family =`. This > You may see strange looking blank configurations like `font-family =`. This
> is a valid syntax to specify the default behavior (no value). The > is a valid syntax to specify the default behavior (no value). The
> `+show-config` outputs it so it's clear that key is defaulting and also > `+show-config` outputs it so it's clear that key is defaulting and also
> to have something to attach the doc comment to. > to have something to attach the doc comment to.
You can also see and read all available configuration options in the source
[Config structure](https://github.com/ghostty-org/ghostty/blob/main/src/config/Config.zig).
The available keys are the keys verbatim, and their possible values are typically
documented in the comments. You also can search for the
[public config files](https://github.com/search?q=path%3Aghostty%2Fconfig&type=code)
of many Ghostty users for examples and inspiration.
> [!NOTE] > [!NOTE]
> >
> Configuration can be reloaded on the fly with the `reload_config` > Configuration can be reloaded on the fly with the `reload_config`

View File

@ -573,17 +573,20 @@ pub fn build(b: *std.Build) !void {
b.installFile("dist/linux/app.desktop", "share/applications/com.mitchellh.ghostty.desktop"); b.installFile("dist/linux/app.desktop", "share/applications/com.mitchellh.ghostty.desktop");
} }
// Right click menu action for Plasma desktop
b.installFile("dist/linux/ghostty_dolphin.desktop", "share/kio/servicemenus/com.mitchellh.ghostty.desktop");
// Various icons that our application can use, including the icon // Various icons that our application can use, including the icon
// that will be used for the desktop. // that will be used for the desktop.
b.installFile("images/icons/icon_16x16.png", "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_16.png", "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_32x32.png", "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_32.png", "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_128x128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_256x256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_512x512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_16x16@2x@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_16@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_32x32@2x@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_32@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_128x128@2x@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_128@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_256x256@2x@2x.png", "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_256@2x.png", "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png");
} }
// libghostty (non-Darwin) // libghostty (non-Darwin)

View File

@ -5,8 +5,8 @@
.dependencies = .{ .dependencies = .{
// Zig libs // Zig libs
.libxev = .{ .libxev = .{
.url = "https://github.com/mitchellh/libxev/archive/b8d1d93e5c899b27abbaa7df23b496c3e6a178c7.tar.gz", .url = "https://github.com/mitchellh/libxev/archive/db6a52bafadf00360e675fefa7926e8e6c0e9931.tar.gz",
.hash = "1220612bc023c21d75234882ec9a8c6a1cbd9d642da3dfb899297f14bb5bd7b6cd78", .hash = "12206029de146b685739f69b10a6f08baee86b3d0a5f9a659fa2b2b66c9602078bbf",
}, },
.mach_glfw = .{ .mach_glfw = .{
.url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz", .url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz",

11
dist/linux/ghostty_dolphin.desktop vendored Executable file
View File

@ -0,0 +1,11 @@
[Desktop Entry]
Type=Service
ServiceTypes=KonqPopupMenu/Plugin
MimeType=inode/directory
Actions=RunGhosttyDir
[Desktop Action RunGhosttyDir]
Name=Open Ghostty Here
Icon=com.mitchellh.ghostty
Exec=ghostty --working-directory=%F --gtk-single-instance=false

BIN
dist/macos/Ghostty.icns vendored Executable file → Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 84 KiB

14
flake.lock generated
View File

@ -20,27 +20,27 @@
}, },
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1726062281, "lastModified": 1733423277,
"narHash": "sha256-PyFVySdGj3enKqm8RQuo4v1KLJLmNLOq2yYOHsI6e2Q=", "narHash": "sha256-TxabjxEgkNbCGFRHgM/b9yZWlBj60gUOUnRT/wbVQR8=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e65aa8301ba4f0ab8cb98f944c14aa9da07394f8", "rev": "e36963a147267afc055f7cf65225958633e536bf",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nixos",
"ref": "release-24.05", "ref": "release-24.11",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"nixpkgs-unstable": { "nixpkgs-unstable": {
"locked": { "locked": {
"lastModified": 1719082008, "lastModified": 1733229606,
"narHash": "sha256-jHJSUH619zBQ6WdC21fFAlDxHErKVDJ5fpN0Hgx4sjs=", "narHash": "sha256-FLYY5M0rpa5C2QAE3CKLYAM6TwbKicdRK6qNrSHlNrE=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9693852a2070b398ee123a329e68f0dab5526681", "rev": "566e53c2ad750c84f6d31f9ccb9d00f823165550",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -7,7 +7,7 @@
# We want to stay as up to date as possible but need to be careful that the # We want to stay as up to date as possible but need to be careful that the
# glibc versions used by our dependencies from Nix are compatible with the # glibc versions used by our dependencies from Nix are compatible with the
# system glibc that the user is building for. # system glibc that the user is building for.
nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.05"; nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11";
zig = { zig = {
url = "github:mitchellh/zig-overlay"; url = "github:mitchellh/zig-overlay";
@ -36,7 +36,6 @@
packages.${system} = let packages.${system} = let
mkArgs = optimize: { mkArgs = optimize: {
inherit (pkgs-unstable) zig_0_13 stdenv;
inherit optimize; inherit optimize;
revision = self.shortRev or self.dirtyShortRev or "dirty"; revision = self.shortRev or self.dirtyShortRev or "dirty";

BIN
images/icons/icon_1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

BIN
images/icons/icon_128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

BIN
images/icons/icon_16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

BIN
images/icons/icon_16@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

BIN
images/icons/icon_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

BIN
images/icons/icon_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
images/icons/icon_32@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

BIN
images/icons/icon_512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View File

@ -1,67 +1,67 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "icon_512x512@2x@2x 1.png", "filename" : "macOS-AppIcon-1024px.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
}, },
{ {
"filename" : "icon_16x16.png", "filename" : "macOS-AppIcon-16px-16pt@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "16x16" "size" : "16x16"
}, },
{ {
"filename" : "icon_16x16@2x@2x.png", "filename" : "macOS-AppIcon-32px-16pt@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "16x16" "size" : "16x16"
}, },
{ {
"filename" : "icon_32x32.png", "filename" : "macOS-AppIcon-32px-32pt@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "32x32" "size" : "32x32"
}, },
{ {
"filename" : "icon_32x32@2x@2x.png", "filename" : "macOS-AppIcon-64px-32pt@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "32x32" "size" : "32x32"
}, },
{ {
"filename" : "icon_128x128.png", "filename" : "macOS-AppIcon-128px-128pt@1x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "128x128" "size" : "128x128"
}, },
{ {
"filename" : "icon_128x128@2x@2x.png", "filename" : "macOS-AppIcon-256px-128pt@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "128x128" "size" : "128x128"
}, },
{ {
"filename" : "icon_256x256.png", "filename" : "macOS-AppIcon-256px-128pt@2x 1.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "256x256" "size" : "256x256"
}, },
{ {
"filename" : "icon_256x256@2x@2x.png", "filename" : "macOS-AppIcon-512px-256pt@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "256x256" "size" : "256x256"
}, },
{ {
"filename" : "icon_512x512.png", "filename" : "macOS-AppIcon-512px.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "512x512" "size" : "512x512"
}, },
{ {
"filename" : "icon_512x512@2x@2x.png", "filename" : "macOS-AppIcon-1024px 1.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "512x512" "size" : "512x512"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 582 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -1,17 +1,17 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "icon_128x128.png", "filename" : "macOS-AppIcon-256px-128pt@2x.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"filename" : "icon_128x128@2x@2x.png", "filename" : "macOS-AppIcon-512px.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"filename" : "icon_256x256@2x@2x.png", "filename" : "macOS-AppIcon-1024px.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "3x" "scale" : "3x"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@ -57,6 +57,7 @@ class QuickTerminalController: BaseTerminalController {
// MARK: NSWindowController // MARK: NSWindowController
override func windowDidLoad() { override func windowDidLoad() {
super.windowDidLoad()
guard let window = self.window else { return } guard let window = self.window else { return }
// The controller is the window delegate so we can detect events such as // The controller is the window delegate so we can detect events such as

View File

@ -404,7 +404,21 @@ class BaseTerminalController: NSWindowController,
} }
} }
//MARK: - NSWindowDelegate // MARK: NSWindowController
override func windowDidLoad() {
guard let window else { return }
// We always initialize our fullscreen style to native if we can because
// initialization sets up some state (i.e. observers). If its set already
// somehow we don't do this.
if fullscreenStyle == nil {
fullscreenStyle = NativeFullscreen(window)
fullscreenStyle?.delegate = self
}
}
// MARK: NSWindowDelegate
// This is called when performClose is called on a window (NOT when close() // This is called when performClose is called on a window (NOT when close()
// is called directly). performClose is called primarily when UI elements such // is called directly). performClose is called primarily when UI elements such

View File

@ -94,6 +94,16 @@ class TerminalController: BaseTerminalController {
} }
} }
override func fullscreenDidChange() {
super.fullscreenDidChange()
// When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not.
guard let focusedSurface else { return }
syncAppearance(focusedSurface.derivedConfig)
}
//MARK: - Methods //MARK: - Methods
@objc private func ghosttyConfigDidChange(_ notification: Notification) { @objc private func ghosttyConfigDidChange(_ notification: Notification) {
@ -204,7 +214,13 @@ class TerminalController: BaseTerminalController {
} }
// If we have window transparency then set it transparent. Otherwise set it opaque. // If we have window transparency then set it transparent. Otherwise set it opaque.
if (surfaceConfig.backgroundOpacity < 1) {
// Window transparency only takes effect if our window is not native fullscreen.
// In native fullscreen we disable transparency/opacity because the background
// becomes gray and widgets show through.
if (!window.styleMask.contains(.fullScreen) &&
surfaceConfig.backgroundOpacity < 1
) {
window.isOpaque = false window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that // This is weird, but we don't use ".clear" because this creates a look that
@ -259,6 +275,7 @@ class TerminalController: BaseTerminalController {
} }
override func windowDidLoad() { override func windowDidLoad() {
super.windowDidLoad()
guard let window = window as? TerminalWindow else { return } guard let window = window as? TerminalWindow else { return }
// I copy this because we may change the source in the future but also because // I copy this because we may change the source in the future but also because

View File

@ -5,10 +5,7 @@ class TerminalWindow: NSWindow {
lazy var titlebarColor: NSColor = backgroundColor { lazy var titlebarColor: NSColor = backgroundColor {
didSet { didSet {
guard let titlebarContainer = contentView?.superview?.subviews.first(where: { guard let titlebarContainer else { return }
$0.className == "NSTitlebarContainerView"
}) else { return }
titlebarContainer.wantsLayer = true titlebarContainer.wantsLayer = true
titlebarContainer.layer?.backgroundColor = titlebarColor.cgColor titlebarContainer.layer?.backgroundColor = titlebarColor.cgColor
} }
@ -68,6 +65,48 @@ class TerminalWindow: NSWindow {
bindings.forEach() { $0.invalidate() } bindings.forEach() { $0.invalidate() }
} }
// MARK: Titlebar Helpers
// These helpers are generic to what we're trying to achieve (i.e. titlebar
// style tabs, titlebar styling, etc.). They're just here to make it easier.
private var titlebarContainer: NSView? {
// If we aren't fullscreen then the titlebar container is part of our window.
if !styleMask.contains(.fullScreen) {
guard let view = contentView?.superview ?? contentView else { return nil }
return titlebarContainerView(in: view)
}
// If we are fullscreen, the titlebar container view is part of a separate
// "fullscreen window", we need to find the window and then get the view.
for window in NSApplication.shared.windows {
// This is the private window class that contains the toolbar
guard window.className == "NSToolbarFullScreenWindow" else { continue }
// The parent will match our window. This is used to filter the correct
// fullscreen window if we have multiple.
guard window.parent == self else { continue }
guard let view = window.contentView else { continue }
return titlebarContainerView(in: view)
}
return nil
}
private func titlebarContainerView(in view: NSView) -> NSView? {
if view.className == "NSTitlebarContainerView" {
return view
}
for subview in view.subviews {
if let found = titlebarContainerView(in: subview) {
return found
}
}
return nil
}
// MARK: - NSWindow // MARK: - NSWindow
override var title: String { override var title: String {
@ -152,9 +191,8 @@ class TerminalWindow: NSWindow {
// would be an opaque color. When the titlebar isn't transparent, however, the system applies // would be an opaque color. When the titlebar isn't transparent, however, the system applies
// a compositing effect to the unselected tab backgrounds, which makes them blend with the // a compositing effect to the unselected tab backgrounds, which makes them blend with the
// titlebar's/window's background. // titlebar's/window's background.
if let titlebarContainer = contentView?.superview?.subviews.first(where: { if let effectView = titlebarContainer?.descendants(
$0.className == "NSTitlebarContainerView" withClassName: "NSVisualEffectView").first {
}), let effectView = titlebarContainer.descendants(withClassName: "NSVisualEffectView").first {
effectView.isHidden = titlebarTabs || !titlebarTabs && !hasVeryDarkBackground effectView.isHidden = titlebarTabs || !titlebarTabs && !hasVeryDarkBackground
} }
@ -223,10 +261,7 @@ class TerminalWindow: NSWindow {
// window's key status changes in terms of becoming less prominent visually, // window's key status changes in terms of becoming less prominent visually,
// so we need to do it manually. // so we need to do it manually.
private func updateNewTabButtonOpacity() { private func updateNewTabButtonOpacity() {
guard let titlebarContainer = contentView?.superview?.subviews.first(where: { guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
$0.className == "NSTitlebarContainerView"
}) else { return }
guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: {
$0 as? NSImageView != nil $0 as? NSImageView != nil
}) as? NSImageView else { return } }) as? NSImageView else { return }
@ -237,10 +272,7 @@ class TerminalWindow: NSWindow {
// Color the new tab button's image to match the color of the tab title/keyboard shortcut labels, // Color the new tab button's image to match the color of the tab title/keyboard shortcut labels,
// just as it does in the stock tab bar. // just as it does in the stock tab bar.
private func updateNewTabButtonImage() { private func updateNewTabButtonImage() {
guard let titlebarContainer = contentView?.superview?.subviews.first(where: { guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
$0.className == "NSTitlebarContainerView"
}) else { return }
guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: {
$0 as? NSImageView != nil $0 as? NSImageView != nil
}) as? NSImageView else { return } }) as? NSImageView else { return }
@ -272,10 +304,7 @@ class TerminalWindow: NSWindow {
private func updateTabsForVeryDarkBackgrounds() { private func updateTabsForVeryDarkBackgrounds() {
guard hasVeryDarkBackground else { return } guard hasVeryDarkBackground else { return }
guard let titlebarContainer else { return }
guard let titlebarContainer = contentView?.superview?.subviews.first(where: {
$0.className == "NSTitlebarContainerView"
}) else { return }
if let tabGroup = tabGroup, tabGroup.isTabBarVisible { if let tabGroup = tabGroup, tabGroup.isTabBarVisible {
guard let activeTabBackgroundView = titlebarContainer.firstDescendant(withClassName: "NSTabButton")?.superview?.subviews.last?.firstDescendant(withID: "_backgroundView") guard let activeTabBackgroundView = titlebarContainer.firstDescendant(withClassName: "NSTabButton")?.superview?.subviews.last?.firstDescendant(withID: "_backgroundView")
@ -301,8 +330,7 @@ class TerminalWindow: NSWindow {
}() }()
private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = { private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = {
guard let titlebarContainer = contentView?.superview?.subviews.first(where: { $0.className == "NSTitlebarContainerView" }) else { return nil } guard let titlebarContainer else { return nil }
let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height) let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height)
let view = NSView(frame: NSRect(origin: .zero, size: size)) let view = NSView(frame: NSRect(origin: .zero, size: size))
@ -390,9 +418,7 @@ class TerminalWindow: NSWindow {
// Find the NSTextField responsible for displaying the titlebar's title. // Find the NSTextField responsible for displaying the titlebar's title.
private var titlebarTextField: NSTextField? { private var titlebarTextField: NSTextField? {
guard let titlebarContainer = contentView?.superview?.subviews guard let titlebarView = titlebarContainer?.subviews
.first(where: { $0.className == "NSTitlebarContainerView" }) else { return nil }
guard let titlebarView = titlebarContainer.subviews
.first(where: { $0.className == "NSTitlebarView" }) else { return nil } .first(where: { $0.className == "NSTitlebarView" }) else { return nil }
return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField
} }
@ -450,10 +476,7 @@ class TerminalWindow: NSWindow {
// For titlebar tabs, we want to hide the separator view so that we get rid // For titlebar tabs, we want to hide the separator view so that we get rid
// of an aesthetically unpleasing shadow. // of an aesthetically unpleasing shadow.
private func hideTitleBarSeparators() { private func hideTitleBarSeparators() {
guard let titlebarContainer = contentView?.superview?.subviews.first(where: { guard let titlebarContainer else { return }
$0.className == "NSTitlebarContainerView"
}) else { return }
for v in titlebarContainer.descendants(withClassName: "NSTitlebarSeparatorView") { for v in titlebarContainer.descendants(withClassName: "NSTitlebarSeparatorView") {
v.isHidden = true v.isHidden = true
} }

View File

@ -50,7 +50,8 @@ extension Ghostty {
} }
case GHOSTTY_TRIGGER_UNICODE: case GHOSTTY_TRIGGER_UNICODE:
equiv = String(trigger.key.unicode) guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }
equiv = String(scalar)
default: default:
return nil return nil

View File

@ -429,12 +429,34 @@ extension Ghostty {
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal /// figure it out so we're going to do this hacky thing to bring focus back to the terminal
/// that should have it. /// that should have it.
static func moveFocus(to: SurfaceView, from: SurfaceView? = nil) { static func moveFocus(
DispatchQueue.main.async { to: SurfaceView,
from: SurfaceView? = nil,
delay: TimeInterval? = nil
) {
// The whole delay machinery is a bit of a hack to work around a
// situation where the window is destroyed and the surface view
// will never be attached to a window. Realistically, we should
// handle this upstream but we also don't want this function to be
// a source of infinite loops.
// Our max delay before we give up
let maxDelay: TimeInterval = 0.5
guard (delay ?? 0) < maxDelay else { return }
// We start at a 50 millisecond delay and do a doubling backoff
let nextDelay: TimeInterval = if let delay {
delay * 2
} else {
// 100 milliseconds
0.05
}
let work: DispatchWorkItem = .init {
// If the callback runs before the surface is attached to a view // If the callback runs before the surface is attached to a view
// then the window will be nil. We just reschedule in that case. // then the window will be nil. We just reschedule in that case.
guard let window = to.window else { guard let window = to.window else {
moveFocus(to: to, from: from) moveFocus(to: to, from: from, delay: nextDelay)
return return
} }
@ -448,5 +470,12 @@ extension Ghostty {
window.makeFirstResponder(to) window.makeFirstResponder(to)
} }
let queue = DispatchQueue.main
if let delay {
queue.asyncAfter(deadline: .now() + delay, execute: work)
} else {
queue.async(execute: work)
}
} }
} }

View File

@ -45,20 +45,53 @@ extension FullscreenDelegate {
func fullscreenDidChange() {} func fullscreenDidChange() {}
} }
/// The base class for fullscreen implementations, cannot be used as a FullscreenStyle on its own.
class FullscreenBase {
let window: NSWindow
weak var delegate: FullscreenDelegate?
required init?(_ window: NSWindow) {
self.window = window
// We want to trigger delegate methods on window native fullscreen
// changes (didEnterFullScreenNotification, etc.) no matter what our
// fullscreen style is.
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(didEnterFullScreenNotification),
name: NSWindow.didEnterFullScreenNotification,
object: window)
center.addObserver(
self,
selector: #selector(didExitFullScreenNotification),
name: NSWindow.didExitFullScreenNotification,
object: window)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func didEnterFullScreenNotification(_ notification: Notification) {
delegate?.fullscreenDidChange()
}
@objc private func didExitFullScreenNotification(_ notification: Notification) {
delegate?.fullscreenDidChange()
}
}
/// macOS native fullscreen. This is the typical behavior you get by pressing the green fullscreen /// macOS native fullscreen. This is the typical behavior you get by pressing the green fullscreen
/// button on regular titlebars. /// button on regular titlebars.
class NativeFullscreen: FullscreenStyle { class NativeFullscreen: FullscreenBase, FullscreenStyle {
private let window: NSWindow
weak var delegate: FullscreenDelegate?
var isFullscreen: Bool { window.styleMask.contains(.fullScreen) } var isFullscreen: Bool { window.styleMask.contains(.fullScreen) }
var supportsTabs: Bool { true } var supportsTabs: Bool { true }
required init?(_ window: NSWindow) { required init?(_ window: NSWindow) {
// TODO: There are many requirements for native fullscreen we should // TODO: There are many requirements for native fullscreen we should
// check here such as the stylemask. // check here such as the stylemask.
super.init(window)
self.window = window
} }
func enter() { func enter() {
@ -72,8 +105,9 @@ class NativeFullscreen: FullscreenStyle {
// Enter fullscreen // Enter fullscreen
window.toggleFullScreen(self) window.toggleFullScreen(self)
// Notify the delegate // Note: we don't call our delegate here because the base class
delegate?.fullscreenDidChange() // will always trigger the delegate on native fullscreen notifications
// and we don't want to double notify.
} }
func exit() { func exit() {
@ -84,14 +118,13 @@ class NativeFullscreen: FullscreenStyle {
window.toggleFullScreen(nil) window.toggleFullScreen(nil)
// Notify the delegate // Note: we don't call our delegate here because the base class
delegate?.fullscreenDidChange() // will always trigger the delegate on native fullscreen notifications
// and we don't want to double notify.
} }
} }
class NonNativeFullscreen: FullscreenStyle { class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
weak var delegate: FullscreenDelegate?
// Non-native fullscreen never supports tabs because tabs require // Non-native fullscreen never supports tabs because tabs require
// the "titled" style and we don't have it for non-native fullscreen. // the "titled" style and we don't have it for non-native fullscreen.
var supportsTabs: Bool { false } var supportsTabs: Bool { false }
@ -110,13 +143,8 @@ class NonNativeFullscreen: FullscreenStyle {
var hideMenu: Bool = true var hideMenu: Bool = true
} }
private let window: NSWindow
private var savedState: SavedState? private var savedState: SavedState?
required init?(_ window: NSWindow) {
self.window = window
}
func enter() { func enter() {
// If we are in fullscreen we don't do it again. // If we are in fullscreen we don't do it again.
guard !isFullscreen else { return } guard !isFullscreen else { return }
@ -170,6 +198,10 @@ class NonNativeFullscreen: FullscreenStyle {
// Being untitled let's our content take up the full frame. // Being untitled let's our content take up the full frame.
window.styleMask.remove(.titled) window.styleMask.remove(.titled)
// We dont' want the non-native fullscreen window to be resizable
// from the edges.
window.styleMask.remove(.resizable)
// Focus window // Focus window
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
@ -187,8 +219,12 @@ class NonNativeFullscreen: FullscreenStyle {
guard isFullscreen else { return } guard isFullscreen else { return }
guard let savedState else { return } guard let savedState else { return }
// Remove all our notifications // Remove all our notifications. We remove them one by one because
NotificationCenter.default.removeObserver(self) // we don't want to remove the observers that our superclass sets.
let center = NotificationCenter.default
center.removeObserver(self, name: NSWindow.didBecomeMainNotification, object: window)
center.removeObserver(self, name: NSWindow.didResignMainNotification, object: window)
center.removeObserver(self, name: NSWindow.didChangeScreenNotification, object: window)
// Unhide our elements // Unhide our elements
if savedState.dock { if savedState.dock {

View File

@ -159,11 +159,20 @@ in
# it to be "portable" across the system. # it to be "portable" across the system.
LD_LIBRARY_PATH = lib.makeLibraryPath rpathLibs; LD_LIBRARY_PATH = lib.makeLibraryPath rpathLibs;
# On Linux we need to setup the environment so that all GTK data shellHook =
# is available (namely icons). (lib.optionalString stdenv.hostPlatform.isLinux ''
shellHook = lib.optionalString stdenv.hostPlatform.isLinux '' # On Linux we need to setup the environment so that all GTK data
# Minimal subset of env set by wrapGAppsHook4 for icons and global settings # is available (namely icons).
export XDG_DATA_DIRS=$XDG_DATA_DIRS:${hicolor-icon-theme}/share:${gnome.adwaita-icon-theme}/share
export XDG_DATA_DIRS=$XDG_DATA_DIRS:$GSETTINGS_SCHEMAS_PATH # from glib setup hook # Minimal subset of env set by wrapGAppsHook4 for icons and global settings
''; export XDG_DATA_DIRS=$XDG_DATA_DIRS:${hicolor-icon-theme}/share:${gnome.adwaita-icon-theme}/share
export XDG_DATA_DIRS=$XDG_DATA_DIRS:$GSETTINGS_SCHEMAS_PATH # from glib setup hook
'')
+ (lib.optionalString stdenv.hostPlatform.isDarwin ''
# On macOS, we unset the macOS SDK env vars that Nix sets up because
# we rely on a system installation. Nix only provides a macOS SDK
# and we need iOS too.
unset SDKROOT
unset DEVELOPER_DIR
'');
} }

View File

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

View File

@ -1182,6 +1182,14 @@ pub fn updateConfig(
log.warn("failed to notify renderer of config change err={}", .{err}); log.warn("failed to notify renderer of config change err={}", .{err});
}; };
// If we have a title set then we update our window to have the
// newly configured title.
if (config.title) |title| try self.rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = title },
);
// Notify the window // Notify the window
try self.rt_app.performAction( try self.rt_app.performAction(
.{ .surface = self }, .{ .surface = self },
@ -2336,7 +2344,7 @@ pub fn scrollCallback(
// If we're scrolling up or down, then send a mouse event. // If we're scrolling up or down, then send a mouse event.
if (self.io.terminal.flags.mouse_event != .none) { if (self.io.terminal.flags.mouse_event != .none) {
if (y.delta != 0) { for (0..@abs(y.delta)) |_| {
const pos = try self.rt_surface.getCursorPos(); const pos = try self.rt_surface.getCursorPos();
try self.mouseReport(switch (y.direction()) { try self.mouseReport(switch (y.direction()) {
.up_right => .four, .up_right => .four,
@ -2344,7 +2352,7 @@ pub fn scrollCallback(
}, .press, self.mouse.mods, pos); }, .press, self.mouse.mods, pos);
} }
if (x.delta != 0) { for (0..@abs(x.delta)) |_| {
const pos = try self.rt_surface.getCursorPos(); const pos = try self.rt_surface.getCursorPos();
try self.mouseReport(switch (x.direction()) { try self.mouseReport(switch (x.direction()) {
.up_right => .six, .up_right => .six,

View File

@ -724,7 +724,7 @@ pub const Surface = struct {
/// Set the shape of the cursor. /// Set the shape of the cursor.
fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void {
if ((comptime builtin.target.isDarwin()) and if ((comptime builtin.target.isDarwin()) and
!internal_os.macosVersionAtLeast(13, 0, 0)) !internal_os.macos.isAtLeastVersion(13, 0, 0))
{ {
// We only set our cursor if we're NOT on Mac, or if we are then the // We only set our cursor if we're NOT on Mac, or if we are then the
// macOS version is >= 13 (Ventura). On prior versions, glfw crashes // macOS version is >= 13 (Ventura). On prior versions, glfw crashes

View File

@ -14,6 +14,7 @@ const std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const builtin = @import("builtin"); const builtin = @import("builtin");
const build_config = @import("../../build_config.zig");
const apprt = @import("../../apprt.zig"); const apprt = @import("../../apprt.zig");
const configpkg = @import("../../config.zig"); const configpkg = @import("../../config.zig");
const input = @import("../../input.zig"); const input = @import("../../input.zig");
@ -99,9 +100,13 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
c.gtk_get_micro_version(), c.gtk_get_micro_version(),
}); });
// Disabling Vulkan can improve startup times by hundreds of
// milliseconds on some systems. We don't use Vulkan so we can just
// disable it.
if (version.atLeast(4, 16, 0)) { if (version.atLeast(4, 16, 0)) {
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
_ = internal_os.setenv("GDK_DISABLE", "gles-api"); // 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");
} else if (version.atLeast(4, 14, 0)) { } else if (version.atLeast(4, 14, 0)) {
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14. // We need to export GDK_DEBUG to run on Wayland after GTK 4.14.
@ -110,11 +115,31 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// reassess... // reassess...
// //
// Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles"); //
// Specific details about values:
// - "opengl" - output OpenGL debug information
// - "gl-disable-gles" - disable GLES, Ghostty can't use GLES
// - "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");
// Wayland-EGL on GTK 4.14 causes "Failed to create EGL context" errors.
// This can be fixed by forcing the backend to prefer X11. This issue
// appears to be fixed in GTK 4.16 but I wasn't able to bisect why.
// The "*" at the end says that if X11 fails, try all remaining
// backends.
_ = internal_os.setenv("GDK_BACKEND", "x11,*");
} 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
// good understanding of what we may need to do.
_ = internal_os.setenv("GDK_DEBUG", "vulkan-disable");
} }
if (version.atLeast(4, 14, 0)) { if (version.atLeast(4, 14, 0)) {
// We need to export GSK_RENDERER to opengl because GTK uses ngl by default after 4.14 // We need to export GSK_RENDERER to opengl because GTK uses ngl by
// default after 4.14
_ = internal_os.setenv("GSK_RENDERER", "opengl"); _ = internal_os.setenv("GSK_RENDERER", "opengl");
} }
@ -181,7 +206,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
} }
} }
const default_id = "com.mitchellh.ghostty"; const default_id = comptime build_config.bundle_id;
break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id; break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id;
}; };
@ -377,22 +402,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
if (config.@"initial-window") if (config.@"initial-window")
c.g_application_activate(gapp); c.g_application_activate(gapp);
// Register for dbus events
if (c.g_application_get_dbus_connection(gapp)) |dbus_connection| {
_ = c.g_dbus_connection_signal_subscribe(
dbus_connection,
null,
"org.freedesktop.portal.Settings",
"SettingChanged",
"/org/freedesktop/portal/desktop",
"org.freedesktop.appearance",
c.G_DBUS_SIGNAL_FLAGS_MATCH_ARG0_NAMESPACE,
&gtkNotifyColorScheme,
core_app,
null,
);
}
// Internally, GTK ensures that only one instance of this provider exists in the provider list // Internally, GTK ensures that only one instance of this provider exists in the provider list
// for the display. // for the display.
const css_provider = c.gtk_css_provider_new(); const css_provider = c.gtk_css_provider_new();
@ -401,12 +410,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
@ptrCast(css_provider), @ptrCast(css_provider),
c.GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 3, c.GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 3,
); );
loadRuntimeCss(core_app.alloc, &config, css_provider) catch |err| switch (err) {
error.OutOfMemory => log.warn(
"out of memory loading runtime CSS, no runtime CSS applied",
.{},
),
};
return .{ return .{
.core_app = core_app, .core_app = core_app,
@ -462,7 +465,7 @@ pub fn performAction(
.equalize_splits => self.equalizeSplits(target), .equalize_splits => self.equalizeSplits(target),
.goto_split => self.gotoSplit(target, value), .goto_split => self.gotoSplit(target, value),
.open_config => try configpkg.edit.open(self.core_app.alloc), .open_config => try configpkg.edit.open(self.core_app.alloc),
.config_change => self.configChange(value.config), .config_change => self.configChange(target, value.config),
.reload_config => try self.reloadConfig(target, value), .reload_config => try self.reloadConfig(target, value),
.inspector => self.controlInspector(target, value), .inspector => self.controlInspector(target, value),
.desktop_notification => self.showDesktopNotification(target, value), .desktop_notification => self.showDesktopNotification(target, value),
@ -818,18 +821,38 @@ fn showDesktopNotification(
c.g_application_send_notification(g_app, n.body.ptr, notification); c.g_application_send_notification(g_app, n.body.ptr, notification);
} }
fn configChange(self: *App, new_config: *const Config) void { fn configChange(
_ = new_config; self: *App,
target: apprt.Target,
new_config: *const Config,
) void {
switch (target) {
// We don't do anything for surface config change events. There
// is nothing to sync with regards to a surface today.
.surface => {},
self.syncConfigChanges() catch |err| { .app => {
log.warn("error handling configuration changes err={}", .{err}); // We clone (to take ownership) and update our configuration.
}; if (new_config.clone(self.core_app.alloc)) |config_clone| {
self.config.deinit();
self.config = config_clone;
} else |err| {
log.warn("error cloning configuration err={}", .{err});
}
if (adwaita.enabled(&self.config)) { self.syncConfigChanges() catch |err| {
if (self.core_app.focusedSurface()) |core_surface| { log.warn("error handling configuration changes err={}", .{err});
const surface = core_surface.rt_surface; };
if (surface.container.window()) |window| window.onConfigReloaded();
} // App changes needs to show a toast that our configuration
// has reloaded.
if (adwaita.enabled(&self.config)) {
if (self.core_app.focusedSurface()) |core_surface| {
const surface = core_surface.rt_surface;
if (surface.container.window()) |window| window.onConfigReloaded();
}
}
},
} }
} }
@ -870,7 +893,7 @@ fn syncConfigChanges(self: *App) !void {
// Load our runtime CSS. If this fails then our window is just stuck // Load our runtime CSS. If this fails then our window is just stuck
// with the old CSS but we don't want to fail the entire sync operation. // with the old CSS but we don't want to fail the entire sync operation.
loadRuntimeCss(self.core_app.alloc, &self.config, self.css_provider) catch |err| switch (err) { self.loadRuntimeCss() catch |err| switch (err) {
error.OutOfMemory => log.warn( error.OutOfMemory => log.warn(
"out of memory loading runtime CSS, no runtime CSS applied", "out of memory loading runtime CSS, no runtime CSS applied",
.{}, .{},
@ -934,15 +957,14 @@ fn syncActionAccelerator(
} }
fn loadRuntimeCss( fn loadRuntimeCss(
alloc: Allocator, self: *const App,
config: *const Config,
provider: *c.GtkCssProvider,
) Allocator.Error!void { ) Allocator.Error!void {
var stack_alloc = std.heap.stackFallback(4096, alloc); var stack_alloc = std.heap.stackFallback(4096, self.core_app.alloc);
var buf = std.ArrayList(u8).init(stack_alloc.get()); var buf = std.ArrayList(u8).init(stack_alloc.get());
defer buf.deinit(); defer buf.deinit();
const writer = buf.writer(); const writer = buf.writer();
const config: *const Config = &self.config;
const window_theme = config.@"window-theme"; const window_theme = config.@"window-theme";
const unfocused_fill: Config.Color = config.@"unfocused-split-fill" orelse config.background; const unfocused_fill: Config.Color = config.@"unfocused-split-fill" orelse config.background;
const headerbar_background = config.background; const headerbar_background = config.background;
@ -1005,7 +1027,7 @@ fn loadRuntimeCss(
// Clears any previously loaded CSS from this provider // Clears any previously loaded CSS from this provider
c.gtk_css_provider_load_from_data( c.gtk_css_provider_load_from_data(
provider, self.css_provider,
buf.items.ptr, buf.items.ptr,
@intCast(buf.items.len), @intCast(buf.items.len),
); );
@ -1054,11 +1076,17 @@ pub fn run(self: *App) !void {
self.transient_cgroup_base = path; self.transient_cgroup_base = path;
} else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"}); } else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"});
// Setup our D-Bus connection for listening to settings changes.
self.initDbus();
// Setup our menu items // Setup our menu items
self.initActions(); self.initActions();
self.initMenu(); self.initMenu();
self.initContextMenu(); self.initContextMenu();
// Setup our initial color scheme
self.colorSchemeEvent(self.getColorScheme());
// On startup, we want to check for configuration errors right away // On startup, we want to check for configuration errors right away
// so we can show our error window. We also need to setup other initial // so we can show our error window. We also need to setup other initial
// state. // state.
@ -1092,6 +1120,26 @@ pub fn run(self: *App) !void {
} }
} }
fn initDbus(self: *App) void {
const dbus = c.g_application_get_dbus_connection(@ptrCast(self.app)) orelse {
log.warn("unable to get dbus connection, not setting up events", .{});
return;
};
_ = c.g_dbus_connection_signal_subscribe(
dbus,
null,
"org.freedesktop.portal.Settings",
"SettingChanged",
"/org/freedesktop/portal/desktop",
"org.freedesktop.appearance",
c.G_DBUS_SIGNAL_FLAGS_MATCH_ARG0_NAMESPACE,
&gtkNotifyColorScheme,
self,
null,
);
}
// This timeout function is started when no surfaces are open. It can be // This timeout function is started when no surfaces are open. It can be
// cancelled if a new surface is opened before the timer expires. // cancelled if a new surface is opened before the timer expires.
pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean { pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean {
@ -1372,7 +1420,7 @@ fn gtkNotifyColorScheme(
parameters: ?*c.GVariant, parameters: ?*c.GVariant,
user_data: ?*anyopaque, user_data: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
const core_app: *CoreApp = @ptrCast(@alignCast(user_data orelse { const self: *App = @ptrCast(@alignCast(user_data orelse {
log.err("style change notification: userdata is null", .{}); log.err("style change notification: userdata is null", .{});
return; return;
})); }));
@ -1404,9 +1452,20 @@ fn gtkNotifyColorScheme(
else else
.light; .light;
for (core_app.surfaces.items) |surface| { self.colorSchemeEvent(color_scheme);
surface.core_surface.colorSchemeCallback(color_scheme) catch |err| { }
log.err("unable to tell surface about color scheme change: {}", .{err});
fn colorSchemeEvent(
self: *App,
scheme: apprt.ColorScheme,
) void {
self.core_app.colorSchemeEvent(self, scheme) catch |err| {
log.err("error updating app color scheme err={}", .{err});
};
for (self.core_app.surfaces.items) |surface| {
surface.core_surface.colorSchemeCallback(scheme) catch |err| {
log.err("unable to tell surface about color scheme change err={}", .{err});
}; };
} }
} }

View File

@ -3,6 +3,7 @@ const ConfigErrors = @This();
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const build_config = @import("../../build_config.zig");
const configpkg = @import("../../config.zig"); const configpkg = @import("../../config.zig");
const Config = configpkg.Config; const Config = configpkg.Config;
@ -53,7 +54,7 @@ fn init(self: *ConfigErrors, app: *App) !void {
c.gtk_window_set_title(gtk_window, "Configuration Errors"); c.gtk_window_set_title(gtk_window, "Configuration Errors");
c.gtk_window_set_default_size(gtk_window, 600, 275); c.gtk_window_set_default_size(gtk_window, 600, 275);
c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_resizable(gtk_window, 0);
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty"); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
// Set some state // Set some state

View File

@ -5,6 +5,7 @@ const Surface = @This();
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const build_config = @import("../../build_config.zig");
const configpkg = @import("../../config.zig"); const configpkg = @import("../../config.zig");
const apprt = @import("../../apprt.zig"); const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig"); const font = @import("../../font/main.zig");
@ -1149,7 +1150,7 @@ pub fn showDesktopNotification(
defer c.g_object_unref(notification); defer c.g_object_unref(notification);
c.g_notification_set_body(notification, body.ptr); c.g_notification_set_body(notification, body.ptr);
const icon = c.g_themed_icon_new("com.mitchellh.ghostty"); const icon = c.g_themed_icon_new(build_config.bundle_id);
defer c.g_object_unref(icon); defer c.g_object_unref(icon);
c.g_notification_set_icon(notification, icon); c.g_notification_set_icon(notification, icon);

View File

@ -103,7 +103,7 @@ pub fn init(self: *Window, app: *App) !void {
// to disable this so that terminal programs can capture F10 (such as htop) // to disable this so that terminal programs can capture F10 (such as htop)
c.gtk_window_set_handle_menubar_accel(gtk_window, 0); c.gtk_window_set_handle_menubar_accel(gtk_window, 0);
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty"); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
// Apply class to color headerbar if window-theme is set to `ghostty` and // Apply class to color headerbar if window-theme is set to `ghostty` and
// GTK version is before 4.16. The conditional is because above 4.16 // GTK version is before 4.16. The conditional is because above 4.16

View File

@ -13,39 +13,39 @@ const icons = [_]struct {
}{ }{
.{ .{
.alias = "16x16", .alias = "16x16",
.source = "16x16", .source = "16",
}, },
.{ .{
.alias = "16x16@2", .alias = "16x16@2",
.source = "16x16@2x@2x", .source = "16@2x",
}, },
.{ .{
.alias = "32x32", .alias = "32x32",
.source = "32x32", .source = "32",
}, },
.{ .{
.alias = "32x32@2", .alias = "32x32@2",
.source = "32x32@2x@2x", .source = "32@2x",
}, },
.{ .{
.alias = "128x128", .alias = "128x128",
.source = "128x128", .source = "128",
}, },
.{ .{
.alias = "128x128@2", .alias = "128x128@2",
.source = "128x128@2x@2x", .source = "128@2x",
}, },
.{ .{
.alias = "256x256", .alias = "256x256",
.source = "256x256", .source = "256",
}, },
.{ .{
.alias = "256x256@2", .alias = "256x256@2",
.source = "256x256@2x@2x", .source = "256@2x",
}, },
.{ .{
.alias = "512x512", .alias = "512x512",
.source = "512x512", .source = "512",
}, },
}; };

View File

@ -2,6 +2,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const assert = std.debug.assert; const assert = std.debug.assert;
const build_config = @import("../../build_config.zig");
const App = @import("App.zig"); const App = @import("App.zig");
const Surface = @import("Surface.zig"); const Surface = @import("Surface.zig");
const TerminalWindow = @import("Window.zig"); const TerminalWindow = @import("Window.zig");
@ -141,7 +142,7 @@ const Window = struct {
self.window = gtk_window; self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector"); c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector");
c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_default_size(gtk_window, 1000, 600);
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty"); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
// Initialize our imgui widget // Initialize our imgui widget
try self.imgui_widget.init(); try self.imgui_widget.init();

View File

@ -42,7 +42,7 @@ pub fn create(b: *std.Build, opts: Options) *MetallibStep {
b, b,
b.fmt("metal {s}", .{opts.name}), b.fmt("metal {s}", .{opts.name}),
); );
run_ir.addArgs(&.{ "xcrun", "-sdk", sdk, "metal", "-o" }); run_ir.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metal", "-o" });
const output_ir = run_ir.addOutputFileArg(b.fmt("{s}.ir", .{opts.name})); const output_ir = run_ir.addOutputFileArg(b.fmt("{s}.ir", .{opts.name}));
run_ir.addArgs(&.{"-c"}); run_ir.addArgs(&.{"-c"});
for (opts.sources) |source| run_ir.addFileArg(source); for (opts.sources) |source| run_ir.addFileArg(source);
@ -62,7 +62,7 @@ pub fn create(b: *std.Build, opts: Options) *MetallibStep {
b, b,
b.fmt("metallib {s}", .{opts.name}), b.fmt("metallib {s}", .{opts.name}),
); );
run_lib.addArgs(&.{ "xcrun", "-sdk", sdk, "metallib", "-o" }); run_lib.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metallib", "-o" });
const output_lib = run_lib.addOutputFileArg(b.fmt("{s}.metallib", .{opts.name})); const output_lib = run_lib.addOutputFileArg(b.fmt("{s}.metallib", .{opts.name}));
run_lib.addFileArg(output_ir); run_lib.addFileArg(output_ir);
run_lib.step.dependOn(&run_ir.step); run_lib.step.dependOn(&run_ir.step);

View File

@ -103,6 +103,20 @@ pub const app_runtime: apprt.Runtime = config.app_runtime;
pub const font_backend: font.Backend = config.font_backend; pub const font_backend: font.Backend = config.font_backend;
pub const renderer: rendererpkg.Impl = config.renderer; pub const renderer: rendererpkg.Impl = config.renderer;
/// The bundle ID for the app. This is used in many places and is currently
/// hardcoded here. We could make this configurable in the future if there
/// is a reason to do so.
///
/// On macOS, this must match the App bundle ID. We can get that dynamically
/// via an API but I don't want to pay the cost of that at runtime.
///
/// On GTK, this should match the various folders with resources.
///
/// There are many places that don't use this variable so simply swapping
/// this variable is NOT ENOUGH to change the bundle ID. I just wanted to
/// avoid it in Zig coe as much as possible.
pub const bundle_id = "com.mitchellh.ghostty";
/// True if we should have "slow" runtime safety checks. The initial motivation /// True if we should have "slow" runtime safety checks. The initial motivation
/// for this was terminal page/pagelist integrity checks. These were VERY /// for this was terminal page/pagelist integrity checks. These were VERY
/// slow but very thorough. But they made it so slow that the terminal couldn't /// slow but very thorough. But they made it so slow that the terminal couldn't

View File

@ -104,7 +104,7 @@ pub fn parse(
try dst._diagnostics.append(arena_alloc, .{ try dst._diagnostics.append(arena_alloc, .{
.key = try arena_alloc.dupeZ(u8, arg), .key = try arena_alloc.dupeZ(u8, arg),
.message = "invalid field", .message = "invalid field",
.location = diags.Location.fromIter(iter), .location = try diags.Location.fromIter(iter, arena_alloc),
}); });
continue; continue;
@ -145,7 +145,7 @@ pub fn parse(
try dst._diagnostics.append(arena_alloc, .{ try dst._diagnostics.append(arena_alloc, .{
.key = try arena_alloc.dupeZ(u8, key), .key = try arena_alloc.dupeZ(u8, key),
.message = message, .message = message,
.location = diags.Location.fromIter(iter), .location = try diags.Location.fromIter(iter, arena_alloc),
}); });
}; };
} }
@ -1140,7 +1140,7 @@ pub fn ArgsIterator(comptime Iterator: type) type {
} }
/// Returns a location for a diagnostic message. /// Returns a location for a diagnostic message.
pub fn location(self: *const Self) ?diags.Location { pub fn location(self: *const Self, _: Allocator) error{}!?diags.Location {
return .{ .cli = self.index }; return .{ .cli = self.index };
} }
}; };
@ -1262,12 +1262,15 @@ pub fn LineIterator(comptime ReaderType: type) type {
} }
/// Returns a location for a diagnostic message. /// Returns a location for a diagnostic message.
pub fn location(self: *const Self) ?diags.Location { pub fn location(
self: *const Self,
alloc: Allocator,
) Allocator.Error!?diags.Location {
// If we have no filepath then we have no location. // If we have no filepath then we have no location.
if (self.filepath.len == 0) return null; if (self.filepath.len == 0) return null;
return .{ .file = .{ return .{ .file = .{
.path = self.filepath, .path = try alloc.dupe(u8, self.filepath),
.line = self.line, .line = self.line,
} }; } };
} }

View File

@ -34,6 +34,14 @@ pub const Diagnostic = struct {
try writer.print("{s}", .{self.message}); try writer.print("{s}", .{self.message});
} }
pub fn clone(self: *const Diagnostic, alloc: Allocator) Allocator.Error!Diagnostic {
return .{
.location = try self.location.clone(alloc),
.key = try alloc.dupeZ(u8, self.key),
.message = try alloc.dupeZ(u8, self.message),
};
}
}; };
/// The possible locations for a diagnostic message. This is used /// The possible locations for a diagnostic message. This is used
@ -48,7 +56,7 @@ pub const Location = union(enum) {
pub const Key = @typeInfo(Location).Union.tag_type.?; pub const Key = @typeInfo(Location).Union.tag_type.?;
pub fn fromIter(iter: anytype) Location { pub fn fromIter(iter: anytype, alloc: Allocator) Allocator.Error!Location {
const Iter = t: { const Iter = t: {
const T = @TypeOf(iter); const T = @TypeOf(iter);
break :t switch (@typeInfo(T)) { break :t switch (@typeInfo(T)) {
@ -59,7 +67,20 @@ pub const Location = union(enum) {
}; };
if (!@hasDecl(Iter, "location")) return .none; if (!@hasDecl(Iter, "location")) return .none;
return iter.location() orelse .none; return (try iter.location(alloc)) orelse .none;
}
pub fn clone(self: *const Location, alloc: Allocator) Allocator.Error!Location {
return switch (self.*) {
.none,
.cli,
=> self.*,
.file => |v| .{ .file = .{
.path = try alloc.dupe(u8, v.path),
.line = v.line,
} },
};
} }
}; };
@ -88,11 +109,45 @@ pub const DiagnosticList = struct {
// We specifically want precompute for libghostty. // We specifically want precompute for libghostty.
.lib => true, .lib => true,
}; };
const Precompute = if (precompute_enabled) struct { const Precompute = if (precompute_enabled) struct {
messages: std.ArrayListUnmanaged([:0]const u8) = .{}, messages: std.ArrayListUnmanaged([:0]const u8) = .{},
pub fn clone(
self: *const Precompute,
alloc: Allocator,
) Allocator.Error!Precompute {
var result: Precompute = .{};
try result.messages.ensureTotalCapacity(alloc, self.messages.items.len);
for (self.messages.items) |msg| {
result.messages.appendAssumeCapacity(
try alloc.dupeZ(u8, msg),
);
}
return result;
}
} else void; } else void;
const precompute_init: Precompute = if (precompute_enabled) .{} else {}; const precompute_init: Precompute = if (precompute_enabled) .{} else {};
pub fn clone(
self: *const DiagnosticList,
alloc: Allocator,
) Allocator.Error!DiagnosticList {
var result: DiagnosticList = .{};
try result.list.ensureTotalCapacity(alloc, self.list.items.len);
for (self.list.items) |*diag| result.list.appendAssumeCapacity(
try diag.clone(alloc),
);
if (comptime precompute_enabled) {
result.precompute = try self.precompute.clone(alloc);
}
return result;
}
pub fn append( pub fn append(
self: *DiagnosticList, self: *DiagnosticList,
alloc: Allocator, alloc: Allocator,

View File

@ -527,6 +527,10 @@ palette: Palette = .{},
/// The opacity level (opposite of transparency) of the background. A value of /// The opacity level (opposite of transparency) of the background. A value of
/// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0 /// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0
/// or greater than 1 will be clamped to the nearest valid value. /// or greater than 1 will be clamped to the nearest valid value.
///
/// 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.
@"background-opacity": f64 = 1.0, @"background-opacity": f64 = 1.0,
/// A positive value enables blurring of the background when background-opacity /// A positive value enables blurring of the background when background-opacity
@ -664,9 +668,6 @@ link: RepeatableLink = .{},
/// does not apply to tabs, splits, etc. However, this setting will apply to all /// does not apply to tabs, splits, etc. However, this setting will apply to all
/// new windows, not just the first one. /// new windows, not just the first one.
/// ///
/// On macOS, this always creates the window in native fullscreen. Non-native
/// fullscreen is not currently supported with this setting.
///
/// On macOS, this setting does not work if window-decoration is set to /// On macOS, this setting does not work if window-decoration is set to
/// "false", because native fullscreen on macOS requires window decorations /// "false", because native fullscreen on macOS requires window decorations
/// to be set. /// to be set.
@ -675,6 +676,12 @@ fullscreen: bool = false,
/// The title Ghostty will use for the window. This will force the title of the /// The title Ghostty will use for the window. This will force the title of the
/// window to be this title at all times and Ghostty will ignore any set title /// window to be this title at all times and Ghostty will ignore any set title
/// escape sequences programs (such as Neovim) may send. /// escape sequences programs (such as Neovim) may send.
///
/// 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
/// to get the new title.
title: ?[:0]const u8 = null, title: ?[:0]const u8 = null,
/// The setting that will change the application class value. /// The setting that will change the application class value.
@ -1793,6 +1800,10 @@ _diagnostics: cli.DiagnosticList = .{},
/// determine if a conditional configuration matches or not. /// determine if a conditional configuration matches or not.
_conditional_state: conditional.State = .{}, _conditional_state: conditional.State = .{},
/// The conditional keys that are used at any point during the configuration
/// loading. This is used to speed up the conditional evaluation process.
_conditional_set: std.EnumSet(conditional.Key) = .{},
/// The steps we can use to reload the configuration after it has been loaded /// The steps we can use to reload the configuration after it has been loaded
/// without reopening the files. This is used in very specific cases such /// without reopening the files. This is used in very specific cases such
/// as loadTheme which has more details on why. /// as loadTheme which has more details on why.
@ -1809,9 +1820,10 @@ pub fn deinit(self: *Config) void {
/// Load the configuration according to the default rules: /// Load the configuration according to the default rules:
/// ///
/// 1. Defaults /// 1. Defaults
/// 2. XDG Config File /// 2. XDG config dir
/// 3. CLI flags /// 3. "Application Support" directory (macOS only)
/// 4. Recursively defined configuration files /// 4. CLI flags
/// 5. Recursively defined configuration files
/// ///
pub fn load(alloc_gpa: Allocator) !Config { pub fn load(alloc_gpa: Allocator) !Config {
var result = try default(alloc_gpa); var result = try default(alloc_gpa);
@ -2394,25 +2406,37 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void {
try self.expandPaths(std.fs.path.dirname(path).?); try self.expandPaths(std.fs.path.dirname(path).?);
} }
/// Load the configuration from the default configuration file. The default /// Load optional configuration file from `path`. All errors are ignored.
/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`. pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void {
pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { self.loadFile(alloc, path) catch |err| switch (err) {
const config_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" });
defer alloc.free(config_path);
self.loadFile(alloc, config_path) catch |err| switch (err) {
error.FileNotFound => std.log.info( error.FileNotFound => std.log.info(
"homedir config not found, not loading path={s}", "optional config file not found, not loading path={s}",
.{config_path}, .{path},
), ),
else => std.log.warn( else => std.log.warn(
"error reading config file, not loading err={} path={s}", "error reading optional config file, not loading err={} path={s}",
.{ err, config_path }, .{ err, path },
), ),
}; };
} }
/// Load configurations from the default configuration files. The default
/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`.
///
/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config`
/// is also loaded.
pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void {
const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" });
defer alloc.free(xdg_path);
self.loadOptionalFile(alloc, xdg_path);
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);
}
}
/// Load and parse the CLI args. /// Load and parse the CLI args.
pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
switch (builtin.os.tag) { switch (builtin.os.tag) {
@ -2444,7 +2468,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
// First, we add an artificial "-e" so that if we // First, we add an artificial "-e" so that if we
// replay the inputs to rebuild the config (i.e. if // replay the inputs to rebuild the config (i.e. if
// a theme is set) then we will get the same behavior. // a theme is set) then we will get the same behavior.
try self._replay_steps.append(arena_alloc, .{ .arg = "-e" }); try self._replay_steps.append(arena_alloc, .@"-e");
// Next, take all remaining args and use that to build up // Next, take all remaining args and use that to build up
// a command to execute. // a command to execute.
@ -2552,6 +2576,24 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
const cwd = std.fs.cwd(); const cwd = std.fs.cwd();
// We need to insert all of our loaded config-file values
// PRIOR to the "-e" in our replay steps, since everything
// after "-e" becomes an "initial-command". To do this, we
// dupe the values if we find it.
var replay_suffix = std.ArrayList(Replay.Step).init(alloc_gpa);
defer replay_suffix.deinit();
for (self._replay_steps.items, 0..) |step, i| if (step == .@"-e") {
// We don't need to clone the steps because they should
// all be allocated in our arena and we're keeping our
// arena.
try replay_suffix.appendSlice(self._replay_steps.items[i..]);
// Remove our old values. Again, don't need to free any
// memory here because its all part of our arena.
self._replay_steps.shrinkRetainingCapacity(i);
break;
};
// We must use a while below and not a for(items) because we // We must use a while below and not a for(items) because we
// may add items to the list while iterating for recursive // may add items to the list while iterating for recursive
// config-file entries. // config-file entries.
@ -2603,6 +2645,14 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
try self.loadIter(alloc_gpa, &iter); try self.loadIter(alloc_gpa, &iter);
try self.expandPaths(std.fs.path.dirname(path).?); try self.expandPaths(std.fs.path.dirname(path).?);
} }
// If we have a suffix, add that back.
if (replay_suffix.items.len > 0) {
try self._replay_steps.appendSlice(
arena_alloc,
replay_suffix.items,
);
}
} }
/// Change the state of conditionals and reload the configuration /// Change the state of conditionals and reload the configuration
@ -2610,6 +2660,10 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
/// on the new state. The caller must free the old configuration if they /// on the new state. The caller must free the old configuration if they
/// wish. /// wish.
/// ///
/// This returns null if the conditional state would result in no changes
/// to the configuration. In this case, the caller can continue to use
/// the existing configuration or clone if they want a copy.
///
/// This doesn't re-read any files, it just re-applies the same /// This doesn't re-read any files, it just re-applies the same
/// configuration with the new conditional state. Importantly, this means /// configuration with the new conditional state. Importantly, this means
/// that if you change the conditional state and the user in the interim /// that if you change the conditional state and the user in the interim
@ -2618,7 +2672,30 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
pub fn changeConditionalState( pub fn changeConditionalState(
self: *const Config, self: *const Config,
new: conditional.State, new: conditional.State,
) !Config { ) !?Config {
// If the conditional state between the old and new is the same,
// then we don't need to do anything.
relevant: {
inline for (@typeInfo(conditional.Key).Enum.fields) |field| {
const key: conditional.Key = @field(conditional.Key, field.name);
// Conditional set contains the keys that this config uses. So we
// only continue if we use this key.
if (self._conditional_set.contains(key) and !equalField(
@TypeOf(@field(self._conditional_state, field.name)),
@field(self._conditional_state, field.name),
@field(new, field.name),
)) {
break :relevant;
}
}
// If we got here, then we didn't find any differences between
// the old and new conditional state that would affect the
// configuration.
return null;
}
// Create our new configuration // Create our new configuration
const alloc_gpa = self._arena.?.child_allocator; const alloc_gpa = self._arena.?.child_allocator;
var new_config = try self.cloneEmpty(alloc_gpa); var new_config = try self.cloneEmpty(alloc_gpa);
@ -2703,39 +2780,46 @@ fn loadTheme(self: *Config, theme: Theme) !void {
try new_config.loadIter(alloc_gpa, &iter); try new_config.loadIter(alloc_gpa, &iter);
// Setup our replay to be conditional. // Setup our replay to be conditional.
for (new_config._replay_steps.items) |*item| switch (item.*) { conditional: for (new_config._replay_steps.items) |*item| {
.expand => {}, switch (item.*) {
.expand => {},
// Change our arg to be conditional on our theme. // If we see "-e" then we do NOT make the following arguments
.arg => |v| { // conditional since they are supposed to be part of the
const alloc_arena = new_config._arena.?.allocator(); // initial command.
const conds = try alloc_arena.alloc(Conditional, 1); .@"-e" => break :conditional,
conds[0] = .{
.key = .theme,
.op = .eq,
.value = @tagName(self._conditional_state.theme),
};
item.* = .{ .conditional_arg = .{
.conditions = conds,
.arg = v,
} };
},
.conditional_arg => |v| { // Change our arg to be conditional on our theme.
const alloc_arena = new_config._arena.?.allocator(); .arg => |v| {
const conds = try alloc_arena.alloc(Conditional, v.conditions.len + 1); const alloc_arena = new_config._arena.?.allocator();
conds[0] = .{ const conds = try alloc_arena.alloc(Conditional, 1);
.key = .theme, conds[0] = .{
.op = .eq, .key = .theme,
.value = @tagName(self._conditional_state.theme), .op = .eq,
}; .value = @tagName(self._conditional_state.theme),
@memcpy(conds[1..], v.conditions); };
item.* = .{ .conditional_arg = .{ item.* = .{ .conditional_arg = .{
.conditions = conds, .conditions = conds,
.arg = v.arg, .arg = v,
} }; } };
}, },
};
.conditional_arg => |v| {
const alloc_arena = new_config._arena.?.allocator();
const conds = try alloc_arena.alloc(Conditional, v.conditions.len + 1);
conds[0] = .{
.key = .theme,
.op = .eq,
.value = @tagName(self._conditional_state.theme),
};
@memcpy(conds[1..], v.conditions);
item.* = .{ .conditional_arg = .{
.conditions = conds,
.arg = v.arg,
} };
},
}
}
// Replay our previous inputs so that we can override values // Replay our previous inputs so that we can override values
// from the theme. // from the theme.
@ -2765,6 +2849,9 @@ pub fn finalize(self: *Config) !void {
// This setting doesn't make sense with different light/dark themes // This setting doesn't make sense with different light/dark themes
// because it'll force the theme based on the Ghostty theme. // because it'll force the theme based on the Ghostty theme.
if (self.@"window-theme" == .auto) self.@"window-theme" = .system; if (self.@"window-theme" == .auto) self.@"window-theme" = .system;
// Mark that we use a conditional theme
self._conditional_set.insert(.theme);
} }
} }
@ -2924,10 +3011,12 @@ pub fn parseManuallyHook(
arg: []const u8, arg: []const u8,
iter: anytype, iter: anytype,
) !bool { ) !bool {
// Keep track of our input args no matter what..
try self._replay_steps.append(alloc, .{ .arg = try alloc.dupe(u8, arg) });
if (std.mem.eql(u8, arg, "-e")) { if (std.mem.eql(u8, arg, "-e")) {
// Add the special -e marker. This prevents:
// (1) config-file from adding args to the end (see #2908)
// (2) dark/light theme from making this conditional
try self._replay_steps.append(alloc, .@"-e");
// Build up the command. We don't clean this up because we take // Build up the command. We don't clean this up because we take
// ownership in our allocator. // ownership in our allocator.
var command = std.ArrayList(u8).init(alloc); var command = std.ArrayList(u8).init(alloc);
@ -2941,7 +3030,7 @@ pub fn parseManuallyHook(
if (command.items.len == 0) { if (command.items.len == 0) {
try self._diagnostics.append(alloc, .{ try self._diagnostics.append(alloc, .{
.location = cli.Location.fromIter(iter), .location = try cli.Location.fromIter(iter, alloc),
.message = try std.fmt.allocPrintZ( .message = try std.fmt.allocPrintZ(
alloc, alloc,
"missing command after {s}", "missing command after {s}",
@ -2963,6 +3052,12 @@ pub fn parseManuallyHook(
return false; return false;
} }
// Keep track of our input args for replay
try self._replay_steps.append(
alloc,
.{ .arg = try alloc.dupe(u8, arg) },
);
// If we didn't find a special case, continue parsing normally // If we didn't find a special case, continue parsing normally
return true; return true;
} }
@ -3016,6 +3111,9 @@ pub fn clone(
); );
} }
// Copy our diagnostics
result._diagnostics = try self._diagnostics.clone(alloc_arena);
// Preserve our replay steps. We copy them exactly to also preserve // Preserve our replay steps. We copy them exactly to also preserve
// the exact conditionals required for some steps. // the exact conditionals required for some steps.
try result._replay_steps.ensureTotalCapacity( try result._replay_steps.ensureTotalCapacity(
@ -3029,6 +3127,9 @@ pub fn clone(
} }
assert(result._replay_steps.items.len == self._replay_steps.items.len); assert(result._replay_steps.items.len == self._replay_steps.items.len);
// Copy the conditional set
result._conditional_set = self._conditional_set;
return result; return result;
} }
@ -3224,11 +3325,22 @@ const Replay = struct {
arg: []const u8, arg: []const u8,
}, },
/// The start of a "-e" argument. This marks the end of
/// traditional configuration and the beginning of the
/// "-e" initial command magic. This is separate from "arg"
/// because there are some behaviors unique to this (i.e.
/// we want to keep this at the end for config-file).
///
/// Note: when "-e" is used, ONLY this is present and
/// not an additional "arg" with "-e" value.
@"-e",
fn clone( fn clone(
self: Step, self: Step,
alloc: Allocator, alloc: Allocator,
) Allocator.Error!Step { ) Allocator.Error!Step {
return switch (self) { return switch (self) {
.@"-e" => self,
.arg => |v| .{ .arg = try alloc.dupe(u8, v) }, .arg => |v| .{ .arg = try alloc.dupe(u8, v) },
.expand => |v| .{ .expand = try alloc.dupe(u8, v) }, .expand => |v| .{ .expand = try alloc.dupe(u8, v) },
.conditional_arg => |v| conditional: { .conditional_arg => |v| conditional: {
@ -3264,10 +3376,6 @@ const Replay = struct {
log.warn("error expanding paths err={}", .{err}); log.warn("error expanding paths err={}", .{err});
}, },
.arg => |arg| {
return arg;
},
.conditional_arg => |v| conditional: { .conditional_arg => |v| conditional: {
// All conditions must match. // All conditions must match.
for (v.conditions) |cond| { for (v.conditions) |cond| {
@ -3278,6 +3386,9 @@ const Replay = struct {
return v.arg; return v.arg;
}, },
.arg => |arg| return arg,
.@"-e" => return "-e",
} }
} }
} }
@ -4560,17 +4671,33 @@ pub const RepeatableLink = struct {
} }
/// Deep copy of the struct. Required by Config. /// Deep copy of the struct. Required by Config.
pub fn clone(self: *const Self, alloc: Allocator) error{}!Self { pub fn clone(
_ = self; self: *const Self,
_ = alloc; alloc: Allocator,
return .{}; ) Allocator.Error!Self {
// Note: we don't do any errdefers below since the allocation
// is expected to be arena allocated.
var list = try std.ArrayListUnmanaged(inputpkg.Link).initCapacity(
alloc,
self.links.items.len,
);
for (self.links.items) |item| {
const copy = try item.clone(alloc);
list.appendAssumeCapacity(copy);
}
return .{ .links = list };
} }
/// Compare if two of our value are requal. Required by Config. /// Compare if two of our value are requal. Required by Config.
pub fn equal(self: Self, other: Self) bool { pub fn equal(self: Self, other: Self) bool {
_ = self; const itemsA = self.links.items;
_ = other; const itemsB = other.links.items;
return true; if (itemsA.len != itemsB.len) return false;
for (itemsA, itemsB) |*a, *b| {
if (!a.equal(b)) return false;
} else return true;
} }
/// Used by Formatter /// Used by Formatter
@ -5258,14 +5385,13 @@ test "clone preserves conditional state" {
var a = try Config.default(alloc); var a = try Config.default(alloc);
defer a.deinit(); defer a.deinit();
var b = try a.changeConditionalState(.{ .theme = .dark }); a._conditional_state.theme = .dark;
defer b.deinit(); try testing.expectEqual(.dark, a._conditional_state.theme);
try testing.expectEqual(.dark, b._conditional_state.theme); var dest = try a.clone(alloc);
var dest = try b.clone(alloc);
defer dest.deinit(); defer dest.deinit();
// Should have no changes // Should have no changes
var it = b.changeIterator(&dest); var it = a.changeIterator(&dest);
try testing.expectEqual(@as(?Key, null), it.next()); try testing.expectEqual(@as(?Key, null), it.next());
// Should have the same conditional state // Should have the same conditional state
@ -5315,7 +5441,7 @@ test "clone can then change conditional state" {
try cfg_light.loadIter(alloc, &it); try cfg_light.loadIter(alloc, &it);
try cfg_light.finalize(); try cfg_light.finalize();
var cfg_dark = try cfg_light.changeConditionalState(.{ .theme = .dark }); var cfg_dark = (try cfg_light.changeConditionalState(.{ .theme = .dark })).?;
defer cfg_dark.deinit(); defer cfg_dark.deinit();
try testing.expectEqual(Color{ try testing.expectEqual(Color{
@ -5332,7 +5458,7 @@ test "clone can then change conditional state" {
.b = 0xEE, .b = 0xEE,
}, cfg_clone.background); }, cfg_clone.background);
var cfg_light2 = try cfg_clone.changeConditionalState(.{ .theme = .light }); var cfg_light2 = (try cfg_clone.changeConditionalState(.{ .theme = .light })).?;
defer cfg_light2.deinit(); defer cfg_light2.deinit();
try testing.expectEqual(Color{ try testing.expectEqual(Color{
.r = 0xFF, .r = 0xFF,
@ -5341,6 +5467,25 @@ test "clone can then change conditional state" {
}, cfg_light2.background); }, cfg_light2.background);
} }
test "clone preserves conditional set" {
const testing = std.testing;
const alloc = testing.allocator;
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--theme=light:foo,dark:bar",
"--window-theme=auto",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
var clone1 = try cfg.clone(alloc);
defer clone1.deinit();
try testing.expect(clone1._conditional_set.contains(.theme));
}
test "changed" { test "changed" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -5355,6 +5500,44 @@ test "changed" {
try testing.expect(!source.changed(&dest, .@"font-size")); try testing.expect(!source.changed(&dest, .@"font-size"));
} }
test "changeConditionalState ignores irrelevant changes" {
const testing = std.testing;
const alloc = testing.allocator;
{
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--theme=foo",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
try testing.expect(try cfg.changeConditionalState(
.{ .theme = .dark },
) == null);
}
}
test "changeConditionalState applies relevant changes" {
const testing = std.testing;
const alloc = testing.allocator;
{
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--theme=light:foo,dark:bar",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
var cfg2 = (try cfg.changeConditionalState(.{ .theme = .dark })).?;
defer cfg2.deinit();
try testing.expect(cfg2._conditional_set.contains(.theme));
}
}
test "theme loading" { test "theme loading" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -5386,6 +5569,9 @@ test "theme loading" {
.g = 0x3A, .g = 0x3A,
.b = 0xBC, .b = 0xBC,
}, cfg.background); }, cfg.background);
// Not a conditional theme
try testing.expect(!cfg._conditional_set.contains(.theme));
} }
test "theme loading preserves conditional state" { test "theme loading preserves conditional state" {
@ -5534,7 +5720,7 @@ test "theme loading correct light/dark" {
try cfg.loadIter(alloc, &it); try cfg.loadIter(alloc, &it);
try cfg.finalize(); try cfg.finalize();
var new = try cfg.changeConditionalState(.{ .theme = .dark }); var new = (try cfg.changeConditionalState(.{ .theme = .dark })).?;
defer new.deinit(); defer new.deinit();
try testing.expectEqual(Color{ try testing.expectEqual(Color{
.r = 0xEE, .r = 0xEE,
@ -5561,3 +5747,22 @@ test "theme specifying light/dark changes window-theme from auto" {
try testing.expect(cfg.@"window-theme" == .system); try testing.expect(cfg.@"window-theme" == .system);
} }
} }
test "theme specifying light/dark sets theme usage in conditional state" {
const testing = std.testing;
const alloc = testing.allocator;
{
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--theme=light:foo,dark:bar",
"--window-theme=auto",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
try testing.expect(cfg.@"window-theme" == .system);
try testing.expect(cfg._conditional_set.contains(.theme));
}
}

View File

@ -45,10 +45,26 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
self.idx += 1; self.idx += 1;
return &self.buf.storage[storage_idx]; return &self.buf.storage[storage_idx];
} }
/// Seek the iterator by a given amount. This will clamp
/// the values to the bounds of the buffer so overflows are
/// not possible.
pub fn seekBy(self: *Iterator, amount: isize) void {
if (amount > 0) {
self.idx +|= @intCast(amount);
} else {
self.idx -|= @intCast(@abs(amount));
}
}
/// Reset the iterator back to the first value.
pub fn reset(self: *Iterator) void {
self.idx = 0;
}
}; };
/// Initialize a new circular buffer that can store size elements. /// Initialize a new circular buffer that can store size elements.
pub fn init(alloc: Allocator, size: usize) !Self { pub fn init(alloc: Allocator, size: usize) Allocator.Error!Self {
const buf = try alloc.alloc(T, size); const buf = try alloc.alloc(T, size);
@memset(buf, default); @memset(buf, default);
@ -56,7 +72,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
.storage = buf, .storage = buf,
.head = 0, .head = 0,
.tail = 0, .tail = 0,
.full = false, .full = size == 0,
}; };
} }
@ -67,7 +83,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
/// Append a single value to the buffer. If the buffer is full, /// Append a single value to the buffer. If the buffer is full,
/// an error will be returned. /// an error will be returned.
pub fn append(self: *Self, v: T) !void { pub fn append(self: *Self, v: T) Allocator.Error!void {
if (self.full) return error.OutOfMemory; if (self.full) return error.OutOfMemory;
self.storage[self.head] = v; self.storage[self.head] = v;
self.head += 1; self.head += 1;
@ -75,6 +91,19 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
self.full = self.head == self.tail; self.full = self.head == self.tail;
} }
/// Append a slice to the buffer. If the buffer cannot fit the
/// entire slice then an error will be returned. It is up to the
/// caller to rotate the circular buffer if they want to overwrite
/// the oldest data.
pub fn appendSlice(
self: *Self,
slice: []const T,
) Allocator.Error!void {
const storage = self.getPtrSlice(self.len(), slice.len);
fastmem.copy(T, storage[0], slice[0..storage[0].len]);
fastmem.copy(T, storage[1], slice[storage[0].len..]);
}
/// Clear the buffer. /// Clear the buffer.
pub fn clear(self: *Self) void { pub fn clear(self: *Self) void {
self.head = 0; self.head = 0;
@ -91,6 +120,34 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
}; };
} }
/// Get the first (oldest) value in the buffer.
pub fn first(self: Self) ?*T {
// Note: this can be more efficient by not using the
// iterator, but this was an easy way to implement it.
var it = self.iterator(.forward);
return it.next();
}
/// Get the last (newest) value in the buffer.
pub fn last(self: Self) ?*T {
// Note: this can be more efficient by not using the
// iterator, but this was an easy way to implement it.
var it = self.iterator(.reverse);
return it.next();
}
/// Ensures that there is enough capacity to store amount more
/// items via append.
pub fn ensureUnusedCapacity(
self: *Self,
alloc: Allocator,
amount: usize,
) Allocator.Error!void {
const new_cap = self.len() + amount;
if (new_cap <= self.capacity()) return;
try self.resize(alloc, new_cap);
}
/// Resize the buffer to the given size (larger or smaller). /// Resize the buffer to the given size (larger or smaller).
/// If larger, new values will be set to the default value. /// If larger, new values will be set to the default value.
pub fn resize(self: *Self, alloc: Allocator, size: usize) Allocator.Error!void { pub fn resize(self: *Self, alloc: Allocator, size: usize) Allocator.Error!void {
@ -256,7 +313,7 @@ test {
try testing.expectEqual(@as(usize, 0), buf.len()); try testing.expectEqual(@as(usize, 0), buf.len());
} }
test "append" { test "CircBuf append" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -273,7 +330,7 @@ test "append" {
try testing.expectError(error.OutOfMemory, buf.append(5)); try testing.expectError(error.OutOfMemory, buf.append(5));
} }
test "forward iterator" { test "CircBuf forward iterator" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -319,7 +376,7 @@ test "forward iterator" {
} }
} }
test "reverse iterator" { test "CircBuf reverse iterator" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -365,7 +422,95 @@ test "reverse iterator" {
} }
} }
test "getPtrSlice fits" { test "CircBuf first/last" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 3);
defer buf.deinit(alloc);
try buf.append(1);
try buf.append(2);
try buf.append(3);
try testing.expectEqual(3, buf.last().?.*);
try testing.expectEqual(1, buf.first().?.*);
}
test "CircBuf first/last empty" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 0);
defer buf.deinit(alloc);
try testing.expect(buf.first() == null);
try testing.expect(buf.last() == null);
}
test "CircBuf first/last empty with cap" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 3);
defer buf.deinit(alloc);
try testing.expect(buf.first() == null);
try testing.expect(buf.last() == null);
}
test "CircBuf append slice" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 5);
defer buf.deinit(alloc);
try buf.appendSlice("hello");
{
var it = buf.iterator(.forward);
try testing.expect(it.next().?.* == 'h');
try testing.expect(it.next().?.* == 'e');
try testing.expect(it.next().?.* == 'l');
try testing.expect(it.next().?.* == 'l');
try testing.expect(it.next().?.* == 'o');
try testing.expect(it.next() == null);
}
}
test "CircBuf append slice with wrap" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 4);
defer buf.deinit(alloc);
// Fill the buffer
_ = buf.getPtrSlice(0, buf.capacity());
try testing.expect(buf.full);
try testing.expectEqual(@as(usize, 4), buf.len());
// Delete
buf.deleteOldest(2);
try testing.expect(!buf.full);
try testing.expectEqual(@as(usize, 2), buf.len());
try buf.appendSlice("AB");
{
var it = buf.iterator(.forward);
try testing.expect(it.next().?.* == 0);
try testing.expect(it.next().?.* == 0);
try testing.expect(it.next().?.* == 'A');
try testing.expect(it.next().?.* == 'B');
try testing.expect(it.next() == null);
}
}
test "CircBuf getPtrSlice fits" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -379,7 +524,7 @@ test "getPtrSlice fits" {
try testing.expectEqual(@as(usize, 11), buf.len()); try testing.expectEqual(@as(usize, 11), buf.len());
} }
test "getPtrSlice wraps" { test "CircBuf getPtrSlice wraps" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -435,7 +580,7 @@ test "getPtrSlice wraps" {
} }
} }
test "rotateToZero" { test "CircBuf rotateToZero" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -447,7 +592,7 @@ test "rotateToZero" {
try buf.rotateToZero(alloc); try buf.rotateToZero(alloc);
} }
test "rotateToZero offset" { test "CircBuf rotateToZero offset" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -471,7 +616,7 @@ test "rotateToZero offset" {
try testing.expectEqual(@as(usize, 1), buf.head); try testing.expectEqual(@as(usize, 1), buf.head);
} }
test "rotateToZero wraps" { test "CircBuf rotateToZero wraps" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -511,7 +656,7 @@ test "rotateToZero wraps" {
} }
} }
test "rotateToZero full no wrap" { test "CircBuf rotateToZero full no wrap" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -549,7 +694,32 @@ test "rotateToZero full no wrap" {
} }
} }
test "resize grow" { test "CircBuf resize grow from zero" {
const testing = std.testing;
const alloc = testing.allocator;
const Buf = CircBuf(u8, 0);
var buf = try Buf.init(alloc, 0);
defer buf.deinit(alloc);
try testing.expect(buf.full);
// Resize
try buf.resize(alloc, 2);
try testing.expect(!buf.full);
try testing.expectEqual(@as(usize, 0), buf.len());
try testing.expectEqual(@as(usize, 2), buf.capacity());
try buf.append(1);
try buf.append(2);
{
const slices = buf.getPtrSlice(0, 2);
try testing.expectEqual(@as(u8, 1), slices[0][0]);
try testing.expectEqual(@as(u8, 2), slices[0][1]);
}
}
test "CircBuf resize grow" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
@ -582,7 +752,7 @@ test "resize grow" {
} }
} }
test "resize shrink" { test "CircBuf resize shrink" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;

View File

@ -280,6 +280,8 @@ const Kind = enum {
// Powerline fonts // Powerline fonts
0xE0B0, 0xE0B0,
0xE0B1,
0xE0B3,
0xE0B4, 0xE0B4,
0xE0B6, 0xE0B6,
0xE0B2, 0xE0B2,

View File

@ -93,6 +93,11 @@ fn draw(self: Powerline, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32)
0xE0BE, 0xE0BE,
=> try self.draw_wedge_triangle(canvas, cp), => try self.draw_wedge_triangle(canvas, cp),
// Soft Dividers
0xE0B1,
0xE0B3,
=> try self.draw_chevron(canvas, cp),
// Half-circles // Half-circles
0xE0B4, 0xE0B4,
0xE0B6, 0xE0B6,
@ -107,6 +112,50 @@ fn draw(self: Powerline, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32)
} }
} }
fn draw_chevron(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void {
const width = self.width;
const height = self.height;
var p1_x: u32 = 0;
var p1_y: u32 = 0;
var p2_x: u32 = 0;
var p2_y: u32 = 0;
var p3_x: u32 = 0;
var p3_y: u32 = 0;
switch (cp) {
0xE0B1 => {
p1_x = 0;
p1_y = 0;
p2_x = width;
p2_y = height / 2;
p3_x = 0;
p3_y = height;
},
0xE0B3 => {
p1_x = width;
p1_y = 0;
p2_x = 0;
p2_y = height / 2;
p3_x = width;
p3_y = height;
},
else => unreachable,
}
try canvas.triangle_outline(.{
.p0 = .{ .x = @floatFromInt(p1_x), .y = @floatFromInt(p1_y) },
.p1 = .{ .x = @floatFromInt(p2_x), .y = @floatFromInt(p2_y) },
.p2 = .{ .x = @floatFromInt(p3_x), .y = @floatFromInt(p3_y) },
},
@floatFromInt(Thickness.light.height(self.thickness)),
.on);
}
fn draw_wedge_triangle(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void { fn draw_wedge_triangle(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void {
const width = self.width; const width = self.width;
const height = self.height; const height = self.height;
@ -501,6 +550,8 @@ test "all" {
0xE0B6, 0xE0B6,
0xE0D2, 0xE0D2,
0xE0D4, 0xE0D4,
0xE0B1,
0xE0B3,
}; };
for (cps) |cp| { for (cps) |cp| {
var atlas_grayscale = try font.Atlas.init(alloc, 512, .grayscale); var atlas_grayscale = try font.Atlas.init(alloc, 512, .grayscale);

View File

@ -231,10 +231,35 @@ pub const Canvas = struct {
try path.lineTo(t.p1.x, t.p1.y); try path.lineTo(t.p1.x, t.p1.y);
try path.lineTo(t.p2.x, t.p2.y); try path.lineTo(t.p2.x, t.p2.y);
try path.close(); try path.close();
try ctx.fill(self.alloc, path); try ctx.fill(self.alloc, path);
} }
pub fn triangle_outline(self: *Canvas, t: Triangle(f64), thickness: f64, color: Color) !void {
var ctx: z2d.Context = .{
.surface = self.sfc,
.pattern = .{
.opaque_pattern = .{
.pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } },
},
},
.line_width = thickness,
.line_cap_mode = .round,
};
var path = z2d.Path.init(self.alloc);
defer path.deinit();
try path.moveTo(t.p0.x, t.p0.y);
try path.lineTo(t.p1.x, t.p1.y);
try path.lineTo(t.p2.x, t.p2.y);
// try path.close();
try ctx.stroke(self.alloc, path);
// try ctx.fill(self.alloc, path);
}
/// Stroke a line. /// Stroke a line.
pub fn line(self: *Canvas, l: Line(f64), thickness: f64, color: Color) !void { pub fn line(self: *Canvas, l: Line(f64), thickness: f64, color: Color) !void {
var ctx: z2d.Context = .{ var ctx: z2d.Context = .{

View File

@ -1454,21 +1454,30 @@ pub const Set = struct {
}; };
// If we have any leaders we need to clone them. // If we have any leaders we need to clone them.
var it = result.bindings.iterator(); {
while (it.next()) |entry| switch (entry.value_ptr.*) { var it = result.bindings.iterator();
// Leaves could have data to clone (i.e. text actions while (it.next()) |entry| switch (entry.value_ptr.*) {
// contain allocated strings). // Leaves could have data to clone (i.e. text actions
.leaf => |*s| s.* = try s.clone(alloc), // contain allocated strings).
.leaf => |*s| s.* = try s.clone(alloc),
// Must be deep cloned. // Must be deep cloned.
.leader => |*s| { .leader => |*s| {
const ptr = try alloc.create(Set); const ptr = try alloc.create(Set);
errdefer alloc.destroy(ptr); errdefer alloc.destroy(ptr);
ptr.* = try s.*.clone(alloc); ptr.* = try s.*.clone(alloc);
errdefer ptr.deinit(alloc); errdefer ptr.deinit(alloc);
s.* = ptr; s.* = ptr;
}, },
}; };
}
// We need to clone the action keys in the reverse map since
// they may contain allocated values.
{
var it = result.reverse.keyIterator();
while (it.next()) |action| action.* = try action.clone(alloc);
}
return result; return result;
} }

View File

@ -4,6 +4,8 @@
//! action types. //! action types.
const Link = @This(); const Link = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const oni = @import("oniguruma"); const oni = @import("oniguruma");
const Mods = @import("key.zig").Mods; const Mods = @import("key.zig").Mods;
@ -59,3 +61,19 @@ pub fn oniRegex(self: *const Link) !oni.Regex {
null, null,
); );
} }
/// Deep clone the link.
pub fn clone(self: *const Link, alloc: Allocator) Allocator.Error!Link {
return .{
.regex = try alloc.dupe(u8, self.regex),
.action = self.action,
.highlight = self.highlight,
};
}
/// Check if two links are equal.
pub fn equal(self: *const Link, other: *const Link) bool {
return std.meta.eql(self.action, other.action) and
std.meta.eql(self.highlight, other.highlight) and
std.mem.eql(u8, self.regex, other.regex);
}

View File

@ -141,7 +141,7 @@ fn logFn(
// Initialize a logger. This is slow to do on every operation // Initialize a logger. This is slow to do on every operation
// but we shouldn't be logging too much. // but we shouldn't be logging too much.
const logger = macos.os.Log.create("com.mitchellh.ghostty", @tagName(scope)); const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope));
defer logger.release(); defer logger.release();
logger.log(std.heap.c_allocator, mac_level, format, args); logger.log(std.heap.c_allocator, mac_level, format, args);
} }

118
src/os/macos.zig Normal file
View File

@ -0,0 +1,118 @@
const std = @import("std");
const builtin = @import("builtin");
const build_config = @import("../build_config.zig");
const assert = std.debug.assert;
const objc = @import("objc");
const Allocator = std.mem.Allocator;
/// Verifies that the running macOS system version is at least the given version.
pub fn isAtLeastVersion(major: i64, minor: i64, patch: i64) bool {
comptime assert(builtin.target.isDarwin());
const NSProcessInfo = objc.getClass("NSProcessInfo").?;
const info = NSProcessInfo.msgSend(objc.Object, objc.sel("processInfo"), .{});
return info.msgSend(bool, objc.sel("isOperatingSystemAtLeastVersion:"), .{
NSOperatingSystemVersion{ .major = major, .minor = minor, .patch = patch },
});
}
pub const AppSupportDirError = Allocator.Error || error{AppleAPIFailed};
/// Return the path to the application support directory for Ghostty
/// with the given sub path joined. This allocates the result using the
/// given allocator.
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"),
.{},
);
const url = manager.msgSend(
objc.Object,
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
.{
NSSearchPathDirectory.NSApplicationSupportDirectory,
NSSearchPathDomainMask.NSUserDomainMask,
@as(?*anyopaque, null),
true,
@as(?*anyopaque, null),
},
);
// 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{
// The thread can't have its QoS class changed usually because
// a different pthread API was called that makes it an invalid
// target.
ThreadIncompatible,
};
/// Set the QoS class of the running thread.
///
/// https://developer.apple.com/documentation/apple-silicon/tuning-your-code-s-performance-for-apple-silicon?preferredLanguage=occ
pub fn setQosClass(class: QosClass) !void {
return switch (std.posix.errno(pthread_set_qos_class_self_np(
class,
0,
))) {
.SUCCESS => {},
.PERM => error.ThreadIncompatible,
// EPERM is the only known error that can happen based on
// the man pages for pthread_set_qos_class_self_np. I haven't
// checked the XNU source code to see if there are other
// possible errors.
else => @panic("unexpected pthread_set_qos_class_self_np error"),
};
}
/// https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/PrioritizeWorkAtTheTaskLevel.html#//apple_ref/doc/uid/TP40013929-CH35-SW1
pub const QosClass = enum(c_uint) {
user_interactive = 0x21,
user_initiated = 0x19,
default = 0x15,
utility = 0x11,
background = 0x09,
unspecified = 0x00,
};
extern "c" fn pthread_set_qos_class_self_np(
qos_class: QosClass,
relative_priority: c_int,
) c_int;
pub const NSOperatingSystemVersion = extern struct {
major: i64,
minor: i64,
patch: i64,
};
pub const NSSearchPathDirectory = enum(c_ulong) {
NSApplicationSupportDirectory = 14,
};
pub const NSSearchPathDomainMask = enum(c_ulong) {
NSUserDomainMask = 1,
};

View File

@ -1,21 +0,0 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const objc = @import("objc");
/// Verifies that the running macOS system version is at least the given version.
pub fn macosVersionAtLeast(major: i64, minor: i64, patch: i64) bool {
assert(builtin.target.isDarwin());
const NSProcessInfo = objc.getClass("NSProcessInfo").?;
const info = NSProcessInfo.msgSend(objc.Object, objc.sel("processInfo"), .{});
return info.msgSend(bool, objc.sel("isOperatingSystemAtLeastVersion:"), .{
NSOperatingSystemVersion{ .major = major, .minor = minor, .patch = patch },
});
}
pub const NSOperatingSystemVersion = extern struct {
major: i64,
minor: i64,
patch: i64,
};

View File

@ -8,7 +8,6 @@ const file = @import("file.zig");
const flatpak = @import("flatpak.zig"); const flatpak = @import("flatpak.zig");
const homedir = @import("homedir.zig"); const homedir = @import("homedir.zig");
const locale = @import("locale.zig"); const locale = @import("locale.zig");
const macos_version = @import("macos_version.zig");
const mouse = @import("mouse.zig"); const mouse = @import("mouse.zig");
const openpkg = @import("open.zig"); const openpkg = @import("open.zig");
const pipepkg = @import("pipe.zig"); const pipepkg = @import("pipe.zig");
@ -21,6 +20,7 @@ pub const hostname = @import("hostname.zig");
pub const passwd = @import("passwd.zig"); pub const passwd = @import("passwd.zig");
pub const xdg = @import("xdg.zig"); pub const xdg = @import("xdg.zig");
pub const windows = @import("windows.zig"); pub const windows = @import("windows.zig");
pub const macos = @import("macos.zig");
// Functions and types // Functions and types
pub const CFReleaseThread = @import("cf_release_thread.zig"); pub const CFReleaseThread = @import("cf_release_thread.zig");
@ -37,7 +37,6 @@ pub const freeTmpDir = file.freeTmpDir;
pub const isFlatpak = flatpak.isFlatpak; pub const isFlatpak = flatpak.isFlatpak;
pub const home = homedir.home; pub const home = homedir.home;
pub const ensureLocale = locale.ensureLocale; pub const ensureLocale = locale.ensureLocale;
pub const macosVersionAtLeast = macos_version.macosVersionAtLeast;
pub const clickInterval = mouse.clickInterval; pub const clickInterval = mouse.clickInterval;
pub const open = openpkg.open; pub const open = openpkg.open;
pub const pipe = pipepkg.pipe; pub const pipe = pipepkg.pipe;

View File

@ -4,8 +4,10 @@ pub const Thread = @This();
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const assert = std.debug.assert;
const xev = @import("xev"); const xev = @import("xev");
const crash = @import("../crash/main.zig"); const crash = @import("../crash/main.zig");
const internal_os = @import("../os/main.zig");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig"); const configpkg = @import("../config.zig");
@ -92,6 +94,10 @@ flags: packed struct {
/// This is true when the view is visible. This is used to determine /// This is true when the view is visible. This is used to determine
/// if we should be rendering or not. /// if we should be rendering or not.
visible: bool = true, visible: bool = true,
/// This is true when the view is focused. This defaults to true
/// and it is up to the apprt to set the correct value.
focused: bool = true,
} = .{}, } = .{},
pub const DerivedConfig = struct { pub const DerivedConfig = struct {
@ -199,6 +205,9 @@ fn threadMain_(self: *Thread) !void {
}; };
defer crash.sentry.thread_state = null; defer crash.sentry.thread_state = null;
// Setup our thread QoS
self.setQosClass();
// Run our loop start/end callbacks if the renderer cares. // Run our loop start/end callbacks if the renderer cares.
const has_loop = @hasDecl(renderer.Renderer, "loopEnter"); const has_loop = @hasDecl(renderer.Renderer, "loopEnter");
if (has_loop) try self.renderer.loopEnter(self); if (has_loop) try self.renderer.loopEnter(self);
@ -237,6 +246,36 @@ fn threadMain_(self: *Thread) !void {
_ = try self.loop.run(.until_done); _ = try self.loop.run(.until_done);
} }
fn setQosClass(self: *const Thread) void {
// Thread QoS classes are only relevant on macOS.
if (comptime !builtin.target.isDarwin()) return;
const class: internal_os.macos.QosClass = class: {
// If we aren't visible (our view is fully occluded) then we
// always drop our rendering priority down because it's just
// mostly wasted work.
//
// The renderer itself should be doing this as well (for example
// Metal will stop our DisplayLink) but this also helps with
// general forced updates and CPU usage i.e. a rebuild cells call.
if (!self.flags.visible) break :class .utility;
// If we're not focused, but we're visible, then we set a higher
// than default priority because framerates still matter but it isn't
// as important as when we're focused.
if (!self.flags.focused) break :class .user_initiated;
// We are focused and visible, we are the definition of user interactive.
break :class .user_interactive;
};
if (internal_os.macos.setQosClass(class)) {
log.debug("thread QoS class set class={}", .{class});
} else |err| {
log.warn("error setting QoS class err={}", .{err});
}
}
fn startDrawTimer(self: *Thread) void { fn startDrawTimer(self: *Thread) void {
// If our renderer doesn't support animations then we never run this. // If our renderer doesn't support animations then we never run this.
if (!@hasDecl(renderer.Renderer, "hasAnimations")) return; if (!@hasDecl(renderer.Renderer, "hasAnimations")) return;
@ -273,10 +312,16 @@ fn drainMailbox(self: *Thread) !void {
switch (message) { switch (message) {
.crash => @panic("crash request, crashing intentionally"), .crash => @panic("crash request, crashing intentionally"),
.visible => |v| { .visible => |v| visible: {
// If our state didn't change we do nothing.
if (self.flags.visible == v) break :visible;
// Set our visible state // Set our visible state
self.flags.visible = v; self.flags.visible = v;
// Visibility affects our QoS class
self.setQosClass();
// If we became visible then we immediately trigger a draw. // If we became visible then we immediately trigger a draw.
// We don't need to update frame data because that should // We don't need to update frame data because that should
// still be happening. // still be happening.
@ -293,7 +338,16 @@ fn drainMailbox(self: *Thread) !void {
// check the visible state themselves to control their behavior. // check the visible state themselves to control their behavior.
}, },
.focus => |v| { .focus => |v| focus: {
// If our state didn't change we do nothing.
if (self.flags.focused == v) break :focus;
// Set our state
self.flags.focused = v;
// Focus affects our QoS class
self.setQosClass();
// Set it on the renderer // Set it on the renderer
try self.renderer.setFocus(v); try self.renderer.setFocus(v);

View File

@ -330,6 +330,96 @@ pub fn deinit(self: *PageList) void {
} }
} }
/// Reset the PageList back to an empty state. This is similar to
/// deinit and reinit but it importantly preserves the pointer
/// stability of tracked pins (they're moved to the top-left since
/// all contents are cleared).
///
/// This can't fail because we always retain at least enough allocated
/// memory to fit the active area.
pub fn reset(self: *PageList) void {
// We need enough pages/nodes to keep our active area. This should
// never fail since we by definition have allocated a page already
// that fits our size but I'm not confident to make that assertion.
const cap = std_capacity.adjust(
.{ .cols = self.cols },
) catch @panic("reset: std_capacity.adjust failed");
assert(cap.rows > 0); // adjust should never return 0 rows
// The number of pages we need is the number of rows in the active
// area divided by the row capacity of a page.
const page_count = std.math.divCeil(
usize,
self.rows,
cap.rows,
) catch unreachable;
// Before resetting our pools we need to free any pages that
// are non-standard size since those were allocated outside
// the pool.
{
const page_alloc = self.pool.pages.arena.child_allocator;
var it = self.pages.first;
while (it) |node| : (it = node.next) {
if (node.data.memory.len > std_size) {
page_alloc.free(node.data.memory);
}
}
}
// Reset our pools to free as much memory as possible while retaining
// the capacity for at least the minimum number of pages we need.
// The return value is whether memory was reclaimed or not, but in
// either case the pool is left in a valid state.
_ = self.pool.pages.reset(.{
.retain_with_limit = page_count * PagePool.item_size,
});
_ = self.pool.nodes.reset(.{
.retain_with_limit = page_count * NodePool.item_size,
});
// Our page pool relies on mmap to zero our page memory. Since we're
// retaining a certain amount of memory, it won't use mmap and won't
// be zeroed. This block zeroes out all the memory in the pool arena.
{
// Note: we only have to do this for the page pool because the
// nodes are always fully overwritten on each allocation.
const page_arena = &self.pool.pages.arena;
var it = page_arena.state.buffer_list.first;
while (it) |node| : (it = node.next) {
// The fully allocated buffer
const alloc_buf = @as([*]u8, @ptrCast(node))[0..node.data];
// The buffer minus our header
const BufNode = @TypeOf(page_arena.state.buffer_list).Node;
const data_buf = alloc_buf[@sizeOf(BufNode)..];
@memset(data_buf, 0);
}
}
// Initialize our pages. This should not be able to fail since
// we retained the capacity for the minimum number of pages we need.
self.pages, self.page_size = initPages(
&self.pool,
self.cols,
self.rows,
) catch @panic("initPages failed");
// Update all our tracked pins to point to our first page top-left
{
var it = self.tracked_pins.iterator();
while (it.next()) |entry| {
const p: *Pin = entry.key_ptr.*;
p.node = self.pages.first.?;
p.x = 0;
p.y = 0;
}
}
// Move our viewport back to the active area since everything is gone.
self.viewport = .active;
}
pub const Clone = struct { pub const Clone = struct {
/// The top and bottom (inclusive) points of the region to clone. /// The top and bottom (inclusive) points of the region to clone.
/// The x coordinate is ignored; the full row is always cloned. /// The x coordinate is ignored; the full row is always cloned.
@ -2356,7 +2446,11 @@ pub fn countTrackedPins(self: *const PageList) usize {
/// Checks if a pin is valid for this pagelist. This is a very slow and /// Checks if a pin is valid for this pagelist. This is a very slow and
/// expensive operation since we traverse the entire linked list in the /// expensive operation since we traverse the entire linked list in the
/// worst case. Only for runtime safety/debug. /// worst case. Only for runtime safety/debug.
fn pinIsValid(self: *const PageList, p: Pin) bool { pub fn pinIsValid(self: *const PageList, p: Pin) bool {
// This is very slow so we want to ensure we only ever
// call this during slow runtime safety builds.
comptime assert(build_config.slow_runtime_safety);
var it = self.pages.first; var it = self.pages.first;
while (it) |node| : (it = node.next) { while (it) |node| : (it = node.next) {
if (node != p.node) continue; if (node != p.node) continue;
@ -2450,6 +2544,50 @@ pub fn getCell(self: *const PageList, pt: point.Point) ?Cell {
}; };
} }
pub const EncodeUtf8Options = struct {
/// The start and end points of the dump, both inclusive. The x will
/// be ignored and the full row will always be dumped.
tl: Pin,
br: ?Pin = null,
/// If true, this will unwrap soft-wrapped lines. If false, this will
/// dump the screen as it is visually seen in a rendered window.
unwrap: bool = true,
/// See Page.EncodeUtf8Options.
cell_map: ?*Page.CellMap = null,
};
/// Encode the pagelist to utf8 to the given writer.
///
/// The writer should be buffered; this function does not attempt to
/// efficiently write and often writes one byte at a time.
///
/// Note: this is tested using Screen.dumpString. This is a function that
/// predates this and is a thin wrapper around it so the tests all live there.
pub fn encodeUtf8(
self: *const PageList,
writer: anytype,
opts: EncodeUtf8Options,
) anyerror!void {
// We don't currently use self at all. There is an argument that this
// function should live on Pin instead but there is some future we might
// need state on here so... letting it go.
_ = self;
var page_opts: Page.EncodeUtf8Options = .{
.unwrap = opts.unwrap,
.cell_map = opts.cell_map,
};
var iter = opts.tl.pageIterator(.right_down, opts.br);
while (iter.next()) |chunk| {
const page: *const Page = &chunk.node.data;
page_opts.start_y = chunk.start;
page_opts.end_y = chunk.end;
page_opts.preceding = try page.encodeUtf8(writer, page_opts);
}
}
/// Log a debug diagram of the page list to the provided writer. /// Log a debug diagram of the page list to the provided writer.
/// ///
/// EXAMPLE: /// EXAMPLE:
@ -8191,3 +8329,66 @@ test "PageList resize reflow wrap moves kitty placeholder" {
} }
try testing.expect(it.next() == null); try testing.expect(it.next() == null);
} }
test "PageList reset" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 80, 24, null);
defer s.deinit();
s.reset();
try testing.expect(s.viewport == .active);
try testing.expect(s.pages.first != null);
try testing.expectEqual(@as(usize, s.rows), s.totalRows());
// Active area should be the top
try testing.expectEqual(Pin{
.node = s.pages.first.?,
.y = 0,
.x = 0,
}, s.getTopLeft(.active));
}
test "PageList reset across two pages" {
const testing = std.testing;
const alloc = testing.allocator;
// Find a cap that makes it so that rows don't fit on one page.
const rows = 100;
const cap = cap: {
var cap = try std_capacity.adjust(.{ .cols = 50 });
while (cap.rows >= rows) cap = try std_capacity.adjust(.{
.cols = cap.cols + 50,
});
break :cap cap;
};
// Init
var s = try init(alloc, cap.cols, rows, null);
defer s.deinit();
s.reset();
try testing.expect(s.viewport == .active);
try testing.expect(s.pages.first != null);
try testing.expectEqual(@as(usize, s.rows), s.totalRows());
}
test "PageList clears history" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 80, 24, null);
defer s.deinit();
try s.growRows(30);
s.reset();
try testing.expect(s.viewport == .active);
try testing.expect(s.pages.first != null);
try testing.expectEqual(@as(usize, s.rows), s.totalRows());
// Active area should be the top
try testing.expectEqual(Pin{
.node = s.pages.first.?,
.y = 0,
.x = 0,
}, s.getTopLeft(.active));
}

View File

@ -83,8 +83,8 @@ pub const Dirty = packed struct {
/// The cursor position and style. /// The cursor position and style.
pub const Cursor = struct { pub const Cursor = struct {
// The x/y position within the viewport. // The x/y position within the viewport.
x: size.CellCountInt, x: size.CellCountInt = 0,
y: size.CellCountInt, y: size.CellCountInt = 0,
/// The visual style of the cursor. This defaults to block because /// The visual style of the cursor. This defaults to block because
/// it has to default to something, but users of this struct are /// it has to default to something, but users of this struct are
@ -249,6 +249,50 @@ pub fn assertIntegrity(self: *const Screen) void {
} }
} }
/// Reset the screen according to the logic of a DEC RIS sequence.
///
/// - Clears the screen and attempts to reclaim memory.
/// - Moves the cursor to the top-left.
/// - Clears any cursor state: style, hyperlink, etc.
/// - Resets the charset
/// - Clears the selection
/// - Deletes all Kitty graphics
/// - Resets Kitty Keyboard settings
/// - Disables protection mode
///
pub fn reset(self: *Screen) void {
// Reset our pages
self.pages.reset();
// The above reset preserves tracked pins so we can still use
// our cursor pin, which should be at the top-left already.
const cursor_pin: *PageList.Pin = self.cursor.page_pin;
assert(cursor_pin.node == self.pages.pages.first.?);
assert(cursor_pin.x == 0);
assert(cursor_pin.y == 0);
const cursor_rac = cursor_pin.rowAndCell();
self.cursor.deinit(self.alloc);
self.cursor = .{
.page_pin = cursor_pin,
.page_row = cursor_rac.row,
.page_cell = cursor_rac.cell,
};
// Clear kitty graphics
self.kitty_images.delete(
self.alloc,
undefined, // All image deletion doesn't need the terminal
.{ .all = true },
);
// Reset our basic state
self.saved_cursor = null;
self.charset = .{};
self.kitty_keyboard = .{};
self.protected_mode = .off;
self.clearSelection();
}
/// Clone the screen. /// Clone the screen.
/// ///
/// This will copy: /// This will copy:
@ -2687,95 +2731,15 @@ pub fn promptPath(
return .{ .x = to_x - from_x, .y = to_y - from_y }; return .{ .x = to_x - from_x, .y = to_y - from_y };
} }
pub const DumpString = struct {
/// The start and end points of the dump, both inclusive. The x will
/// be ignored and the full row will always be dumped.
tl: Pin,
br: ?Pin = null,
/// If true, this will unwrap soft-wrapped lines. If false, this will
/// dump the screen as it is visually seen in a rendered window.
unwrap: bool = true,
};
/// Dump the screen to a string. The writer given should be buffered; /// Dump the screen to a string. The writer given should be buffered;
/// this function does not attempt to efficiently write and generally writes /// this function does not attempt to efficiently write and generally writes
/// one byte at a time. /// one byte at a time.
pub fn dumpString( pub fn dumpString(
self: *const Screen, self: *const Screen,
writer: anytype, writer: anytype,
opts: DumpString, opts: PageList.EncodeUtf8Options,
) !void { ) anyerror!void {
var blank_rows: usize = 0; try self.pages.encodeUtf8(writer, opts);
var blank_cells: usize = 0;
var iter = opts.tl.rowIterator(.right_down, opts.br);
while (iter.next()) |row_offset| {
const rac = row_offset.rowAndCell();
const row = rac.row;
const cells = cells: {
const cells: [*]pagepkg.Cell = @ptrCast(rac.cell);
break :cells cells[0..self.pages.cols];
};
if (!pagepkg.Cell.hasTextAny(cells)) {
blank_rows += 1;
continue;
}
if (blank_rows > 0) {
for (0..blank_rows) |_| try writer.writeByte('\n');
blank_rows = 0;
}
if (!row.wrap or !opts.unwrap) {
// If we're not wrapped, we always add a newline.
// If we are wrapped, we only add a new line if we're unwrapping
// soft-wrapped lines.
blank_rows += 1;
}
if (!row.wrap_continuation or !opts.unwrap) {
// We should also reset blank cell counts at the start of each row
// unless we're unwrapping and this row is a wrap continuation.
blank_cells = 0;
}
for (cells) |*cell| {
// Skip spacers
switch (cell.wide) {
.narrow, .wide => {},
.spacer_head, .spacer_tail => continue,
}
// If we have a zero value, then we accumulate a counter. We
// only want to turn zero values into spaces if we have a non-zero
// char sometime later.
if (!cell.hasText()) {
blank_cells += 1;
continue;
}
if (blank_cells > 0) {
try writer.writeByteNTimes(' ', blank_cells);
blank_cells = 0;
}
switch (cell.content_tag) {
.codepoint => {
try writer.print("{u}", .{cell.content.codepoint});
},
.codepoint_grapheme => {
try writer.print("{u}", .{cell.content.codepoint});
const cps = row_offset.node.data.lookupGrapheme(cell).?;
for (cps) |cp| {
try writer.print("{u}", .{cp});
}
},
else => unreachable,
}
}
}
} }
/// You should use dumpString, this is a restricted version mostly for /// You should use dumpString, this is a restricted version mostly for
@ -8504,3 +8468,81 @@ test "Screen: adjustCapacity cursor style ref count" {
); );
} }
} }
test "Screen UTF8 cell map with newlines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 0);
defer s.deinit();
try s.testWriteString("A\n\nB\n\nC");
var cell_map = Page.CellMap.init(alloc);
defer cell_map.deinit();
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
try s.dumpString(builder.writer(), .{
.tl = s.pages.getTopLeft(.screen),
.br = s.pages.getBottomRight(.screen),
.cell_map = &cell_map,
});
try testing.expectEqual(7, builder.items.len);
try testing.expectEqualStrings("A\n\nB\n\nC", builder.items);
try testing.expectEqual(builder.items.len, cell_map.items.len);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 0,
}, cell_map.items[0]);
try testing.expectEqual(Page.CellMapEntry{
.x = 1,
.y = 0,
}, cell_map.items[1]);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 1,
}, cell_map.items[2]);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 2,
}, cell_map.items[3]);
}
test "Screen UTF8 cell map with blank prefix" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 0);
defer s.deinit();
s.cursorAbsolute(2, 1);
try s.testWriteString("B");
var cell_map = Page.CellMap.init(alloc);
defer cell_map.deinit();
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
try s.dumpString(builder.writer(), .{
.tl = s.pages.getTopLeft(.screen),
.br = s.pages.getBottomRight(.screen),
.cell_map = &cell_map,
});
try testing.expectEqualStrings("\n B", builder.items);
try testing.expectEqual(builder.items.len, cell_map.items.len);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 0,
}, cell_map.items[0]);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 1,
}, cell_map.items[1]);
try testing.expectEqual(Page.CellMapEntry{
.x = 1,
.y = 1,
}, cell_map.items[2]);
try testing.expectEqual(Page.CellMapEntry{
.x = 2,
.y = 1,
}, cell_map.items[3]);
}

View File

@ -193,6 +193,10 @@ pub const Options = struct {
cols: size.CellCountInt, cols: size.CellCountInt,
rows: size.CellCountInt, rows: size.CellCountInt,
max_scrollback: usize = 10_000, max_scrollback: usize = 10_000,
/// The default mode state. When the terminal gets a reset, it
/// will revert back to this state.
default_modes: modes.ModePacked = .{},
}; };
/// Initialize a new terminal. /// Initialize a new terminal.
@ -216,6 +220,10 @@ pub fn init(
.right = cols - 1, .right = cols - 1,
}, },
.pwd = std.ArrayList(u8).init(alloc), .pwd = std.ArrayList(u8).init(alloc),
.modes = .{
.values = opts.default_modes,
.default = opts.default_modes,
},
}; };
} }
@ -1955,13 +1963,9 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void {
} }
pub fn eraseChars(self: *Terminal, count_req: usize) void { pub fn eraseChars(self: *Terminal, count_req: usize) void {
const count = @max(count_req, 1); const count = end: {
// Our last index is at most the end of the number of chars we have
// in the current line.
const end = end: {
const remaining = self.cols - self.screen.cursor.x; const remaining = self.cols - self.screen.cursor.x;
var end = @min(remaining, count); var end = @min(remaining, @max(count_req, 1));
// If our last cell is a wide char then we need to also clear the // If our last cell is a wide char then we need to also clear the
// cell beyond it since we can't just split a wide char. // cell beyond it since we can't just split a wide char.
@ -1979,7 +1983,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
// protected modes. We need to figure out how to make `clearCells` or at // protected modes. We need to figure out how to make `clearCells` or at
// least `clearUnprotectedCells` handle boundary conditions... // least `clearUnprotectedCells` handle boundary conditions...
self.screen.splitCellBoundary(self.screen.cursor.x); self.screen.splitCellBoundary(self.screen.cursor.x);
self.screen.splitCellBoundary(end); self.screen.splitCellBoundary(self.screen.cursor.x + count);
// Reset our row's soft-wrap. // Reset our row's soft-wrap.
self.screen.cursorResetWrap(); self.screen.cursorResetWrap();
@ -1997,7 +2001,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
self.screen.clearCells( self.screen.clearCells(
&self.screen.cursor.page_pin.node.data, &self.screen.cursor.page_pin.node.data,
self.screen.cursor.page_row, self.screen.cursor.page_row,
cells[0..end], cells[0..count],
); );
return; return;
} }
@ -2005,7 +2009,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
self.screen.clearUnprotectedCells( self.screen.clearUnprotectedCells(
&self.screen.cursor.page_pin.node.data, &self.screen.cursor.page_pin.node.data,
self.screen.cursor.page_row, self.screen.cursor.page_row,
cells[0..end], cells[0..count],
); );
} }
@ -2623,82 +2627,38 @@ pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 {
/// Full reset. /// Full reset.
/// ///
/// This will attempt to free the existing screen memory and allocate /// This will attempt to free the existing screen memory but if that fails
/// new screens but if that fails this will reuse the existing memory /// this will reuse the existing memory. In the latter case, memory may
/// from the prior screens. In the latter case, memory may be wasted /// be wasted (since its unused) but it isn't leaked.
/// (since its unused) but it isn't leaked.
pub fn fullReset(self: *Terminal) void { pub fn fullReset(self: *Terminal) void {
// Attempt to initialize new screens. // Reset our screens
var new_primary = Screen.init( self.screen.reset();
self.screen.alloc, self.secondary_screen.reset();
self.cols,
self.rows,
self.screen.pages.explicit_max_size,
) catch |err| {
log.warn("failed to allocate new primary screen, reusing old memory err={}", .{err});
self.fallbackReset();
return;
};
const new_secondary = Screen.init(
self.secondary_screen.alloc,
self.cols,
self.rows,
0,
) catch |err| {
log.warn("failed to allocate new secondary screen, reusing old memory err={}", .{err});
new_primary.deinit();
self.fallbackReset();
return;
};
// If we got here, both new screens were successfully allocated // Ensure we're back on primary screen
// and we can deinitialize the old screens. if (self.active_screen != .primary) {
self.screen.deinit(); const old = self.screen;
self.secondary_screen.deinit(); self.screen = self.secondary_screen;
self.secondary_screen = old;
self.active_screen = .primary;
}
// Replace with the newly allocated screens. // Rest our basic state
self.screen = new_primary; self.modes.reset();
self.secondary_screen = new_secondary;
self.resetCommonState();
}
fn fallbackReset(self: *Terminal) void {
// Clear existing screens without reallocation
self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = false });
self.screen.clearSelection();
self.eraseDisplay(.scrollback, false);
self.eraseDisplay(.complete, false);
self.screen.cursorAbsolute(0, 0);
self.resetCommonState();
}
fn resetCommonState(self: *Terminal) void {
// We set the saved cursor to null and then restore. This will force
// our cursor to go back to the default which will also move the cursor
// to the top-left.
self.screen.saved_cursor = null;
self.restoreCursor() catch |err| {
log.warn("restore cursor on primary screen failed err={}", .{err});
};
self.screen.endHyperlink();
self.screen.charset = .{};
self.modes = .{};
self.flags = .{}; self.flags = .{};
self.tabstops.reset(TABSTOP_INTERVAL); self.tabstops.reset(TABSTOP_INTERVAL);
self.screen.kitty_keyboard = .{}; self.previous_char = null;
self.secondary_screen.kitty_keyboard = .{}; self.pwd.clearRetainingCapacity();
self.screen.protected_mode = .off; self.status_display = .main;
self.scrolling_region = .{ self.scrolling_region = .{
.top = 0, .top = 0,
.bottom = self.rows - 1, .bottom = self.rows - 1,
.left = 0, .left = 0,
.right = self.cols - 1, .right = self.cols - 1,
}; };
self.previous_char = null;
self.pwd.clearRetainingCapacity(); // Always mark dirty so we redraw everything
self.status_display = .main; self.flags.dirty.clear = true;
} }
/// Returns true if the point is dirty, used for testing. /// Returns true if the point is dirty, used for testing.
@ -6104,6 +6064,36 @@ test "Terminal: eraseChars wide char boundary conditions" {
} }
} }
test "Terminal: eraseChars wide char splits proper cell boundaries" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 1, .cols = 30 });
defer t.deinit(alloc);
// This is a test for a bug: https://github.com/ghostty-org/ghostty/issues/2817
// To explain the setup:
// (1) We need our wide characters starting on an even (1-based) column.
// (2) We need our cursor to be in the middle somewhere.
// (3) We need our count to be less than our cursor X and on a split cell.
// The bug was that we split the wrong cell boundaries.
try t.printString("x食べて下さい");
{
const str = try t.plainString(alloc);
defer testing.allocator.free(str);
try testing.expectEqualStrings("x食べて下さい", str);
}
t.setCursorPos(1, 6); // At:
t.eraseChars(4); // Delete:
t.screen.cursor.page_pin.node.data.assertIntegrity();
{
const str = try t.plainString(alloc);
defer testing.allocator.free(str);
try testing.expectEqualStrings("x食べ さい", str);
}
}
test "Terminal: eraseChars wide char wrap boundary conditions" { test "Terminal: eraseChars wide char wrap boundary conditions" {
const alloc = testing.allocator; const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 3, .cols = 8 }); var t = try init(alloc, .{ .rows = 3, .cols = 8 });
@ -10529,6 +10519,28 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" {
try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int()); try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int());
} }
test "Terminal: fullReset default modes" {
var t = try init(testing.allocator, .{
.cols = 10,
.rows = 10,
.default_modes = .{ .grapheme_cluster = true },
});
defer t.deinit(testing.allocator);
try testing.expect(t.modes.get(.grapheme_cluster));
t.fullReset();
try testing.expect(t.modes.get(.grapheme_cluster));
}
test "Terminal: fullReset tracked pins" {
var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 });
defer t.deinit(testing.allocator);
// Create a tracked pin
const p = try t.screen.pages.trackPin(t.screen.cursor.page_pin.*);
t.fullReset();
try testing.expect(t.screen.pages.pinIsValid(p.*));
}
// https://github.com/mitchellh/ghostty/issues/272 // https://github.com/mitchellh/ghostty/issues/272
// This is also tested in depth in screen resize tests but I want to keep // This is also tested in depth in screen resize tests but I want to keep
// this test around to ensure we don't regress at multiple layers. // this test around to ensure we don't regress at multiple layers.

View File

@ -164,9 +164,9 @@ fn transmit(
// If there are more chunks expected we do not respond. // If there are more chunks expected we do not respond.
if (load.more) return .{}; if (load.more) return .{};
// If our image has no ID or number, we don't respond at all. Conversely, // If the loaded image was assigned its ID automatically, not based
// if we have either an ID or number, we always respond. // on a number or explicitly specified ID, then we don't respond.
if (load.image.id == 0 and load.image.number == 0) return .{}; if (load.image.implicit_id) return .{};
// After the image is added, set the ID in case it changed. // After the image is added, set the ID in case it changed.
// The resulting image number and placement ID never change. // The resulting image number and placement ID never change.
@ -335,6 +335,10 @@ fn loadAndAddImage(
if (loading.image.id == 0) { if (loading.image.id == 0) {
loading.image.id = storage.next_image_id; loading.image.id = storage.next_image_id;
storage.next_image_id +%= 1; storage.next_image_id +%= 1;
// If the image also has no number then its auto-ID is "implicit".
// See the doc comment on the Image.implicit_id field for more detail.
if (loading.image.number == 0) loading.image.implicit_id = true;
} }
// If this is chunked, this is the beginning of a new chunked transmission. // If this is chunked, this is the beginning of a new chunked transmission.
@ -529,3 +533,21 @@ test "kittygfx test valid i32 (expect invalid image ID)" {
try testing.expect(!resp.ok()); try testing.expect(!resp.ok());
try testing.expectEqual(resp.message, "ENOENT: image not found"); try testing.expectEqual(resp.message, "ENOENT: image not found");
} }
test "kittygfx no response with no image ID or number" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
{
const cmd = try command.Parser.parseString(
alloc,
"a=t,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd);
try testing.expect(resp == null);
}
}

View File

@ -455,6 +455,12 @@ pub const Image = struct {
data: []const u8 = "", data: []const u8 = "",
transmit_time: std.time.Instant = undefined, transmit_time: std.time.Instant = undefined,
/// Set this to true if this image was loaded by a command that
/// doesn't specify an ID or number, since such commands should
/// not be responded to, even though we do currently give them
/// IDs in the public range (which is bad!).
implicit_id: bool = false,
pub const Error = error{ pub const Error = error{
InternalError, InternalError,
InvalidData, InvalidData,

View File

@ -31,6 +31,9 @@ pub const ImageStorage = struct {
/// This is the next automatically assigned image ID. We start mid-way /// This is the next automatically assigned image ID. We start mid-way
/// through the u32 range to avoid collisions with buggy programs. /// through the u32 range to avoid collisions with buggy programs.
/// TODO: This isn't good enough, it's perfectly legal for programs
/// to use IDs in the latter half of the range and collisions
/// are not gracefully handled.
next_image_id: u32 = 2147483647, next_image_id: u32 = 2147483647,
/// This is the next automatically assigned placement ID. This is never /// This is the next automatically assigned placement ID. This is never
@ -690,7 +693,7 @@ pub const ImageStorage = struct {
br.x = @min( br.x = @min(
// We need to sub one here because the x value is // We need to sub one here because the x value is
// one width already. So if the image is width "1" // one width already. So if the image is width "1"
// then we add zero to X because X itelf is width 1. // then we add zero to X because X itself is width 1.
pin.x + (grid_size.cols - 1), pin.x + (grid_size.cols - 1),
t.cols - 1, t.cols - 1,
); );

View File

@ -18,6 +18,7 @@ pub const kitty = @import("kitty.zig");
pub const modes = @import("modes.zig"); pub const modes = @import("modes.zig");
pub const page = @import("page.zig"); pub const page = @import("page.zig");
pub const parse_table = @import("parse_table.zig"); pub const parse_table = @import("parse_table.zig");
pub const search = @import("search.zig");
pub const size = @import("size.zig"); pub const size = @import("size.zig");
pub const tmux = @import("tmux.zig"); pub const tmux = @import("tmux.zig");
pub const x11_color = @import("x11_color.zig"); pub const x11_color = @import("x11_color.zig");
@ -47,6 +48,7 @@ pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = ansi.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle;
pub const DeviceAttributeReq = ansi.DeviceAttributeReq; pub const DeviceAttributeReq = ansi.DeviceAttributeReq;
pub const Mode = modes.Mode; pub const Mode = modes.Mode;
pub const ModePacked = modes.ModePacked;
pub const ModifyKeyFormat = ansi.ModifyKeyFormat; pub const ModifyKeyFormat = ansi.ModifyKeyFormat;
pub const ProtectedMode = ansi.ProtectedMode; pub const ProtectedMode = ansi.ProtectedMode;
pub const StatusLineType = ansi.StatusLineType; pub const StatusLineType = ansi.StatusLineType;

View File

@ -21,6 +21,17 @@ pub const ModeState = struct {
/// a real-world issue but we need to be aware of a DoS vector. /// a real-world issue but we need to be aware of a DoS vector.
saved: ModePacked = .{}, saved: ModePacked = .{},
/// The default values for the modes. This is used to reset
/// the modes to their default values during reset.
default: ModePacked = .{},
/// Reset the modes to their default values. This also clears the
/// saved state.
pub fn reset(self: *ModeState) void {
self.values = self.default;
self.saved = .{};
}
/// Set a mode to a value. /// Set a mode to a value.
pub fn set(self: *ModeState, mode: Mode, value: bool) void { pub fn set(self: *ModeState, mode: Mode, value: bool) void {
switch (mode) { switch (mode) {

View File

@ -1481,6 +1481,179 @@ pub const Page = struct {
return self.grapheme_map.map(self.memory).capacity(); return self.grapheme_map.map(self.memory).capacity();
} }
/// Options for encoding the page as UTF-8.
pub const EncodeUtf8Options = struct {
/// The range of rows to encode. If end_y is null, then it will
/// encode to the end of the page.
start_y: size.CellCountInt = 0,
end_y: ?size.CellCountInt = null,
/// If true, this will unwrap soft-wrapped lines. If false, this will
/// dump the screen as it is visually seen in a rendered window.
unwrap: bool = true,
/// Preceding state from encoding the prior page. Used to preserve
/// blanks properly across multiple pages.
preceding: TrailingUtf8State = .{},
/// If non-null, this will be cleared and filled with the x/y
/// coordinates of each byte in the UTF-8 encoded output.
/// The index in the array is the byte offset in the output
/// where 0 is the cursor of the writer when the function is
/// called.
cell_map: ?*CellMap = null,
/// Trailing state for UTF-8 encoding.
pub const TrailingUtf8State = struct {
rows: usize = 0,
cells: usize = 0,
};
};
/// See cell_map
pub const CellMap = std.ArrayList(CellMapEntry);
/// The x/y coordinate of a single cell in the cell map.
pub const CellMapEntry = struct {
y: size.CellCountInt,
x: size.CellCountInt,
};
/// Encode the page contents as UTF-8.
///
/// If preceding is non-null, then it will be used to initialize our
/// blank rows/cells count so that we can accumulate blanks across
/// multiple pages.
///
/// Note: Many tests for this function are done via Screen.dumpString
/// tests since that function is a thin wrapper around this one and
/// it makes it easier to test input contents.
pub fn encodeUtf8(
self: *const Page,
writer: anytype,
opts: EncodeUtf8Options,
) anyerror!EncodeUtf8Options.TrailingUtf8State {
var blank_rows: usize = opts.preceding.rows;
var blank_cells: usize = opts.preceding.cells;
const start_y: size.CellCountInt = opts.start_y;
const end_y: size.CellCountInt = opts.end_y orelse self.size.rows;
// We can probably avoid this by doing the logic below in a different
// way. The reason this exists is so that when we end a non-blank
// line with a newline, we can correctly map the cell map over to
// the correct x value.
//
// For example "A\nB". The cell map for "\n" should be (1, 0).
// This is tested in Screen.zig so feel free to refactor this.
var last_x: size.CellCountInt = 0;
for (start_y..end_y) |y_usize| {
const y: size.CellCountInt = @intCast(y_usize);
const row: *Row = self.getRow(y);
const cells: []const Cell = self.getCells(row);
// If this row is blank, accumulate to avoid a bunch of extra
// work later. If it isn't blank, make sure we dump all our
// blanks.
if (!Cell.hasTextAny(cells)) {
blank_rows += 1;
continue;
}
for (1..blank_rows + 1) |i| {
try writer.writeByte('\n');
// This is tested in Screen.zig, i.e. one test is
// "cell map with newlines"
if (opts.cell_map) |cell_map| {
try cell_map.append(.{
.x = last_x,
.y = @intCast(y - blank_rows + i - 1),
});
last_x = 0;
}
}
blank_rows = 0;
// If we're not wrapped, we always add a newline so after
// the row is printed we can add a newline.
if (!row.wrap or !opts.unwrap) blank_rows += 1;
// If the row doesn't continue a wrap then we need to reset
// our blank cell count.
if (!row.wrap_continuation or !opts.unwrap) blank_cells = 0;
// Go through each cell and print it
for (cells, 0..) |*cell, x_usize| {
const x: size.CellCountInt = @intCast(x_usize);
// Skip spacers
switch (cell.wide) {
.narrow, .wide => {},
.spacer_head, .spacer_tail => continue,
}
// If we have a zero value, then we accumulate a counter. We
// only want to turn zero values into spaces if we have a non-zero
// char sometime later.
if (!cell.hasText()) {
blank_cells += 1;
continue;
}
if (blank_cells > 0) {
try writer.writeByteNTimes(' ', blank_cells);
if (opts.cell_map) |cell_map| {
for (0..blank_cells) |i| try cell_map.append(.{
.x = @intCast(x - blank_cells + i),
.y = y,
});
}
blank_cells = 0;
}
switch (cell.content_tag) {
.codepoint => {
try writer.print("{u}", .{cell.content.codepoint});
if (opts.cell_map) |cell_map| {
last_x = x + 1;
try cell_map.append(.{
.x = x,
.y = y,
});
}
},
.codepoint_grapheme => {
try writer.print("{u}", .{cell.content.codepoint});
if (opts.cell_map) |cell_map| {
last_x = x + 1;
try cell_map.append(.{
.x = x,
.y = y,
});
}
for (self.lookupGrapheme(cell).?) |cp| {
try writer.print("{u}", .{cp});
if (opts.cell_map) |cell_map| try cell_map.append(.{
.x = x,
.y = y,
});
}
},
// Unreachable since we do hasText() above
.bg_color_palette,
.bg_color_rgb,
=> unreachable,
}
}
}
return .{ .rows = blank_rows, .cells = blank_cells };
}
/// Returns the bitset for the dirty bits on this page. /// Returns the bitset for the dirty bits on this page.
/// ///
/// The returned value is a DynamicBitSetUnmanaged but it is NOT /// The returned value is a DynamicBitSetUnmanaged but it is NOT

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