Merge branch 'ghostty-org:main' into main
41
README.md
@ -1,7 +1,7 @@
|
||||
<!-- LOGO -->
|
||||
<h1>
|
||||
<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
|
||||
</h1>
|
||||
<p align="center">
|
||||
@ -107,25 +107,40 @@ palette = 7=#a89984
|
||||
palette = 15=#fbf1c7
|
||||
```
|
||||
|
||||
You can view all available configuration options and their documentation
|
||||
by executing the command `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.
|
||||
#### Configuration Documentation
|
||||
|
||||
There are multiple places to find documentation on the configuration options.
|
||||
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]
|
||||
>
|
||||
> 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
|
||||
> `+show-config` outputs it so it's clear that key is defaulting and also
|
||||
> 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]
|
||||
>
|
||||
> Configuration can be reloaded on the fly with the `reload_config`
|
||||
|
21
build.zig
@ -573,17 +573,20 @@ pub fn build(b: *std.Build) !void {
|
||||
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
|
||||
// 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_32x32.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_256x256.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_16x16@2x@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_128x128@2x@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_16.png", "share/icons/hicolor/16x16/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_128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_16@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_32@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_128@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_256@2x.png", "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png");
|
||||
}
|
||||
|
||||
// libghostty (non-Darwin)
|
||||
|
@ -5,8 +5,8 @@
|
||||
.dependencies = .{
|
||||
// Zig libs
|
||||
.libxev = .{
|
||||
.url = "https://github.com/mitchellh/libxev/archive/b8d1d93e5c899b27abbaa7df23b496c3e6a178c7.tar.gz",
|
||||
.hash = "1220612bc023c21d75234882ec9a8c6a1cbd9d642da3dfb899297f14bb5bd7b6cd78",
|
||||
.url = "https://github.com/mitchellh/libxev/archive/db6a52bafadf00360e675fefa7926e8e6c0e9931.tar.gz",
|
||||
.hash = "12206029de146b685739f69b10a6f08baee86b3d0a5f9a659fa2b2b66c9602078bbf",
|
||||
},
|
||||
.mach_glfw = .{
|
||||
.url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz",
|
||||
|
11
dist/linux/ghostty_dolphin.desktop
vendored
Executable 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
BIN
dist/windows/ghostty.ico
vendored
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 84 KiB |
14
flake.lock
generated
@ -20,27 +20,27 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1726062281,
|
||||
"narHash": "sha256-PyFVySdGj3enKqm8RQuo4v1KLJLmNLOq2yYOHsI6e2Q=",
|
||||
"lastModified": 1733423277,
|
||||
"narHash": "sha256-TxabjxEgkNbCGFRHgM/b9yZWlBj60gUOUnRT/wbVQR8=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e65aa8301ba4f0ab8cb98f944c14aa9da07394f8",
|
||||
"rev": "e36963a147267afc055f7cf65225958633e536bf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "release-24.05",
|
||||
"ref": "release-24.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1719082008,
|
||||
"narHash": "sha256-jHJSUH619zBQ6WdC21fFAlDxHErKVDJ5fpN0Hgx4sjs=",
|
||||
"lastModified": 1733229606,
|
||||
"narHash": "sha256-FLYY5M0rpa5C2QAE3CKLYAM6TwbKicdRK6qNrSHlNrE=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9693852a2070b398ee123a329e68f0dab5526681",
|
||||
"rev": "566e53c2ad750c84f6d31f9ccb9d00f823165550",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -7,7 +7,7 @@
|
||||
# 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
|
||||
# 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 = {
|
||||
url = "github:mitchellh/zig-overlay";
|
||||
@ -36,7 +36,6 @@
|
||||
|
||||
packages.${system} = let
|
||||
mkArgs = optimize: {
|
||||
inherit (pkgs-unstable) zig_0_13 stdenv;
|
||||
inherit optimize;
|
||||
|
||||
revision = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
|
BIN
images/icons/icon_1024.png
Normal file
After Width: | Height: | Size: 454 KiB |
BIN
images/icons/icon_128.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
images/icons/icon_128@2x.png
Normal file
After Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 18 KiB |
BIN
images/icons/icon_16.png
Normal file
After Width: | Height: | Size: 666 B |
BIN
images/icons/icon_16@2x.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 649 B |
Before Width: | Height: | Size: 1.5 KiB |
BIN
images/icons/icon_256.png
Normal file
After Width: | Height: | Size: 67 KiB |
BIN
images/icons/icon_256@2x.png
Normal file
After Width: | Height: | Size: 216 KiB |
Before Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 40 KiB |
BIN
images/icons/icon_32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
images/icons/icon_32@2x.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 3.0 KiB |
BIN
images/icons/icon_512.png
Normal file
After Width: | Height: | Size: 216 KiB |
Before Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 93 KiB |
@ -1,67 +1,67 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_512x512@2x@2x 1.png",
|
||||
"filename" : "macOS-AppIcon-1024px.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_16x16.png",
|
||||
"filename" : "macOS-AppIcon-16px-16pt@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_16x16@2x@2x.png",
|
||||
"filename" : "macOS-AppIcon-32px-16pt@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32.png",
|
||||
"filename" : "macOS-AppIcon-32px-32pt@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32@2x@2x.png",
|
||||
"filename" : "macOS-AppIcon-64px-32pt@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128.png",
|
||||
"filename" : "macOS-AppIcon-128px-128pt@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128@2x@2x.png",
|
||||
"filename" : "macOS-AppIcon-256px-128pt@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256.png",
|
||||
"filename" : "macOS-AppIcon-256px-128pt@2x 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256@2x@2x.png",
|
||||
"filename" : "macOS-AppIcon-512px-256pt@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512.png",
|
||||
"filename" : "macOS-AppIcon-512px.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512@2x@2x.png",
|
||||
"filename" : "macOS-AppIcon-1024px 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
Before Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 582 B |
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 93 KiB |
Before Width: | Height: | Size: 93 KiB |
After Width: | Height: | Size: 454 KiB |
After Width: | Height: | Size: 454 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 666 B |
After Width: | Height: | Size: 67 KiB |
After Width: | Height: | Size: 67 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 216 KiB |
BIN
macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png
Normal file
After Width: | Height: | Size: 216 KiB |
After Width: | Height: | Size: 4.4 KiB |
@ -1,17 +1,17 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_128x128.png",
|
||||
"filename" : "macOS-AppIcon-256px-128pt@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128@2x@2x.png",
|
||||
"filename" : "macOS-AppIcon-512px.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256@2x@2x.png",
|
||||
"filename" : "macOS-AppIcon-1024px.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 40 KiB |
BIN
macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png
vendored
Normal file
After Width: | Height: | Size: 454 KiB |
BIN
macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-256px-128pt@2x.png
vendored
Normal file
After Width: | Height: | Size: 67 KiB |
BIN
macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-512px.png
vendored
Normal file
After Width: | Height: | Size: 216 KiB |
@ -57,6 +57,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
// MARK: NSWindowController
|
||||
|
||||
override func windowDidLoad() {
|
||||
super.windowDidLoad()
|
||||
guard let window = self.window else { return }
|
||||
|
||||
// The controller is the window delegate so we can detect events such as
|
||||
|
@ -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()
|
||||
// is called directly). performClose is called primarily when UI elements such
|
||||
|
@ -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
|
||||
|
||||
@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 (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
|
||||
|
||||
// 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() {
|
||||
super.windowDidLoad()
|
||||
guard let window = window as? TerminalWindow else { return }
|
||||
|
||||
// I copy this because we may change the source in the future but also because
|
||||
|
@ -5,10 +5,7 @@ class TerminalWindow: NSWindow {
|
||||
|
||||
lazy var titlebarColor: NSColor = backgroundColor {
|
||||
didSet {
|
||||
guard let titlebarContainer = contentView?.superview?.subviews.first(where: {
|
||||
$0.className == "NSTitlebarContainerView"
|
||||
}) else { return }
|
||||
|
||||
guard let titlebarContainer else { return }
|
||||
titlebarContainer.wantsLayer = true
|
||||
titlebarContainer.layer?.backgroundColor = titlebarColor.cgColor
|
||||
}
|
||||
@ -68,6 +65,48 @@ class TerminalWindow: NSWindow {
|
||||
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
|
||||
|
||||
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
|
||||
// a compositing effect to the unselected tab backgrounds, which makes them blend with the
|
||||
// titlebar's/window's background.
|
||||
if let titlebarContainer = contentView?.superview?.subviews.first(where: {
|
||||
$0.className == "NSTitlebarContainerView"
|
||||
}), let effectView = titlebarContainer.descendants(withClassName: "NSVisualEffectView").first {
|
||||
if let effectView = titlebarContainer?.descendants(
|
||||
withClassName: "NSVisualEffectView").first {
|
||||
effectView.isHidden = titlebarTabs || !titlebarTabs && !hasVeryDarkBackground
|
||||
}
|
||||
|
||||
@ -223,10 +261,7 @@ class TerminalWindow: NSWindow {
|
||||
// window's key status changes in terms of becoming less prominent visually,
|
||||
// so we need to do it manually.
|
||||
private func updateNewTabButtonOpacity() {
|
||||
guard let titlebarContainer = contentView?.superview?.subviews.first(where: {
|
||||
$0.className == "NSTitlebarContainerView"
|
||||
}) else { return }
|
||||
guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
|
||||
guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
|
||||
guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: {
|
||||
$0 as? NSImageView != nil
|
||||
}) 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,
|
||||
// just as it does in the stock tab bar.
|
||||
private func updateNewTabButtonImage() {
|
||||
guard let titlebarContainer = contentView?.superview?.subviews.first(where: {
|
||||
$0.className == "NSTitlebarContainerView"
|
||||
}) else { return }
|
||||
guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
|
||||
guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
|
||||
guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: {
|
||||
$0 as? NSImageView != nil
|
||||
}) as? NSImageView else { return }
|
||||
@ -272,10 +304,7 @@ class TerminalWindow: NSWindow {
|
||||
|
||||
private func updateTabsForVeryDarkBackgrounds() {
|
||||
guard hasVeryDarkBackground else { return }
|
||||
|
||||
guard let titlebarContainer = contentView?.superview?.subviews.first(where: {
|
||||
$0.className == "NSTitlebarContainerView"
|
||||
}) else { return }
|
||||
guard let titlebarContainer else { return }
|
||||
|
||||
if let tabGroup = tabGroup, tabGroup.isTabBarVisible {
|
||||
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? = {
|
||||
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 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.
|
||||
private var titlebarTextField: NSTextField? {
|
||||
guard let titlebarContainer = contentView?.superview?.subviews
|
||||
.first(where: { $0.className == "NSTitlebarContainerView" }) else { return nil }
|
||||
guard let titlebarView = titlebarContainer.subviews
|
||||
guard let titlebarView = titlebarContainer?.subviews
|
||||
.first(where: { $0.className == "NSTitlebarView" }) else { return nil }
|
||||
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
|
||||
// of an aesthetically unpleasing shadow.
|
||||
private func hideTitleBarSeparators() {
|
||||
guard let titlebarContainer = contentView?.superview?.subviews.first(where: {
|
||||
$0.className == "NSTitlebarContainerView"
|
||||
}) else { return }
|
||||
|
||||
guard let titlebarContainer else { return }
|
||||
for v in titlebarContainer.descendants(withClassName: "NSTitlebarSeparatorView") {
|
||||
v.isHidden = true
|
||||
}
|
||||
|
@ -50,7 +50,8 @@ extension Ghostty {
|
||||
}
|
||||
|
||||
case GHOSTTY_TRIGGER_UNICODE:
|
||||
equiv = String(trigger.key.unicode)
|
||||
guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }
|
||||
equiv = String(scalar)
|
||||
|
||||
default:
|
||||
return nil
|
||||
|
@ -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
|
||||
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal
|
||||
/// that should have it.
|
||||
static func moveFocus(to: SurfaceView, from: SurfaceView? = nil) {
|
||||
DispatchQueue.main.async {
|
||||
static func moveFocus(
|
||||
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
|
||||
// then the window will be nil. We just reschedule in that case.
|
||||
guard let window = to.window else {
|
||||
moveFocus(to: to, from: from)
|
||||
moveFocus(to: to, from: from, delay: nextDelay)
|
||||
return
|
||||
}
|
||||
|
||||
@ -448,5 +470,12 @@ extension Ghostty {
|
||||
|
||||
window.makeFirstResponder(to)
|
||||
}
|
||||
|
||||
let queue = DispatchQueue.main
|
||||
if let delay {
|
||||
queue.asyncAfter(deadline: .now() + delay, execute: work)
|
||||
} else {
|
||||
queue.async(execute: work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -45,20 +45,53 @@ extension FullscreenDelegate {
|
||||
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
|
||||
/// button on regular titlebars.
|
||||
class NativeFullscreen: FullscreenStyle {
|
||||
private let window: NSWindow
|
||||
|
||||
weak var delegate: FullscreenDelegate?
|
||||
class NativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
var isFullscreen: Bool { window.styleMask.contains(.fullScreen) }
|
||||
var supportsTabs: Bool { true }
|
||||
|
||||
required init?(_ window: NSWindow) {
|
||||
// TODO: There are many requirements for native fullscreen we should
|
||||
// check here such as the stylemask.
|
||||
|
||||
self.window = window
|
||||
super.init(window)
|
||||
}
|
||||
|
||||
func enter() {
|
||||
@ -72,8 +105,9 @@ class NativeFullscreen: FullscreenStyle {
|
||||
// Enter fullscreen
|
||||
window.toggleFullScreen(self)
|
||||
|
||||
// Notify the delegate
|
||||
delegate?.fullscreenDidChange()
|
||||
// Note: we don't call our delegate here because the base class
|
||||
// will always trigger the delegate on native fullscreen notifications
|
||||
// and we don't want to double notify.
|
||||
}
|
||||
|
||||
func exit() {
|
||||
@ -84,14 +118,13 @@ class NativeFullscreen: FullscreenStyle {
|
||||
|
||||
window.toggleFullScreen(nil)
|
||||
|
||||
// Notify the delegate
|
||||
delegate?.fullscreenDidChange()
|
||||
// Note: we don't call our delegate here because the base class
|
||||
// will always trigger the delegate on native fullscreen notifications
|
||||
// and we don't want to double notify.
|
||||
}
|
||||
}
|
||||
|
||||
class NonNativeFullscreen: FullscreenStyle {
|
||||
weak var delegate: FullscreenDelegate?
|
||||
|
||||
class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
// Non-native fullscreen never supports tabs because tabs require
|
||||
// the "titled" style and we don't have it for non-native fullscreen.
|
||||
var supportsTabs: Bool { false }
|
||||
@ -110,13 +143,8 @@ class NonNativeFullscreen: FullscreenStyle {
|
||||
var hideMenu: Bool = true
|
||||
}
|
||||
|
||||
private let window: NSWindow
|
||||
private var savedState: SavedState?
|
||||
|
||||
required init?(_ window: NSWindow) {
|
||||
self.window = window
|
||||
}
|
||||
|
||||
func enter() {
|
||||
// If we are in fullscreen we don't do it again.
|
||||
guard !isFullscreen else { return }
|
||||
@ -170,6 +198,10 @@ class NonNativeFullscreen: FullscreenStyle {
|
||||
// Being untitled let's our content take up the full frame.
|
||||
window.styleMask.remove(.titled)
|
||||
|
||||
// We dont' want the non-native fullscreen window to be resizable
|
||||
// from the edges.
|
||||
window.styleMask.remove(.resizable)
|
||||
|
||||
// Focus window
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
@ -187,8 +219,12 @@ class NonNativeFullscreen: FullscreenStyle {
|
||||
guard isFullscreen else { return }
|
||||
guard let savedState else { return }
|
||||
|
||||
// Remove all our notifications
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
// Remove all our notifications. We remove them one by one because
|
||||
// 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
|
||||
if savedState.dock {
|
||||
|
@ -159,11 +159,20 @@ in
|
||||
# it to be "portable" across the system.
|
||||
LD_LIBRARY_PATH = lib.makeLibraryPath rpathLibs;
|
||||
|
||||
# On Linux we need to setup the environment so that all GTK data
|
||||
# is available (namely icons).
|
||||
shellHook = lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
# 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
|
||||
'';
|
||||
shellHook =
|
||||
(lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
# On Linux we need to setup the environment so that all GTK data
|
||||
# is available (namely icons).
|
||||
|
||||
# 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
|
||||
'');
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
# This file is auto-generated! check build-support/check-zig-cache-hash.sh for
|
||||
# more details.
|
||||
"sha256-D1SQIlmdP9x1PDgRVOy1qJGmu9osDbuyxGOcFj646N4="
|
||||
"sha256-c3MQJG7vwQBOaxHQ8cYP0HxdsLqlgsVmAiT1d7gq6js="
|
||||
|
@ -1182,6 +1182,14 @@ pub fn updateConfig(
|
||||
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
|
||||
try self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
@ -2336,7 +2344,7 @@ pub fn scrollCallback(
|
||||
|
||||
// If we're scrolling up or down, then send a mouse event.
|
||||
if (self.io.terminal.flags.mouse_event != .none) {
|
||||
if (y.delta != 0) {
|
||||
for (0..@abs(y.delta)) |_| {
|
||||
const pos = try self.rt_surface.getCursorPos();
|
||||
try self.mouseReport(switch (y.direction()) {
|
||||
.up_right => .four,
|
||||
@ -2344,7 +2352,7 @@ pub fn scrollCallback(
|
||||
}, .press, self.mouse.mods, pos);
|
||||
}
|
||||
|
||||
if (x.delta != 0) {
|
||||
for (0..@abs(x.delta)) |_| {
|
||||
const pos = try self.rt_surface.getCursorPos();
|
||||
try self.mouseReport(switch (x.direction()) {
|
||||
.up_right => .six,
|
||||
|
@ -724,7 +724,7 @@ pub const Surface = struct {
|
||||
/// Set the shape of the cursor.
|
||||
fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void {
|
||||
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
|
||||
// macOS version is >= 13 (Ventura). On prior versions, glfw crashes
|
||||
|
@ -14,6 +14,7 @@ const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const builtin = @import("builtin");
|
||||
const build_config = @import("../../build_config.zig");
|
||||
const apprt = @import("../../apprt.zig");
|
||||
const configpkg = @import("../../config.zig");
|
||||
const input = @import("../../input.zig");
|
||||
@ -99,9 +100,13 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
|
||||
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)) {
|
||||
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE
|
||||
_ = internal_os.setenv("GDK_DISABLE", "gles-api");
|
||||
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
|
||||
// For the remainder of "why" see the 4.14 comment below.
|
||||
_ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan");
|
||||
_ = internal_os.setenv("GDK_DEBUG", "opengl");
|
||||
} else if (version.atLeast(4, 14, 0)) {
|
||||
// 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...
|
||||
//
|
||||
// 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)) {
|
||||
// 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");
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -377,22 +402,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
|
||||
if (config.@"initial-window")
|
||||
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,
|
||||
>kNotifyColorScheme,
|
||||
core_app,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// Internally, GTK ensures that only one instance of this provider exists in the provider list
|
||||
// for the display.
|
||||
const css_provider = c.gtk_css_provider_new();
|
||||
@ -401,12 +410,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
|
||||
@ptrCast(css_provider),
|
||||
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 .{
|
||||
.core_app = core_app,
|
||||
@ -462,7 +465,7 @@ pub fn performAction(
|
||||
.equalize_splits => self.equalizeSplits(target),
|
||||
.goto_split => self.gotoSplit(target, value),
|
||||
.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),
|
||||
.inspector => self.controlInspector(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);
|
||||
}
|
||||
|
||||
fn configChange(self: *App, new_config: *const Config) void {
|
||||
_ = new_config;
|
||||
fn configChange(
|
||||
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| {
|
||||
log.warn("error handling configuration changes err={}", .{err});
|
||||
};
|
||||
.app => {
|
||||
// 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)) {
|
||||
if (self.core_app.focusedSurface()) |core_surface| {
|
||||
const surface = core_surface.rt_surface;
|
||||
if (surface.container.window()) |window| window.onConfigReloaded();
|
||||
}
|
||||
self.syncConfigChanges() catch |err| {
|
||||
log.warn("error handling configuration changes err={}", .{err});
|
||||
};
|
||||
|
||||
// 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
|
||||
// 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(
|
||||
"out of memory loading runtime CSS, no runtime CSS applied",
|
||||
.{},
|
||||
@ -934,15 +957,14 @@ fn syncActionAccelerator(
|
||||
}
|
||||
|
||||
fn loadRuntimeCss(
|
||||
alloc: Allocator,
|
||||
config: *const Config,
|
||||
provider: *c.GtkCssProvider,
|
||||
self: *const App,
|
||||
) 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());
|
||||
defer buf.deinit();
|
||||
const writer = buf.writer();
|
||||
|
||||
const config: *const Config = &self.config;
|
||||
const window_theme = config.@"window-theme";
|
||||
const unfocused_fill: Config.Color = config.@"unfocused-split-fill" orelse config.background;
|
||||
const headerbar_background = config.background;
|
||||
@ -1005,7 +1027,7 @@ fn loadRuntimeCss(
|
||||
|
||||
// Clears any previously loaded CSS from this provider
|
||||
c.gtk_css_provider_load_from_data(
|
||||
provider,
|
||||
self.css_provider,
|
||||
buf.items.ptr,
|
||||
@intCast(buf.items.len),
|
||||
);
|
||||
@ -1054,11 +1076,17 @@ pub fn run(self: *App) !void {
|
||||
self.transient_cgroup_base = path;
|
||||
} else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"});
|
||||
|
||||
// Setup our D-Bus connection for listening to settings changes.
|
||||
self.initDbus();
|
||||
|
||||
// Setup our menu items
|
||||
self.initActions();
|
||||
self.initMenu();
|
||||
self.initContextMenu();
|
||||
|
||||
// Setup our initial color scheme
|
||||
self.colorSchemeEvent(self.getColorScheme());
|
||||
|
||||
// On startup, we want to check for configuration errors right away
|
||||
// so we can show our error window. We also need to setup other initial
|
||||
// state.
|
||||
@ -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,
|
||||
>kNotifyColorScheme,
|
||||
self,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// This timeout function is started when no surfaces are open. It can be
|
||||
// cancelled if a new surface is opened before the timer expires.
|
||||
pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean {
|
||||
@ -1372,7 +1420,7 @@ fn gtkNotifyColorScheme(
|
||||
parameters: ?*c.GVariant,
|
||||
user_data: ?*anyopaque,
|
||||
) 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", .{});
|
||||
return;
|
||||
}));
|
||||
@ -1404,9 +1452,20 @@ fn gtkNotifyColorScheme(
|
||||
else
|
||||
.light;
|
||||
|
||||
for (core_app.surfaces.items) |surface| {
|
||||
surface.core_surface.colorSchemeCallback(color_scheme) catch |err| {
|
||||
log.err("unable to tell surface about color scheme change: {}", .{err});
|
||||
self.colorSchemeEvent(color_scheme);
|
||||
}
|
||||
|
||||
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});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ const ConfigErrors = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const build_config = @import("../../build_config.zig");
|
||||
const configpkg = @import("../../config.zig");
|
||||
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_default_size(gtk_window, 600, 275);
|
||||
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(>kDestroy), self, null, c.G_CONNECT_DEFAULT);
|
||||
|
||||
// Set some state
|
||||
|
@ -5,6 +5,7 @@ const Surface = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const build_config = @import("../../build_config.zig");
|
||||
const configpkg = @import("../../config.zig");
|
||||
const apprt = @import("../../apprt.zig");
|
||||
const font = @import("../../font/main.zig");
|
||||
@ -1149,7 +1150,7 @@ pub fn showDesktopNotification(
|
||||
defer c.g_object_unref(notification);
|
||||
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);
|
||||
c.g_notification_set_icon(notification, icon);
|
||||
|
||||
|
@ -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)
|
||||
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
|
||||
// GTK version is before 4.16. The conditional is because above 4.16
|
||||
|
@ -13,39 +13,39 @@ const icons = [_]struct {
|
||||
}{
|
||||
.{
|
||||
.alias = "16x16",
|
||||
.source = "16x16",
|
||||
.source = "16",
|
||||
},
|
||||
.{
|
||||
.alias = "16x16@2",
|
||||
.source = "16x16@2x@2x",
|
||||
.source = "16@2x",
|
||||
},
|
||||
.{
|
||||
.alias = "32x32",
|
||||
.source = "32x32",
|
||||
.source = "32",
|
||||
},
|
||||
.{
|
||||
.alias = "32x32@2",
|
||||
.source = "32x32@2x@2x",
|
||||
.source = "32@2x",
|
||||
},
|
||||
.{
|
||||
.alias = "128x128",
|
||||
.source = "128x128",
|
||||
.source = "128",
|
||||
},
|
||||
.{
|
||||
.alias = "128x128@2",
|
||||
.source = "128x128@2x@2x",
|
||||
.source = "128@2x",
|
||||
},
|
||||
.{
|
||||
.alias = "256x256",
|
||||
.source = "256x256",
|
||||
.source = "256",
|
||||
},
|
||||
.{
|
||||
.alias = "256x256@2",
|
||||
.source = "256x256@2x@2x",
|
||||
.source = "256@2x",
|
||||
},
|
||||
.{
|
||||
.alias = "512x512",
|
||||
.source = "512x512",
|
||||
.source = "512",
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -2,6 +2,7 @@ const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const assert = std.debug.assert;
|
||||
|
||||
const build_config = @import("../../build_config.zig");
|
||||
const App = @import("App.zig");
|
||||
const Surface = @import("Surface.zig");
|
||||
const TerminalWindow = @import("Window.zig");
|
||||
@ -141,7 +142,7 @@ const Window = struct {
|
||||
self.window = gtk_window;
|
||||
c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector");
|
||||
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
|
||||
try self.imgui_widget.init();
|
||||
|
@ -42,7 +42,7 @@ pub fn create(b: *std.Build, opts: Options) *MetallibStep {
|
||||
b,
|
||||
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}));
|
||||
run_ir.addArgs(&.{"-c"});
|
||||
for (opts.sources) |source| run_ir.addFileArg(source);
|
||||
@ -62,7 +62,7 @@ pub fn create(b: *std.Build, opts: Options) *MetallibStep {
|
||||
b,
|
||||
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}));
|
||||
run_lib.addFileArg(output_ir);
|
||||
run_lib.step.dependOn(&run_ir.step);
|
||||
|
@ -103,6 +103,20 @@ pub const app_runtime: apprt.Runtime = config.app_runtime;
|
||||
pub const font_backend: font.Backend = config.font_backend;
|
||||
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
|
||||
/// 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
|
||||
|
@ -104,7 +104,7 @@ pub fn parse(
|
||||
try dst._diagnostics.append(arena_alloc, .{
|
||||
.key = try arena_alloc.dupeZ(u8, arg),
|
||||
.message = "invalid field",
|
||||
.location = diags.Location.fromIter(iter),
|
||||
.location = try diags.Location.fromIter(iter, arena_alloc),
|
||||
});
|
||||
|
||||
continue;
|
||||
@ -145,7 +145,7 @@ pub fn parse(
|
||||
try dst._diagnostics.append(arena_alloc, .{
|
||||
.key = try arena_alloc.dupeZ(u8, key),
|
||||
.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.
|
||||
pub fn location(self: *const Self) ?diags.Location {
|
||||
pub fn location(self: *const Self, _: Allocator) error{}!?diags.Location {
|
||||
return .{ .cli = self.index };
|
||||
}
|
||||
};
|
||||
@ -1262,12 +1262,15 @@ pub fn LineIterator(comptime ReaderType: type) type {
|
||||
}
|
||||
|
||||
/// 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 (self.filepath.len == 0) return null;
|
||||
|
||||
return .{ .file = .{
|
||||
.path = self.filepath,
|
||||
.path = try alloc.dupe(u8, self.filepath),
|
||||
.line = self.line,
|
||||
} };
|
||||
}
|
||||
|
@ -34,6 +34,14 @@ pub const Diagnostic = struct {
|
||||
|
||||
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
|
||||
@ -48,7 +56,7 @@ pub const Location = union(enum) {
|
||||
|
||||
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 T = @TypeOf(iter);
|
||||
break :t switch (@typeInfo(T)) {
|
||||
@ -59,7 +67,20 @@ pub const Location = union(enum) {
|
||||
};
|
||||
|
||||
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.
|
||||
.lib => true,
|
||||
};
|
||||
|
||||
const Precompute = if (precompute_enabled) struct {
|
||||
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;
|
||||
|
||||
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(
|
||||
self: *DiagnosticList,
|
||||
alloc: Allocator,
|
||||
|
@ -527,6 +527,10 @@ palette: Palette = .{},
|
||||
/// 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
|
||||
/// 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,
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// "false", because native fullscreen on macOS requires window decorations
|
||||
/// 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
|
||||
/// window to be this title at all times and Ghostty will ignore any set title
|
||||
/// 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,
|
||||
|
||||
/// The setting that will change the application class value.
|
||||
@ -1793,6 +1800,10 @@ _diagnostics: cli.DiagnosticList = .{},
|
||||
/// determine if a conditional configuration matches or not.
|
||||
_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
|
||||
/// without reopening the files. This is used in very specific cases such
|
||||
/// 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:
|
||||
///
|
||||
/// 1. Defaults
|
||||
/// 2. XDG Config File
|
||||
/// 3. CLI flags
|
||||
/// 4. Recursively defined configuration files
|
||||
/// 2. XDG config dir
|
||||
/// 3. "Application Support" directory (macOS only)
|
||||
/// 4. CLI flags
|
||||
/// 5. Recursively defined configuration files
|
||||
///
|
||||
pub fn load(alloc_gpa: Allocator) !Config {
|
||||
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).?);
|
||||
}
|
||||
|
||||
/// Load the configuration from the default configuration file. The default
|
||||
/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`.
|
||||
pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void {
|
||||
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) {
|
||||
/// Load optional configuration file from `path`. All errors are ignored.
|
||||
pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void {
|
||||
self.loadFile(alloc, path) catch |err| switch (err) {
|
||||
error.FileNotFound => std.log.info(
|
||||
"homedir config not found, not loading path={s}",
|
||||
.{config_path},
|
||||
"optional config file not found, not loading path={s}",
|
||||
.{path},
|
||||
),
|
||||
|
||||
else => std.log.warn(
|
||||
"error reading config file, not loading err={} path={s}",
|
||||
.{ err, config_path },
|
||||
"error reading optional config file, not loading err={} path={s}",
|
||||
.{ 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.
|
||||
pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
|
||||
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
|
||||
// replay the inputs to rebuild the config (i.e. if
|
||||
// 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
|
||||
// a command to execute.
|
||||
@ -2552,6 +2576,24 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
|
||||
|
||||
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
|
||||
// may add items to the list while iterating for recursive
|
||||
// config-file entries.
|
||||
@ -2603,6 +2645,14 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
|
||||
try self.loadIter(alloc_gpa, &iter);
|
||||
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
|
||||
@ -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
|
||||
/// 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
|
||||
/// configuration with the new conditional state. Importantly, this means
|
||||
/// 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(
|
||||
self: *const Config,
|
||||
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
|
||||
const alloc_gpa = self._arena.?.child_allocator;
|
||||
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);
|
||||
|
||||
// Setup our replay to be conditional.
|
||||
for (new_config._replay_steps.items) |*item| switch (item.*) {
|
||||
.expand => {},
|
||||
conditional: for (new_config._replay_steps.items) |*item| {
|
||||
switch (item.*) {
|
||||
.expand => {},
|
||||
|
||||
// Change our arg to be conditional on our theme.
|
||||
.arg => |v| {
|
||||
const alloc_arena = new_config._arena.?.allocator();
|
||||
const conds = try alloc_arena.alloc(Conditional, 1);
|
||||
conds[0] = .{
|
||||
.key = .theme,
|
||||
.op = .eq,
|
||||
.value = @tagName(self._conditional_state.theme),
|
||||
};
|
||||
item.* = .{ .conditional_arg = .{
|
||||
.conditions = conds,
|
||||
.arg = v,
|
||||
} };
|
||||
},
|
||||
// If we see "-e" then we do NOT make the following arguments
|
||||
// conditional since they are supposed to be part of the
|
||||
// initial command.
|
||||
.@"-e" => break :conditional,
|
||||
|
||||
.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,
|
||||
} };
|
||||
},
|
||||
};
|
||||
// Change our arg to be conditional on our theme.
|
||||
.arg => |v| {
|
||||
const alloc_arena = new_config._arena.?.allocator();
|
||||
const conds = try alloc_arena.alloc(Conditional, 1);
|
||||
conds[0] = .{
|
||||
.key = .theme,
|
||||
.op = .eq,
|
||||
.value = @tagName(self._conditional_state.theme),
|
||||
};
|
||||
item.* = .{ .conditional_arg = .{
|
||||
.conditions = conds,
|
||||
.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
|
||||
// from the theme.
|
||||
@ -2765,6 +2849,9 @@ pub fn finalize(self: *Config) !void {
|
||||
// This setting doesn't make sense with different light/dark themes
|
||||
// because it'll force the theme based on the Ghostty theme.
|
||||
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,
|
||||
iter: anytype,
|
||||
) !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")) {
|
||||
// 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
|
||||
// ownership in our allocator.
|
||||
var command = std.ArrayList(u8).init(alloc);
|
||||
@ -2941,7 +3030,7 @@ pub fn parseManuallyHook(
|
||||
|
||||
if (command.items.len == 0) {
|
||||
try self._diagnostics.append(alloc, .{
|
||||
.location = cli.Location.fromIter(iter),
|
||||
.location = try cli.Location.fromIter(iter, alloc),
|
||||
.message = try std.fmt.allocPrintZ(
|
||||
alloc,
|
||||
"missing command after {s}",
|
||||
@ -2963,6 +3052,12 @@ pub fn parseManuallyHook(
|
||||
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
|
||||
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
|
||||
// the exact conditionals required for some steps.
|
||||
try result._replay_steps.ensureTotalCapacity(
|
||||
@ -3029,6 +3127,9 @@ pub fn clone(
|
||||
}
|
||||
assert(result._replay_steps.items.len == self._replay_steps.items.len);
|
||||
|
||||
// Copy the conditional set
|
||||
result._conditional_set = self._conditional_set;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -3224,11 +3325,22 @@ const Replay = struct {
|
||||
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(
|
||||
self: Step,
|
||||
alloc: Allocator,
|
||||
) Allocator.Error!Step {
|
||||
return switch (self) {
|
||||
.@"-e" => self,
|
||||
.arg => |v| .{ .arg = try alloc.dupe(u8, v) },
|
||||
.expand => |v| .{ .expand = try alloc.dupe(u8, v) },
|
||||
.conditional_arg => |v| conditional: {
|
||||
@ -3264,10 +3376,6 @@ const Replay = struct {
|
||||
log.warn("error expanding paths err={}", .{err});
|
||||
},
|
||||
|
||||
.arg => |arg| {
|
||||
return arg;
|
||||
},
|
||||
|
||||
.conditional_arg => |v| conditional: {
|
||||
// All conditions must match.
|
||||
for (v.conditions) |cond| {
|
||||
@ -3278,6 +3386,9 @@ const Replay = struct {
|
||||
|
||||
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.
|
||||
pub fn clone(self: *const Self, alloc: Allocator) error{}!Self {
|
||||
_ = self;
|
||||
_ = alloc;
|
||||
return .{};
|
||||
pub fn clone(
|
||||
self: *const Self,
|
||||
alloc: Allocator,
|
||||
) 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.
|
||||
pub fn equal(self: Self, other: Self) bool {
|
||||
_ = self;
|
||||
_ = other;
|
||||
return true;
|
||||
const itemsA = self.links.items;
|
||||
const itemsB = other.links.items;
|
||||
if (itemsA.len != itemsB.len) return false;
|
||||
for (itemsA, itemsB) |*a, *b| {
|
||||
if (!a.equal(b)) return false;
|
||||
} else return true;
|
||||
}
|
||||
|
||||
/// Used by Formatter
|
||||
@ -5258,14 +5385,13 @@ test "clone preserves conditional state" {
|
||||
|
||||
var a = try Config.default(alloc);
|
||||
defer a.deinit();
|
||||
var b = try a.changeConditionalState(.{ .theme = .dark });
|
||||
defer b.deinit();
|
||||
try testing.expectEqual(.dark, b._conditional_state.theme);
|
||||
var dest = try b.clone(alloc);
|
||||
a._conditional_state.theme = .dark;
|
||||
try testing.expectEqual(.dark, a._conditional_state.theme);
|
||||
var dest = try a.clone(alloc);
|
||||
defer dest.deinit();
|
||||
|
||||
// Should have no changes
|
||||
var it = b.changeIterator(&dest);
|
||||
var it = a.changeIterator(&dest);
|
||||
try testing.expectEqual(@as(?Key, null), it.next());
|
||||
|
||||
// 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.finalize();
|
||||
|
||||
var cfg_dark = try cfg_light.changeConditionalState(.{ .theme = .dark });
|
||||
var cfg_dark = (try cfg_light.changeConditionalState(.{ .theme = .dark })).?;
|
||||
defer cfg_dark.deinit();
|
||||
|
||||
try testing.expectEqual(Color{
|
||||
@ -5332,7 +5458,7 @@ test "clone can then change conditional state" {
|
||||
.b = 0xEE,
|
||||
}, 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();
|
||||
try testing.expectEqual(Color{
|
||||
.r = 0xFF,
|
||||
@ -5341,6 +5467,25 @@ test "clone can then change conditional state" {
|
||||
}, 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" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
@ -5355,6 +5500,44 @@ test "changed" {
|
||||
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" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
@ -5386,6 +5569,9 @@ test "theme loading" {
|
||||
.g = 0x3A,
|
||||
.b = 0xBC,
|
||||
}, cfg.background);
|
||||
|
||||
// Not a conditional theme
|
||||
try testing.expect(!cfg._conditional_set.contains(.theme));
|
||||
}
|
||||
|
||||
test "theme loading preserves conditional state" {
|
||||
@ -5534,7 +5720,7 @@ test "theme loading correct light/dark" {
|
||||
try cfg.loadIter(alloc, &it);
|
||||
try cfg.finalize();
|
||||
|
||||
var new = try cfg.changeConditionalState(.{ .theme = .dark });
|
||||
var new = (try cfg.changeConditionalState(.{ .theme = .dark })).?;
|
||||
defer new.deinit();
|
||||
try testing.expectEqual(Color{
|
||||
.r = 0xEE,
|
||||
@ -5561,3 +5747,22 @@ test "theme specifying light/dark changes window-theme from auto" {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
@ -45,10 +45,26 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
|
||||
self.idx += 1;
|
||||
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.
|
||||
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);
|
||||
@memset(buf, default);
|
||||
|
||||
@ -56,7 +72,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
|
||||
.storage = buf,
|
||||
.head = 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,
|
||||
/// 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;
|
||||
self.storage[self.head] = v;
|
||||
self.head += 1;
|
||||
@ -75,6 +91,19 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
|
||||
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.
|
||||
pub fn clear(self: *Self) void {
|
||||
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).
|
||||
/// If larger, new values will be set to the default value.
|
||||
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());
|
||||
}
|
||||
|
||||
test "append" {
|
||||
test "CircBuf append" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@ -273,7 +330,7 @@ test "append" {
|
||||
try testing.expectError(error.OutOfMemory, buf.append(5));
|
||||
}
|
||||
|
||||
test "forward iterator" {
|
||||
test "CircBuf forward iterator" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@ -319,7 +376,7 @@ test "forward iterator" {
|
||||
}
|
||||
}
|
||||
|
||||
test "reverse iterator" {
|
||||
test "CircBuf reverse iterator" {
|
||||
const testing = std.testing;
|
||||
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 alloc = testing.allocator;
|
||||
|
||||
@ -379,7 +524,7 @@ test "getPtrSlice fits" {
|
||||
try testing.expectEqual(@as(usize, 11), buf.len());
|
||||
}
|
||||
|
||||
test "getPtrSlice wraps" {
|
||||
test "CircBuf getPtrSlice wraps" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@ -435,7 +580,7 @@ test "getPtrSlice wraps" {
|
||||
}
|
||||
}
|
||||
|
||||
test "rotateToZero" {
|
||||
test "CircBuf rotateToZero" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@ -447,7 +592,7 @@ test "rotateToZero" {
|
||||
try buf.rotateToZero(alloc);
|
||||
}
|
||||
|
||||
test "rotateToZero offset" {
|
||||
test "CircBuf rotateToZero offset" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@ -471,7 +616,7 @@ test "rotateToZero offset" {
|
||||
try testing.expectEqual(@as(usize, 1), buf.head);
|
||||
}
|
||||
|
||||
test "rotateToZero wraps" {
|
||||
test "CircBuf rotateToZero wraps" {
|
||||
const testing = std.testing;
|
||||
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 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 alloc = testing.allocator;
|
||||
|
||||
@ -582,7 +752,7 @@ test "resize grow" {
|
||||
}
|
||||
}
|
||||
|
||||
test "resize shrink" {
|
||||
test "CircBuf resize shrink" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
|
@ -280,6 +280,8 @@ const Kind = enum {
|
||||
|
||||
// Powerline fonts
|
||||
0xE0B0,
|
||||
0xE0B1,
|
||||
0xE0B3,
|
||||
0xE0B4,
|
||||
0xE0B6,
|
||||
0xE0B2,
|
||||
|
@ -93,6 +93,11 @@ fn draw(self: Powerline, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32)
|
||||
0xE0BE,
|
||||
=> try self.draw_wedge_triangle(canvas, cp),
|
||||
|
||||
// Soft Dividers
|
||||
0xE0B1,
|
||||
0xE0B3,
|
||||
=> try self.draw_chevron(canvas, cp),
|
||||
|
||||
// Half-circles
|
||||
0xE0B4,
|
||||
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 {
|
||||
const width = self.width;
|
||||
const height = self.height;
|
||||
@ -501,6 +550,8 @@ test "all" {
|
||||
0xE0B6,
|
||||
0xE0D2,
|
||||
0xE0D4,
|
||||
0xE0B1,
|
||||
0xE0B3,
|
||||
};
|
||||
for (cps) |cp| {
|
||||
var atlas_grayscale = try font.Atlas.init(alloc, 512, .grayscale);
|
||||
|
@ -235,6 +235,31 @@ pub const Canvas = struct {
|
||||
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.
|
||||
pub fn line(self: *Canvas, l: Line(f64), thickness: f64, color: Color) !void {
|
||||
var ctx: z2d.Context = .{
|
||||
|
@ -1454,21 +1454,30 @@ pub const Set = struct {
|
||||
};
|
||||
|
||||
// If we have any leaders we need to clone them.
|
||||
var it = result.bindings.iterator();
|
||||
while (it.next()) |entry| switch (entry.value_ptr.*) {
|
||||
// Leaves could have data to clone (i.e. text actions
|
||||
// contain allocated strings).
|
||||
.leaf => |*s| s.* = try s.clone(alloc),
|
||||
{
|
||||
var it = result.bindings.iterator();
|
||||
while (it.next()) |entry| switch (entry.value_ptr.*) {
|
||||
// Leaves could have data to clone (i.e. text actions
|
||||
// contain allocated strings).
|
||||
.leaf => |*s| s.* = try s.clone(alloc),
|
||||
|
||||
// Must be deep cloned.
|
||||
.leader => |*s| {
|
||||
const ptr = try alloc.create(Set);
|
||||
errdefer alloc.destroy(ptr);
|
||||
ptr.* = try s.*.clone(alloc);
|
||||
errdefer ptr.deinit(alloc);
|
||||
s.* = ptr;
|
||||
},
|
||||
};
|
||||
// Must be deep cloned.
|
||||
.leader => |*s| {
|
||||
const ptr = try alloc.create(Set);
|
||||
errdefer alloc.destroy(ptr);
|
||||
ptr.* = try s.*.clone(alloc);
|
||||
errdefer ptr.deinit(alloc);
|
||||
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;
|
||||
}
|
||||
|
@ -4,6 +4,8 @@
|
||||
//! action types.
|
||||
const Link = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const oni = @import("oniguruma");
|
||||
const Mods = @import("key.zig").Mods;
|
||||
|
||||
@ -59,3 +61,19 @@ pub fn oniRegex(self: *const Link) !oni.Regex {
|
||||
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);
|
||||
}
|
||||
|
@ -141,7 +141,7 @@ fn logFn(
|
||||
|
||||
// Initialize a logger. This is slow to do on every operation
|
||||
// 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();
|
||||
logger.log(std.heap.c_allocator, mac_level, format, args);
|
||||
}
|
||||
|
118
src/os/macos.zig
Normal 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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -8,7 +8,6 @@ const file = @import("file.zig");
|
||||
const flatpak = @import("flatpak.zig");
|
||||
const homedir = @import("homedir.zig");
|
||||
const locale = @import("locale.zig");
|
||||
const macos_version = @import("macos_version.zig");
|
||||
const mouse = @import("mouse.zig");
|
||||
const openpkg = @import("open.zig");
|
||||
const pipepkg = @import("pipe.zig");
|
||||
@ -21,6 +20,7 @@ pub const hostname = @import("hostname.zig");
|
||||
pub const passwd = @import("passwd.zig");
|
||||
pub const xdg = @import("xdg.zig");
|
||||
pub const windows = @import("windows.zig");
|
||||
pub const macos = @import("macos.zig");
|
||||
|
||||
// Functions and types
|
||||
pub const CFReleaseThread = @import("cf_release_thread.zig");
|
||||
@ -37,7 +37,6 @@ pub const freeTmpDir = file.freeTmpDir;
|
||||
pub const isFlatpak = flatpak.isFlatpak;
|
||||
pub const home = homedir.home;
|
||||
pub const ensureLocale = locale.ensureLocale;
|
||||
pub const macosVersionAtLeast = macos_version.macosVersionAtLeast;
|
||||
pub const clickInterval = mouse.clickInterval;
|
||||
pub const open = openpkg.open;
|
||||
pub const pipe = pipepkg.pipe;
|
||||
|
@ -4,8 +4,10 @@ pub const Thread = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
const xev = @import("xev");
|
||||
const crash = @import("../crash/main.zig");
|
||||
const internal_os = @import("../os/main.zig");
|
||||
const renderer = @import("../renderer.zig");
|
||||
const apprt = @import("../apprt.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
|
||||
/// if we should be rendering or not.
|
||||
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 {
|
||||
@ -199,6 +205,9 @@ fn threadMain_(self: *Thread) !void {
|
||||
};
|
||||
defer crash.sentry.thread_state = null;
|
||||
|
||||
// Setup our thread QoS
|
||||
self.setQosClass();
|
||||
|
||||
// Run our loop start/end callbacks if the renderer cares.
|
||||
const has_loop = @hasDecl(renderer.Renderer, "loopEnter");
|
||||
if (has_loop) try self.renderer.loopEnter(self);
|
||||
@ -237,6 +246,36 @@ fn threadMain_(self: *Thread) !void {
|
||||
_ = 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 {
|
||||
// If our renderer doesn't support animations then we never run this.
|
||||
if (!@hasDecl(renderer.Renderer, "hasAnimations")) return;
|
||||
@ -273,10 +312,16 @@ fn drainMailbox(self: *Thread) !void {
|
||||
switch (message) {
|
||||
.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
|
||||
self.flags.visible = v;
|
||||
|
||||
// Visibility affects our QoS class
|
||||
self.setQosClass();
|
||||
|
||||
// If we became visible then we immediately trigger a draw.
|
||||
// We don't need to update frame data because that should
|
||||
// still be happening.
|
||||
@ -293,7 +338,16 @@ fn drainMailbox(self: *Thread) !void {
|
||||
// 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
|
||||
try self.renderer.setFocus(v);
|
||||
|
||||
|
@ -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 {
|
||||
/// The top and bottom (inclusive) points of the region to clone.
|
||||
/// 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
|
||||
/// expensive operation since we traverse the entire linked list in the
|
||||
/// 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;
|
||||
while (it) |node| : (it = node.next) {
|
||||
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.
|
||||
///
|
||||
/// EXAMPLE:
|
||||
@ -8191,3 +8329,66 @@ test "PageList resize reflow wrap moves kitty placeholder" {
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
@ -83,8 +83,8 @@ pub const Dirty = packed struct {
|
||||
/// The cursor position and style.
|
||||
pub const Cursor = struct {
|
||||
// The x/y position within the viewport.
|
||||
x: size.CellCountInt,
|
||||
y: size.CellCountInt,
|
||||
x: size.CellCountInt = 0,
|
||||
y: size.CellCountInt = 0,
|
||||
|
||||
/// The visual style of the cursor. This defaults to block because
|
||||
/// 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.
|
||||
///
|
||||
/// This will copy:
|
||||
@ -2687,95 +2731,15 @@ pub fn promptPath(
|
||||
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;
|
||||
/// this function does not attempt to efficiently write and generally writes
|
||||
/// one byte at a time.
|
||||
pub fn dumpString(
|
||||
self: *const Screen,
|
||||
writer: anytype,
|
||||
opts: DumpString,
|
||||
) !void {
|
||||
var blank_rows: usize = 0;
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
opts: PageList.EncodeUtf8Options,
|
||||
) anyerror!void {
|
||||
try self.pages.encodeUtf8(writer, opts);
|
||||
}
|
||||
|
||||
/// 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]);
|
||||
}
|
||||
|
@ -193,6 +193,10 @@ pub const Options = struct {
|
||||
cols: size.CellCountInt,
|
||||
rows: size.CellCountInt,
|
||||
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.
|
||||
@ -216,6 +220,10 @@ pub fn init(
|
||||
.right = cols - 1,
|
||||
},
|
||||
.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 {
|
||||
const count = @max(count_req, 1);
|
||||
|
||||
// Our last index is at most the end of the number of chars we have
|
||||
// in the current line.
|
||||
const end = end: {
|
||||
const count = end: {
|
||||
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
|
||||
// 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
|
||||
// least `clearUnprotectedCells` handle boundary conditions...
|
||||
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.
|
||||
self.screen.cursorResetWrap();
|
||||
@ -1997,7 +2001,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
|
||||
self.screen.clearCells(
|
||||
&self.screen.cursor.page_pin.node.data,
|
||||
self.screen.cursor.page_row,
|
||||
cells[0..end],
|
||||
cells[0..count],
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -2005,7 +2009,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
|
||||
self.screen.clearUnprotectedCells(
|
||||
&self.screen.cursor.page_pin.node.data,
|
||||
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.
|
||||
///
|
||||
/// This will attempt to free the existing screen memory and allocate
|
||||
/// new screens but if that fails this will reuse the existing memory
|
||||
/// from the prior screens. In the latter case, memory may be wasted
|
||||
/// (since its unused) but it isn't leaked.
|
||||
/// This will attempt to free the existing screen memory but if that fails
|
||||
/// this will reuse the existing memory. In the latter case, memory may
|
||||
/// be wasted (since its unused) but it isn't leaked.
|
||||
pub fn fullReset(self: *Terminal) void {
|
||||
// Attempt to initialize new screens.
|
||||
var new_primary = Screen.init(
|
||||
self.screen.alloc,
|
||||
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;
|
||||
};
|
||||
// Reset our screens
|
||||
self.screen.reset();
|
||||
self.secondary_screen.reset();
|
||||
|
||||
// If we got here, both new screens were successfully allocated
|
||||
// and we can deinitialize the old screens.
|
||||
self.screen.deinit();
|
||||
self.secondary_screen.deinit();
|
||||
// Ensure we're back on primary screen
|
||||
if (self.active_screen != .primary) {
|
||||
const old = self.screen;
|
||||
self.screen = self.secondary_screen;
|
||||
self.secondary_screen = old;
|
||||
self.active_screen = .primary;
|
||||
}
|
||||
|
||||
// Replace with the newly allocated screens.
|
||||
self.screen = new_primary;
|
||||
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 = .{};
|
||||
// Rest our basic state
|
||||
self.modes.reset();
|
||||
self.flags = .{};
|
||||
self.tabstops.reset(TABSTOP_INTERVAL);
|
||||
self.screen.kitty_keyboard = .{};
|
||||
self.secondary_screen.kitty_keyboard = .{};
|
||||
self.screen.protected_mode = .off;
|
||||
self.previous_char = null;
|
||||
self.pwd.clearRetainingCapacity();
|
||||
self.status_display = .main;
|
||||
self.scrolling_region = .{
|
||||
.top = 0,
|
||||
.bottom = self.rows - 1,
|
||||
.left = 0,
|
||||
.right = self.cols - 1,
|
||||
};
|
||||
self.previous_char = null;
|
||||
self.pwd.clearRetainingCapacity();
|
||||
self.status_display = .main;
|
||||
|
||||
// Always mark dirty so we redraw everything
|
||||
self.flags.dirty.clear = true;
|
||||
}
|
||||
|
||||
/// 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" {
|
||||
const alloc = testing.allocator;
|
||||
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());
|
||||
}
|
||||
|
||||
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
|
||||
// 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.
|
||||
|
@ -164,9 +164,9 @@ fn transmit(
|
||||
// If there are more chunks expected we do not respond.
|
||||
if (load.more) return .{};
|
||||
|
||||
// If our image has no ID or number, we don't respond at all. Conversely,
|
||||
// if we have either an ID or number, we always respond.
|
||||
if (load.image.id == 0 and load.image.number == 0) return .{};
|
||||
// If the loaded image was assigned its ID automatically, not based
|
||||
// on a number or explicitly specified ID, then we don't respond.
|
||||
if (load.image.implicit_id) return .{};
|
||||
|
||||
// After the image is added, set the ID in case it changed.
|
||||
// The resulting image number and placement ID never change.
|
||||
@ -335,6 +335,10 @@ fn loadAndAddImage(
|
||||
if (loading.image.id == 0) {
|
||||
loading.image.id = storage.next_image_id;
|
||||
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.
|
||||
@ -529,3 +533,21 @@ test "kittygfx test valid i32 (expect invalid image ID)" {
|
||||
try testing.expect(!resp.ok());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -455,6 +455,12 @@ pub const Image = struct {
|
||||
data: []const u8 = "",
|
||||
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{
|
||||
InternalError,
|
||||
InvalidData,
|
||||
|
@ -31,6 +31,9 @@ pub const ImageStorage = struct {
|
||||
|
||||
/// This is the next automatically assigned image ID. We start mid-way
|
||||
/// 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,
|
||||
|
||||
/// This is the next automatically assigned placement ID. This is never
|
||||
@ -690,7 +693,7 @@ pub const ImageStorage = struct {
|
||||
br.x = @min(
|
||||
// We need to sub one here because the x value is
|
||||
// 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),
|
||||
t.cols - 1,
|
||||
);
|
||||
|
@ -18,6 +18,7 @@ pub const kitty = @import("kitty.zig");
|
||||
pub const modes = @import("modes.zig");
|
||||
pub const page = @import("page.zig");
|
||||
pub const parse_table = @import("parse_table.zig");
|
||||
pub const search = @import("search.zig");
|
||||
pub const size = @import("size.zig");
|
||||
pub const tmux = @import("tmux.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 DeviceAttributeReq = ansi.DeviceAttributeReq;
|
||||
pub const Mode = modes.Mode;
|
||||
pub const ModePacked = modes.ModePacked;
|
||||
pub const ModifyKeyFormat = ansi.ModifyKeyFormat;
|
||||
pub const ProtectedMode = ansi.ProtectedMode;
|
||||
pub const StatusLineType = ansi.StatusLineType;
|
||||
|
@ -21,6 +21,17 @@ pub const ModeState = struct {
|
||||
/// a real-world issue but we need to be aware of a DoS vector.
|
||||
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.
|
||||
pub fn set(self: *ModeState, mode: Mode, value: bool) void {
|
||||
switch (mode) {
|
||||
|
@ -1481,6 +1481,179 @@ pub const Page = struct {
|
||||
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.
|
||||
///
|
||||
/// The returned value is a DynamicBitSetUnmanaged but it is NOT
|
||||
|