mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
Merge branch 'main' of https://github.com/AlexJuca/ghostty into feature/display-memory-size-in-bytes-and-kb
This commit is contained in:
8
.github/workflows/release-tag.yml
vendored
8
.github/workflows/release-tag.yml
vendored
@ -105,8 +105,8 @@ jobs:
|
||||
with:
|
||||
name: source-tarball
|
||||
path: |-
|
||||
"ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz"
|
||||
"ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz.minisig"
|
||||
ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz
|
||||
ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz.minisig
|
||||
ghostty-source.tar.gz
|
||||
ghostty-source.tar.gz.minisig
|
||||
|
||||
@ -360,8 +360,8 @@ jobs:
|
||||
run: |
|
||||
mkdir blob
|
||||
mkdir -p blob/${GHOSTTY_VERSION}
|
||||
mv "ghostty-${GHOSTTY_VERSION}.tar.gz blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz"
|
||||
mv ghostty-${GHOSTTY_VERSION}.tar.gz.minisig blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz.minisig
|
||||
mv "ghostty-${GHOSTTY_VERSION}.tar.gz" blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz
|
||||
mv "ghostty-${GHOSTTY_VERSION}.tar.gz.minisig" blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz.minisig
|
||||
mv ghostty-source.tar.gz blob/${GHOSTTY_VERSION}/ghostty-source.tar.gz
|
||||
mv ghostty-source.tar.gz.minisig blob/${GHOSTTY_VERSION}/ghostty-source.tar.gz.minisig
|
||||
mv ghostty-macos-universal.zip blob/${GHOSTTY_VERSION}/ghostty-macos-universal.zip
|
||||
|
70
.github/workflows/test.yml
vendored
70
.github/workflows/test.yml
vendored
@ -376,6 +376,41 @@ jobs:
|
||||
-Dgtk-adwaita=${{ matrix.adwaita }} \
|
||||
-Dgtk-x11=${{ matrix.x11 }}
|
||||
|
||||
test-sentry-linux:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
sentry: ["true", "false"]
|
||||
name: Build -Dsentry=${{ matrix.sentry }}
|
||||
runs-on: namespace-profile-ghostty-sm
|
||||
needs: test
|
||||
env:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.0
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@v30
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Test Sentry Build
|
||||
run: |
|
||||
nix develop -c zig build -Dsentry=${{ matrix.sentry }}
|
||||
|
||||
test-macos:
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
needs: test
|
||||
@ -478,3 +513,38 @@ jobs:
|
||||
useDaemon: false # sometimes fails on short jobs
|
||||
- name: typos check
|
||||
run: nix develop -c typos
|
||||
|
||||
test-pkg-linux:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pkg: ["wuffs"]
|
||||
name: Test pkg/${{ matrix.pkg }}
|
||||
runs-on: namespace-profile-ghostty-sm
|
||||
needs: test
|
||||
env:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.0
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@v30
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Test ${{ matrix.pkg }} Build
|
||||
run: |
|
||||
nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test"
|
||||
|
@ -117,3 +117,11 @@ relevant to package maintainers:
|
||||
often necessary for system packages to specify a specific minimum Linux
|
||||
version, glibc, etc. Run `zig targets` to a get a full list of available
|
||||
targets.
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> **The GLFW runtime is not meant for distribution.** The GLFW runtime
|
||||
> (`-Dapp-runtime=glfw`) is meant for development and testing only. It is
|
||||
> missing many features, has known memory leak scenarios, known crashes,
|
||||
> and more. Please do not package the GLFW-based Ghostty runtime for
|
||||
> distribution.
|
||||
|
43
build.zig
43
build.zig
@ -43,7 +43,7 @@ comptime {
|
||||
}
|
||||
|
||||
/// The version of the next release.
|
||||
const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 1 };
|
||||
const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 2 };
|
||||
|
||||
pub fn build(b: *std.Build) !void {
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
@ -152,12 +152,36 @@ pub fn build(b: *std.Build) !void {
|
||||
}
|
||||
};
|
||||
|
||||
config.sentry = b.option(
|
||||
bool,
|
||||
"sentry",
|
||||
"Build with Sentry crash reporting. Default for macOS is true, false for any other system.",
|
||||
) orelse sentry: {
|
||||
switch (target.result.os.tag) {
|
||||
.macos, .ios => break :sentry true,
|
||||
|
||||
// Note its false for linux because the crash reports on Linux
|
||||
// don't have much useful information.
|
||||
else => break :sentry false,
|
||||
}
|
||||
};
|
||||
|
||||
const pie = b.option(
|
||||
bool,
|
||||
"pie",
|
||||
"Build a Position Independent Executable. Default true for system packages.",
|
||||
) orelse system_package;
|
||||
|
||||
const strip = b.option(
|
||||
bool,
|
||||
"strip",
|
||||
"Strip the final executable. Default true for fast and small releases",
|
||||
) orelse switch (optimize) {
|
||||
.Debug => false,
|
||||
.ReleaseSafe => false,
|
||||
.ReleaseFast, .ReleaseSmall => true,
|
||||
};
|
||||
|
||||
const conformance = b.option(
|
||||
[]const u8,
|
||||
"conformance",
|
||||
@ -342,11 +366,7 @@ pub fn build(b: *std.Build) !void {
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.strip = switch (optimize) {
|
||||
.Debug => false,
|
||||
.ReleaseSafe => false,
|
||||
.ReleaseFast, .ReleaseSmall => true,
|
||||
},
|
||||
.strip = strip,
|
||||
}) else null;
|
||||
|
||||
// Exe
|
||||
@ -669,7 +689,12 @@ pub fn build(b: *std.Build) !void {
|
||||
b.installFile("images/icons/icon_128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png");
|
||||
|
||||
// Flatpaks only support icons up to 512x512.
|
||||
if (!config.flatpak) {
|
||||
b.installFile("images/icons/icon_1024.png", "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png");
|
||||
}
|
||||
|
||||
b.installFile("images/icons/icon_16@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_32@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png");
|
||||
b.installFile("images/icons/icon_128@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png");
|
||||
@ -685,6 +710,7 @@ pub fn build(b: *std.Build) !void {
|
||||
.root_source_file = b.path("src/main_c.zig"),
|
||||
.optimize = optimize,
|
||||
.target = target,
|
||||
.strip = strip,
|
||||
});
|
||||
_ = try addDeps(b, lib, config);
|
||||
|
||||
@ -702,6 +728,7 @@ pub fn build(b: *std.Build) !void {
|
||||
.root_source_file = b.path("src/main_c.zig"),
|
||||
.optimize = optimize,
|
||||
.target = target,
|
||||
.strip = strip,
|
||||
});
|
||||
_ = try addDeps(b, lib, config);
|
||||
|
||||
@ -1240,13 +1267,15 @@ fn addDeps(
|
||||
}
|
||||
|
||||
// Sentry
|
||||
if (config.sentry) {
|
||||
const sentry_dep = b.dependency("sentry", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.backend = .breakpad,
|
||||
});
|
||||
|
||||
step.root_module.addImport("sentry", sentry_dep.module("sentry"));
|
||||
if (target.result.os.tag != .windows) {
|
||||
|
||||
// Sentry
|
||||
step.linkLibrary(sentry_dep.artifact("sentry"));
|
||||
try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin());
|
||||
|
@ -1,6 +1,6 @@
|
||||
.{
|
||||
.name = "ghostty",
|
||||
.version = "1.0.1",
|
||||
.version = "1.0.2",
|
||||
.paths = .{""},
|
||||
.dependencies = .{
|
||||
// Zig libs
|
||||
@ -49,8 +49,8 @@
|
||||
// Other
|
||||
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
|
||||
.iterm2_themes = .{
|
||||
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/08df2e8a72dde535f8d17d8115fe792dbcdc6f35.tar.gz",
|
||||
.hash = "1220d1090ac2edf1e47059b592403deacd56e7289c7ad8744ed20dd2f297596744b8",
|
||||
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz",
|
||||
.hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620",
|
||||
},
|
||||
.vaxis = .{
|
||||
.url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b",
|
||||
|
13
default.nix
Normal file
13
default.nix
Normal file
@ -0,0 +1,13 @@
|
||||
(import (
|
||||
let
|
||||
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
|
||||
nodeName = lock.nodes.root.inputs.flake-compat;
|
||||
in
|
||||
fetchTarball {
|
||||
url =
|
||||
lock.nodes.${nodeName}.locked.url
|
||||
or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz";
|
||||
sha256 = lock.nodes.${nodeName}.locked.narHash;
|
||||
}
|
||||
) {src = ./.;})
|
||||
.defaultNix
|
@ -375,9 +375,9 @@ typedef enum {
|
||||
typedef enum {
|
||||
GHOSTTY_GOTO_SPLIT_PREVIOUS,
|
||||
GHOSTTY_GOTO_SPLIT_NEXT,
|
||||
GHOSTTY_GOTO_SPLIT_TOP,
|
||||
GHOSTTY_GOTO_SPLIT_UP,
|
||||
GHOSTTY_GOTO_SPLIT_LEFT,
|
||||
GHOSTTY_GOTO_SPLIT_BOTTOM,
|
||||
GHOSTTY_GOTO_SPLIT_DOWN,
|
||||
GHOSTTY_GOTO_SPLIT_RIGHT,
|
||||
} ghostty_action_goto_split_e;
|
||||
|
||||
|
@ -358,8 +358,8 @@ class AppDelegate: NSObject,
|
||||
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
|
||||
syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit)
|
||||
syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit)
|
||||
syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove)
|
||||
syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow)
|
||||
syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove)
|
||||
syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow)
|
||||
syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
|
||||
syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
|
||||
syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
|
||||
@ -486,15 +486,16 @@ class AppDelegate: NSObject,
|
||||
|
||||
// Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is
|
||||
// explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior
|
||||
// defined by our "auto-update" configuration.
|
||||
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool != false {
|
||||
updaterController.updater.automaticallyChecksForUpdates =
|
||||
config.autoUpdate == .check || config.autoUpdate == .download
|
||||
updaterController.updater.automaticallyDownloadsUpdates =
|
||||
config.autoUpdate == .download
|
||||
} else {
|
||||
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
|
||||
// user-based defaults.
|
||||
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
|
||||
updaterController.updater.automaticallyChecksForUpdates = false
|
||||
updaterController.updater.automaticallyDownloadsUpdates = false
|
||||
} else if let autoUpdate = config.autoUpdate {
|
||||
updaterController.updater.automaticallyChecksForUpdates =
|
||||
autoUpdate == .check || autoUpdate == .download
|
||||
updaterController.updater.automaticallyDownloadsUpdates =
|
||||
autoUpdate == .download
|
||||
}
|
||||
|
||||
// Config could change keybindings, so update everything that depends on that
|
||||
|
@ -69,7 +69,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
window.isRestorable = false
|
||||
|
||||
// Setup our configured appearance that we support.
|
||||
syncAppearance(ghostty.config)
|
||||
syncAppearance()
|
||||
|
||||
// Setup our initial size based on our configured position
|
||||
position.setLoaded(window)
|
||||
@ -214,6 +214,10 @@ class QuickTerminalController: BaseTerminalController {
|
||||
// If we canceled our animation in we do nothing
|
||||
guard self.visible else { return }
|
||||
|
||||
// Now that the window is visible, sync our appearance. This function
|
||||
// requires the window is visible.
|
||||
self.syncAppearance()
|
||||
|
||||
// Once our animation is done, we must grab focus since we can't grab
|
||||
// focus of a non-visible window.
|
||||
self.makeWindowKey(window)
|
||||
@ -304,24 +308,18 @@ class QuickTerminalController: BaseTerminalController {
|
||||
})
|
||||
}
|
||||
|
||||
private func syncAppearance(_ config: Ghostty.Config) {
|
||||
private func syncAppearance() {
|
||||
guard let window else { return }
|
||||
|
||||
// If our window is not visible, then delay this. This is possible specifically
|
||||
// during state restoration but probably in other scenarios as well. To delay,
|
||||
// we just loop directly on the dispatch queue. We have to delay because some
|
||||
// APIs such as window blur have no effect unless the window is visible.
|
||||
guard window.isVisible else {
|
||||
// Weak window so that if the window changes or is destroyed we aren't holding a ref
|
||||
DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) }
|
||||
return
|
||||
}
|
||||
// If our window is not visible, then no need to sync the appearance yet.
|
||||
// Some APIs such as window blur have no effect unless the window is visible.
|
||||
guard window.isVisible else { return }
|
||||
|
||||
// Terminals typically operate in sRGB color space and macOS defaults
|
||||
// to "native" which is typically P3. There is a lot more resources
|
||||
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
|
||||
// Ghostty defaults to sRGB but this can be overridden.
|
||||
switch (config.windowColorspace) {
|
||||
switch (self.derivedConfig.windowColorspace) {
|
||||
case "display-p3":
|
||||
window.colorSpace = .displayP3
|
||||
case "srgb":
|
||||
@ -331,7 +329,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
}
|
||||
|
||||
// If we have window transparency then set it transparent. Otherwise set it opaque.
|
||||
if (config.backgroundOpacity < 1) {
|
||||
if (self.derivedConfig.backgroundOpacity < 1) {
|
||||
window.isOpaque = false
|
||||
|
||||
// This is weird, but we don't use ".clear" because this creates a look that
|
||||
@ -391,24 +389,30 @@ class QuickTerminalController: BaseTerminalController {
|
||||
// Update our derived config
|
||||
self.derivedConfig = DerivedConfig(config)
|
||||
|
||||
syncAppearance(config)
|
||||
syncAppearance()
|
||||
}
|
||||
|
||||
private struct DerivedConfig {
|
||||
let quickTerminalScreen: QuickTerminalScreen
|
||||
let quickTerminalAnimationDuration: Double
|
||||
let quickTerminalAutoHide: Bool
|
||||
let windowColorspace: String
|
||||
let backgroundOpacity: Double
|
||||
|
||||
init() {
|
||||
self.quickTerminalScreen = .main
|
||||
self.quickTerminalAnimationDuration = 0.2
|
||||
self.quickTerminalAutoHide = true
|
||||
self.windowColorspace = ""
|
||||
self.backgroundOpacity = 1.0
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
self.quickTerminalScreen = config.quickTerminalScreen
|
||||
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
|
||||
self.quickTerminalAutoHide = config.quickTerminalAutoHide
|
||||
self.windowColorspace = config.windowColorspace
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -45,6 +45,11 @@ class BaseTerminalController: NSWindowController,
|
||||
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
|
||||
}
|
||||
|
||||
/// Whether the terminal surface should focus when the mouse is over it.
|
||||
var focusFollowsMouse: Bool {
|
||||
self.derivedConfig.focusFollowsMouse
|
||||
}
|
||||
|
||||
/// Non-nil when an alert is active so we don't overlap multiple.
|
||||
private var alert: NSAlert? = nil
|
||||
|
||||
@ -106,8 +111,8 @@ class BaseTerminalController: NSWindowController,
|
||||
// Listen for local events that we need to know of outside of
|
||||
// single surface handlers.
|
||||
self.eventMonitor = NSEvent.addLocalMonitorForEvents(
|
||||
matching: [.flagsChanged],
|
||||
handler: localEventHandler)
|
||||
matching: [.flagsChanged]
|
||||
) { [weak self] event in self?.localEventHandler(event) }
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -262,7 +267,6 @@ class BaseTerminalController: NSWindowController,
|
||||
|
||||
// Set the main window title
|
||||
window.title = to
|
||||
|
||||
}
|
||||
|
||||
func pwdDidChange(to: URL?) {
|
||||
@ -309,11 +313,11 @@ class BaseTerminalController: NSWindowController,
|
||||
// We consider our mode changed if the types change (obvious) but
|
||||
// also if its nil (not obvious) because nil means that the style has
|
||||
// likely changed but we don't support it.
|
||||
if newStyle == nil || type(of: newStyle) != type(of: oldStyle) {
|
||||
if newStyle == nil || type(of: newStyle!) != type(of: oldStyle) {
|
||||
// Our mode changed. Exit fullscreen (since we're toggling anyways)
|
||||
// and then unset the style so that we replace it next time.
|
||||
// and then set the new style for future use
|
||||
oldStyle.exit()
|
||||
self.fullscreenStyle = nil
|
||||
self.fullscreenStyle = newStyle
|
||||
|
||||
// We're done
|
||||
return
|
||||
@ -536,11 +540,11 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
|
||||
@IBAction func splitMoveFocusAbove(_ sender: Any) {
|
||||
splitMoveFocus(direction: .top)
|
||||
splitMoveFocus(direction: .up)
|
||||
}
|
||||
|
||||
@IBAction func splitMoveFocusBelow(_ sender: Any) {
|
||||
splitMoveFocus(direction: .bottom)
|
||||
splitMoveFocus(direction: .down)
|
||||
}
|
||||
|
||||
@IBAction func splitMoveFocusLeft(_ sender: Any) {
|
||||
@ -604,15 +608,18 @@ class BaseTerminalController: NSWindowController,
|
||||
private struct DerivedConfig {
|
||||
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
|
||||
let windowStepResize: Bool
|
||||
let focusFollowsMouse: Bool
|
||||
|
||||
init() {
|
||||
self.macosTitlebarProxyIcon = .visible
|
||||
self.windowStepResize = false
|
||||
self.focusFollowsMouse = false
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
|
||||
self.windowStepResize = config.windowStepResize
|
||||
self.focusFollowsMouse = config.focusFollowsMouse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -101,6 +101,12 @@ class TerminalController: BaseTerminalController {
|
||||
// When our fullscreen state changes, we resync our appearance because some
|
||||
// properties change when fullscreen or not.
|
||||
guard let focusedSurface else { return }
|
||||
if (!(fullscreenStyle?.isFullscreen ?? false) &&
|
||||
ghostty.config.macosTitlebarStyle == "hidden")
|
||||
{
|
||||
applyHiddenTitlebarStyle()
|
||||
}
|
||||
|
||||
syncAppearance(focusedSurface.derivedConfig)
|
||||
}
|
||||
|
||||
@ -117,9 +123,6 @@ class TerminalController: BaseTerminalController {
|
||||
// Update our derived config
|
||||
self.derivedConfig = DerivedConfig(config)
|
||||
|
||||
guard let window = window as? TerminalWindow else { return }
|
||||
window.focusFollowsMouse = config.focusFollowsMouse
|
||||
|
||||
// If we have no surfaces in our window (is that possible?) then we update
|
||||
// our window appearance based on the root config. If we have surfaces, we
|
||||
// don't call this because the TODO
|
||||
@ -247,7 +250,9 @@ class TerminalController: BaseTerminalController {
|
||||
let backgroundColor: OSColor
|
||||
if let surfaceTree {
|
||||
if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) {
|
||||
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor)
|
||||
// Similar to above, an alpha component of "0" causes compositor issues, so
|
||||
// we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308
|
||||
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001)
|
||||
} else {
|
||||
// We don't have a focused surface or our surface doesn't border the
|
||||
// top. We choose to match the color of the top-left most surface.
|
||||
@ -270,6 +275,28 @@ class TerminalController: BaseTerminalController {
|
||||
}
|
||||
}
|
||||
|
||||
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
|
||||
guard let window else { return }
|
||||
|
||||
// If we don't have both an X and Y we center.
|
||||
guard let x, let y else {
|
||||
window.center()
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the screen our window is being placed on otherwise our primary screen.
|
||||
guard let screen = window.screen ?? NSScreen.screens.first else {
|
||||
window.center()
|
||||
return
|
||||
}
|
||||
|
||||
// Orient based on the top left of the primary monitor
|
||||
let frame = screen.visibleFrame
|
||||
window.setFrameOrigin(.init(
|
||||
x: frame.minX + CGFloat(x),
|
||||
y: frame.maxY - (CGFloat(y) + window.frame.height)))
|
||||
}
|
||||
|
||||
//MARK: - NSWindowController
|
||||
|
||||
override func windowWillLoad() {
|
||||
@ -277,6 +304,43 @@ class TerminalController: BaseTerminalController {
|
||||
shouldCascadeWindows = false
|
||||
}
|
||||
|
||||
fileprivate func applyHiddenTitlebarStyle() {
|
||||
guard let window else { return }
|
||||
|
||||
window.styleMask = [
|
||||
// We need `titled` in the mask to get the normal window frame
|
||||
.titled,
|
||||
|
||||
// Full size content view so we can extend
|
||||
// content in to the hidden titlebar's area
|
||||
.fullSizeContentView,
|
||||
|
||||
.resizable,
|
||||
.closable,
|
||||
.miniaturizable,
|
||||
]
|
||||
|
||||
// Hide the title
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
|
||||
// Hide the traffic lights (window control buttons)
|
||||
window.standardWindowButton(.closeButton)?.isHidden = true
|
||||
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
window.standardWindowButton(.zoomButton)?.isHidden = true
|
||||
|
||||
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
|
||||
window.tabbingMode = .disallowed
|
||||
|
||||
// Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
|
||||
// some operations that appear to bring back the titlebar visibility so this ensures
|
||||
// it is gone forever.
|
||||
if let themeFrame = window.contentView?.superview,
|
||||
let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
|
||||
titleBarContainer.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
override func windowDidLoad() {
|
||||
super.windowDidLoad()
|
||||
guard let window = window as? TerminalWindow else { return }
|
||||
@ -328,9 +392,12 @@ class TerminalController: BaseTerminalController {
|
||||
}
|
||||
}
|
||||
|
||||
// Center the window to start, we'll move the window frame automatically
|
||||
// when cascading.
|
||||
window.center()
|
||||
// Set our window positioning to coordinates if config value exists, otherwise
|
||||
// fallback to original centering behavior
|
||||
setInitialWindowPosition(
|
||||
x: config.windowPositionX,
|
||||
y: config.windowPositionY,
|
||||
windowDecorations: config.windowDecorations)
|
||||
|
||||
// Make sure our theme is set on the window so styling is correct.
|
||||
if let windowTheme = config.windowTheme {
|
||||
@ -368,38 +435,7 @@ class TerminalController: BaseTerminalController {
|
||||
|
||||
// If our titlebar style is "hidden" we adjust the style appropriately
|
||||
if (config.macosTitlebarStyle == "hidden") {
|
||||
window.styleMask = [
|
||||
// We need `titled` in the mask to get the normal window frame
|
||||
.titled,
|
||||
|
||||
// Full size content view so we can extend
|
||||
// content in to the hidden titlebar's area
|
||||
.fullSizeContentView,
|
||||
|
||||
.resizable,
|
||||
.closable,
|
||||
.miniaturizable,
|
||||
]
|
||||
|
||||
// Hide the title
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
|
||||
// Hide the traffic lights (window control buttons)
|
||||
window.standardWindowButton(.closeButton)?.isHidden = true
|
||||
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
window.standardWindowButton(.zoomButton)?.isHidden = true
|
||||
|
||||
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
|
||||
window.tabbingMode = .disallowed
|
||||
|
||||
// Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
|
||||
// some operations that appear to bring back the titlebar visibility so this ensures
|
||||
// it is gone forever.
|
||||
if let themeFrame = window.contentView?.superview,
|
||||
let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
|
||||
titleBarContainer.isHidden = true
|
||||
}
|
||||
applyHiddenTitlebarStyle()
|
||||
}
|
||||
|
||||
// In various situations, macOS automatically tabs new windows. Ghostty handles
|
||||
@ -422,8 +458,6 @@ class TerminalController: BaseTerminalController {
|
||||
}
|
||||
}
|
||||
|
||||
window.focusFollowsMouse = config.focusFollowsMouse
|
||||
|
||||
// Apply any additional appearance-related properties to the new window. We
|
||||
// apply this based on the root config but change it later based on surface
|
||||
// config (see focused surface change callback).
|
||||
|
@ -414,8 +414,6 @@ class TerminalWindow: NSWindow {
|
||||
}
|
||||
}
|
||||
|
||||
var focusFollowsMouse: Bool = false
|
||||
|
||||
// Find the NSTextField responsible for displaying the titlebar's title.
|
||||
private var titlebarTextField: NSTextField? {
|
||||
guard let titlebarView = titlebarContainer?.subviews
|
||||
|
@ -150,6 +150,20 @@ extension Ghostty {
|
||||
return String(cString: ptr)
|
||||
}
|
||||
|
||||
var windowPositionX: Int16? {
|
||||
guard let config = self.config else { return nil }
|
||||
var v: Int16 = 0
|
||||
let key = "window-position-x"
|
||||
return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil
|
||||
}
|
||||
|
||||
var windowPositionY: Int16? {
|
||||
guard let config = self.config else { return nil }
|
||||
var v: Int16 = 0
|
||||
let key = "window-position-y"
|
||||
return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil
|
||||
}
|
||||
|
||||
var windowNewTabPosition: String {
|
||||
guard let config = self.config else { return "" }
|
||||
var v: UnsafePointer<Int8>? = nil
|
||||
@ -437,15 +451,14 @@ extension Ghostty {
|
||||
return v;
|
||||
}
|
||||
|
||||
var autoUpdate: AutoUpdate {
|
||||
let defaultValue = AutoUpdate.check
|
||||
guard let config = self.config else { return defaultValue }
|
||||
var autoUpdate: AutoUpdate? {
|
||||
guard let config = self.config else { return nil }
|
||||
var v: UnsafePointer<Int8>? = nil
|
||||
let key = "auto-update"
|
||||
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
|
||||
guard let ptr = v else { return defaultValue }
|
||||
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil }
|
||||
guard let ptr = v else { return nil }
|
||||
let str = String(cString: ptr)
|
||||
return AutoUpdate(rawValue: str) ?? defaultValue
|
||||
return AutoUpdate(rawValue: str)
|
||||
}
|
||||
|
||||
var autoUpdateChannel: AutoUpdateChannel {
|
||||
|
@ -51,7 +51,7 @@ extension Ghostty {
|
||||
/// Returns the view that would prefer receiving focus in this tree. This is always the
|
||||
/// top-left-most view. This is used when creating a split or closing a split to find the
|
||||
/// next view to send focus to.
|
||||
func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView {
|
||||
func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView {
|
||||
let container: Container
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
@ -64,10 +64,10 @@ extension Ghostty {
|
||||
|
||||
let node: SplitNode
|
||||
switch (direction) {
|
||||
case .previous, .top, .left:
|
||||
case .previous, .up, .left:
|
||||
node = container.bottomRight
|
||||
|
||||
case .next, .bottom, .right:
|
||||
case .next, .down, .right:
|
||||
node = container.topLeft
|
||||
}
|
||||
|
||||
@ -431,12 +431,12 @@ extension Ghostty {
|
||||
struct Neighbors {
|
||||
var left: SplitNode?
|
||||
var right: SplitNode?
|
||||
var top: SplitNode?
|
||||
var bottom: SplitNode?
|
||||
var up: SplitNode?
|
||||
var down: SplitNode?
|
||||
|
||||
/// These are the previous/next nodes. It will certainly be one of the above as well
|
||||
/// but we keep track of these separately because depending on the split direction
|
||||
/// of the containing node, previous may be left OR top (same for next).
|
||||
/// of the containing node, previous may be left OR up (same for next).
|
||||
var previous: SplitNode?
|
||||
var next: SplitNode?
|
||||
|
||||
@ -448,8 +448,8 @@ extension Ghostty {
|
||||
let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
|
||||
.previous: \.previous,
|
||||
.next: \.next,
|
||||
.top: \.top,
|
||||
.bottom: \.bottom,
|
||||
.up: \.up,
|
||||
.down: \.down,
|
||||
.left: \.left,
|
||||
.right: \.right,
|
||||
]
|
||||
|
@ -308,7 +308,7 @@ extension Ghostty {
|
||||
resizeIncrements: .init(width: 1, height: 1),
|
||||
resizePublisher: container.resizeEvent,
|
||||
left: {
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.bottom
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.down
|
||||
|
||||
TerminalSplitNested(
|
||||
node: closeableTopLeft(),
|
||||
@ -318,7 +318,7 @@ extension Ghostty {
|
||||
])
|
||||
)
|
||||
}, right: {
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.top
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.up
|
||||
|
||||
TerminalSplitNested(
|
||||
node: closeableBottomRight(),
|
||||
|
@ -66,7 +66,7 @@ extension Ghostty {
|
||||
|
||||
/// An enum that is used for the directions that a split focus event can change.
|
||||
enum SplitFocusDirection {
|
||||
case previous, next, top, bottom, left, right
|
||||
case previous, next, up, down, left, right
|
||||
|
||||
/// Initialize from a Ghostty API enum.
|
||||
static func from(direction: ghostty_action_goto_split_e) -> Self? {
|
||||
@ -77,11 +77,11 @@ extension Ghostty {
|
||||
case GHOSTTY_GOTO_SPLIT_NEXT:
|
||||
return .next
|
||||
|
||||
case GHOSTTY_GOTO_SPLIT_TOP:
|
||||
return .top
|
||||
case GHOSTTY_GOTO_SPLIT_UP:
|
||||
return .up
|
||||
|
||||
case GHOSTTY_GOTO_SPLIT_BOTTOM:
|
||||
return .bottom
|
||||
case GHOSTTY_GOTO_SPLIT_DOWN:
|
||||
return .down
|
||||
|
||||
case GHOSTTY_GOTO_SPLIT_LEFT:
|
||||
return .left
|
||||
@ -102,11 +102,11 @@ extension Ghostty {
|
||||
case .next:
|
||||
return GHOSTTY_GOTO_SPLIT_NEXT
|
||||
|
||||
case .top:
|
||||
return GHOSTTY_GOTO_SPLIT_TOP
|
||||
case .up:
|
||||
return GHOSTTY_GOTO_SPLIT_UP
|
||||
|
||||
case .bottom:
|
||||
return GHOSTTY_GOTO_SPLIT_BOTTOM
|
||||
case .down:
|
||||
return GHOSTTY_GOTO_SPLIT_DOWN
|
||||
|
||||
case .left:
|
||||
return GHOSTTY_GOTO_SPLIT_LEFT
|
||||
|
@ -617,11 +617,12 @@ extension Ghostty {
|
||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
||||
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods)
|
||||
|
||||
// If focus follows mouse is enabled then move focus to this surface.
|
||||
if let window = self.window as? TerminalWindow,
|
||||
window.isKeyWindow &&
|
||||
window.focusFollowsMouse &&
|
||||
!self.focused
|
||||
// Handle focus-follows-mouse
|
||||
if let window,
|
||||
let controller = window.windowController as? BaseTerminalController,
|
||||
(window.isKeyWindow &&
|
||||
!self.focused &&
|
||||
controller.focusFollowsMouse)
|
||||
{
|
||||
Ghostty.moveFocus(to: self)
|
||||
}
|
||||
|
@ -111,7 +111,7 @@
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "ghostty";
|
||||
version = "1.0.1";
|
||||
version = "1.0.2";
|
||||
inherit src;
|
||||
|
||||
nativeBuildInputs = [
|
||||
|
@ -1,3 +1,3 @@
|
||||
# This file is auto-generated! check build-support/check-zig-cache-hash.sh for
|
||||
# more details.
|
||||
"sha256-lfXkAoEd0lB25YV2cB4VVe+CI01DDY5PVOtvq//Qw/U="
|
||||
"sha256-njCce+r1DPTKLNrmrD2ObEoBS9nR7q03hqegQWe1UuY="
|
||||
|
@ -14,7 +14,6 @@ pub fn build(b: *std.Build) !void {
|
||||
.@"enable-libpng" = true,
|
||||
});
|
||||
const macos = b.dependency("macos", .{ .target = target, .optimize = optimize });
|
||||
const upstream = b.dependency("harfbuzz", .{});
|
||||
|
||||
const module = b.addModule("harfbuzz", .{
|
||||
.root_source_file = b.path("main.zig"),
|
||||
@ -26,6 +25,62 @@ pub fn build(b: *std.Build) !void {
|
||||
},
|
||||
});
|
||||
|
||||
// For dynamic linking, we prefer dynamic linking and to search by
|
||||
// mode first. Mode first will search all paths for a dynamic library
|
||||
// before falling back to static.
|
||||
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
|
||||
.preferred_link_mode = .dynamic,
|
||||
.search_strategy = .mode_first,
|
||||
};
|
||||
|
||||
const test_exe = b.addTest(.{
|
||||
.name = "test",
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
{
|
||||
var it = module.import_table.iterator();
|
||||
while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*);
|
||||
test_exe.linkLibrary(freetype.artifact("freetype"));
|
||||
const tests_run = b.addRunArtifact(test_exe);
|
||||
const test_step = b.step("test", "Run tests");
|
||||
test_step.dependOn(&tests_run.step);
|
||||
}
|
||||
|
||||
if (b.systemIntegrationOption("harfbuzz", .{})) {
|
||||
module.linkSystemLibrary("harfbuzz", dynamic_link_opts);
|
||||
test_exe.linkSystemLibrary2("harfbuzz", dynamic_link_opts);
|
||||
} else {
|
||||
const lib = try buildLib(b, module, .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
|
||||
.coretext_enabled = coretext_enabled,
|
||||
.freetype_enabled = freetype_enabled,
|
||||
|
||||
.dynamic_link_opts = dynamic_link_opts,
|
||||
});
|
||||
|
||||
test_exe.linkLibrary(lib);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile {
|
||||
const target = options.target;
|
||||
const optimize = options.optimize;
|
||||
|
||||
const coretext_enabled = options.coretext_enabled;
|
||||
const freetype_enabled = options.freetype_enabled;
|
||||
|
||||
const freetype = b.dependency("freetype", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.@"enable-libpng" = true,
|
||||
});
|
||||
|
||||
const upstream = b.dependency("harfbuzz", .{});
|
||||
const lib = b.addStaticLibrary(.{
|
||||
.name = "harfbuzz",
|
||||
.target = target,
|
||||
@ -41,13 +96,7 @@ pub fn build(b: *std.Build) !void {
|
||||
try apple_sdk.addPaths(b, module);
|
||||
}
|
||||
|
||||
// For dynamic linking, we prefer dynamic linking and to search by
|
||||
// mode first. Mode first will search all paths for a dynamic library
|
||||
// before falling back to static.
|
||||
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
|
||||
.preferred_link_mode = .dynamic,
|
||||
.search_strategy = .mode_first,
|
||||
};
|
||||
const dynamic_link_opts = options.dynamic_link_opts;
|
||||
|
||||
var flags = std.ArrayList([]const u8).init(b.allocator);
|
||||
defer flags.deinit();
|
||||
@ -102,20 +151,5 @@ pub fn build(b: *std.Build) !void {
|
||||
|
||||
b.installArtifact(lib);
|
||||
|
||||
{
|
||||
const test_exe = b.addTest(.{
|
||||
.name = "test",
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
test_exe.linkLibrary(lib);
|
||||
|
||||
var it = module.import_table.iterator();
|
||||
while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*);
|
||||
test_exe.linkLibrary(freetype.artifact("freetype"));
|
||||
const tests_run = b.addRunArtifact(test_exe);
|
||||
const test_step = b.step("test", "Run tests");
|
||||
test_step.dependOn(&tests_run.step);
|
||||
}
|
||||
return lib;
|
||||
}
|
||||
|
@ -30,4 +30,36 @@ pub fn build(b: *std.Build) !void {
|
||||
.file = wuffs.path("release/c/wuffs-v0.4.c"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
const unit_tests = b.addTest(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
unit_tests.linkLibC();
|
||||
unit_tests.addIncludePath(wuffs.path("release/c"));
|
||||
unit_tests.addCSourceFile(.{
|
||||
.file = wuffs.path("release/c/wuffs-v0.4.c"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
const pixels = b.dependency("pixels", .{});
|
||||
|
||||
inline for (.{ "000000", "FFFFFF" }) |color| {
|
||||
inline for (.{ "gif", "jpg", "png", "ppm" }) |extension| {
|
||||
const filename = std.fmt.comptimePrint("1x1#{s}.{s}", .{ color, extension });
|
||||
unit_tests.root_module.addAnonymousImport(
|
||||
filename,
|
||||
.{
|
||||
.root_source_file = pixels.path(filename),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const run_unit_tests = b.addRunArtifact(unit_tests);
|
||||
|
||||
const test_step = b.step("test", "Run unit tests");
|
||||
test_step.dependOn(&run_unit_tests.step);
|
||||
}
|
||||
|
@ -3,8 +3,13 @@
|
||||
.version = "0.0.0",
|
||||
.dependencies = .{
|
||||
.wuffs = .{
|
||||
.url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.8.tar.gz",
|
||||
.hash = "12200984439edc817fbcbbaff564020e5104a0d04a2d0f53080700827052de700462",
|
||||
.url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.9.tar.gz",
|
||||
.hash = "122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd",
|
||||
},
|
||||
|
||||
.pixels = .{
|
||||
.url = "git+https://github.com/make-github-pseudonymous-again/pixels?ref=main#d843c2714d32e15b48b8d7eeb480295af537f877",
|
||||
.hash = "12207ff340169c7d40c570b4b6a97db614fe47e0d83b5801a932dcd44917424c8806",
|
||||
},
|
||||
|
||||
.apple_sdk = .{ .path = "../apple-sdk" },
|
||||
|
@ -1,3 +1,13 @@
|
||||
const std = @import("std");
|
||||
|
||||
const c = @import("c.zig").c;
|
||||
|
||||
pub const Error = std.mem.Allocator.Error || error{WuffsError};
|
||||
|
||||
pub fn check(log: anytype, status: *const c.struct_wuffs_base__status__struct) error{WuffsError}!void {
|
||||
if (!c.wuffs_base__status__is_ok(status)) {
|
||||
const e = c.wuffs_base__status__message(status);
|
||||
log.warn("decode err={s}", .{e});
|
||||
return error.WuffsError;
|
||||
}
|
||||
}
|
||||
|
143
pkg/wuffs/src/jpeg.zig
Normal file
143
pkg/wuffs/src/jpeg.zig
Normal file
@ -0,0 +1,143 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const c = @import("c.zig").c;
|
||||
const Error = @import("error.zig").Error;
|
||||
const check = @import("error.zig").check;
|
||||
const ImageData = @import("main.zig").ImageData;
|
||||
|
||||
const log = std.log.scoped(.wuffs_jpeg);
|
||||
|
||||
/// Decode a JPEG image.
|
||||
pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
|
||||
// Work around some weirdness in WUFFS/Zig, there are some structs that
|
||||
// are defined as "extern" by the Zig compiler which means that Zig won't
|
||||
// allocate them on the stack at compile time. WUFFS has functions for
|
||||
// dynamically allocating these structs but they use the C malloc/free. This
|
||||
// gets around that by using the Zig allocator to allocate enough memory for
|
||||
// the struct and then casts it to the appropriate pointer.
|
||||
|
||||
const decoder_buf = try alloc.alloc(u8, c.sizeof__wuffs_jpeg__decoder());
|
||||
defer alloc.free(decoder_buf);
|
||||
|
||||
const decoder: ?*c.wuffs_jpeg__decoder = @ptrCast(decoder_buf);
|
||||
{
|
||||
const status = c.wuffs_jpeg__decoder__initialize(
|
||||
decoder,
|
||||
c.sizeof__wuffs_jpeg__decoder(),
|
||||
c.WUFFS_VERSION,
|
||||
0,
|
||||
);
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
var source_buffer: c.wuffs_base__io_buffer = .{
|
||||
.data = .{ .ptr = @constCast(@ptrCast(data.ptr)), .len = data.len },
|
||||
.meta = .{
|
||||
.wi = data.len,
|
||||
.ri = 0,
|
||||
.pos = 0,
|
||||
.closed = true,
|
||||
},
|
||||
};
|
||||
|
||||
var image_config: c.wuffs_base__image_config = undefined;
|
||||
{
|
||||
const status = c.wuffs_jpeg__decoder__decode_image_config(
|
||||
decoder,
|
||||
&image_config,
|
||||
&source_buffer,
|
||||
);
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg);
|
||||
const height = c.wuffs_base__pixel_config__height(&image_config.pixcfg);
|
||||
|
||||
c.wuffs_base__pixel_config__set(
|
||||
&image_config.pixcfg,
|
||||
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL,
|
||||
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
const destination = try alloc.alloc(
|
||||
u8,
|
||||
width * height * @sizeOf(c.wuffs_base__color_u32_argb_premul),
|
||||
);
|
||||
errdefer alloc.free(destination);
|
||||
|
||||
// temporary buffer for intermediate processing of image
|
||||
const work_buffer = try alloc.alloc(
|
||||
u8,
|
||||
|
||||
// The type of this is a u64 on all systems but our allocator
|
||||
// uses a usize which is a u32 on 32-bit systems.
|
||||
std.math.cast(
|
||||
usize,
|
||||
c.wuffs_jpeg__decoder__workbuf_len(decoder).max_incl,
|
||||
) orelse return error.OutOfMemory,
|
||||
);
|
||||
defer alloc.free(work_buffer);
|
||||
|
||||
const work_slice = c.wuffs_base__make_slice_u8(
|
||||
work_buffer.ptr,
|
||||
work_buffer.len,
|
||||
);
|
||||
|
||||
var pixel_buffer: c.wuffs_base__pixel_buffer = undefined;
|
||||
{
|
||||
const status = c.wuffs_base__pixel_buffer__set_from_slice(
|
||||
&pixel_buffer,
|
||||
&image_config.pixcfg,
|
||||
c.wuffs_base__make_slice_u8(destination.ptr, destination.len),
|
||||
);
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
var frame_config: c.wuffs_base__frame_config = undefined;
|
||||
{
|
||||
const status = c.wuffs_jpeg__decoder__decode_frame_config(
|
||||
decoder,
|
||||
&frame_config,
|
||||
&source_buffer,
|
||||
);
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
{
|
||||
const status = c.wuffs_jpeg__decoder__decode_frame(
|
||||
decoder,
|
||||
&pixel_buffer,
|
||||
&source_buffer,
|
||||
c.WUFFS_BASE__PIXEL_BLEND__SRC,
|
||||
work_slice,
|
||||
null,
|
||||
);
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
return .{
|
||||
.width = width,
|
||||
.height = height,
|
||||
.data = destination,
|
||||
};
|
||||
}
|
||||
|
||||
test "jpeg_decode_000000" {
|
||||
const data = try decode(std.testing.allocator, @embedFile("1x1#000000.jpg"));
|
||||
defer std.testing.allocator.free(data.data);
|
||||
|
||||
try std.testing.expectEqual(1, data.width);
|
||||
try std.testing.expectEqual(1, data.height);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data);
|
||||
}
|
||||
|
||||
test "jpeg_decode_FFFFFF" {
|
||||
const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.jpg"));
|
||||
defer std.testing.allocator.free(data.data);
|
||||
|
||||
try std.testing.expectEqual(1, data.width);
|
||||
try std.testing.expectEqual(1, data.height);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data);
|
||||
}
|
@ -1,2 +1,15 @@
|
||||
const std = @import("std");
|
||||
|
||||
pub const png = @import("png.zig");
|
||||
pub const jpeg = @import("jpeg.zig");
|
||||
pub const swizzle = @import("swizzle.zig");
|
||||
|
||||
pub const ImageData = struct {
|
||||
width: u32,
|
||||
height: u32,
|
||||
data: []const u8,
|
||||
};
|
||||
|
||||
test {
|
||||
std.testing.refAllDeclsRecursive(@This());
|
||||
}
|
||||
|
@ -2,15 +2,13 @@ const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const c = @import("c.zig").c;
|
||||
const Error = @import("error.zig").Error;
|
||||
const check = @import("error.zig").check;
|
||||
const ImageData = @import("main.zig").ImageData;
|
||||
|
||||
const log = std.log.scoped(.wuffs_png);
|
||||
|
||||
/// Decode a PNG image.
|
||||
pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
|
||||
width: u32,
|
||||
height: u32,
|
||||
data: []const u8,
|
||||
} {
|
||||
pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
|
||||
// Work around some weirdness in WUFFS/Zig, there are some structs that
|
||||
// are defined as "extern" by the Zig compiler which means that Zig won't
|
||||
// allocate them on the stack at compile time. WUFFS has functions for
|
||||
@ -29,11 +27,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
|
||||
c.WUFFS_VERSION,
|
||||
0,
|
||||
);
|
||||
if (!c.wuffs_base__status__is_ok(&status)) {
|
||||
const e = c.wuffs_base__status__message(&status);
|
||||
log.warn("decode err={s}", .{e});
|
||||
return error.WuffsError;
|
||||
}
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
var source_buffer: c.wuffs_base__io_buffer = .{
|
||||
@ -53,11 +47,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
|
||||
&image_config,
|
||||
&source_buffer,
|
||||
);
|
||||
if (!c.wuffs_base__status__is_ok(&status)) {
|
||||
const e = c.wuffs_base__status__message(&status);
|
||||
log.warn("decode err={s}", .{e});
|
||||
return error.WuffsError;
|
||||
}
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg);
|
||||
@ -102,11 +92,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
|
||||
&image_config.pixcfg,
|
||||
c.wuffs_base__make_slice_u8(destination.ptr, destination.len),
|
||||
);
|
||||
if (!c.wuffs_base__status__is_ok(&status)) {
|
||||
const e = c.wuffs_base__status__message(&status);
|
||||
log.warn("decode err={s}", .{e});
|
||||
return error.WuffsError;
|
||||
}
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
var frame_config: c.wuffs_base__frame_config = undefined;
|
||||
@ -116,11 +102,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
|
||||
&frame_config,
|
||||
&source_buffer,
|
||||
);
|
||||
if (!c.wuffs_base__status__is_ok(&status)) {
|
||||
const e = c.wuffs_base__status__message(&status);
|
||||
log.warn("decode err={s}", .{e});
|
||||
return error.WuffsError;
|
||||
}
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
{
|
||||
@ -132,11 +114,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
|
||||
work_slice,
|
||||
null,
|
||||
);
|
||||
if (!c.wuffs_base__status__is_ok(&status)) {
|
||||
const e = c.wuffs_base__status__message(&status);
|
||||
log.warn("decode err={s}", .{e});
|
||||
return error.WuffsError;
|
||||
}
|
||||
try check(log, &status);
|
||||
}
|
||||
|
||||
return .{
|
||||
@ -145,3 +123,21 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
|
||||
.data = destination,
|
||||
};
|
||||
}
|
||||
|
||||
test "png_decode_000000" {
|
||||
const data = try decode(std.testing.allocator, @embedFile("1x1#000000.png"));
|
||||
defer std.testing.allocator.free(data.data);
|
||||
|
||||
try std.testing.expectEqual(1, data.width);
|
||||
try std.testing.expectEqual(1, data.height);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data);
|
||||
}
|
||||
|
||||
test "png_decode_FFFFFF" {
|
||||
const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.png"));
|
||||
defer std.testing.allocator.free(data.data);
|
||||
|
||||
try std.testing.expectEqual(1, data.width);
|
||||
try std.testing.expectEqual(1, data.height);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data);
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ const Command = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const global_state = &@import("global.zig").state;
|
||||
const internal_os = @import("os/main.zig");
|
||||
const windows = internal_os.windows;
|
||||
const TempDir = internal_os.TempDir;
|
||||
@ -175,6 +176,10 @@ fn startPosix(self: *Command, arena: Allocator) !void {
|
||||
// We don't log because that'll show up in the output.
|
||||
};
|
||||
|
||||
// Restore any rlimits that were set by Ghostty. This might fail but
|
||||
// any failures are ignored (its best effort).
|
||||
global_state.rlimits.restore();
|
||||
|
||||
// If the user requested a pre exec callback, call it now.
|
||||
if (self.pre_exec) |f| f(self);
|
||||
|
||||
|
133
src/Surface.zig
133
src/Surface.zig
@ -853,11 +853,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
|
||||
},
|
||||
|
||||
.color_change => |change| {
|
||||
// On any color change, we have to report for mode 2031
|
||||
// if it is enabled.
|
||||
self.reportColorScheme(false);
|
||||
|
||||
// Notify our apprt
|
||||
// Notify our apprt, but don't send a mode 2031 DSR report
|
||||
// because VT sequences were used to change the color.
|
||||
try self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.color_change,
|
||||
@ -1159,7 +1156,6 @@ pub fn updateConfig(
|
||||
}
|
||||
|
||||
// If we are in the middle of a key sequence, clear it.
|
||||
self.keyboard.bindings = null;
|
||||
self.endKeySequence(.drop, .free);
|
||||
|
||||
// Before sending any other config changes, we give the renderer a new font
|
||||
@ -1710,6 +1706,13 @@ pub fn keyCallback(
|
||||
// Update our modifiers, this will update mouse mods too
|
||||
self.modsChanged(event.mods);
|
||||
|
||||
// We only refresh links if
|
||||
// 1. mouse reporting is off
|
||||
// OR
|
||||
// 2. mouse reporting is on and we are not reporting shift to the terminal
|
||||
if (self.io.terminal.flags.mouse_event == .none or
|
||||
(self.mouse.mods.shift and !self.mouseShiftCapture(false)))
|
||||
{
|
||||
// Refresh our link state
|
||||
const pos = self.rt_surface.getCursorPos() catch break :mouse_mods;
|
||||
self.mouseRefreshLinks(
|
||||
@ -1720,6 +1723,20 @@ pub fn keyCallback(
|
||||
log.warn("failed to refresh links err={}", .{err});
|
||||
break :mouse_mods;
|
||||
};
|
||||
} else if (self.io.terminal.flags.mouse_event != .none and !self.mouse.mods.shift) {
|
||||
// If we have mouse reports on and we don't have shift pressed, we reset state
|
||||
try self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.mouse_shape,
|
||||
self.io.terminal.mouse_shape,
|
||||
);
|
||||
try self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.mouse_over_link,
|
||||
.{ .url = "" },
|
||||
);
|
||||
try self.queueRender();
|
||||
}
|
||||
}
|
||||
|
||||
// Process the cursor state logic. This will update the cursor shape if
|
||||
@ -1835,9 +1852,6 @@ fn maybeHandleBinding(
|
||||
if (self.keyboard.bindings != null and
|
||||
!event.key.modifier())
|
||||
{
|
||||
// Reset to the root set
|
||||
self.keyboard.bindings = null;
|
||||
|
||||
// Encode everything up to this point
|
||||
self.endKeySequence(.flush, .retain);
|
||||
}
|
||||
@ -1923,10 +1937,21 @@ fn maybeHandleBinding(
|
||||
return .closed;
|
||||
}
|
||||
|
||||
// If we have the performable flag and the action was not performed,
|
||||
// then we act as though a binding didn't exist.
|
||||
if (leaf.flags.performable and !performed) {
|
||||
// If we're in a sequence, we treat this as if we pressed a key
|
||||
// that doesn't exist in the sequence. Reset our sequence and flush
|
||||
// any queued events.
|
||||
self.endKeySequence(.flush, .retain);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we consume this event, then we are done. If we don't consume
|
||||
// it, we processed the action but we still want to process our
|
||||
// encodings, too.
|
||||
if (performed and consumed) {
|
||||
if (consumed) {
|
||||
// If we had queued events, we deinit them since we consumed
|
||||
self.endKeySequence(.drop, .retain);
|
||||
|
||||
@ -1968,6 +1993,10 @@ fn endKeySequence(
|
||||
);
|
||||
};
|
||||
|
||||
// No matter what we clear our current binding set. This restores
|
||||
// the set we look at to the root set.
|
||||
self.keyboard.bindings = null;
|
||||
|
||||
if (self.keyboard.queued.items.len > 0) {
|
||||
switch (action) {
|
||||
.flush => for (self.keyboard.queued.items) |write_req| {
|
||||
@ -3195,7 +3224,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
|
||||
.trim = false,
|
||||
});
|
||||
defer self.alloc.free(str);
|
||||
try internal_os.open(self.alloc, str);
|
||||
try internal_os.open(self.alloc, .unknown, str);
|
||||
},
|
||||
|
||||
._open_osc8 => {
|
||||
@ -3203,7 +3232,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
|
||||
log.warn("failed to get URI for OSC8 hyperlink", .{});
|
||||
return false;
|
||||
};
|
||||
try internal_os.open(self.alloc, uri);
|
||||
try internal_os.open(self.alloc, .unknown, uri);
|
||||
},
|
||||
}
|
||||
|
||||
@ -3350,6 +3379,27 @@ pub fn cursorPosCallback(
|
||||
try self.queueRender();
|
||||
}
|
||||
|
||||
// Handle link hovering
|
||||
// We refresh links when
|
||||
// 1. we were previously over a link
|
||||
// OR
|
||||
// 2. the cursor position has changed (either we have no previous state, or the state has
|
||||
// changed)
|
||||
// AND
|
||||
// 1. mouse reporting is off
|
||||
// OR
|
||||
// 2. mouse reporting is on and we are not reporting shift to the terminal
|
||||
if ((over_link or
|
||||
self.mouse.link_point == null or
|
||||
(self.mouse.link_point != null and !self.mouse.link_point.?.eql(pos_vp))) and
|
||||
(self.io.terminal.flags.mouse_event == .none or
|
||||
(self.mouse.mods.shift and !self.mouseShiftCapture(false))))
|
||||
{
|
||||
// If we were previously over a link, we always update. We do this so that if the text
|
||||
// changed underneath us, even if the mouse didn't move, we update the URL hints and state
|
||||
try self.mouseRefreshLinks(pos, pos_vp, over_link);
|
||||
}
|
||||
|
||||
// Do a mouse report
|
||||
if (self.io.terminal.flags.mouse_event != .none) report: {
|
||||
// Shift overrides mouse "grabbing" in the window, taken from Kitty.
|
||||
@ -3370,18 +3420,6 @@ pub fn cursorPosCallback(
|
||||
|
||||
try self.mouseReport(button, .motion, self.mouse.mods, pos);
|
||||
|
||||
// If we were previously over a link, we need to undo the link state.
|
||||
// We also queue a render so the renderer can undo the rendered link
|
||||
// state.
|
||||
if (over_link) {
|
||||
try self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.mouse_over_link,
|
||||
.{ .url = "" },
|
||||
);
|
||||
try self.queueRender();
|
||||
}
|
||||
|
||||
// If we're doing mouse motion tracking, we do not support text
|
||||
// selection.
|
||||
return;
|
||||
@ -3437,30 +3475,6 @@ pub fn cursorPosCallback(
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle link hovering
|
||||
if (self.mouse.link_point) |last_vp| {
|
||||
// Mark the link's row as dirty.
|
||||
if (over_link) {
|
||||
self.renderer_state.terminal.screen.dirty.hyperlink_hover = true;
|
||||
}
|
||||
|
||||
// If our last link viewport point is unchanged, then don't process
|
||||
// links. This avoids constantly reprocessing regular expressions
|
||||
// for every pixel change.
|
||||
if (last_vp.eql(pos_vp)) {
|
||||
// We have to restore old values that are always cleared
|
||||
if (over_link) {
|
||||
self.mouse.over_link = over_link;
|
||||
self.renderer_state.mouse.point = pos_vp;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// We can process new links.
|
||||
try self.mouseRefreshLinks(pos, pos_vp, over_link);
|
||||
}
|
||||
|
||||
/// Double-click dragging moves the selection one "word" at a time.
|
||||
@ -3886,7 +3900,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
log.err("error setting clipboard string err={}", .{err});
|
||||
return true;
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
.paste_from_clipboard => try self.startClipboardRequest(
|
||||
@ -4239,7 +4257,13 @@ fn writeScreenFile(
|
||||
const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)});
|
||||
|
||||
// Open our scrollback file
|
||||
var file = try tmp_dir.dir.createFile(filename, .{});
|
||||
var file = try tmp_dir.dir.createFile(
|
||||
filename,
|
||||
switch (builtin.os.tag) {
|
||||
.windows => .{},
|
||||
else => .{ .mode = 0o600 },
|
||||
},
|
||||
);
|
||||
defer file.close();
|
||||
|
||||
// Screen.dumpString writes byte-by-byte, so buffer it
|
||||
@ -4287,11 +4311,16 @@ fn writeScreenFile(
|
||||
tmp_dir.deinit();
|
||||
return;
|
||||
};
|
||||
|
||||
// Use topLeft and bottomRight to ensure correct coordinate ordering
|
||||
const tl = sel.topLeft(&self.io.terminal.screen);
|
||||
const br = sel.bottomRight(&self.io.terminal.screen);
|
||||
|
||||
try self.io.terminal.screen.dumpString(
|
||||
buf_writer.writer(),
|
||||
.{
|
||||
.tl = sel.start(),
|
||||
.br = sel.end(),
|
||||
.tl = tl,
|
||||
.br = br,
|
||||
.unwrap = true,
|
||||
},
|
||||
);
|
||||
@ -4303,7 +4332,7 @@ fn writeScreenFile(
|
||||
const path = try tmp_dir.dir.realpath(filename, &path_buf);
|
||||
|
||||
switch (write_action) {
|
||||
.open => try internal_os.open(self.alloc, path),
|
||||
.open => try internal_os.open(self.alloc, .text, path),
|
||||
.paste => self.io.queueMessage(try termio.Message.writeReq(
|
||||
self.alloc,
|
||||
path,
|
||||
|
@ -332,9 +332,9 @@ pub const GotoSplit = enum(c_int) {
|
||||
previous,
|
||||
next,
|
||||
|
||||
top,
|
||||
up,
|
||||
left,
|
||||
bottom,
|
||||
down,
|
||||
right,
|
||||
};
|
||||
|
||||
|
@ -510,6 +510,13 @@ pub const Surface = struct {
|
||||
) orelse return glfw.mustGetErrorCode();
|
||||
errdefer win.destroy();
|
||||
|
||||
// Setup our
|
||||
setInitialWindowPosition(
|
||||
win,
|
||||
app.config.@"window-position-x",
|
||||
app.config.@"window-position-y",
|
||||
);
|
||||
|
||||
// Get our physical DPI - debug only because we don't have a use for
|
||||
// this but the logging of it may be useful
|
||||
if (builtin.mode == .Debug) {
|
||||
@ -663,6 +670,17 @@ pub const Surface = struct {
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the initial window position. This is called exactly once at
|
||||
/// surface initialization time. This may be called before "self"
|
||||
/// is fully initialized.
|
||||
fn setInitialWindowPosition(win: glfw.Window, x: ?i16, y: ?i16) void {
|
||||
const start_position_x = x orelse return;
|
||||
const start_position_y = y orelse return;
|
||||
|
||||
log.debug("setting initial window position ({},{})", .{ start_position_x, start_position_y });
|
||||
win.setPos(.{ .x = start_position_x, .y = start_position_y });
|
||||
}
|
||||
|
||||
/// Set the size limits of the window.
|
||||
/// Note: this interface is not good, we should redo it if we plan
|
||||
/// to use this more. i.e. you can't set max width but no max height,
|
||||
|
@ -81,6 +81,9 @@ transient_cgroup_base: ?[]const u8 = null,
|
||||
/// CSS Provider for any styles based on ghostty configuration values
|
||||
css_provider: *c.GtkCssProvider,
|
||||
|
||||
/// Providers for loading custom stylesheets defined by user
|
||||
custom_css_providers: std.ArrayListUnmanaged(*c.GtkCssProvider) = .{},
|
||||
|
||||
/// The timer used to quit the application after the last window is closed.
|
||||
quit_timer: union(enum) {
|
||||
off: void,
|
||||
@ -108,7 +111,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
|
||||
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
|
||||
// For the remainder of "why" see the 4.14 comment below.
|
||||
_ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan");
|
||||
_ = internal_os.setenv("GDK_DEBUG", "opengl");
|
||||
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-no-fractional");
|
||||
} else if (version.atLeast(4, 14, 0)) {
|
||||
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14.
|
||||
// Older versions of GTK do not support these values so it is safe
|
||||
@ -123,7 +126,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
|
||||
// - "vulkan-disable" - disable Vulkan, Ghostty can't use Vulkan
|
||||
// and initializing a Vulkan context was causing a longer delay
|
||||
// on some systems.
|
||||
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable");
|
||||
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable,gl-no-fractional");
|
||||
} else {
|
||||
// Versions prior to 4.14 are a bit of an unknown for Ghostty. It
|
||||
// is an environment that isn't tested well and we don't have a
|
||||
@ -441,6 +444,11 @@ pub fn terminate(self: *App) void {
|
||||
if (self.context_menu) |context_menu| c.g_object_unref(context_menu);
|
||||
if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path);
|
||||
|
||||
for (self.custom_css_providers.items) |provider| {
|
||||
c.g_object_unref(provider);
|
||||
}
|
||||
self.custom_css_providers.deinit(self.core_app.alloc);
|
||||
|
||||
self.config.deinit();
|
||||
}
|
||||
|
||||
@ -786,6 +794,7 @@ fn setInitialSize(
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn showDesktopNotification(
|
||||
self: *App,
|
||||
target: apprt.Target,
|
||||
@ -892,7 +901,7 @@ fn syncConfigChanges(self: *App) !void {
|
||||
try self.updateConfigErrors();
|
||||
try self.syncActionAccelerators();
|
||||
|
||||
// Load our runtime CSS. If this fails then our window is just stuck
|
||||
// Load our runtime and custom CSS. If this fails then our window is just stuck
|
||||
// with the old CSS but we don't want to fail the entire sync operation.
|
||||
self.loadRuntimeCss() catch |err| switch (err) {
|
||||
error.OutOfMemory => log.warn(
|
||||
@ -900,6 +909,9 @@ fn syncConfigChanges(self: *App) !void {
|
||||
.{},
|
||||
),
|
||||
};
|
||||
self.loadCustomCss() catch |err| {
|
||||
log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
/// This should be called whenever the configuration changes to update
|
||||
@ -972,9 +984,6 @@ fn loadRuntimeCss(
|
||||
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
|
||||
|
||||
try writer.print(
|
||||
\\window.without-window-decoration-and-with-titlebar {{
|
||||
\\ border-radius: 0 0;
|
||||
\\}}
|
||||
\\widget.unfocused-split {{
|
||||
\\ opacity: {d:.2};
|
||||
\\ background-color: rgb({d},{d},{d});
|
||||
@ -1036,11 +1045,68 @@ fn loadRuntimeCss(
|
||||
}
|
||||
|
||||
// Clears any previously loaded CSS from this provider
|
||||
c.gtk_css_provider_load_from_data(
|
||||
self.css_provider,
|
||||
buf.items.ptr,
|
||||
@intCast(buf.items.len),
|
||||
loadCssProviderFromData(self.css_provider, buf.items);
|
||||
}
|
||||
|
||||
fn loadCustomCss(self: *App) !void {
|
||||
const display = c.gdk_display_get_default();
|
||||
|
||||
// unload the previously loaded style providers
|
||||
for (self.custom_css_providers.items) |provider| {
|
||||
c.gtk_style_context_remove_provider_for_display(
|
||||
display,
|
||||
@ptrCast(provider),
|
||||
);
|
||||
c.g_object_unref(provider);
|
||||
}
|
||||
self.custom_css_providers.clearRetainingCapacity();
|
||||
|
||||
for (self.config.@"gtk-custom-css".value.items) |p| {
|
||||
const path, const optional = switch (p) {
|
||||
.optional => |path| .{ path, true },
|
||||
.required => |path| .{ path, false },
|
||||
};
|
||||
const file = std.fs.openFileAbsolute(path, .{}) catch |err| {
|
||||
if (err != error.FileNotFound or !optional) {
|
||||
log.err("error opening gtk-custom-css file {s}: {}", .{ path, err });
|
||||
}
|
||||
continue;
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
log.info("loading gtk-custom-css path={s}", .{path});
|
||||
const contents = try file.reader().readAllAlloc(
|
||||
self.core_app.alloc,
|
||||
5 * 1024 * 1024 // 5MB
|
||||
);
|
||||
defer self.core_app.alloc.free(contents);
|
||||
|
||||
const provider = c.gtk_css_provider_new();
|
||||
c.gtk_style_context_add_provider_for_display(
|
||||
display,
|
||||
@ptrCast(provider),
|
||||
c.GTK_STYLE_PROVIDER_PRIORITY_USER,
|
||||
);
|
||||
|
||||
loadCssProviderFromData(provider, contents);
|
||||
|
||||
try self.custom_css_providers.append(self.core_app.alloc, provider);
|
||||
}
|
||||
}
|
||||
|
||||
fn loadCssProviderFromData(provider: *c.GtkCssProvider, data: []const u8) void {
|
||||
if (version.atLeast(4, 12, 0)) {
|
||||
const g_bytes = c.g_bytes_new(data.ptr, data.len);
|
||||
defer c.g_bytes_unref(g_bytes);
|
||||
|
||||
c.gtk_css_provider_load_from_bytes(provider, g_bytes);
|
||||
} else {
|
||||
c.gtk_css_provider_load_from_data(
|
||||
provider,
|
||||
data.ptr,
|
||||
@intCast(data.len),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by CoreApp to wake up the event loop.
|
||||
@ -1403,7 +1469,15 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme {
|
||||
null,
|
||||
&err,
|
||||
) orelse {
|
||||
if (err) |e| log.err("unable to get current color scheme: {s}", .{e.message});
|
||||
if (err) |e| {
|
||||
// If ReadOne is not yet implemented, fall back to deprecated "Read" method
|
||||
// Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne”
|
||||
if (e.code == 19) {
|
||||
return self.getColorSchemeDeprecated();
|
||||
}
|
||||
// Otherwise, log the error and return .light
|
||||
log.err("unable to get current color scheme: {s}", .{e.message});
|
||||
}
|
||||
return .light;
|
||||
};
|
||||
defer c.g_variant_unref(value);
|
||||
@ -1420,6 +1494,49 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme {
|
||||
return .light;
|
||||
}
|
||||
|
||||
/// Call the deprecated D-Bus "Read" method to determine the current color scheme. If
|
||||
/// there is any error at any point we'll log the error and return "light"
|
||||
fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme {
|
||||
const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app));
|
||||
var err: ?*c.GError = null;
|
||||
defer if (err) |e| c.g_error_free(e);
|
||||
|
||||
const value = c.g_dbus_connection_call_sync(
|
||||
dbus_connection,
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
"org.freedesktop.portal.Settings",
|
||||
"Read",
|
||||
c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
|
||||
c.G_VARIANT_TYPE("(v)"),
|
||||
c.G_DBUS_CALL_FLAGS_NONE,
|
||||
-1,
|
||||
null,
|
||||
&err,
|
||||
) orelse {
|
||||
if (err) |e| log.err("Read method failed: {s}", .{e.message});
|
||||
return .light;
|
||||
};
|
||||
defer c.g_variant_unref(value);
|
||||
|
||||
if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) {
|
||||
var inner: ?*c.GVariant = null;
|
||||
c.g_variant_get(value, "(v)", &inner);
|
||||
defer if (inner) |i| c.g_variant_unref(i);
|
||||
|
||||
if (inner) |i| {
|
||||
const child = c.g_variant_get_child_value(i, 0) orelse {
|
||||
return .light;
|
||||
};
|
||||
defer c.g_variant_unref(child);
|
||||
|
||||
const val = c.g_variant_get_uint32(child);
|
||||
return if (val == 1) .dark else .light;
|
||||
}
|
||||
}
|
||||
return .light;
|
||||
}
|
||||
|
||||
/// This will be called by D-Bus when the style changes between light & dark.
|
||||
fn gtkNotifyColorScheme(
|
||||
_: ?*c.GDBusConnection,
|
||||
|
@ -131,6 +131,7 @@ const PrimaryView = struct {
|
||||
c.gtk_text_view_set_bottom_margin(@ptrCast(text), 8);
|
||||
c.gtk_text_view_set_left_margin(@ptrCast(text), 8);
|
||||
c.gtk_text_view_set_right_margin(@ptrCast(text), 8);
|
||||
c.gtk_text_view_set_monospace(@ptrCast(text), 1);
|
||||
|
||||
return .{ .root = view.root, .text = @ptrCast(text) };
|
||||
}
|
||||
@ -238,7 +239,7 @@ fn promptText(req: apprt.ClipboardRequest) [:0]const u8 {
|
||||
\\Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.
|
||||
,
|
||||
.osc_52_read =>
|
||||
\\An appliclication is attempting to read from the clipboard.
|
||||
\\An application is attempting to read from the clipboard.
|
||||
\\The current clipboard contents are shown below.
|
||||
,
|
||||
.osc_52_write =>
|
||||
|
@ -111,16 +111,6 @@ pub fn init(
|
||||
// Keep a long-lived reference, which we unref in destroy.
|
||||
_ = c.g_object_ref(paned);
|
||||
|
||||
// Clicks
|
||||
const gesture_click = c.gtk_gesture_click_new();
|
||||
errdefer c.g_object_unref(gesture_click);
|
||||
c.gtk_event_controller_set_propagation_phase(@ptrCast(gesture_click), c.GTK_PHASE_CAPTURE);
|
||||
c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 1);
|
||||
c.gtk_widget_add_controller(paned, @ptrCast(gesture_click));
|
||||
|
||||
// Signals
|
||||
_ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(>kMouseDown), self, null, c.G_CONNECT_DEFAULT);
|
||||
|
||||
// Update all of our containers to point to the right place.
|
||||
// The split has to point to where the sibling pointed to because
|
||||
// we're inheriting its parent. The sibling points to its location
|
||||
@ -246,19 +236,6 @@ pub fn equalize(self: *Split) f64 {
|
||||
return weight;
|
||||
}
|
||||
|
||||
fn gtkMouseDown(
|
||||
_: *c.GtkGestureClick,
|
||||
n_press: c.gint,
|
||||
_: c.gdouble,
|
||||
_: c.gdouble,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.C) void {
|
||||
if (n_press == 2) {
|
||||
const self: *Split = @ptrCast(@alignCast(ud));
|
||||
_ = equalize(self);
|
||||
}
|
||||
}
|
||||
|
||||
// maxPosition returns the maximum position of the GtkPaned, which is the
|
||||
// "max-position" attribute.
|
||||
fn maxPosition(self: *Split) f64 {
|
||||
@ -339,7 +316,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap {
|
||||
// This behavior matches the behavior of macOS at the time of writing
|
||||
// this. There is an open issue (#524) to make this depend on the
|
||||
// actual physical location of the current split.
|
||||
result.put(.top, prev.surface);
|
||||
result.put(.up, prev.surface);
|
||||
result.put(.left, prev.surface);
|
||||
}
|
||||
}
|
||||
@ -347,7 +324,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap {
|
||||
if (self.directionNext(from)) |next| {
|
||||
result.put(.next, next.surface);
|
||||
if (!next.wrapped) {
|
||||
result.put(.bottom, next.surface);
|
||||
result.put(.down, next.surface);
|
||||
result.put(.right, next.surface);
|
||||
}
|
||||
}
|
||||
|
@ -794,10 +794,11 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
|
||||
// can support fractional scaling.
|
||||
const gtk_scale: f32 = @floatFromInt(c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area)));
|
||||
|
||||
// If we are on X11, we also have to scale using Xft.dpi
|
||||
const xft_dpi_scale = if (!x11.is_current_display_server()) 1.0 else xft_scale: {
|
||||
// Here we use GTK to retrieve gtk-xft-dpi, which is Xft.dpi multiplied
|
||||
// by 1024. See https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html
|
||||
// Also scale using font-specific DPI, which is often exposed to the user
|
||||
// via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html).
|
||||
const xft_dpi_scale = xft_scale: {
|
||||
// gtk-xft-dpi is font DPI multiplied by 1024. See
|
||||
// https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html
|
||||
const settings = c.gtk_settings_get_default();
|
||||
|
||||
var value: c.GValue = std.mem.zeroes(c.GValue);
|
||||
@ -806,10 +807,9 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
|
||||
c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value);
|
||||
const gtk_xft_dpi = c.g_value_get_int(&value);
|
||||
|
||||
// As noted above Xft.dpi is multiplied by 1024, so we divide by 1024,
|
||||
// then divide by the default value of Xft.dpi (96) to derive a scale.
|
||||
// Note that gtk-xft-dpi can be fractional, so we use floating point
|
||||
// math here.
|
||||
// As noted above gtk-xft-dpi is multiplied by 1024, so we divide by
|
||||
// 1024, then divide by the default value (96) to derive a scale. Note
|
||||
// gtk-xft-dpi can be fractional, so we use floating point math here.
|
||||
const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024;
|
||||
break :xft_scale xft_dpi / 96;
|
||||
};
|
||||
@ -1426,15 +1426,23 @@ fn gtkMouseMotion(
|
||||
.y = @floatCast(scaled.y),
|
||||
};
|
||||
|
||||
// Our pos changed, update
|
||||
self.cursor_pos = pos;
|
||||
// When the GLArea is resized under the mouse, GTK issues a mouse motion
|
||||
// event. This has the unfortunate side effect of causing focus to potentially
|
||||
// change when `focus-follows-mouse` is enabled. To prevent this, we check
|
||||
// if the cursor is still in the same place as the last event and only grab
|
||||
// focus if it has moved.
|
||||
const is_cursor_still = @abs(self.cursor_pos.x - pos.x) < 1 and
|
||||
@abs(self.cursor_pos.y - pos.y) < 1;
|
||||
|
||||
// If we don't have focus, and we want it, grab it.
|
||||
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
|
||||
if (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") {
|
||||
if (!is_cursor_still and c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") {
|
||||
self.grabFocus();
|
||||
}
|
||||
|
||||
// Our pos changed, update
|
||||
self.cursor_pos = pos;
|
||||
|
||||
// Get our modifiers
|
||||
const gtk_mods = c.gdk_event_get_modifier_state(event);
|
||||
const mods = gtk_key.translateMods(gtk_mods);
|
||||
|
@ -76,7 +76,7 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void {
|
||||
|
||||
// Set the userdata of the box to point to this tab.
|
||||
c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self);
|
||||
try window.notebook.addTab(self, "Ghostty");
|
||||
window.notebook.addTab(self, "Ghostty");
|
||||
|
||||
// Attach all events
|
||||
_ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT);
|
||||
|
@ -83,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
|
||||
// Create the window
|
||||
const window: *c.GtkWidget = window: {
|
||||
if (self.isAdwWindow()) {
|
||||
if ((comptime adwaita.versionAtLeast(0, 0, 0)) and adwaita.enabled(&self.app.config)) {
|
||||
const window = c.adw_application_window_new(app.app);
|
||||
c.gtk_widget_add_css_class(@ptrCast(window), "adw");
|
||||
break :window window;
|
||||
@ -99,6 +99,7 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
self.window = gtk_window;
|
||||
c.gtk_window_set_title(gtk_window, "Ghostty");
|
||||
c.gtk_window_set_default_size(gtk_window, 1000, 600);
|
||||
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "terminal-window");
|
||||
|
||||
// GTK4 grabs F10 input by default to focus the menubar icon. We want
|
||||
// to disable this so that terminal programs can capture F10 (such as htop)
|
||||
@ -122,12 +123,12 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0);
|
||||
|
||||
// Setup our notebook
|
||||
self.notebook = Notebook.create(self);
|
||||
self.notebook.init();
|
||||
|
||||
// If we are using Adwaita, then we can support the tab overview.
|
||||
self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 3, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 3, 0)) overview: {
|
||||
self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 4, 0)) overview: {
|
||||
const tab_overview = c.adw_tab_overview_new();
|
||||
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view);
|
||||
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view);
|
||||
c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1);
|
||||
_ = c.g_signal_connect_data(
|
||||
tab_overview,
|
||||
@ -156,6 +157,9 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
if (app.config.@"gtk-titlebar") {
|
||||
const header = HeaderBar.init(self);
|
||||
|
||||
// If we are not decorated then we hide the titlebar.
|
||||
header.setVisible(app.config.@"window-decoration");
|
||||
|
||||
{
|
||||
const btn = c.gtk_menu_button_new();
|
||||
c.gtk_widget_set_tooltip_text(btn, "Main Menu");
|
||||
@ -167,7 +171,7 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
// If we're using an AdwWindow then we can support the tab overview.
|
||||
if (self.tab_overview) |tab_overview| {
|
||||
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
|
||||
assert(self.isAdwWindow());
|
||||
assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0));
|
||||
const btn = switch (app.config.@"gtk-tabs-location") {
|
||||
.top, .bottom, .left, .right => btn: {
|
||||
const btn = c.gtk_toggle_button_new();
|
||||
@ -186,7 +190,7 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
|
||||
.hidden => btn: {
|
||||
const btn = c.adw_tab_button_new();
|
||||
c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw_tab_view);
|
||||
c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view);
|
||||
c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open");
|
||||
break :btn btn;
|
||||
},
|
||||
@ -216,6 +220,14 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
}
|
||||
}
|
||||
|
||||
// If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we
|
||||
// need to stick the headerbar into the content box.
|
||||
if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
|
||||
if (self.header) |h| {
|
||||
c.gtk_box_append(@ptrCast(box), h.asWidget());
|
||||
}
|
||||
}
|
||||
|
||||
// In debug we show a warning and apply the 'devel' class to the window.
|
||||
// This is a really common issue where people build from source in debug and performance is really bad.
|
||||
if (comptime std.debug.runtime_safety) {
|
||||
@ -256,8 +268,8 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
// If we have a tab overview then we can set it on our notebook.
|
||||
if (self.tab_overview) |tab_overview| {
|
||||
if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable;
|
||||
assert(self.notebook == .adw_tab_view);
|
||||
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view);
|
||||
assert(self.notebook == .adw);
|
||||
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view);
|
||||
}
|
||||
|
||||
self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu)));
|
||||
@ -284,16 +296,17 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
// Our actions for the menu
|
||||
initActions(self);
|
||||
|
||||
if (self.isAdwWindow()) {
|
||||
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
|
||||
if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
|
||||
const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new());
|
||||
|
||||
const header_widget: *c.GtkWidget = self.header.?.asWidget();
|
||||
if (self.header) |header| {
|
||||
const header_widget = header.asWidget();
|
||||
c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget);
|
||||
}
|
||||
|
||||
if (self.app.config.@"gtk-tabs-location" != .hidden) {
|
||||
const tab_bar = c.adw_tab_bar_new();
|
||||
c.adw_tab_bar_set_view(tab_bar, self.notebook.adw_tab_view);
|
||||
c.adw_tab_bar_set_view(tab_bar, self.notebook.adw.tab_view);
|
||||
|
||||
if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0);
|
||||
|
||||
@ -315,33 +328,19 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
c.adw_toolbar_view_set_top_bar_style(toolbar_view, toolbar_style);
|
||||
c.adw_toolbar_view_set_bottom_bar_style(toolbar_view, toolbar_style);
|
||||
|
||||
// If we are not decorated then we hide the titlebar.
|
||||
if (!app.config.@"window-decoration") {
|
||||
c.gtk_widget_set_visible(header_widget, 0);
|
||||
}
|
||||
|
||||
// Set our application window content. The content depends on if
|
||||
// we're using an AdwTabOverview or not.
|
||||
if (self.tab_overview) |tab_overview| {
|
||||
// Set our application window content.
|
||||
c.adw_tab_overview_set_child(
|
||||
@ptrCast(tab_overview),
|
||||
@ptrCast(self.tab_overview),
|
||||
@ptrCast(@alignCast(toolbar_view)),
|
||||
);
|
||||
c.adw_application_window_set_content(
|
||||
@ptrCast(gtk_window),
|
||||
@ptrCast(@alignCast(tab_overview)),
|
||||
@ptrCast(@alignCast(self.tab_overview)),
|
||||
);
|
||||
} else {
|
||||
c.adw_application_window_set_content(
|
||||
@ptrCast(gtk_window),
|
||||
@ptrCast(@alignCast(toolbar_view)),
|
||||
);
|
||||
}
|
||||
} else tab_bar: {
|
||||
switch (self.notebook) {
|
||||
.adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) {
|
||||
.adw => |*adw| if (comptime adwaita.versionAtLeast(0, 0, 0)) {
|
||||
if (app.config.@"gtk-tabs-location" == .hidden) break :tab_bar;
|
||||
|
||||
// In earlier adwaita versions, we need to add the tabbar manually since we do not use
|
||||
// an AdwToolbarView.
|
||||
const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?;
|
||||
@ -361,17 +360,26 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
),
|
||||
.hidden => unreachable,
|
||||
}
|
||||
c.adw_tab_bar_set_view(tab_bar, tab_view);
|
||||
c.adw_tab_bar_set_view(tab_bar, adw.tab_view);
|
||||
|
||||
if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0);
|
||||
},
|
||||
|
||||
.gtk_notebook => {},
|
||||
.gtk => {},
|
||||
}
|
||||
|
||||
// The box is our main child
|
||||
if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
|
||||
c.adw_application_window_set_content(
|
||||
@ptrCast(gtk_window),
|
||||
box,
|
||||
);
|
||||
} else {
|
||||
c.gtk_window_set_child(gtk_window, box);
|
||||
if (self.header) |h| c.gtk_window_set_titlebar(gtk_window, h.asWidget());
|
||||
if (self.header) |h| {
|
||||
c.gtk_window_set_titlebar(gtk_window, h.asWidget());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show the window
|
||||
@ -420,17 +428,6 @@ pub fn deinit(self: *Window) void {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this window should use an Adwaita window.
|
||||
///
|
||||
/// This must be `inline` so that the comptime check noops conditional
|
||||
/// paths that are not enabled.
|
||||
inline fn isAdwWindow(self: *Window) bool {
|
||||
return (comptime adwaita.versionAtLeast(1, 4, 0)) and
|
||||
adwaita.enabled(&self.app.config) and
|
||||
adwaita.versionAtLeast(1, 4, 0) and
|
||||
self.app.config.@"gtk-titlebar";
|
||||
}
|
||||
|
||||
/// Add a new tab to this window.
|
||||
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {
|
||||
const alloc = self.app.core_app.alloc;
|
||||
@ -517,13 +514,19 @@ pub fn toggleWindowDecorations(self: *Window) void {
|
||||
const new_decorated = !old_decorated;
|
||||
c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated));
|
||||
|
||||
// Fix any artifacting that may occur in window corners.
|
||||
if (new_decorated) {
|
||||
c.gtk_widget_add_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar");
|
||||
} else {
|
||||
c.gtk_widget_remove_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar");
|
||||
}
|
||||
|
||||
// If we have a titlebar, then we also show/hide it depending on the
|
||||
// decorated state. GTK tends to consider the titlebar part of the frame
|
||||
// and hides it with decorations, but libadwaita doesn't. This makes it
|
||||
// explicit.
|
||||
if (self.header) |v| {
|
||||
const widget = v.asWidget();
|
||||
c.gtk_widget_set_visible(widget, @intFromBool(new_decorated));
|
||||
if (self.header) |headerbar| {
|
||||
headerbar.setVisible(new_decorated);
|
||||
}
|
||||
}
|
||||
|
||||
@ -562,12 +565,12 @@ fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
|
||||
/// because we need to return an AdwTabPage from this function.
|
||||
fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwTabPage {
|
||||
const self: *Window = userdataSelf(ud.?);
|
||||
assert(self.isAdwWindow());
|
||||
assert((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config));
|
||||
|
||||
const alloc = self.app.core_app.alloc;
|
||||
const surface = self.actionSurface();
|
||||
const tab = Tab.create(alloc, self, surface) catch return null;
|
||||
return c.adw_tab_view_get_page(self.notebook.adw_tab_view, @ptrCast(@alignCast(tab.box)));
|
||||
return c.adw_tab_view_get_page(self.notebook.adw.tab_view, @ptrCast(@alignCast(tab.box)));
|
||||
}
|
||||
|
||||
fn adwTabOverviewOpen(
|
||||
@ -744,7 +747,7 @@ fn gtkActionAbout(
|
||||
|
||||
if ((comptime adwaita.versionAtLeast(1, 5, 0)) and
|
||||
adwaita.versionAtLeast(1, 5, 0) and
|
||||
self.isAdwWindow())
|
||||
adwaita.enabled(&self.app.config))
|
||||
{
|
||||
c.adw_show_about_dialog(
|
||||
@ptrCast(self.window),
|
||||
@ -892,8 +895,10 @@ fn gtkActionCopy(
|
||||
return;
|
||||
};
|
||||
|
||||
if (self.app.config.@"adw-toast".@"clipboard-copy") {
|
||||
self.sendToast("Copied to clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
fn gtkActionPaste(
|
||||
_: *c.GSimpleAction,
|
||||
|
@ -25,7 +25,10 @@ pub inline fn enabled(config: *const Config) bool {
|
||||
/// in the headers. If it is run in a runtime context, it will
|
||||
/// check the actual version of the library we are linked against.
|
||||
/// So generally you probably want to do both checks!
|
||||
pub fn versionAtLeast(
|
||||
///
|
||||
/// This is inlined so that the comptime checks will disable the
|
||||
/// runtime checks if the comptime checks fail.
|
||||
pub inline fn versionAtLeast(
|
||||
comptime major: u16,
|
||||
comptime minor: u16,
|
||||
comptime micro: u16,
|
||||
@ -37,8 +40,9 @@ pub fn versionAtLeast(
|
||||
// compiling against unknown symbols and makes runtime checks
|
||||
// very slightly faster.
|
||||
if (comptime c.ADW_MAJOR_VERSION < major or
|
||||
c.ADW_MINOR_VERSION < minor or
|
||||
c.ADW_MICRO_VERSION < micro) return false;
|
||||
(c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION < minor) or
|
||||
(c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION == minor and c.ADW_MICRO_VERSION < micro))
|
||||
return false;
|
||||
|
||||
// If we're in comptime then we can't check the runtime version.
|
||||
if (@inComptime()) return true;
|
||||
@ -56,3 +60,16 @@ pub fn versionAtLeast(
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
test "versionAtLeast" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
|
||||
try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
|
||||
try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
|
||||
try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
|
||||
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
|
||||
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
|
||||
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
|
||||
try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1));
|
||||
}
|
||||
|
@ -30,6 +30,10 @@ pub const HeaderBar = union(enum) {
|
||||
return .{ .gtk = @ptrCast(headerbar) };
|
||||
}
|
||||
|
||||
pub fn setVisible(self: HeaderBar, visible: bool) void {
|
||||
c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible));
|
||||
}
|
||||
|
||||
pub fn asWidget(self: HeaderBar) *c.GtkWidget {
|
||||
return switch (self) {
|
||||
.adw => |headerbar| @ptrCast(@alignCast(headerbar)),
|
||||
|
@ -4,161 +4,76 @@ const c = @import("c.zig").c;
|
||||
|
||||
const Window = @import("Window.zig");
|
||||
const Tab = @import("Tab.zig");
|
||||
const NotebookAdw = @import("notebook_adw.zig").NotebookAdw;
|
||||
const NotebookGtk = @import("notebook_gtk.zig").NotebookGtk;
|
||||
const adwaita = @import("adwaita.zig");
|
||||
|
||||
const log = std.log.scoped(.gtk);
|
||||
|
||||
const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque;
|
||||
|
||||
/// An abstraction over the GTK notebook and Adwaita tab view to manage
|
||||
/// all the terminal tabs in a window.
|
||||
/// An abstraction over the GTK notebook and Adwaita tab view to manage
|
||||
/// all the terminal tabs in a window.
|
||||
pub const Notebook = union(enum) {
|
||||
adw_tab_view: *AdwTabView,
|
||||
gtk_notebook: *c.GtkNotebook,
|
||||
adw: NotebookAdw,
|
||||
gtk: NotebookGtk,
|
||||
|
||||
pub fn create(window: *Window) Notebook {
|
||||
pub fn init(self: *Notebook) void {
|
||||
const window: *Window = @fieldParentPtr("notebook", self);
|
||||
const app = window.app;
|
||||
if (adwaita.enabled(&app.config)) return initAdw(window);
|
||||
return initGtk(window);
|
||||
if (adwaita.enabled(&app.config)) return NotebookAdw.init(self);
|
||||
|
||||
return NotebookGtk.init(self);
|
||||
}
|
||||
|
||||
fn initGtk(window: *Window) Notebook {
|
||||
const app = window.app;
|
||||
|
||||
// Create a notebook to hold our tabs.
|
||||
const notebook_widget: *c.GtkWidget = c.gtk_notebook_new();
|
||||
const notebook: *c.GtkNotebook = @ptrCast(notebook_widget);
|
||||
const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") {
|
||||
.top, .hidden => c.GTK_POS_TOP,
|
||||
.bottom => c.GTK_POS_BOTTOM,
|
||||
.left => c.GTK_POS_LEFT,
|
||||
.right => c.GTK_POS_RIGHT,
|
||||
};
|
||||
c.gtk_notebook_set_tab_pos(notebook, notebook_tab_pos);
|
||||
c.gtk_notebook_set_scrollable(notebook, 1);
|
||||
c.gtk_notebook_set_show_tabs(notebook, 0);
|
||||
c.gtk_notebook_set_show_border(notebook, 0);
|
||||
|
||||
// This enables all Ghostty terminal tabs to be exchanged across windows.
|
||||
c.gtk_notebook_set_group_name(notebook, "ghostty-terminal-tabs");
|
||||
|
||||
// This is important so the notebook expands to fit available space.
|
||||
// Otherwise, it will be zero/zero in the box below.
|
||||
c.gtk_widget_set_vexpand(notebook_widget, 1);
|
||||
c.gtk_widget_set_hexpand(notebook_widget, 1);
|
||||
|
||||
// Remove the background from the stack widget
|
||||
const stack = c.gtk_widget_get_last_child(notebook_widget);
|
||||
c.gtk_widget_add_css_class(stack, "transparent");
|
||||
|
||||
// All of our events
|
||||
_ = c.g_signal_connect_data(notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT);
|
||||
|
||||
return .{ .gtk_notebook = notebook };
|
||||
}
|
||||
|
||||
fn initAdw(window: *Window) Notebook {
|
||||
const app = window.app;
|
||||
assert(adwaita.enabled(&app.config));
|
||||
|
||||
const tab_view: *c.AdwTabView = c.adw_tab_view_new().?;
|
||||
|
||||
if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) {
|
||||
// Adwaita enables all of the shortcuts by default.
|
||||
// We want to manage keybindings ourselves.
|
||||
c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS);
|
||||
}
|
||||
|
||||
_ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT);
|
||||
|
||||
return .{ .adw_tab_view = tab_view };
|
||||
}
|
||||
|
||||
pub fn asWidget(self: Notebook) *c.GtkWidget {
|
||||
return switch (self) {
|
||||
.adw_tab_view => |tab_view| @ptrCast(@alignCast(tab_view)),
|
||||
.gtk_notebook => |notebook| @ptrCast(@alignCast(notebook)),
|
||||
pub fn asWidget(self: *Notebook) *c.GtkWidget {
|
||||
return switch (self.*) {
|
||||
.adw => |*adw| adw.asWidget(),
|
||||
.gtk => |*gtk| gtk.asWidget(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn nPages(self: Notebook) c_int {
|
||||
return switch (self) {
|
||||
.gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook),
|
||||
.adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0))
|
||||
c.adw_tab_view_get_n_pages(tab_view)
|
||||
else
|
||||
unreachable,
|
||||
pub fn nPages(self: *Notebook) c_int {
|
||||
return switch (self.*) {
|
||||
.adw => |*adw| adw.nPages(),
|
||||
.gtk => |*gtk| gtk.nPages(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the index of the currently selected page.
|
||||
/// Returns null if the notebook has no pages.
|
||||
fn currentPage(self: Notebook) ?c_int {
|
||||
switch (self) {
|
||||
.adw_tab_view => |tab_view| {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null;
|
||||
return c.adw_tab_view_get_page_position(tab_view, page);
|
||||
},
|
||||
|
||||
.gtk_notebook => |notebook| {
|
||||
const current = c.gtk_notebook_get_current_page(notebook);
|
||||
return if (current == -1) null else current;
|
||||
},
|
||||
}
|
||||
fn currentPage(self: *Notebook) ?c_int {
|
||||
return switch (self.*) {
|
||||
.adw => |*adw| adw.currentPage(),
|
||||
.gtk => |*gtk| gtk.currentPage(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the currently selected tab or null if there are none.
|
||||
pub fn currentTab(self: Notebook) ?*Tab {
|
||||
const child = switch (self) {
|
||||
.adw_tab_view => |tab_view| child: {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null;
|
||||
const child = c.adw_tab_page_get_child(page);
|
||||
break :child child;
|
||||
},
|
||||
|
||||
.gtk_notebook => |notebook| child: {
|
||||
const page = self.currentPage() orelse return null;
|
||||
break :child c.gtk_notebook_get_nth_page(notebook, page);
|
||||
},
|
||||
};
|
||||
return @ptrCast(@alignCast(
|
||||
c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null,
|
||||
));
|
||||
}
|
||||
|
||||
pub fn gotoNthTab(self: Notebook, position: c_int) void {
|
||||
switch (self) {
|
||||
.adw_tab_view => |tab_view| {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page_to_select = c.adw_tab_view_get_nth_page(tab_view, position);
|
||||
c.adw_tab_view_set_selected_page(tab_view, page_to_select);
|
||||
},
|
||||
.gtk_notebook => |notebook| c.gtk_notebook_set_current_page(notebook, position),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getTabPosition(self: Notebook, tab: *Tab) ?c_int {
|
||||
return switch (self) {
|
||||
.adw_tab_view => |tab_view| page_idx: {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return null;
|
||||
break :page_idx c.adw_tab_view_get_page_position(tab_view, page);
|
||||
},
|
||||
.gtk_notebook => |notebook| page_idx: {
|
||||
const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return null;
|
||||
break :page_idx getNotebookPageIndex(page);
|
||||
},
|
||||
pub fn currentTab(self: *Notebook) ?*Tab {
|
||||
return switch (self.*) {
|
||||
.adw => |*adw| adw.currentTab(),
|
||||
.gtk => |*gtk| gtk.currentTab(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn gotoPreviousTab(self: Notebook, tab: *Tab) void {
|
||||
pub fn gotoNthTab(self: *Notebook, position: c_int) void {
|
||||
switch (self.*) {
|
||||
.adw => |*adw| adw.gotoNthTab(position),
|
||||
.gtk => |*gtk| gtk.gotoNthTab(position),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getTabPosition(self: *Notebook, tab: *Tab) ?c_int {
|
||||
return switch (self.*) {
|
||||
.adw => |*adw| adw.getTabPosition(tab),
|
||||
.gtk => |*gtk| gtk.getTabPosition(tab),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn gotoPreviousTab(self: *Notebook, tab: *Tab) void {
|
||||
const page_idx = self.getTabPosition(tab) orelse return;
|
||||
|
||||
// The next index is the previous or we wrap around.
|
||||
@ -173,7 +88,7 @@ pub const Notebook = union(enum) {
|
||||
self.gotoNthTab(next_idx);
|
||||
}
|
||||
|
||||
pub fn gotoNextTab(self: Notebook, tab: *Tab) void {
|
||||
pub fn gotoNextTab(self: *Notebook, tab: *Tab) void {
|
||||
const page_idx = self.getTabPosition(tab) orelse return;
|
||||
|
||||
const max = self.nPages() -| 1;
|
||||
@ -183,7 +98,7 @@ pub const Notebook = union(enum) {
|
||||
self.gotoNthTab(next_idx);
|
||||
}
|
||||
|
||||
pub fn moveTab(self: Notebook, tab: *Tab, position: c_int) void {
|
||||
pub fn moveTab(self: *Notebook, tab: *Tab, position: c_int) void {
|
||||
const page_idx = self.getTabPosition(tab) orelse return;
|
||||
|
||||
const max = self.nPages() -| 1;
|
||||
@ -199,42 +114,28 @@ pub const Notebook = union(enum) {
|
||||
self.reorderPage(tab, new_position);
|
||||
}
|
||||
|
||||
pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void {
|
||||
switch (self) {
|
||||
.gtk_notebook => |notebook| {
|
||||
c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position);
|
||||
},
|
||||
.adw_tab_view => |tab_view| {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
|
||||
_ = c.adw_tab_view_reorder_page(tab_view, page, position);
|
||||
},
|
||||
pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void {
|
||||
switch (self.*) {
|
||||
.adw => |*adw| adw.reorderPage(tab, position),
|
||||
.gtk => |*gtk| gtk.reorderPage(tab, position),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void {
|
||||
switch (self) {
|
||||
.adw_tab_view => |tab_view| {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
|
||||
c.adw_tab_page_set_title(page, title.ptr);
|
||||
},
|
||||
.gtk_notebook => c.gtk_label_set_text(tab.label_text, title.ptr),
|
||||
pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
|
||||
switch (self.*) {
|
||||
.adw => |*adw| adw.setTabLabel(tab, title),
|
||||
.gtk => |*gtk| gtk.setTabLabel(tab, title),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setTabTooltip(self: Notebook, tab: *Tab, tooltip: [:0]const u8) void {
|
||||
switch (self) {
|
||||
.adw_tab_view => |tab_view| {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
|
||||
c.adw_tab_page_set_tooltip(page, tooltip.ptr);
|
||||
},
|
||||
.gtk_notebook => c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr),
|
||||
pub fn setTabTooltip(self: *Notebook, tab: *Tab, tooltip: [:0]const u8) void {
|
||||
switch (self.*) {
|
||||
.adw => |*adw| adw.setTabTooltip(tab, tooltip),
|
||||
.gtk => |*gtk| gtk.setTabTooltip(tab, tooltip),
|
||||
}
|
||||
}
|
||||
|
||||
fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int {
|
||||
fn newTabInsertPosition(self: *Notebook, tab: *Tab) c_int {
|
||||
const numPages = self.nPages();
|
||||
return switch (tab.window.app.config.@"window-new-tab-position") {
|
||||
.current => if (self.currentPage()) |page| page + 1 else numPages,
|
||||
@ -243,249 +144,23 @@ pub const Notebook = union(enum) {
|
||||
}
|
||||
|
||||
/// Adds a new tab with the given title to the notebook.
|
||||
pub fn addTab(self: Notebook, tab: *Tab, title: [:0]const u8) !void {
|
||||
const box_widget: *c.GtkWidget = @ptrCast(tab.box);
|
||||
switch (self) {
|
||||
.adw_tab_view => |tab_view| {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
|
||||
const page = c.adw_tab_view_insert(tab_view, box_widget, self.newTabInsertPosition(tab));
|
||||
c.adw_tab_page_set_title(page, title.ptr);
|
||||
|
||||
// Switch to the new tab
|
||||
c.adw_tab_view_set_selected_page(tab_view, page);
|
||||
},
|
||||
.gtk_notebook => |notebook| {
|
||||
// Build the tab label
|
||||
const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0);
|
||||
const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget));
|
||||
const label_text_widget = c.gtk_label_new(title.ptr);
|
||||
const label_text: *c.GtkLabel = @ptrCast(label_text_widget);
|
||||
c.gtk_box_append(label_box, label_text_widget);
|
||||
tab.label_text = label_text;
|
||||
|
||||
const window = tab.window;
|
||||
if (window.app.config.@"gtk-wide-tabs") {
|
||||
c.gtk_widget_set_hexpand(label_box_widget, 1);
|
||||
c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL);
|
||||
c.gtk_widget_set_hexpand(label_text_widget, 1);
|
||||
c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL);
|
||||
|
||||
// This ensures that tabs are always equal width. If they're too
|
||||
// long, they'll be truncated with an ellipsis.
|
||||
c.gtk_label_set_max_width_chars(label_text, 1);
|
||||
c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END);
|
||||
|
||||
// We need to set a minimum width so that at a certain point
|
||||
// the notebook will have an arrow button rather than shrinking tabs
|
||||
// to an unreadably small size.
|
||||
c.gtk_widget_set_size_request(label_text_widget, 100, 1);
|
||||
}
|
||||
|
||||
// Build the close button for the tab
|
||||
const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic");
|
||||
const label_close: *c.GtkButton = @ptrCast(label_close_widget);
|
||||
c.gtk_button_set_has_frame(label_close, 0);
|
||||
c.gtk_box_append(label_box, label_close_widget);
|
||||
|
||||
const page_idx = c.gtk_notebook_insert_page(
|
||||
notebook,
|
||||
box_widget,
|
||||
label_box_widget,
|
||||
self.newTabInsertPosition(tab),
|
||||
);
|
||||
|
||||
// Clicks
|
||||
const gesture_tab_click = c.gtk_gesture_click_new();
|
||||
c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0);
|
||||
c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click));
|
||||
|
||||
_ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT);
|
||||
|
||||
// Tab settings
|
||||
c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1);
|
||||
c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1);
|
||||
|
||||
if (self.nPages() > 1) {
|
||||
c.gtk_notebook_set_show_tabs(notebook, 1);
|
||||
}
|
||||
|
||||
// Switch to the new tab
|
||||
c.gtk_notebook_set_current_page(notebook, page_idx);
|
||||
},
|
||||
pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
|
||||
const position = self.newTabInsertPosition(tab);
|
||||
switch (self.*) {
|
||||
.adw => |*adw| adw.addTab(tab, position, title),
|
||||
.gtk => |*gtk| gtk.addTab(tab, position, title),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn closeTab(self: Notebook, tab: *Tab) void {
|
||||
const window = tab.window;
|
||||
switch (self) {
|
||||
.adw_tab_view => |tab_view| {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
|
||||
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return;
|
||||
c.adw_tab_view_close_page(tab_view, page);
|
||||
|
||||
// If we have no more tabs we close the window
|
||||
if (self.nPages() == 0) {
|
||||
// libadw versions <= 1.3.x leak the final page view
|
||||
// which causes our surface to not properly cleanup. We
|
||||
// unref to force the cleanup. This will trigger a critical
|
||||
// warning from GTK, but I don't know any other workaround.
|
||||
// Note: I'm not actually sure if 1.4.0 contains the fix,
|
||||
// I just know that 1.3.x is broken and 1.5.1 is fixed.
|
||||
// If we know that 1.4.0 is fixed, we can change this.
|
||||
if (!adwaita.versionAtLeast(1, 4, 0)) {
|
||||
c.g_object_unref(tab.box);
|
||||
pub fn closeTab(self: *Notebook, tab: *Tab) void {
|
||||
switch (self.*) {
|
||||
.adw => |*adw| adw.closeTab(tab),
|
||||
.gtk => |*gtk| gtk.closeTab(tab),
|
||||
}
|
||||
|
||||
c.gtk_window_destroy(window.window);
|
||||
}
|
||||
},
|
||||
.gtk_notebook => |notebook| {
|
||||
const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return;
|
||||
|
||||
// Find page and tab which we're closing
|
||||
const page_idx = getNotebookPageIndex(page);
|
||||
|
||||
// Remove the page. This will destroy the GTK widgets in the page which
|
||||
// will trigger Tab cleanup. The `tab` variable is therefore unusable past that point.
|
||||
c.gtk_notebook_remove_page(notebook, page_idx);
|
||||
|
||||
const remaining = self.nPages();
|
||||
switch (remaining) {
|
||||
// If we have no more tabs we close the window
|
||||
0 => c.gtk_window_destroy(tab.window.window),
|
||||
|
||||
// If we have one more tab we hide the tab bar
|
||||
1 => c.gtk_notebook_set_show_tabs(notebook, 0),
|
||||
|
||||
else => {},
|
||||
}
|
||||
|
||||
// If we have remaining tabs, we need to make sure we grab focus.
|
||||
if (remaining > 0) window.focusCurrentTab();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int {
|
||||
var value: c.GValue = std.mem.zeroes(c.GValue);
|
||||
defer c.g_value_unset(&value);
|
||||
_ = c.g_value_init(&value, c.G_TYPE_INT);
|
||||
c.g_object_get_property(
|
||||
@ptrCast(@alignCast(page)),
|
||||
"position",
|
||||
&value,
|
||||
);
|
||||
|
||||
return c.g_value_get_int(&value);
|
||||
}
|
||||
};
|
||||
|
||||
fn gtkPageRemoved(
|
||||
_: *c.GtkNotebook,
|
||||
_: *c.GtkWidget,
|
||||
_: c.guint,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.C) void {
|
||||
const self: *Window = @ptrCast(@alignCast(ud.?));
|
||||
|
||||
const notebook: *c.GtkNotebook = self.notebook.gtk_notebook;
|
||||
|
||||
// Hide the tab bar if we only have one tab after removal
|
||||
const remaining = c.gtk_notebook_get_n_pages(notebook);
|
||||
if (remaining == 1) {
|
||||
c.gtk_notebook_set_show_tabs(notebook, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn adwPageAttached(tab_view: *AdwTabView, page: *c.AdwTabPage, position: c_int, ud: ?*anyopaque) callconv(.C) void {
|
||||
_ = position;
|
||||
_ = tab_view;
|
||||
const self: *Window = @ptrCast(@alignCast(ud.?));
|
||||
|
||||
const child = c.adw_tab_page_get_child(page);
|
||||
const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return));
|
||||
tab.window = self;
|
||||
|
||||
self.focusCurrentTab();
|
||||
}
|
||||
|
||||
fn gtkPageAdded(
|
||||
notebook: *c.GtkNotebook,
|
||||
_: *c.GtkWidget,
|
||||
page_idx: c.guint,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.C) void {
|
||||
const self: *Window = @ptrCast(@alignCast(ud.?));
|
||||
|
||||
// The added page can come from another window with drag and drop, thus we migrate the tab
|
||||
// window to be self.
|
||||
const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx));
|
||||
const tab: *Tab = @ptrCast(@alignCast(
|
||||
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return,
|
||||
));
|
||||
tab.window = self;
|
||||
|
||||
// Whenever a new page is added, we always grab focus of the
|
||||
// currently selected page. This was added specifically so that when
|
||||
// we drag a tab out to create a new window ("create-window" event)
|
||||
// we grab focus in the new window. Without this, the terminal didn't
|
||||
// have focus.
|
||||
self.focusCurrentTab();
|
||||
}
|
||||
|
||||
fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void {
|
||||
const window: *Window = @ptrCast(@alignCast(ud.?));
|
||||
const page = c.adw_tab_view_get_selected_page(window.notebook.adw_tab_view) orelse return;
|
||||
const title = c.adw_tab_page_get_title(page);
|
||||
c.gtk_window_set_title(window.window, title);
|
||||
}
|
||||
|
||||
fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void {
|
||||
const window: *Window = @ptrCast(@alignCast(ud.?));
|
||||
const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(window.notebook.gtk_notebook, page)));
|
||||
const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box)));
|
||||
const label_text = c.gtk_label_get_text(gtk_label);
|
||||
c.gtk_window_set_title(window.window, label_text);
|
||||
}
|
||||
|
||||
fn adwTabViewCreateWindow(
|
||||
_: *AdwTabView,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.C) ?*AdwTabView {
|
||||
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
|
||||
const window = createWindow(currentWindow) catch |err| {
|
||||
log.warn("error creating new window error={}", .{err});
|
||||
return null;
|
||||
};
|
||||
return window.notebook.adw_tab_view;
|
||||
}
|
||||
|
||||
fn gtkNotebookCreateWindow(
|
||||
_: *c.GtkNotebook,
|
||||
page: *c.GtkWidget,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.C) ?*c.GtkNotebook {
|
||||
// The tab for the page is stored in the widget data.
|
||||
const tab: *Tab = @ptrCast(@alignCast(
|
||||
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null,
|
||||
));
|
||||
|
||||
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
|
||||
const window = createWindow(currentWindow) catch |err| {
|
||||
log.warn("error creating new window error={}", .{err});
|
||||
return null;
|
||||
};
|
||||
|
||||
// And add it to the new window.
|
||||
tab.window = window;
|
||||
|
||||
return window.notebook.gtk_notebook;
|
||||
}
|
||||
|
||||
fn createWindow(currentWindow: *Window) !*Window {
|
||||
pub fn createWindow(currentWindow: *Window) !*Window {
|
||||
const alloc = currentWindow.app.core_app.alloc;
|
||||
const app = currentWindow.app;
|
||||
|
||||
|
163
src/apprt/gtk/notebook_adw.zig
Normal file
163
src/apprt/gtk/notebook_adw.zig
Normal file
@ -0,0 +1,163 @@
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const c = @import("c.zig").c;
|
||||
|
||||
const Window = @import("Window.zig");
|
||||
const Tab = @import("Tab.zig");
|
||||
const Notebook = @import("notebook.zig").Notebook;
|
||||
const createWindow = @import("notebook.zig").createWindow;
|
||||
const adwaita = @import("adwaita.zig");
|
||||
|
||||
const log = std.log.scoped(.gtk);
|
||||
|
||||
const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque;
|
||||
const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopaque;
|
||||
|
||||
pub const NotebookAdw = struct {
|
||||
/// the tab view
|
||||
tab_view: *AdwTabView,
|
||||
|
||||
pub fn init(notebook: *Notebook) void {
|
||||
const window: *Window = @fieldParentPtr("notebook", notebook);
|
||||
const app = window.app;
|
||||
assert(adwaita.enabled(&app.config));
|
||||
|
||||
const tab_view: *c.AdwTabView = c.adw_tab_view_new().?;
|
||||
c.gtk_widget_add_css_class(@ptrCast(@alignCast(tab_view)), "notebook");
|
||||
|
||||
if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) {
|
||||
// Adwaita enables all of the shortcuts by default.
|
||||
// We want to manage keybindings ourselves.
|
||||
c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS);
|
||||
}
|
||||
|
||||
notebook.* = .{
|
||||
.adw = .{
|
||||
.tab_view = tab_view,
|
||||
},
|
||||
};
|
||||
|
||||
_ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT);
|
||||
}
|
||||
|
||||
pub fn asWidget(self: *NotebookAdw) *c.GtkWidget {
|
||||
return @ptrCast(@alignCast(self.tab_view));
|
||||
}
|
||||
|
||||
pub fn nPages(self: *NotebookAdw) c_int {
|
||||
if (comptime adwaita.versionAtLeast(0, 0, 0))
|
||||
return c.adw_tab_view_get_n_pages(self.tab_view)
|
||||
else
|
||||
unreachable;
|
||||
}
|
||||
|
||||
/// Returns the index of the currently selected page.
|
||||
/// Returns null if the notebook has no pages.
|
||||
pub fn currentPage(self: *NotebookAdw) ?c_int {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null;
|
||||
return c.adw_tab_view_get_page_position(self.tab_view, page);
|
||||
}
|
||||
|
||||
/// Returns the currently selected tab or null if there are none.
|
||||
pub fn currentTab(self: *NotebookAdw) ?*Tab {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null;
|
||||
const child = c.adw_tab_page_get_child(page);
|
||||
return @ptrCast(@alignCast(
|
||||
c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null,
|
||||
));
|
||||
}
|
||||
|
||||
pub fn gotoNthTab(self: *NotebookAdw, position: c_int) void {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page_to_select = c.adw_tab_view_get_nth_page(self.tab_view, position);
|
||||
c.adw_tab_view_set_selected_page(self.tab_view, page_to_select);
|
||||
}
|
||||
|
||||
pub fn getTabPosition(self: *NotebookAdw, tab: *Tab) ?c_int {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return null;
|
||||
return c.adw_tab_view_get_page_position(self.tab_view, page);
|
||||
}
|
||||
|
||||
pub fn reorderPage(self: *NotebookAdw, tab: *Tab, position: c_int) void {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box));
|
||||
_ = c.adw_tab_view_reorder_page(self.tab_view, page, position);
|
||||
}
|
||||
|
||||
pub fn setTabLabel(self: *NotebookAdw, tab: *Tab, title: [:0]const u8) void {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box));
|
||||
c.adw_tab_page_set_title(page, title.ptr);
|
||||
}
|
||||
|
||||
pub fn setTabTooltip(self: *NotebookAdw, tab: *Tab, tooltip: [:0]const u8) void {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box));
|
||||
c.adw_tab_page_set_tooltip(page, tooltip.ptr);
|
||||
}
|
||||
|
||||
pub fn addTab(self: *NotebookAdw, tab: *Tab, position: c_int, title: [:0]const u8) void {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
const box_widget: *c.GtkWidget = @ptrCast(tab.box);
|
||||
const page = c.adw_tab_view_insert(self.tab_view, box_widget, position);
|
||||
c.adw_tab_page_set_title(page, title.ptr);
|
||||
c.adw_tab_view_set_selected_page(self.tab_view, page);
|
||||
}
|
||||
|
||||
pub fn closeTab(self: *NotebookAdw, tab: *Tab) void {
|
||||
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
|
||||
|
||||
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return;
|
||||
c.adw_tab_view_close_page(self.tab_view, page);
|
||||
|
||||
// If we have no more tabs we close the window
|
||||
if (self.nPages() == 0) {
|
||||
// libadw versions <= 1.3.x leak the final page view
|
||||
// which causes our surface to not properly cleanup. We
|
||||
// unref to force the cleanup. This will trigger a critical
|
||||
// warning from GTK, but I don't know any other workaround.
|
||||
// Note: I'm not actually sure if 1.4.0 contains the fix,
|
||||
// I just know that 1.3.x is broken and 1.5.1 is fixed.
|
||||
// If we know that 1.4.0 is fixed, we can change this.
|
||||
if (!adwaita.versionAtLeast(1, 4, 0)) {
|
||||
c.g_object_unref(tab.box);
|
||||
}
|
||||
|
||||
c.gtk_window_destroy(tab.window.window);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaque) callconv(.C) void {
|
||||
const window: *Window = @ptrCast(@alignCast(ud.?));
|
||||
|
||||
const child = c.adw_tab_page_get_child(page);
|
||||
const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return));
|
||||
tab.window = window;
|
||||
|
||||
window.focusCurrentTab();
|
||||
}
|
||||
|
||||
fn adwTabViewCreateWindow(
|
||||
_: *AdwTabView,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.C) ?*AdwTabView {
|
||||
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
|
||||
const window = createWindow(currentWindow) catch |err| {
|
||||
log.warn("error creating new window error={}", .{err});
|
||||
return null;
|
||||
};
|
||||
return window.notebook.adw.tab_view;
|
||||
}
|
||||
|
||||
fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void {
|
||||
const window: *Window = @ptrCast(@alignCast(ud.?));
|
||||
const page = c.adw_tab_view_get_selected_page(window.notebook.adw.tab_view) orelse return;
|
||||
const title = c.adw_tab_page_get_title(page);
|
||||
c.gtk_window_set_title(window.window, title);
|
||||
}
|
285
src/apprt/gtk/notebook_gtk.zig
Normal file
285
src/apprt/gtk/notebook_gtk.zig
Normal file
@ -0,0 +1,285 @@
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const c = @import("c.zig").c;
|
||||
|
||||
const Window = @import("Window.zig");
|
||||
const Tab = @import("Tab.zig");
|
||||
const Notebook = @import("notebook.zig").Notebook;
|
||||
const createWindow = @import("notebook.zig").createWindow;
|
||||
|
||||
const log = std.log.scoped(.gtk);
|
||||
|
||||
/// An abstraction over the GTK notebook and Adwaita tab view to manage
|
||||
/// all the terminal tabs in a window.
|
||||
pub const NotebookGtk = struct {
|
||||
notebook: *c.GtkNotebook,
|
||||
|
||||
pub fn init(notebook: *Notebook) void {
|
||||
const window: *Window = @fieldParentPtr("notebook", notebook);
|
||||
const app = window.app;
|
||||
|
||||
// Create a notebook to hold our tabs.
|
||||
const notebook_widget: *c.GtkWidget = c.gtk_notebook_new();
|
||||
c.gtk_widget_add_css_class(notebook_widget, "notebook");
|
||||
|
||||
const gtk_notebook: *c.GtkNotebook = @ptrCast(notebook_widget);
|
||||
const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") {
|
||||
.top, .hidden => c.GTK_POS_TOP,
|
||||
.bottom => c.GTK_POS_BOTTOM,
|
||||
.left => c.GTK_POS_LEFT,
|
||||
.right => c.GTK_POS_RIGHT,
|
||||
};
|
||||
c.gtk_notebook_set_tab_pos(gtk_notebook, notebook_tab_pos);
|
||||
c.gtk_notebook_set_scrollable(gtk_notebook, 1);
|
||||
c.gtk_notebook_set_show_tabs(gtk_notebook, 0);
|
||||
c.gtk_notebook_set_show_border(gtk_notebook, 0);
|
||||
|
||||
// This enables all Ghostty terminal tabs to be exchanged across windows.
|
||||
c.gtk_notebook_set_group_name(gtk_notebook, "ghostty-terminal-tabs");
|
||||
|
||||
// This is important so the notebook expands to fit available space.
|
||||
// Otherwise, it will be zero/zero in the box below.
|
||||
c.gtk_widget_set_vexpand(notebook_widget, 1);
|
||||
c.gtk_widget_set_hexpand(notebook_widget, 1);
|
||||
|
||||
// Remove the background from the stack widget
|
||||
const stack = c.gtk_widget_get_last_child(notebook_widget);
|
||||
c.gtk_widget_add_css_class(stack, "transparent");
|
||||
|
||||
notebook.* = .{
|
||||
.gtk = .{
|
||||
.notebook = gtk_notebook,
|
||||
},
|
||||
};
|
||||
|
||||
// All of our events
|
||||
_ = c.g_signal_connect_data(gtk_notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(gtk_notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(gtk_notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(gtk_notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT);
|
||||
}
|
||||
|
||||
/// return the underlying widget as a generic GtkWidget
|
||||
pub fn asWidget(self: *NotebookGtk) *c.GtkWidget {
|
||||
return @ptrCast(@alignCast(self.notebook));
|
||||
}
|
||||
|
||||
/// returns the number of pages in the notebook
|
||||
pub fn nPages(self: *NotebookGtk) c_int {
|
||||
return c.gtk_notebook_get_n_pages(self.notebook);
|
||||
}
|
||||
|
||||
/// Returns the index of the currently selected page.
|
||||
/// Returns null if the notebook has no pages.
|
||||
pub fn currentPage(self: *NotebookGtk) ?c_int {
|
||||
const current = c.gtk_notebook_get_current_page(self.notebook);
|
||||
return if (current == -1) null else current;
|
||||
}
|
||||
|
||||
/// Returns the currently selected tab or null if there are none.
|
||||
pub fn currentTab(self: *NotebookGtk) ?*Tab {
|
||||
log.warn("currentTab", .{});
|
||||
const page = self.currentPage() orelse return null;
|
||||
const child = c.gtk_notebook_get_nth_page(self.notebook, page);
|
||||
return @ptrCast(@alignCast(
|
||||
c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null,
|
||||
));
|
||||
}
|
||||
|
||||
/// focus the nth tab
|
||||
pub fn gotoNthTab(self: *NotebookGtk, position: c_int) void {
|
||||
c.gtk_notebook_set_current_page(self.notebook, position);
|
||||
}
|
||||
|
||||
/// get the position of the current tab
|
||||
pub fn getTabPosition(self: *NotebookGtk, tab: *Tab) ?c_int {
|
||||
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return null;
|
||||
return getNotebookPageIndex(page);
|
||||
}
|
||||
|
||||
pub fn reorderPage(self: *NotebookGtk, tab: *Tab, position: c_int) void {
|
||||
c.gtk_notebook_reorder_child(self.notebook, @ptrCast(tab.box), position);
|
||||
}
|
||||
|
||||
pub fn setTabLabel(_: *NotebookGtk, tab: *Tab, title: [:0]const u8) void {
|
||||
c.gtk_label_set_text(tab.label_text, title.ptr);
|
||||
}
|
||||
|
||||
pub fn setTabTooltip(_: *NotebookGtk, tab: *Tab, tooltip: [:0]const u8) void {
|
||||
c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr);
|
||||
}
|
||||
|
||||
/// Adds a new tab with the given title to the notebook.
|
||||
pub fn addTab(self: *NotebookGtk, tab: *Tab, position: c_int, title: [:0]const u8) void {
|
||||
const box_widget: *c.GtkWidget = @ptrCast(tab.box);
|
||||
|
||||
// Build the tab label
|
||||
const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0);
|
||||
const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget));
|
||||
const label_text_widget = c.gtk_label_new(title.ptr);
|
||||
const label_text: *c.GtkLabel = @ptrCast(label_text_widget);
|
||||
c.gtk_box_append(label_box, label_text_widget);
|
||||
tab.label_text = label_text;
|
||||
|
||||
const window = tab.window;
|
||||
if (window.app.config.@"gtk-wide-tabs") {
|
||||
c.gtk_widget_set_hexpand(label_box_widget, 1);
|
||||
c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL);
|
||||
c.gtk_widget_set_hexpand(label_text_widget, 1);
|
||||
c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL);
|
||||
|
||||
// This ensures that tabs are always equal width. If they're too
|
||||
// long, they'll be truncated with an ellipsis.
|
||||
c.gtk_label_set_max_width_chars(label_text, 1);
|
||||
c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END);
|
||||
|
||||
// We need to set a minimum width so that at a certain point
|
||||
// the notebook will have an arrow button rather than shrinking tabs
|
||||
// to an unreadably small size.
|
||||
c.gtk_widget_set_size_request(label_text_widget, 100, 1);
|
||||
}
|
||||
|
||||
// Build the close button for the tab
|
||||
const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic");
|
||||
const label_close: *c.GtkButton = @ptrCast(label_close_widget);
|
||||
c.gtk_button_set_has_frame(label_close, 0);
|
||||
c.gtk_box_append(label_box, label_close_widget);
|
||||
|
||||
const page_idx = c.gtk_notebook_insert_page(
|
||||
self.notebook,
|
||||
box_widget,
|
||||
label_box_widget,
|
||||
position,
|
||||
);
|
||||
|
||||
// Clicks
|
||||
const gesture_tab_click = c.gtk_gesture_click_new();
|
||||
c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0);
|
||||
c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click));
|
||||
|
||||
_ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT);
|
||||
_ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT);
|
||||
|
||||
// Tab settings
|
||||
c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1);
|
||||
c.gtk_notebook_set_tab_detachable(self.notebook, box_widget, 1);
|
||||
|
||||
if (self.nPages() > 1) {
|
||||
c.gtk_notebook_set_show_tabs(self.notebook, 1);
|
||||
}
|
||||
|
||||
// Switch to the new tab
|
||||
c.gtk_notebook_set_current_page(self.notebook, page_idx);
|
||||
}
|
||||
|
||||
pub fn closeTab(self: *NotebookGtk, tab: *Tab) void {
|
||||
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return;
|
||||
|
||||
// Find page and tab which we're closing
|
||||
const page_idx = getNotebookPageIndex(page);
|
||||
|
||||
// Remove the page. This will destroy the GTK widgets in the page which
|
||||
// will trigger Tab cleanup. The `tab` variable is therefore unusable past that point.
|
||||
c.gtk_notebook_remove_page(self.notebook, page_idx);
|
||||
|
||||
const remaining = self.nPages();
|
||||
switch (remaining) {
|
||||
// If we have no more tabs we close the window
|
||||
0 => c.gtk_window_destroy(tab.window.window),
|
||||
|
||||
// If we have one more tab we hide the tab bar
|
||||
1 => c.gtk_notebook_set_show_tabs(self.notebook, 0),
|
||||
|
||||
else => {},
|
||||
}
|
||||
|
||||
// If we have remaining tabs, we need to make sure we grab focus.
|
||||
if (remaining > 0)
|
||||
(self.currentTab() orelse return).window.focusCurrentTab();
|
||||
}
|
||||
};
|
||||
|
||||
fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int {
|
||||
var value: c.GValue = std.mem.zeroes(c.GValue);
|
||||
defer c.g_value_unset(&value);
|
||||
_ = c.g_value_init(&value, c.G_TYPE_INT);
|
||||
c.g_object_get_property(
|
||||
@ptrCast(@alignCast(page)),
|
||||
"position",
|
||||
&value,
|
||||
);
|
||||
|
||||
return c.g_value_get_int(&value);
|
||||
}
|
||||
|
||||
fn gtkPageAdded(
|
||||
notebook: *c.GtkNotebook,
|
||||
_: *c.GtkWidget,
|
||||
page_idx: c.guint,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.C) void {
|
||||
const self: *Window = @ptrCast(@alignCast(ud.?));
|
||||
|
||||
// The added page can come from another window with drag and drop, thus we migrate the tab
|
||||
// window to be self.
|
||||
const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx));
|
||||
const tab: *Tab = @ptrCast(@alignCast(
|
||||
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return,
|
||||
));
|
||||
tab.window = self;
|
||||
|
||||
// Whenever a new page is added, we always grab focus of the
|
||||
// currently selected page. This was added specifically so that when
|
||||
// we drag a tab out to create a new window ("create-window" event)
|
||||
// we grab focus in the new window. Without this, the terminal didn't
|
||||
// have focus.
|
||||
self.focusCurrentTab();
|
||||
}
|
||||
|
||||
fn gtkPageRemoved(
|
||||
_: *c.GtkNotebook,
|
||||
_: *c.GtkWidget,
|
||||
_: c.guint,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.C) void {
|
||||
log.warn("gtkPageRemoved", .{});
|
||||
const window: *Window = @ptrCast(@alignCast(ud.?));
|
||||
|
||||
// Hide the tab bar if we only have one tab after removal
|
||||
const remaining = c.gtk_notebook_get_n_pages(window.notebook.gtk.notebook);
|
||||
|
||||
if (remaining == 1) {
|
||||
c.gtk_notebook_set_show_tabs(window.notebook.gtk.notebook, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void {
|
||||
const window: *Window = @ptrCast(@alignCast(ud.?));
|
||||
const self = &window.notebook.gtk;
|
||||
const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(self.notebook, page)));
|
||||
const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box)));
|
||||
const label_text = c.gtk_label_get_text(gtk_label);
|
||||
c.gtk_window_set_title(window.window, label_text);
|
||||
}
|
||||
|
||||
fn gtkNotebookCreateWindow(
|
||||
_: *c.GtkNotebook,
|
||||
page: *c.GtkWidget,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.C) ?*c.GtkNotebook {
|
||||
// The tab for the page is stored in the widget data.
|
||||
const tab: *Tab = @ptrCast(@alignCast(
|
||||
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null,
|
||||
));
|
||||
|
||||
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
|
||||
const newWindow = createWindow(currentWindow) catch |err| {
|
||||
log.warn("error creating new window error={}", .{err});
|
||||
return null;
|
||||
};
|
||||
|
||||
// And add it to the new window.
|
||||
tab.window = newWindow;
|
||||
|
||||
return newWindow.notebook.gtk.notebook;
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
separator {
|
||||
.terminal-window .notebook separator {
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
@ -33,11 +33,15 @@ label.size-overlay.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
window.without-window-decoration-and-with-titlebar {
|
||||
border-radius: 0 0;
|
||||
}
|
||||
|
||||
.transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
separator {
|
||||
.terminal-window .notebook separator {
|
||||
background-color: rgba(250, 250, 250, 1);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
@ -19,8 +19,9 @@ pub inline fn atLeast(
|
||||
// compiling against unknown symbols and makes runtime checks
|
||||
// very slightly faster.
|
||||
if (comptime c.GTK_MAJOR_VERSION < major or
|
||||
c.GTK_MINOR_VERSION < minor or
|
||||
c.GTK_MICRO_VERSION < micro) return false;
|
||||
(c.GTK_MAJOR_VERSION == major and c.GTK_MINOR_VERSION < minor) or
|
||||
(c.GTK_MAJOR_VERSION == major and c.GTK_MINOR_VERSION == minor and c.GTK_MICRO_VERSION < micro))
|
||||
return false;
|
||||
|
||||
// If we're in comptime then we can't check the runtime version.
|
||||
if (@inComptime()) return true;
|
||||
@ -38,3 +39,20 @@ pub inline fn atLeast(
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
test "atLeast" {
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
|
||||
|
||||
try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
|
||||
try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
|
||||
try testing.expect(!atLeast(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
|
||||
|
||||
try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
|
||||
try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
|
||||
try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
|
||||
|
||||
try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1));
|
||||
}
|
||||
|
@ -56,7 +56,7 @@ fn writeFishCompletions(writer: anytype) !void {
|
||||
else {
|
||||
try writer.writeAll(if (field.type != Config.RepeatablePath) " -f" else " -F");
|
||||
switch (@typeInfo(field.type)) {
|
||||
.Bool => try writer.writeAll(" -a \"true false\""),
|
||||
.Bool => {},
|
||||
.Enum => |info| {
|
||||
try writer.writeAll(" -a \"");
|
||||
for (info.fields, 0..) |f, i| {
|
||||
@ -114,7 +114,7 @@ fn writeFishCompletions(writer: anytype) !void {
|
||||
} else try writer.writeAll(" -f");
|
||||
|
||||
switch (@typeInfo(opt.type)) {
|
||||
.Bool => try writer.writeAll(" -a \"true false\""),
|
||||
.Bool => {},
|
||||
.Enum => |info| {
|
||||
try writer.writeAll(" -a \"");
|
||||
for (info.fields, 0..) |f, i| {
|
||||
|
@ -7,6 +7,8 @@ const Action = @import("../cli/action.zig").Action;
|
||||
/// and options.
|
||||
pub const zsh_completions = comptimeGenerateZshCompletions();
|
||||
|
||||
const equals_required = "=-:::";
|
||||
|
||||
fn comptimeGenerateZshCompletions() []const u8 {
|
||||
comptime {
|
||||
@setEvalBranchQuota(50000);
|
||||
@ -47,34 +49,42 @@ fn writeZshCompletions(writer: anytype) !void {
|
||||
if (field.name[0] == '_') continue;
|
||||
try writer.writeAll(" \"--");
|
||||
try writer.writeAll(field.name);
|
||||
try writer.writeAll("=-:::");
|
||||
|
||||
if (std.mem.startsWith(u8, field.name, "font-family"))
|
||||
try writer.writeAll("_fonts")
|
||||
else if (std.mem.eql(u8, "theme", field.name))
|
||||
try writer.writeAll("_themes")
|
||||
else if (std.mem.eql(u8, "working-directory", field.name))
|
||||
try writer.writeAll("{_files -/}")
|
||||
else if (field.type == Config.RepeatablePath)
|
||||
try writer.writeAll("_files") // todo check if this is needed
|
||||
else {
|
||||
try writer.writeAll("(");
|
||||
if (std.mem.startsWith(u8, field.name, "font-family")) {
|
||||
try writer.writeAll(equals_required);
|
||||
try writer.writeAll("_fonts");
|
||||
} else if (std.mem.eql(u8, "theme", field.name)) {
|
||||
try writer.writeAll(equals_required);
|
||||
try writer.writeAll("_themes");
|
||||
} else if (std.mem.eql(u8, "working-directory", field.name)) {
|
||||
try writer.writeAll(equals_required);
|
||||
try writer.writeAll("{_files -/}");
|
||||
} else if (field.type == Config.RepeatablePath) {
|
||||
try writer.writeAll(equals_required);
|
||||
try writer.writeAll("_files"); // todo check if this is needed
|
||||
} else {
|
||||
switch (@typeInfo(field.type)) {
|
||||
.Bool => try writer.writeAll("true false"),
|
||||
.Bool => {},
|
||||
.Enum => |info| {
|
||||
try writer.writeAll(equals_required);
|
||||
try writer.writeAll("(");
|
||||
for (info.fields, 0..) |f, i| {
|
||||
if (i > 0) try writer.writeAll(" ");
|
||||
try writer.writeAll(f.name);
|
||||
}
|
||||
try writer.writeAll(")");
|
||||
},
|
||||
.Struct => |info| {
|
||||
try writer.writeAll(equals_required);
|
||||
if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") {
|
||||
try writer.writeAll("(");
|
||||
for (info.fields, 0..) |f, i| {
|
||||
if (i > 0) try writer.writeAll(" ");
|
||||
try writer.writeAll(f.name);
|
||||
try writer.writeAll(" no-");
|
||||
try writer.writeAll(f.name);
|
||||
}
|
||||
try writer.writeAll(")");
|
||||
} else {
|
||||
//resize-overlay-duration
|
||||
//keybind
|
||||
@ -85,12 +95,14 @@ fn writeZshCompletions(writer: anytype) !void {
|
||||
//foreground
|
||||
//font-variation*
|
||||
//font-feature
|
||||
try writer.writeAll(" ");
|
||||
try writer.writeAll("( )");
|
||||
}
|
||||
},
|
||||
else => try writer.writeAll(" "),
|
||||
else => {
|
||||
try writer.writeAll(equals_required);
|
||||
try writer.writeAll("( )");
|
||||
},
|
||||
}
|
||||
try writer.writeAll(")");
|
||||
}
|
||||
|
||||
try writer.writeAll("\" \\\n");
|
||||
@ -170,10 +182,11 @@ fn writeZshCompletions(writer: anytype) !void {
|
||||
|
||||
try writer.writeAll(padding ++ " '--");
|
||||
try writer.writeAll(opt.name);
|
||||
try writer.writeAll("=-:::");
|
||||
|
||||
switch (@typeInfo(opt.type)) {
|
||||
.Bool => try writer.writeAll("(true false)"),
|
||||
.Bool => {},
|
||||
.Enum => |info| {
|
||||
try writer.writeAll(equals_required);
|
||||
try writer.writeAll("(");
|
||||
for (info.fields, 0..) |f, i| {
|
||||
if (i > 0) try writer.writeAll(" ");
|
||||
@ -182,6 +195,7 @@ fn writeZshCompletions(writer: anytype) !void {
|
||||
try writer.writeAll(")");
|
||||
},
|
||||
.Optional => |optional| {
|
||||
try writer.writeAll(equals_required);
|
||||
switch (@typeInfo(optional.child)) {
|
||||
.Enum => |info| {
|
||||
try writer.writeAll("(");
|
||||
@ -199,11 +213,13 @@ fn writeZshCompletions(writer: anytype) !void {
|
||||
}
|
||||
},
|
||||
else => {
|
||||
try writer.writeAll(equals_required);
|
||||
if (std.mem.eql(u8, "config-file", opt.name)) {
|
||||
try writer.writeAll("_files");
|
||||
} else try writer.writeAll("( )");
|
||||
},
|
||||
}
|
||||
|
||||
try writer.writeAll("' \\\n");
|
||||
}
|
||||
try writer.writeAll(padding ++ ";;\n");
|
||||
|
@ -23,6 +23,7 @@ pub const BuildConfig = struct {
|
||||
flatpak: bool = false,
|
||||
adwaita: bool = false,
|
||||
x11: bool = false,
|
||||
sentry: bool = true,
|
||||
app_runtime: apprt.Runtime = .none,
|
||||
renderer: rendererpkg.Impl = .opengl,
|
||||
font_backend: font.Backend = .freetype,
|
||||
@ -43,6 +44,7 @@ pub const BuildConfig = struct {
|
||||
step.addOption(bool, "flatpak", self.flatpak);
|
||||
step.addOption(bool, "adwaita", self.adwaita);
|
||||
step.addOption(bool, "x11", self.x11);
|
||||
step.addOption(bool, "sentry", self.sentry);
|
||||
step.addOption(apprt.Runtime, "app_runtime", self.app_runtime);
|
||||
step.addOption(font.Backend, "font_backend", self.font_backend);
|
||||
step.addOption(rendererpkg.Impl, "renderer", self.renderer);
|
||||
|
@ -63,7 +63,7 @@ pub fn run(alloc: Allocator) !u8 {
|
||||
try stdout.writeAll(
|
||||
\\
|
||||
\\Specify `+<action> --help` to see the help for a specific action,
|
||||
\\where `<action>` is one of actions listed below.
|
||||
\\where `<action>` is one of actions listed above.
|
||||
\\
|
||||
);
|
||||
|
||||
|
@ -20,8 +20,9 @@ pub const Options = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// The `list-actions` command is used to list all the available keybind actions
|
||||
/// for Ghostty.
|
||||
/// The `list-actions` command is used to list all the available keybind
|
||||
/// actions for Ghostty. These are distinct from the CLI Actions which can
|
||||
/// be listed via `+help`
|
||||
///
|
||||
/// The `--docs` argument will print out the documentation for each action.
|
||||
pub fn run(alloc: Allocator) !u8 {
|
||||
|
@ -3,6 +3,7 @@ const build_options = @import("build_options");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const builtin = @import("builtin");
|
||||
const build_config = @import("../build_config.zig");
|
||||
const internal_os = @import("../os/main.zig");
|
||||
const xev = @import("xev");
|
||||
const renderer = @import("../renderer.zig");
|
||||
const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void;
|
||||
@ -37,6 +38,7 @@ pub fn run(alloc: Allocator) !u8 {
|
||||
try stdout.print(" - renderer : {}\n", .{renderer.Renderer});
|
||||
try stdout.print(" - libxev : {}\n", .{xev.backend});
|
||||
if (comptime build_config.app_runtime == .gtk) {
|
||||
try stdout.print(" - desktop env: {s}\n", .{@tagName(internal_os.desktopEnvironment())});
|
||||
try stdout.print(" - GTK version:\n", .{});
|
||||
try stdout.print(" build : {d}.{d}.{d}\n", .{
|
||||
gtk.GTK_MAJOR_VERSION,
|
||||
|
@ -147,23 +147,28 @@ const c = @cImport({
|
||||
/// By default, synthetic styles are enabled.
|
||||
@"font-synthetic-style": FontSyntheticStyle = .{},
|
||||
|
||||
/// Apply a font feature. This can be repeated multiple times to enable multiple
|
||||
/// font features. You can NOT set multiple font features with a single value
|
||||
/// (yet).
|
||||
/// Apply a font feature. To enable multiple font features you can repeat
|
||||
/// this multiple times or use a comma-separated list of feature settings.
|
||||
///
|
||||
/// The syntax for feature settings is as follows, where `feat` is a feature:
|
||||
///
|
||||
/// * Enable features with e.g. `feat`, `+feat`, `feat on`, `feat=1`.
|
||||
/// * Disabled features with e.g. `-feat`, `feat off`, `feat=0`.
|
||||
/// * Set a feature value with e.g. `feat=2`, `feat = 3`, `feat 4`.
|
||||
/// * Feature names may be wrapped in quotes, meaning this config should be
|
||||
/// syntactically compatible with the `font-feature-settings` CSS property.
|
||||
///
|
||||
/// The syntax is fairly loose, but invalid settings will be silently ignored.
|
||||
///
|
||||
/// The font feature will apply to all fonts rendered by Ghostty. A future
|
||||
/// enhancement will allow targeting specific faces.
|
||||
///
|
||||
/// A valid value is the name of a feature. Prefix the feature with a `-` to
|
||||
/// explicitly disable it. Example: `ss20` or `-ss20`.
|
||||
///
|
||||
/// To disable programming ligatures, use `-calt` since this is the typical
|
||||
/// feature name for programming ligatures. To look into what font features
|
||||
/// your font has and what they do, use a font inspection tool such as
|
||||
/// [fontdrop.info](https://fontdrop.info).
|
||||
///
|
||||
/// To generally disable most ligatures, use `-calt`, `-liga`, and `-dlig` (as
|
||||
/// separate repetitive entries in your config).
|
||||
/// To generally disable most ligatures, use `-calt, -liga, -dlig`.
|
||||
@"font-feature": RepeatableString = .{},
|
||||
|
||||
/// Font size in points. This value can be a non-integer and the nearest integer
|
||||
@ -177,6 +182,10 @@ const c = @cImport({
|
||||
/// depending on your `window-inherit-font-size` setting. If that setting is
|
||||
/// true, only the first window will be affected by this change since all
|
||||
/// subsequent windows will inherit the font size of the previous window.
|
||||
///
|
||||
/// On Linux with GTK, font size is scaled according to both display-wide and
|
||||
/// text-specific scaling factors, which are often managed by your desktop
|
||||
/// environment (e.g. the GNOME display scale and large text settings).
|
||||
@"font-size": f32 = switch (builtin.os.tag) {
|
||||
// On macOS we default a little bigger since this tends to look better. This
|
||||
// is purely subjective but this is easy to modify.
|
||||
@ -320,7 +329,7 @@ const c = @cImport({
|
||||
|
||||
/// FreeType load flags to enable. The format of this is a list of flags to
|
||||
/// enable separated by commas. If you prefix a flag with `no-` then it is
|
||||
/// disabled. If you omit a flag, it's default value is used, so you must
|
||||
/// disabled. If you omit a flag, its default value is used, so you must
|
||||
/// explicitly disable flags you don't want. You can also use `true` or `false`
|
||||
/// to turn all flags on or off.
|
||||
///
|
||||
@ -398,14 +407,17 @@ const c = @cImport({
|
||||
theme: ?Theme = null,
|
||||
|
||||
/// Background color for the window.
|
||||
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
|
||||
background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 },
|
||||
|
||||
/// Foreground color for the window.
|
||||
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
|
||||
foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
|
||||
|
||||
/// The foreground and background color for selection. If this is not set, then
|
||||
/// the selection color is just the inverted window background and foreground
|
||||
/// (note: not to be confused with the cell bg/fg).
|
||||
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
|
||||
@"selection-foreground": ?Color = null,
|
||||
@"selection-background": ?Color = null,
|
||||
|
||||
@ -431,15 +443,20 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
|
||||
@"minimum-contrast": f64 = 1,
|
||||
|
||||
/// Color palette for the 256 color form that many terminal applications use.
|
||||
/// The syntax of this configuration is `N=HEXCODE` where `N` is 0 to 255 (for
|
||||
/// the 256 colors in the terminal color table) and `HEXCODE` is a typical RGB
|
||||
/// color code such as `#AABBCC`.
|
||||
/// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for
|
||||
/// the 256 colors in the terminal color table) and `COLOR` is a typical RGB
|
||||
/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color.
|
||||
///
|
||||
/// For definitions on all the codes [see this cheat
|
||||
/// sheet](https://www.ditig.com/256-colors-cheat-sheet).
|
||||
/// The palette index can be in decimal, binary, octal, or hexadecimal.
|
||||
/// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal,
|
||||
/// and `0x` for hexadecimal.
|
||||
///
|
||||
/// For definitions on the color indices and what they canonically map to,
|
||||
/// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet).
|
||||
palette: Palette = .{},
|
||||
|
||||
/// The color of the cursor. If this is not set, a default will be chosen.
|
||||
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
|
||||
@"cursor-color": ?Color = null,
|
||||
|
||||
/// Swap the foreground and background colors of the cell under the cursor. This
|
||||
@ -493,6 +510,7 @@ palette: Palette = .{},
|
||||
|
||||
/// The color of the text under the cursor. If this is not set, a default will
|
||||
/// be chosen.
|
||||
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
|
||||
@"cursor-text": ?Color = null,
|
||||
|
||||
/// Enables the ability to move the cursor at prompts by using `alt+click` on
|
||||
@ -548,7 +566,7 @@ palette: Palette = .{},
|
||||
/// than 0.01 or greater than 10,000 will be clamped to the nearest valid
|
||||
/// value.
|
||||
///
|
||||
/// A value of "1" (default) scrolls te default amount. A value of "2" scrolls
|
||||
/// A value of "1" (default) scrolls the default amount. A value of "2" scrolls
|
||||
/// double the default amount. A value of "0.5" scrolls half the default amount.
|
||||
/// Et cetera.
|
||||
@"mouse-scroll-multiplier": f64 = 1.0,
|
||||
@ -560,6 +578,8 @@ palette: Palette = .{},
|
||||
/// On macOS, background opacity is disabled when the terminal enters native
|
||||
/// fullscreen. This is because the background becomes gray and it can cause
|
||||
/// widgets to show through which isn't generally desirable.
|
||||
///
|
||||
/// On macOS, changing this configuration requires restarting Ghostty completely.
|
||||
@"background-opacity": f64 = 1.0,
|
||||
|
||||
/// A positive value enables blurring of the background when background-opacity
|
||||
@ -586,6 +606,8 @@ palette: Palette = .{},
|
||||
/// that rectangle and can be used to carefully control the dimming effect.
|
||||
///
|
||||
/// This will default to the background color.
|
||||
///
|
||||
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
|
||||
@"unfocused-split-fill": ?Color = null,
|
||||
|
||||
/// The command to run, usually a shell. If this is not an absolute path, it'll
|
||||
@ -724,7 +746,7 @@ fullscreen: bool = false,
|
||||
/// This configuration can be reloaded at runtime. If it is set, the title
|
||||
/// will update for all windows. If it is unset, the next title change escape
|
||||
/// sequence will be honored but previous changes will not retroactively
|
||||
/// be set. This latter case may require you restart programs such as neovim
|
||||
/// be set. This latter case may require you to restart programs such as Neovim
|
||||
/// to get the new title.
|
||||
title: ?[:0]const u8 = null,
|
||||
|
||||
@ -907,6 +929,15 @@ class: ?[:0]const u8 = null,
|
||||
/// Since they are not associated with a specific terminal surface,
|
||||
/// they're never encoded.
|
||||
///
|
||||
/// * `performable:` - Only consume the input if the action is able to be
|
||||
/// performed. For example, the `copy_to_clipboard` action will only
|
||||
/// consume the input if there is a selection to copy. If there is no
|
||||
/// selection, Ghostty behaves as if the keybind was not set. This has
|
||||
/// no effect with `global:` or `all:`-prefixed keybinds. For key
|
||||
/// sequences, this will reset the sequence if the action is not
|
||||
/// performable (acting identically to not having a keybind set at
|
||||
/// all).
|
||||
///
|
||||
/// Keybind triggers are not unique per prefix combination. For example,
|
||||
/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind
|
||||
/// set later will overwrite the keybind set earlier. In this case, the
|
||||
@ -1104,6 +1135,32 @@ keybind: Keybinds = .{},
|
||||
@"window-height": u32 = 0,
|
||||
@"window-width": u32 = 0,
|
||||
|
||||
/// The starting window position. This position is in pixels and is relative
|
||||
/// to the top-left corner of the primary monitor. Both values must be set to take
|
||||
/// effect. If only one value is set, it is ignored.
|
||||
///
|
||||
/// Note that the window manager may put limits on the position or override
|
||||
/// the position. For example, a tiling window manager may force the window
|
||||
/// to be a certain position to fit within the grid. There is nothing Ghostty
|
||||
/// will do about this, but it will make an effort.
|
||||
///
|
||||
/// Also note that negative values are also up to the operating system and
|
||||
/// window manager. Some window managers may not allow windows to be placed
|
||||
/// off-screen.
|
||||
///
|
||||
/// Invalid positions are runtime-specific, but generally the positions are
|
||||
/// clamped to the nearest valid position.
|
||||
///
|
||||
/// On macOS, the window position is relative to the top-left corner of
|
||||
/// the visible screen area. This means that if the menu bar is visible, the
|
||||
/// window will be placed below the menu bar.
|
||||
///
|
||||
/// Note: this is only supported on macOS and Linux GLFW builds. The GTK
|
||||
/// runtime does not support setting the window position (this is a limitation
|
||||
/// of GTK 4.0).
|
||||
@"window-position-x": ?i16 = null,
|
||||
@"window-position-y": ?i16 = null,
|
||||
|
||||
/// Whether to enable saving and restoring window state. Window state includes
|
||||
/// their position, size, tabs, splits, etc. Some window state requires shell
|
||||
/// integration, such as preserving working directories. See `shell-integration`
|
||||
@ -1152,11 +1209,15 @@ keybind: Keybinds = .{},
|
||||
/// Background color for the window titlebar. This only takes effect if
|
||||
/// window-theme is set to ghostty. Currently only supported in the GTK app
|
||||
/// runtime.
|
||||
///
|
||||
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
|
||||
@"window-titlebar-background": ?Color = null,
|
||||
|
||||
/// Foreground color for the window titlebar. This only takes effect if
|
||||
/// window-theme is set to ghostty. Currently only supported in the GTK app
|
||||
/// runtime.
|
||||
///
|
||||
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
|
||||
@"window-titlebar-foreground": ?Color = null,
|
||||
|
||||
/// This controls when resize overlays are shown. Resize overlays are a
|
||||
@ -1772,21 +1833,19 @@ keybind: Keybinds = .{},
|
||||
|
||||
/// The color of the ghost in the macOS app icon.
|
||||
///
|
||||
/// The format of the color is the same as the `background` configuration;
|
||||
/// see that for more information.
|
||||
///
|
||||
/// Note: This configuration is required when `macos-icon` is set to
|
||||
/// `custom-style`.
|
||||
///
|
||||
/// This only has an effect when `macos-icon` is set to `custom-style`.
|
||||
///
|
||||
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
|
||||
@"macos-icon-ghost-color": ?Color = null,
|
||||
|
||||
/// The color of the screen in the macOS app icon.
|
||||
///
|
||||
/// The screen is a gradient so you can specify multiple colors that
|
||||
/// make up the gradient. Colors should be separated by commas. The
|
||||
/// format of the color is the same as the `background` configuration;
|
||||
/// see that for more information.
|
||||
/// make up the gradient. Comma-separated colors may be specified as
|
||||
/// as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 colors.
|
||||
///
|
||||
/// Note: This configuration is required when `macos-icon` is set to
|
||||
/// `custom-style`.
|
||||
@ -1905,6 +1964,29 @@ keybind: Keybinds = .{},
|
||||
/// Changing this value at runtime will only affect new windows.
|
||||
@"adw-toolbar-style": AdwToolbarStyle = .raised,
|
||||
|
||||
/// Control the toasts that Ghostty shows. Toasts are small notifications
|
||||
/// that appear overlaid on top of the terminal window. They are used to
|
||||
/// show information that is not critical but may be important.
|
||||
///
|
||||
/// Possible toasts are:
|
||||
///
|
||||
/// - `clipboard-copy` (default: true) - Show a toast when text is copied
|
||||
/// to the clipboard.
|
||||
///
|
||||
/// To specify a toast to enable, specify the name of the toast. To specify
|
||||
/// a toast to disable, prefix the name with `no-`. For example, to disable
|
||||
/// the clipboard-copy toast, set this configuration to `no-clipboard-copy`.
|
||||
/// To enable the clipboard-copy toast, set this configuration to
|
||||
/// `clipboard-copy`.
|
||||
///
|
||||
/// Multiple toasts can be enabled or disabled by separating them with a comma.
|
||||
///
|
||||
/// A value of "false" will disable all toasts. A value of "true" will
|
||||
/// enable all toasts.
|
||||
///
|
||||
/// This configuration only applies to GTK with Adwaita enabled.
|
||||
@"adw-toast": AdwToast = .{},
|
||||
|
||||
/// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs
|
||||
/// are the new typical Gnome style where tabs fill their available space.
|
||||
/// If you set this to `false` then tabs will only take up space they need,
|
||||
@ -1925,6 +2007,15 @@ keybind: Keybinds = .{},
|
||||
/// Adwaita support.
|
||||
@"gtk-adwaita": bool = true,
|
||||
|
||||
/// Custom CSS files to be loaded.
|
||||
///
|
||||
/// This configuration can be repeated multiple times to load multiple files.
|
||||
/// Prepend a ? character to the file path to suppress errors if the file does
|
||||
/// not exist. If you want to include a file that begins with a literal ?
|
||||
/// character, surround the file path in double quotes (").
|
||||
/// The file size limit for a single stylesheet is 5MiB.
|
||||
@"gtk-custom-css": RepeatablePath = .{},
|
||||
|
||||
/// If `true` (default), applications running in the terminal can show desktop
|
||||
/// notifications using certain escape sequences such as OSC 9 or OSC 777.
|
||||
@"desktop-notifications": bool = true,
|
||||
@ -1963,10 +2054,11 @@ term: []const u8 = "xterm-ghostty",
|
||||
/// * `download` - Check for updates, automatically download the update,
|
||||
/// notify the user, but do not automatically install the update.
|
||||
///
|
||||
/// The default value is `check`.
|
||||
/// If unset, we defer to Sparkle's default behavior, which respects the
|
||||
/// preference stored in the standard user defaults (`defaults(1)`).
|
||||
///
|
||||
/// Changing this value at runtime works after a small delay.
|
||||
@"auto-update": AutoUpdate = .check,
|
||||
@"auto-update": ?AutoUpdate = null,
|
||||
|
||||
/// The release channel to use for auto-updates.
|
||||
///
|
||||
@ -2083,6 +2175,20 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
|
||||
.{ .key = .{ .translated = .v }, .mods = mods },
|
||||
.{ .paste_from_clipboard = {} },
|
||||
);
|
||||
// On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an
|
||||
// alt keybinding for Copy and shift+ins is an alt keybinding for Paste
|
||||
if (!builtin.target.isDarwin()) {
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } },
|
||||
.{ .copy_to_clipboard = {} },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } },
|
||||
.{ .paste_from_clipboard = {} },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Increase font size mapping for keyboards with dedicated plus keys (like german)
|
||||
@ -2124,45 +2230,53 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
|
||||
);
|
||||
|
||||
// Expand Selection
|
||||
try result.keybind.set.put(
|
||||
try result.keybind.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .left }, .mods = .{ .shift = true } },
|
||||
.{ .adjust_selection = .left },
|
||||
.{ .performable = true },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
try result.keybind.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .right }, .mods = .{ .shift = true } },
|
||||
.{ .adjust_selection = .right },
|
||||
.{ .performable = true },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
try result.keybind.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .up }, .mods = .{ .shift = true } },
|
||||
.{ .adjust_selection = .up },
|
||||
.{ .performable = true },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
try result.keybind.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .down }, .mods = .{ .shift = true } },
|
||||
.{ .adjust_selection = .down },
|
||||
.{ .performable = true },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
try result.keybind.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } },
|
||||
.{ .adjust_selection = .page_up },
|
||||
.{ .performable = true },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
try result.keybind.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } },
|
||||
.{ .adjust_selection = .page_down },
|
||||
.{ .performable = true },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
try result.keybind.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .home }, .mods = .{ .shift = true } },
|
||||
.{ .adjust_selection = .home },
|
||||
.{ .performable = true },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
try result.keybind.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .end }, .mods = .{ .shift = true } },
|
||||
.{ .adjust_selection = .end },
|
||||
.{ .performable = true },
|
||||
);
|
||||
|
||||
// Tabs common to all platforms
|
||||
@ -2247,12 +2361,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } },
|
||||
.{ .goto_split = .top },
|
||||
.{ .goto_split = .up },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } },
|
||||
.{ .goto_split = .bottom },
|
||||
.{ .goto_split = .down },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
@ -2412,10 +2526,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
|
||||
.{ .key = .{ .translated = .q }, .mods = .{ .super = true } },
|
||||
.{ .quit = {} },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
try result.keybind.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .k }, .mods = .{ .super = true } },
|
||||
.{ .clear_screen = {} },
|
||||
.{ .performable = true },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
@ -2516,12 +2631,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } },
|
||||
.{ .goto_split = .top },
|
||||
.{ .goto_split = .up },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
.{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } },
|
||||
.{ .goto_split = .bottom },
|
||||
.{ .goto_split = .down },
|
||||
);
|
||||
try result.keybind.set.put(
|
||||
alloc,
|
||||
@ -2668,18 +2783,43 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void {
|
||||
try self.expandPaths(std.fs.path.dirname(path).?);
|
||||
}
|
||||
|
||||
pub const OptionalFileAction = enum { loaded, not_found, @"error" };
|
||||
|
||||
/// Load optional configuration file from `path`. All errors are ignored.
|
||||
pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void {
|
||||
self.loadFile(alloc, path) catch |err| switch (err) {
|
||||
error.FileNotFound => std.log.info(
|
||||
"optional config file not found, not loading path={s}",
|
||||
.{path},
|
||||
),
|
||||
else => std.log.warn(
|
||||
///
|
||||
/// Returns the action that was taken.
|
||||
pub fn loadOptionalFile(
|
||||
self: *Config,
|
||||
alloc: Allocator,
|
||||
path: []const u8,
|
||||
) OptionalFileAction {
|
||||
if (self.loadFile(alloc, path)) {
|
||||
return .loaded;
|
||||
} else |err| switch (err) {
|
||||
error.FileNotFound => return .not_found,
|
||||
else => {
|
||||
std.log.warn(
|
||||
"error reading optional config file, not loading err={} path={s}",
|
||||
.{ err, path },
|
||||
),
|
||||
};
|
||||
);
|
||||
|
||||
return .@"error";
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn writeConfigTemplate(path: []const u8) !void {
|
||||
log.info("creating template config file: path={s}", .{path});
|
||||
if (std.fs.path.dirname(path)) |dir_path| {
|
||||
try std.fs.makeDirAbsolute(dir_path);
|
||||
}
|
||||
const file = try std.fs.createFileAbsolute(path, .{});
|
||||
defer file.close();
|
||||
try std.fmt.format(
|
||||
file.writer(),
|
||||
@embedFile("./config-template"),
|
||||
.{ .path = path },
|
||||
);
|
||||
}
|
||||
|
||||
/// Load configurations from the default configuration files. The default
|
||||
@ -2688,14 +2828,30 @@ pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void
|
||||
/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config`
|
||||
/// is also loaded.
|
||||
pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void {
|
||||
// Load XDG first
|
||||
const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" });
|
||||
defer alloc.free(xdg_path);
|
||||
self.loadOptionalFile(alloc, xdg_path);
|
||||
const xdg_action = self.loadOptionalFile(alloc, xdg_path);
|
||||
|
||||
// On macOS load the app support directory as well
|
||||
if (comptime builtin.os.tag == .macos) {
|
||||
const app_support_path = try internal_os.macos.appSupportDir(alloc, "config");
|
||||
defer alloc.free(app_support_path);
|
||||
self.loadOptionalFile(alloc, app_support_path);
|
||||
const app_support_action = self.loadOptionalFile(alloc, app_support_path);
|
||||
|
||||
// If both files are not found, then we create a template file.
|
||||
// For macOS, we only create the template file in the app support
|
||||
if (app_support_action == .not_found and xdg_action == .not_found) {
|
||||
writeConfigTemplate(app_support_path) catch |err| {
|
||||
log.warn("error creating template config file err={}", .{err});
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (xdg_action == .not_found) {
|
||||
writeConfigTemplate(xdg_path) catch |err| {
|
||||
log.warn("error creating template config file err={}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2805,6 +2961,9 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
|
||||
// replace the entire list with the new list.
|
||||
inline for (fields, 0..) |field, i| {
|
||||
const v = &@field(self, field);
|
||||
|
||||
// The list can be empty if it was reset, i.e. --font-family=""
|
||||
if (v.list.items.len > 0) {
|
||||
const len = v.list.items.len - counter[i];
|
||||
if (len > 0) {
|
||||
// Note: we don't have to worry about freeing the memory
|
||||
@ -2819,6 +2978,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Config files loaded from the CLI args are relative to pwd
|
||||
if (self.@"config-file".value.items.len > 0) {
|
||||
@ -3797,17 +3957,22 @@ pub const Color = struct {
|
||||
pub fn fromHex(input: []const u8) !Color {
|
||||
// Trim the beginning '#' if it exists
|
||||
const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input;
|
||||
if (trimmed.len != 6 and trimmed.len != 3) return error.InvalidValue;
|
||||
|
||||
// We expect exactly 6 for RRGGBB
|
||||
if (trimmed.len != 6) return error.InvalidValue;
|
||||
// Expand short hex values to full hex values
|
||||
const rgb: []const u8 = if (trimmed.len == 3) &.{
|
||||
trimmed[0], trimmed[0],
|
||||
trimmed[1], trimmed[1],
|
||||
trimmed[2], trimmed[2],
|
||||
} else trimmed;
|
||||
|
||||
// Parse the colors two at a time.
|
||||
var result: Color = undefined;
|
||||
comptime var i: usize = 0;
|
||||
inline while (i < 6) : (i += 2) {
|
||||
const v: u8 =
|
||||
((try std.fmt.charToDigit(trimmed[i], 16)) * 16) +
|
||||
try std.fmt.charToDigit(trimmed[i + 1], 16);
|
||||
((try std.fmt.charToDigit(rgb[i], 16)) * 16) +
|
||||
try std.fmt.charToDigit(rgb[i + 1], 16);
|
||||
|
||||
@field(result, switch (i) {
|
||||
0 => "r",
|
||||
@ -3827,6 +3992,8 @@ pub const Color = struct {
|
||||
try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C"));
|
||||
try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C"));
|
||||
try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF"));
|
||||
try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFF"));
|
||||
try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.fromHex("#345"));
|
||||
}
|
||||
|
||||
test "parseCLI from name" {
|
||||
@ -3987,7 +4154,7 @@ pub const Palette = struct {
|
||||
const eqlIdx = std.mem.indexOf(u8, value, "=") orelse
|
||||
return error.InvalidValue;
|
||||
|
||||
const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 10);
|
||||
const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 0);
|
||||
const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]);
|
||||
self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b };
|
||||
}
|
||||
@ -4027,6 +4194,28 @@ pub const Palette = struct {
|
||||
try testing.expect(p.value[0].b == 0xCC);
|
||||
}
|
||||
|
||||
test "parseCLI base" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Self = .{};
|
||||
|
||||
try p.parseCLI("0b1=#014589");
|
||||
try p.parseCLI("0o7=#234567");
|
||||
try p.parseCLI("0xF=#ABCDEF");
|
||||
|
||||
try testing.expect(p.value[0b1].r == 0x01);
|
||||
try testing.expect(p.value[0b1].g == 0x45);
|
||||
try testing.expect(p.value[0b1].b == 0x89);
|
||||
|
||||
try testing.expect(p.value[0o7].r == 0x23);
|
||||
try testing.expect(p.value[0o7].g == 0x45);
|
||||
try testing.expect(p.value[0o7].b == 0x67);
|
||||
|
||||
try testing.expect(p.value[0xF].r == 0xAB);
|
||||
try testing.expect(p.value[0xF].g == 0xCD);
|
||||
try testing.expect(p.value[0xF].b == 0xEF);
|
||||
}
|
||||
|
||||
test "parseCLI overflow" {
|
||||
const testing = std.testing;
|
||||
|
||||
@ -4291,6 +4480,45 @@ pub const RepeatablePath = struct {
|
||||
// If it isn't absolute, we need to make it absolute relative
|
||||
// to the base.
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
|
||||
// Check if the path starts with a tilde and expand it to the
|
||||
// home directory on Linux/macOS. We explicitly look for "~/"
|
||||
// because we don't support alternate users such as "~alice/"
|
||||
if (std.mem.startsWith(u8, path, "~/")) expand: {
|
||||
// Windows isn't supported yet
|
||||
if (comptime builtin.os.tag == .windows) break :expand;
|
||||
|
||||
const expanded: []const u8 = internal_os.expandHome(
|
||||
path,
|
||||
&buf,
|
||||
) catch |err| {
|
||||
try diags.append(alloc, .{
|
||||
.message = try std.fmt.allocPrintZ(
|
||||
alloc,
|
||||
"error expanding home directory for path {s}: {}",
|
||||
.{ path, err },
|
||||
),
|
||||
});
|
||||
|
||||
// Blank this path so that we don't attempt to resolve it
|
||||
// again
|
||||
self.value.items[i] = .{ .required = "" };
|
||||
|
||||
continue;
|
||||
};
|
||||
|
||||
log.debug(
|
||||
"expanding file path from home directory: path={s}",
|
||||
.{expanded},
|
||||
);
|
||||
|
||||
switch (self.value.items[i]) {
|
||||
.optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded),
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const abs = dir.realpath(path, &buf) catch |err| abs: {
|
||||
if (err == error.FileNotFound) {
|
||||
// The file doesn't exist. Try to resolve the relative path
|
||||
@ -4701,9 +4929,11 @@ pub const Keybinds = struct {
|
||||
try list.parseCLI(alloc, "ctrl+z>2=goto_tab:2");
|
||||
try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer()));
|
||||
|
||||
// Note they turn into translated keys because they match
|
||||
// their ASCII mapping.
|
||||
const want =
|
||||
\\keybind = ctrl+z>1=goto_tab:1
|
||||
\\keybind = ctrl+z>2=goto_tab:2
|
||||
\\keybind = ctrl+z>two=goto_tab:2
|
||||
\\keybind = ctrl+z>one=goto_tab:1
|
||||
\\
|
||||
;
|
||||
try std.testing.expectEqualStrings(want, buf.items);
|
||||
@ -5297,6 +5527,11 @@ pub const AdwToolbarStyle = enum {
|
||||
@"raised-border",
|
||||
};
|
||||
|
||||
/// See adw-toast
|
||||
pub const AdwToast = packed struct {
|
||||
@"clipboard-copy": bool = true,
|
||||
};
|
||||
|
||||
/// See mouse-shift-capture
|
||||
pub const MouseShiftCapture = enum {
|
||||
false,
|
||||
|
@ -42,6 +42,11 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool {
|
||||
ptr.* = @intCast(value);
|
||||
},
|
||||
|
||||
i16 => {
|
||||
const ptr: *c_short = @ptrCast(@alignCast(ptr_raw));
|
||||
ptr.* = @intCast(value);
|
||||
},
|
||||
|
||||
f32, f64 => |Float| {
|
||||
const ptr: *Float = @ptrCast(@alignCast(ptr_raw));
|
||||
ptr.* = @floatCast(value);
|
||||
|
43
src/config/config-template
Normal file
43
src/config/config-template
Normal file
@ -0,0 +1,43 @@
|
||||
# This is the configuration file for Ghostty.
|
||||
#
|
||||
# This template file has been automatically created at the following
|
||||
# path since Ghostty couldn't find any existing config files on your system:
|
||||
#
|
||||
# {[path]s}
|
||||
#
|
||||
# The template does not set any default options, since Ghostty ships
|
||||
# with sensible defaults for all options. Users should only need to set
|
||||
# options that they want to change from the default.
|
||||
#
|
||||
# Run `ghostty +show-config --default --docs` to view a list of
|
||||
# all available config options and their default values.
|
||||
#
|
||||
# Additionally, each config option is also explained in detail
|
||||
# on Ghostty's website, at https://ghostty.org/docs/config.
|
||||
|
||||
# Config syntax crash course
|
||||
# ==========================
|
||||
# # The config file consists of simple key-value pairs,
|
||||
# # separated by equals signs.
|
||||
# font-family = Iosevka
|
||||
# window-padding-x = 2
|
||||
#
|
||||
# # Spacing around the equals sign does not matter.
|
||||
# # All of these are identical:
|
||||
# key=value
|
||||
# key= value
|
||||
# key =value
|
||||
# key = value
|
||||
#
|
||||
# # Any line beginning with a # is a comment. It's not possible to put
|
||||
# # a comment after a config option, since it would be interpreted as a
|
||||
# # part of the value. For example, this will have a value of "#123abc":
|
||||
# background = #123abc
|
||||
#
|
||||
# # Empty values are used to reset config keys to default.
|
||||
# key =
|
||||
#
|
||||
# # Some config options have unique syntaxes for their value,
|
||||
# # which is explained in the docs for that config option.
|
||||
# # Just for example:
|
||||
# resize-overlay-duration = 4s 200ms
|
@ -1,31 +1,29 @@
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const internal_os = @import("../os/main.zig");
|
||||
|
||||
/// Open the configuration in the OS default editor according to the default
|
||||
/// paths the main config file could be in.
|
||||
///
|
||||
/// On Linux, this will open the file at the XDG config path. This is the
|
||||
/// only valid path for Linux so we don't need to check for other paths.
|
||||
///
|
||||
/// On macOS, both XDG and AppSupport paths are valid. Because Ghostty
|
||||
/// prioritizes AppSupport over XDG, we will open AppSupport if it exists,
|
||||
/// followed by XDG if it exists, and finally AppSupport if neither exist.
|
||||
/// For the existence check, we also prefer non-empty files over empty
|
||||
/// files.
|
||||
pub fn open(alloc_gpa: Allocator) !void {
|
||||
// default location
|
||||
const config_path = config_path: {
|
||||
const xdg_config_path = try internal_os.xdg.config(alloc_gpa, .{ .subdir = "ghostty/config" });
|
||||
// Use an arena to make memory management easier in here.
|
||||
var arena = ArenaAllocator.init(alloc_gpa);
|
||||
defer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
if (comptime builtin.os.tag == .macos) macos: {
|
||||
// On macOS, use the application support path if the XDG path doesn't exists.
|
||||
if (std.fs.accessAbsolute(xdg_config_path, .{})) {
|
||||
break :macos;
|
||||
} else |err| switch (err) {
|
||||
error.BadPathName, error.FileNotFound => {},
|
||||
else => break :macos,
|
||||
}
|
||||
|
||||
alloc_gpa.free(xdg_config_path);
|
||||
break :config_path try internal_os.macos.appSupportDir(alloc_gpa, "config");
|
||||
}
|
||||
|
||||
break :config_path xdg_config_path;
|
||||
};
|
||||
defer alloc_gpa.free(config_path);
|
||||
// Get the path we should open
|
||||
const config_path = try configPath(alloc);
|
||||
|
||||
// Create config directory recursively.
|
||||
if (std.fs.path.dirname(config_path)) |config_dir| {
|
||||
@ -43,5 +41,67 @@ pub fn open(alloc_gpa: Allocator) !void {
|
||||
}
|
||||
};
|
||||
|
||||
try internal_os.open(alloc_gpa, config_path);
|
||||
try internal_os.open(alloc, .text, config_path);
|
||||
}
|
||||
|
||||
/// Returns the config path to use for open for the current OS.
|
||||
///
|
||||
/// The allocator must be an arena allocator. No memory is freed by this
|
||||
/// function and the resulting path is not all the memory that is allocated.
|
||||
fn configPath(alloc_arena: Allocator) ![]const u8 {
|
||||
const paths: []const []const u8 = try configPathCandidates(alloc_arena);
|
||||
assert(paths.len > 0);
|
||||
|
||||
// Find the first path that exists and is non-empty. If no paths are
|
||||
// non-empty but at least one exists, we will return the first path that
|
||||
// exists.
|
||||
var exists: ?[]const u8 = null;
|
||||
for (paths) |path| {
|
||||
const f = std.fs.openFileAbsolute(path, .{}) catch |err| {
|
||||
switch (err) {
|
||||
// File doesn't exist, continue.
|
||||
error.BadPathName, error.FileNotFound => continue,
|
||||
|
||||
// Some other error, assume it exists and return it.
|
||||
else => return err,
|
||||
}
|
||||
};
|
||||
defer f.close();
|
||||
|
||||
// We expect stat to succeed because we just opened the file.
|
||||
const stat = try f.stat();
|
||||
|
||||
// If the file is non-empty, return it.
|
||||
if (stat.size > 0) return path;
|
||||
|
||||
// If the file is empty, remember it exists.
|
||||
if (exists == null) exists = path;
|
||||
}
|
||||
|
||||
// No paths are non-empty, return the first path that exists.
|
||||
if (exists) |v| return v;
|
||||
|
||||
// No paths are non-empty or exist, return the first path.
|
||||
return paths[0];
|
||||
}
|
||||
|
||||
/// Returns a const list of possible paths the main config file could be
|
||||
/// in for the current OS.
|
||||
fn configPathCandidates(alloc_arena: Allocator) ![]const []const u8 {
|
||||
var paths = try std.ArrayList([]const u8).initCapacity(alloc_arena, 2);
|
||||
errdefer paths.deinit();
|
||||
|
||||
if (comptime builtin.os.tag == .macos) {
|
||||
paths.appendAssumeCapacity(try internal_os.macos.appSupportDir(
|
||||
alloc_arena,
|
||||
"config",
|
||||
));
|
||||
}
|
||||
|
||||
paths.appendAssumeCapacity(try internal_os.xdg.config(
|
||||
alloc_arena,
|
||||
.{ .subdir = "ghostty/config" },
|
||||
));
|
||||
|
||||
return paths.items;
|
||||
}
|
||||
|
@ -3,7 +3,8 @@ const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const builtin = @import("builtin");
|
||||
const build_config = @import("../build_config.zig");
|
||||
const sentry = @import("sentry");
|
||||
const build_options = @import("build_options");
|
||||
const sentry = if (build_options.sentry) @import("sentry");
|
||||
const internal_os = @import("../os/main.zig");
|
||||
const crash = @import("main.zig");
|
||||
const state = &@import("../global.zig").state;
|
||||
@ -47,6 +48,8 @@ pub threadlocal var thread_state: ?ThreadState = null;
|
||||
/// It is up to the user to grab the logs and manually send them to us
|
||||
/// (or they own Sentry instance) if they want to.
|
||||
pub fn init(gpa: Allocator) !void {
|
||||
if (comptime !build_options.sentry) return;
|
||||
|
||||
// Not supported on Windows currently, doesn't build.
|
||||
if (comptime builtin.os.tag == .windows) return;
|
||||
|
||||
@ -76,6 +79,8 @@ pub fn init(gpa: Allocator) !void {
|
||||
}
|
||||
|
||||
fn initThread(gpa: Allocator) !void {
|
||||
if (comptime !build_options.sentry) return;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(gpa);
|
||||
defer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
@ -101,7 +106,23 @@ fn initThread(gpa: Allocator) !void {
|
||||
sentry.c.sentry_options_set_before_send(opts, beforeSend, null);
|
||||
|
||||
// Determine the Sentry cache directory.
|
||||
const cache_dir = try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" });
|
||||
const cache_dir = cache_dir: {
|
||||
// On macOS, we prefer to use the NSCachesDirectory value to be
|
||||
// a more idiomatic macOS application. But if XDG env vars are set
|
||||
// we will respect them.
|
||||
if (comptime builtin.os.tag == .macos) macos: {
|
||||
if (std.posix.getenv("XDG_CACHE_HOME") != null) break :macos;
|
||||
break :cache_dir try internal_os.macos.cacheDir(
|
||||
alloc,
|
||||
"sentry",
|
||||
);
|
||||
}
|
||||
|
||||
break :cache_dir try internal_os.xdg.cache(
|
||||
alloc,
|
||||
.{ .subdir = "ghostty/sentry" },
|
||||
);
|
||||
};
|
||||
sentry.c.sentry_options_set_database_path_n(
|
||||
opts,
|
||||
cache_dir.ptr,
|
||||
@ -129,6 +150,8 @@ fn initThread(gpa: Allocator) !void {
|
||||
/// Process-wide deinitialization of our Sentry client. This ensures all
|
||||
/// our data is flushed.
|
||||
pub fn deinit() void {
|
||||
if (comptime !build_options.sentry) return;
|
||||
|
||||
if (comptime builtin.os.tag == .windows) return;
|
||||
|
||||
// If we're still initializing then wait for init to finish. This
|
||||
|
@ -362,16 +362,9 @@ pub const CoreText = struct {
|
||||
const list = set.createMatchingFontDescriptors();
|
||||
defer list.release();
|
||||
|
||||
// Bring the list of descriptors in to zig land
|
||||
var zig_list = try copyMatchingDescriptors(alloc, list);
|
||||
errdefer alloc.free(zig_list);
|
||||
|
||||
// Filter them. We don't use `CTFontCollectionSetExclusionDescriptors`
|
||||
// to do this because that requires a mutable collection. This way is
|
||||
// much more straight forward.
|
||||
zig_list = try alloc.realloc(zig_list, filterDescriptors(zig_list));
|
||||
|
||||
// Sort our descriptors
|
||||
const zig_list = try copyMatchingDescriptors(alloc, list);
|
||||
errdefer alloc.free(zig_list);
|
||||
sortMatchingDescriptors(&desc, zig_list);
|
||||
|
||||
return DiscoverIterator{
|
||||
@ -558,47 +551,13 @@ pub const CoreText = struct {
|
||||
for (0..result.len) |i| {
|
||||
result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i);
|
||||
|
||||
// We need to retain because once the list
|
||||
// is freed it will release all its members.
|
||||
// We need to retain because once the list is freed it will
|
||||
// release all its members.
|
||||
result[i].retain();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Filter any descriptors out of the list that aren't acceptable for
|
||||
/// some reason or another (e.g. the font isn't in a format we can handle).
|
||||
///
|
||||
/// Invalid descriptors are filled in from the end of
|
||||
/// the list and the new length for the list is returned.
|
||||
fn filterDescriptors(list: []*macos.text.FontDescriptor) usize {
|
||||
var end = list.len;
|
||||
var i: usize = 0;
|
||||
while (i < end) {
|
||||
if (validDescriptor(list[i])) {
|
||||
i += 1;
|
||||
} else {
|
||||
list[i].release();
|
||||
end -= 1;
|
||||
list[i] = list[end];
|
||||
}
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/// Used by `filterDescriptors` to decide whether a descriptor is valid.
|
||||
fn validDescriptor(desc: *macos.text.FontDescriptor) bool {
|
||||
if (desc.copyAttribute(macos.text.FontAttribute.format)) |format| {
|
||||
defer format.release();
|
||||
var value: c_int = undefined;
|
||||
assert(format.getValue(.int, &value));
|
||||
|
||||
// Bitmap fonts are not currently supported.
|
||||
if (value == macos.text.c.kCTFontFormatBitmap) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
fn sortMatchingDescriptors(
|
||||
desc: *const Descriptor,
|
||||
list: []*macos.text.FontDescriptor,
|
||||
|
@ -34,3 +34,6 @@ pub const cozette = @embedFile("res/CozetteVector.ttf");
|
||||
/// Monaspace has weird ligature behaviors we want to test in our shapers
|
||||
/// so we embed it here.
|
||||
pub const monaspace_neon = @embedFile("res/MonaspaceNeon-Regular.otf");
|
||||
|
||||
/// Terminus TTF is a scalable font with bitmap glyphs at various sizes.
|
||||
pub const terminus_ttf = @embedFile("res/TerminusTTF-Regular.ttf");
|
||||
|
@ -515,8 +515,17 @@ pub const Face = struct {
|
||||
fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics {
|
||||
// Read the 'head' table out of the font data.
|
||||
const head: opentype.Head = head: {
|
||||
const tag = macos.text.FontTableTag.init("head");
|
||||
const data = ct_font.copyTable(tag) orelse return error.CopyTableError;
|
||||
// macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but
|
||||
// the table format is byte-identical to the 'head' table, so if we
|
||||
// can't find 'head' we try 'bhed' instead before failing.
|
||||
//
|
||||
// ref: https://fontforge.org/docs/techref/bitmaponlysfnt.html
|
||||
const head_tag = macos.text.FontTableTag.init("head");
|
||||
const bhed_tag = macos.text.FontTableTag.init("bhed");
|
||||
const data =
|
||||
ct_font.copyTable(head_tag) orelse
|
||||
ct_font.copyTable(bhed_tag) orelse
|
||||
return error.CopyTableError;
|
||||
defer data.release();
|
||||
const ptr = data.getPointer();
|
||||
const len = data.getLength();
|
||||
|
@ -288,7 +288,6 @@ pub const Face = struct {
|
||||
self.face.loadGlyph(glyph_id, .{
|
||||
.render = true,
|
||||
.color = self.face.hasColor(),
|
||||
.no_bitmap = !self.face.hasColor(),
|
||||
}) catch return false;
|
||||
|
||||
// If the glyph is SVG we assume colorized
|
||||
@ -323,14 +322,6 @@ pub const Face = struct {
|
||||
// glyph properties before render so we don't render here.
|
||||
.render = !self.synthetic.bold,
|
||||
|
||||
// Disable bitmap strikes for now since it causes issues with
|
||||
// our cell metrics and rasterization. In the future, this is
|
||||
// all fixable so we can enable it.
|
||||
//
|
||||
// This must be enabled for color faces though because those are
|
||||
// often colored bitmaps, which we support.
|
||||
.no_bitmap = !self.face.hasColor(),
|
||||
|
||||
// use options from config
|
||||
.no_hinting = !self.load_flags.hinting,
|
||||
.force_autohint = !self.load_flags.@"force-autohint",
|
||||
@ -385,7 +376,7 @@ pub const Face = struct {
|
||||
return error.UnsupportedPixelMode;
|
||||
};
|
||||
|
||||
log.warn("converting from pixel_mode={} to atlas_format={}", .{
|
||||
log.debug("converting from pixel_mode={} to atlas_format={}", .{
|
||||
bitmap_ft.pixel_mode,
|
||||
atlas.format,
|
||||
});
|
||||
@ -1005,3 +996,59 @@ test "svg font table" {
|
||||
|
||||
try testing.expectEqual(430, table.len);
|
||||
}
|
||||
|
||||
const terminus_i =
|
||||
\\........
|
||||
\\........
|
||||
\\...#....
|
||||
\\...#....
|
||||
\\........
|
||||
\\..##....
|
||||
\\...#....
|
||||
\\...#....
|
||||
\\...#....
|
||||
\\...#....
|
||||
\\...#....
|
||||
\\..###...
|
||||
\\........
|
||||
\\........
|
||||
\\........
|
||||
\\........
|
||||
;
|
||||
// Including the newline
|
||||
const terminus_i_pitch = 9;
|
||||
|
||||
test "bitmap glyph" {
|
||||
const alloc = testing.allocator;
|
||||
const testFont = font.embedded.terminus_ttf;
|
||||
|
||||
var lib = try Library.init();
|
||||
defer lib.deinit();
|
||||
|
||||
var atlas = try font.Atlas.init(alloc, 512, .grayscale);
|
||||
defer atlas.deinit(alloc);
|
||||
|
||||
// Any glyph at 12pt @ 96 DPI is a bitmap
|
||||
var ft_font = try Face.init(lib, testFont, .{ .size = .{
|
||||
.points = 12,
|
||||
.xdpi = 96,
|
||||
.ydpi = 96,
|
||||
} });
|
||||
defer ft_font.deinit();
|
||||
|
||||
// glyph 77 = 'i'
|
||||
const glyph = try ft_font.renderGlyph(alloc, &atlas, 77, .{});
|
||||
|
||||
// should render crisp
|
||||
try testing.expectEqual(8, glyph.width);
|
||||
try testing.expectEqual(16, glyph.height);
|
||||
for (0..glyph.height) |y| {
|
||||
for (0..glyph.width) |x| {
|
||||
const pixel = terminus_i[y * terminus_i_pitch + x];
|
||||
try testing.expectEqual(
|
||||
@as(u8, if (pixel == '#') 255 else 0),
|
||||
atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -43,28 +43,16 @@ pub fn monoToGrayscale(alloc: Allocator, bm: Bitmap) Allocator.Error!Bitmap {
|
||||
var buf = try alloc.alloc(u8, bm.width * bm.rows);
|
||||
errdefer alloc.free(buf);
|
||||
|
||||
// width divided by 8 because each byte has 8 pixels. This is therefore
|
||||
// the number of bytes in each row.
|
||||
const bytes_per_row = bm.width >> 3;
|
||||
|
||||
var source_i: usize = 0;
|
||||
var target_i: usize = 0;
|
||||
var i: usize = bm.rows;
|
||||
while (i > 0) : (i -= 1) {
|
||||
var j: usize = bytes_per_row;
|
||||
while (j > 0) : (j -= 1) {
|
||||
var bit: u4 = 8;
|
||||
while (bit > 0) : (bit -= 1) {
|
||||
const mask = @as(u8, 1) << @as(u3, @intCast(bit - 1));
|
||||
const bitval: u8 = if (bm.buffer[source_i + (j - 1)] & mask > 0) 0xFF else 0;
|
||||
buf[target_i] = bitval;
|
||||
target_i += 1;
|
||||
for (0..bm.rows) |y| {
|
||||
const row_offset = y * @as(usize, @intCast(bm.pitch));
|
||||
for (0..bm.width) |x| {
|
||||
const byte_offset = row_offset + @divTrunc(x, 8);
|
||||
const mask = @as(u8, 1) << @intCast(7 - (x % 8));
|
||||
const bit: u8 = @intFromBool((bm.buffer[byte_offset] & mask) != 0);
|
||||
buf[y * bm.width + x] = bit * 255;
|
||||
}
|
||||
}
|
||||
|
||||
source_i += @intCast(bm.pitch);
|
||||
}
|
||||
|
||||
var copy = bm;
|
||||
copy.buffer = buf.ptr;
|
||||
copy.pixel_mode = freetype.c.FT_PIXEL_MODE_GRAY;
|
||||
|
@ -25,6 +25,9 @@ This project uses several fonts which fall under the SIL Open Font License (OFL-
|
||||
- [Copyright 2013 Google LLC](https://github.com/googlefonts/noto-emoji/blob/main/LICENSE)
|
||||
- Cozette (MIT)
|
||||
- [Copyright (c) 2020, Slavfox](https://github.com/slavfox/Cozette/blob/main/LICENSE)
|
||||
- Terminus TTF (OFL-1.1)
|
||||
- [Copyright (c) 2010-2020 Dimitar Toshkov Zhekov with Reserved Font Name "Terminus Font"](https://sourceforge.net/projects/terminus-font/)
|
||||
- [Copyright (c) 2011-2023 Tilman Blumenbach with Reserved Font Name "Terminus (TTF)"](https://files.ax86.net/terminus-ttf/)
|
||||
|
||||
A full copy of the OFL license can be found at [OFL.txt](./OFL.txt).
|
||||
An accompanying FAQ is also available at <https://openfontlicense.org/>.
|
||||
|
BIN
src/font/res/TerminusTTF-Regular.ttf
Normal file
BIN
src/font/res/TerminusTTF-Regular.ttf
Normal file
Binary file not shown.
@ -1,6 +1,7 @@
|
||||
const builtin = @import("builtin");
|
||||
const options = @import("main.zig").options;
|
||||
const run = @import("shaper/run.zig");
|
||||
const feature = @import("shaper/feature.zig");
|
||||
pub const noop = @import("shaper/noop.zig");
|
||||
pub const harfbuzz = @import("shaper/harfbuzz.zig");
|
||||
pub const coretext = @import("shaper/coretext.zig");
|
||||
@ -8,6 +9,9 @@ pub const web_canvas = @import("shaper/web_canvas.zig");
|
||||
pub const Cache = @import("shaper/Cache.zig");
|
||||
pub const TextRun = run.TextRun;
|
||||
pub const RunIterator = run.RunIterator;
|
||||
pub const Feature = feature.Feature;
|
||||
pub const FeatureList = feature.FeatureList;
|
||||
pub const default_features = feature.default_features;
|
||||
|
||||
/// Shaper implementation for our compile options.
|
||||
pub const Shaper = switch (options.backend) {
|
||||
@ -49,10 +53,7 @@ pub const Cell = struct {
|
||||
|
||||
/// Options for shapers.
|
||||
pub const Options = struct {
|
||||
/// Font features to use when shaping. These can be in the following
|
||||
/// formats: "-feat" "+feat" "feat". A "-"-prefix is used to disable
|
||||
/// a feature and the others are used to enable a feature. If a feature
|
||||
/// isn't supported or is invalid, it will be ignored.
|
||||
/// Font features to use when shaping.
|
||||
///
|
||||
/// Note: eventually, this will move to font.Face probably as we may
|
||||
/// want to support per-face feature configuration. For now, we only
|
||||
|
@ -7,6 +7,9 @@ const trace = @import("tracy").trace;
|
||||
const font = @import("../main.zig");
|
||||
const os = @import("../../os/main.zig");
|
||||
const terminal = @import("../../terminal/main.zig");
|
||||
const Feature = font.shape.Feature;
|
||||
const FeatureList = font.shape.FeatureList;
|
||||
const default_features = font.shape.default_features;
|
||||
const Face = font.Face;
|
||||
const Collection = font.Collection;
|
||||
const DeferredFace = font.DeferredFace;
|
||||
@ -40,9 +43,10 @@ pub const Shaper = struct {
|
||||
/// The string used for shaping the current run.
|
||||
run_state: RunState,
|
||||
|
||||
/// The font features we want to use. The hardcoded features are always
|
||||
/// set first.
|
||||
features: FeatureList,
|
||||
/// CoreFoundation Dictionary which represents our font feature settings.
|
||||
features: *macos.foundation.Dictionary,
|
||||
/// A version of the features dictionary with the default features excluded.
|
||||
features_no_default: *macos.foundation.Dictionary,
|
||||
|
||||
/// The shared memory used for shaping results.
|
||||
cell_buf: CellBuf,
|
||||
@ -100,51 +104,17 @@ pub const Shaper = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// List of font features, parsed into the data structures used by
|
||||
/// the CoreText API. The CoreText API requires a pretty annoying wrapping
|
||||
/// to setup font features:
|
||||
///
|
||||
/// - The key parsed into a CFString
|
||||
/// - The value parsed into a CFNumber
|
||||
/// - The key and value are then put into a CFDictionary
|
||||
/// - The CFDictionary is then put into a CFArray
|
||||
/// - The CFArray is then put into another CFDictionary
|
||||
/// - The CFDictionary is then passed to the CoreText API to create
|
||||
/// a new font with the features set.
|
||||
///
|
||||
/// This structure handles up to the point that we have a CFArray of
|
||||
/// CFDictionary objects representing the font features and provides
|
||||
/// functions for creating the dictionary to init the font.
|
||||
const FeatureList = struct {
|
||||
list: *macos.foundation.MutableArray,
|
||||
|
||||
pub fn init() !FeatureList {
|
||||
var list = try macos.foundation.MutableArray.create();
|
||||
/// Create a CoreFoundation Dictionary suitable for
|
||||
/// settings the font features of a CoreText font.
|
||||
fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary {
|
||||
const list = try macos.foundation.MutableArray.create();
|
||||
errdefer list.release();
|
||||
return .{ .list = list };
|
||||
}
|
||||
|
||||
pub fn deinit(self: FeatureList) void {
|
||||
self.list.release();
|
||||
}
|
||||
|
||||
/// Append the given feature to the list. The feature syntax is
|
||||
/// the same as Harfbuzz: "feat" enables it and "-feat" disables it.
|
||||
pub fn append(self: *FeatureList, name_raw: []const u8) !void {
|
||||
// If the name is `-name` then we are disabling the feature,
|
||||
// otherwise we are enabling it, so we need to parse this out.
|
||||
const name = if (name_raw[0] == '-') name_raw[1..] else name_raw;
|
||||
const dict = try featureDict(name, name_raw[0] != '-');
|
||||
defer dict.release();
|
||||
self.list.appendValue(macos.foundation.Dictionary, dict);
|
||||
}
|
||||
|
||||
/// Create the dictionary for the given feature and value.
|
||||
fn featureDict(name: []const u8, v: bool) !*macos.foundation.Dictionary {
|
||||
const value_num: c_int = @intFromBool(v);
|
||||
for (feats) |feat| {
|
||||
const value_num: c_int = @intCast(feat.value);
|
||||
|
||||
// Keys can only be ASCII.
|
||||
var key = try macos.foundation.String.createWithBytes(name, .ascii, false);
|
||||
var key = try macos.foundation.String.createWithBytes(&feat.tag, .ascii, false);
|
||||
defer key.release();
|
||||
var value = try macos.foundation.Number.create(.int, &value_num);
|
||||
defer value.release();
|
||||
@ -154,50 +124,44 @@ pub const Shaper = struct {
|
||||
macos.text.c.kCTFontOpenTypeFeatureTag,
|
||||
macos.text.c.kCTFontOpenTypeFeatureValue,
|
||||
},
|
||||
&[_]?*const anyopaque{
|
||||
key,
|
||||
value,
|
||||
},
|
||||
&[_]?*const anyopaque{ key, value },
|
||||
);
|
||||
errdefer dict.release();
|
||||
return dict;
|
||||
}
|
||||
defer dict.release();
|
||||
|
||||
/// Returns the dictionary to use with the font API to set the
|
||||
/// features. This should be released by the caller.
|
||||
pub fn attrsDict(
|
||||
self: FeatureList,
|
||||
omit_defaults: bool,
|
||||
) !*macos.foundation.Dictionary {
|
||||
// Get our feature list. If we're omitting defaults then we
|
||||
// slice off the hardcoded features.
|
||||
const list = if (!omit_defaults) self.list else list: {
|
||||
const list = try macos.foundation.MutableArray.createCopy(@ptrCast(self.list));
|
||||
for (hardcoded_features) |_| list.removeValue(0);
|
||||
break :list list;
|
||||
};
|
||||
defer if (omit_defaults) list.release();
|
||||
list.appendValue(macos.foundation.Dictionary, dict);
|
||||
}
|
||||
|
||||
var dict = try macos.foundation.Dictionary.create(
|
||||
&[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute},
|
||||
&[_]?*const anyopaque{list},
|
||||
);
|
||||
errdefer dict.release();
|
||||
|
||||
return dict;
|
||||
}
|
||||
};
|
||||
|
||||
// These features are hardcoded to always be on by default. Users
|
||||
// can turn them off by setting the features to "-liga" for example.
|
||||
const hardcoded_features = [_][]const u8{ "dlig", "liga" };
|
||||
|
||||
/// The cell_buf argument is the buffer to use for storing shaped results.
|
||||
/// This should be at least the number of columns in the terminal.
|
||||
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
|
||||
var feats = try FeatureList.init();
|
||||
errdefer feats.deinit();
|
||||
for (hardcoded_features) |name| try feats.append(name);
|
||||
for (opts.features) |name| try feats.append(name);
|
||||
var feature_list: FeatureList = .{};
|
||||
defer feature_list.deinit(alloc);
|
||||
for (opts.features) |feature_str| {
|
||||
try feature_list.appendFromString(alloc, feature_str);
|
||||
}
|
||||
|
||||
// We need to construct two attrs dictionaries for font features;
|
||||
// one without the default features included, and one with them.
|
||||
const feats = feature_list.features.items;
|
||||
const feats_df = try alloc.alloc(Feature, feats.len + default_features.len);
|
||||
defer alloc.free(feats_df);
|
||||
|
||||
@memcpy(feats_df[0..default_features.len], &default_features);
|
||||
@memcpy(feats_df[default_features.len..], feats);
|
||||
|
||||
const features = try makeFeaturesDict(feats_df);
|
||||
errdefer features.release();
|
||||
const features_no_default = try makeFeaturesDict(feats);
|
||||
errdefer features_no_default.release();
|
||||
|
||||
var run_state = RunState.init();
|
||||
errdefer run_state.deinit(alloc);
|
||||
@ -242,7 +206,8 @@ pub const Shaper = struct {
|
||||
.alloc = alloc,
|
||||
.cell_buf = .{},
|
||||
.run_state = run_state,
|
||||
.features = feats,
|
||||
.features = features,
|
||||
.features_no_default = features_no_default,
|
||||
.writing_direction = writing_direction,
|
||||
.cached_fonts = .{},
|
||||
.cached_font_grid = 0,
|
||||
@ -255,7 +220,8 @@ pub const Shaper = struct {
|
||||
pub fn deinit(self: *Shaper) void {
|
||||
self.cell_buf.deinit(self.alloc);
|
||||
self.run_state.deinit(self.alloc);
|
||||
self.features.deinit();
|
||||
self.features.release();
|
||||
self.features_no_default.release();
|
||||
self.writing_direction.release();
|
||||
|
||||
{
|
||||
@ -509,8 +475,8 @@ pub const Shaper = struct {
|
||||
// If we have it, return the cached attr dict.
|
||||
if (self.cached_fonts.items[index_int]) |cached| return cached;
|
||||
|
||||
// Features dictionary, font descriptor, font
|
||||
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3);
|
||||
// Font descriptor, font
|
||||
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 2);
|
||||
|
||||
const run_font = font: {
|
||||
// The CoreText shaper relies on CoreText and CoreText claims
|
||||
@ -533,8 +499,10 @@ pub const Shaper = struct {
|
||||
const face = try grid.resolver.collection.getFace(index);
|
||||
const original = face.font;
|
||||
|
||||
const attrs = try self.features.attrsDict(face.quirks_disable_default_font_features);
|
||||
self.cf_release_pool.appendAssumeCapacity(attrs);
|
||||
const attrs = if (face.quirks_disable_default_font_features)
|
||||
self.features_no_default
|
||||
else
|
||||
self.features;
|
||||
|
||||
const desc = try macos.text.FontDescriptor.createWithAttributes(attrs);
|
||||
self.cf_release_pool.appendAssumeCapacity(desc);
|
||||
|
390
src/font/shaper/feature.zig
Normal file
390
src/font/shaper/feature.zig
Normal file
@ -0,0 +1,390 @@
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = std.log.scoped(.font_shaper);
|
||||
|
||||
/// Represents an OpenType font feature setting, which consists of a tag and
|
||||
/// a numeric parameter >= 0. Most features are boolean, so only parameters
|
||||
/// of 0 and 1 make sense for them, but some (e.g. 'cv01'..'cv99') can take
|
||||
/// parameters to choose between multiple variants of a given character or
|
||||
/// characters.
|
||||
///
|
||||
/// Ref:
|
||||
/// - https://learn.microsoft.com/en-us/typography/opentype/spec/chapter2#features-and-lookups
|
||||
/// - https://harfbuzz.github.io/shaping-opentype-features.html
|
||||
pub const Feature = struct {
|
||||
tag: [4]u8,
|
||||
value: u32,
|
||||
|
||||
pub fn fromString(str: []const u8) ?Feature {
|
||||
var fbs = std.io.fixedBufferStream(str);
|
||||
const reader = fbs.reader();
|
||||
return Feature.fromReader(reader);
|
||||
}
|
||||
|
||||
/// Parse a single font feature setting from a std.io.Reader, with a version
|
||||
/// of the syntax of HarfBuzz's font feature strings. Stops at end of stream
|
||||
/// or when a ',' is encountered.
|
||||
///
|
||||
/// This parsing aims to be as error-tolerant as possible while avoiding any
|
||||
/// assumptions in ambiguous scenarios. When invalid syntax is encountered,
|
||||
/// the reader is advanced to the next boundary (end-of-stream or ',') so
|
||||
/// that further features may be read.
|
||||
///
|
||||
/// Ref: https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string
|
||||
pub fn fromReader(reader: anytype) ?Feature {
|
||||
var tag: [4]u8 = undefined;
|
||||
var value: ?u32 = null;
|
||||
|
||||
// TODO: when we move to Zig 0.14 this can be replaced with a
|
||||
// labeled switch continue pattern rather than this loop.
|
||||
var state: union(enum) {
|
||||
/// Initial state.
|
||||
start: void,
|
||||
/// Parsing the tag, data is index.
|
||||
tag: u2,
|
||||
/// In the space between the tag and the value.
|
||||
space: void,
|
||||
/// Parsing an integer parameter directly in to `value`.
|
||||
int: void,
|
||||
/// Parsing a boolean keyword parameter ("on"/"off").
|
||||
bool: void,
|
||||
/// Encountered an unrecoverable syntax error, advancing to boundary.
|
||||
err: void,
|
||||
/// Done parsing feature.
|
||||
done: void,
|
||||
} = .start;
|
||||
while (true) {
|
||||
// If we hit the end of the stream we just pretend it's a comma.
|
||||
const byte = reader.readByte() catch ',';
|
||||
switch (state) {
|
||||
// If we're done then we skip whitespace until we see a ','.
|
||||
.done => switch (byte) {
|
||||
' ', '\t' => continue,
|
||||
',' => break,
|
||||
// If we see something other than whitespace or a ','
|
||||
// then this is an error since the intent is unclear.
|
||||
else => {
|
||||
state = .err;
|
||||
continue;
|
||||
},
|
||||
},
|
||||
|
||||
// If we're fast-forwarding from an error we just wanna
|
||||
// stop at the first boundary and ignore all other bytes.
|
||||
.err => if (byte == ',') return null,
|
||||
|
||||
.start => switch (byte) {
|
||||
// Ignore leading whitespace.
|
||||
' ', '\t' => continue,
|
||||
// Empty feature string.
|
||||
',' => return null,
|
||||
// '+' prefix to explicitly enable feature.
|
||||
'+' => {
|
||||
value = 1;
|
||||
state = .{ .tag = 0 };
|
||||
continue;
|
||||
},
|
||||
// '-' prefix to explicitly disable feature.
|
||||
'-' => {
|
||||
value = 0;
|
||||
state = .{ .tag = 0 };
|
||||
continue;
|
||||
},
|
||||
// Quote mark introducing a tag.
|
||||
'"', '\'' => {
|
||||
state = .{ .tag = 0 };
|
||||
continue;
|
||||
},
|
||||
// First letter of tag.
|
||||
else => {
|
||||
tag[0] = byte;
|
||||
state = .{ .tag = 1 };
|
||||
continue;
|
||||
},
|
||||
},
|
||||
|
||||
.tag => |*i| switch (byte) {
|
||||
// If the tag is interrupted by a comma it's invalid.
|
||||
',' => return null,
|
||||
// Ignore quote marks.
|
||||
'"', '\'' => continue,
|
||||
// A prefix of '+' or '-'
|
||||
// In all other cases we add the byte to our tag.
|
||||
else => {
|
||||
tag[i.*] = byte;
|
||||
if (i.* == 3) {
|
||||
state = .space;
|
||||
continue;
|
||||
}
|
||||
i.* += 1;
|
||||
},
|
||||
},
|
||||
|
||||
.space => switch (byte) {
|
||||
' ', '\t' => continue,
|
||||
// Ignore quote marks since we might have a
|
||||
// closing quote from the tag still ahead.
|
||||
'"', '\'' => continue,
|
||||
// Allow an '=' (which we can safely ignore)
|
||||
// only if we don't already have a value due
|
||||
// to a '+' or '-' prefix.
|
||||
'=' => if (value != null) {
|
||||
state = .err;
|
||||
continue;
|
||||
},
|
||||
',' => {
|
||||
// Specifying only a tag turns a feature on.
|
||||
if (value == null) value = 1;
|
||||
break;
|
||||
},
|
||||
'0'...'9' => {
|
||||
// If we already have value because of a
|
||||
// '+' or '-' prefix then this is an error.
|
||||
if (value != null) {
|
||||
state = .err;
|
||||
continue;
|
||||
}
|
||||
value = byte - '0';
|
||||
state = .int;
|
||||
continue;
|
||||
},
|
||||
'o', 'O' => {
|
||||
// If we already have value because of a
|
||||
// '+' or '-' prefix then this is an error.
|
||||
if (value != null) {
|
||||
state = .err;
|
||||
continue;
|
||||
}
|
||||
state = .bool;
|
||||
continue;
|
||||
},
|
||||
else => {
|
||||
state = .err;
|
||||
continue;
|
||||
},
|
||||
},
|
||||
|
||||
.int => switch (byte) {
|
||||
',' => break,
|
||||
'0'...'9' => {
|
||||
// If our value gets too big while
|
||||
// parsing we consider it an error.
|
||||
value = std.math.mul(u32, value.?, 10) catch {
|
||||
state = .err;
|
||||
continue;
|
||||
};
|
||||
value.? += byte - '0';
|
||||
},
|
||||
else => {
|
||||
state = .err;
|
||||
continue;
|
||||
},
|
||||
},
|
||||
|
||||
.bool => switch (byte) {
|
||||
',' => return null,
|
||||
'n', 'N' => {
|
||||
// "ofn"
|
||||
if (value != null) {
|
||||
assert(value == 0);
|
||||
state = .err;
|
||||
continue;
|
||||
}
|
||||
value = 1;
|
||||
state = .done;
|
||||
continue;
|
||||
},
|
||||
'f', 'F' => {
|
||||
// To make sure we consume two 'f's.
|
||||
if (value == null) {
|
||||
value = 0;
|
||||
} else {
|
||||
assert(value == 0);
|
||||
state = .done;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
else => {
|
||||
state = .err;
|
||||
continue;
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
assert(value != null);
|
||||
|
||||
return .{
|
||||
.tag = tag,
|
||||
.value = value.?,
|
||||
};
|
||||
}
|
||||
|
||||
/// Serialize this feature to the provided buffer.
|
||||
/// The string that this produces should be valid to parse.
|
||||
pub fn toString(self: *const Feature, buf: []u8) !void {
|
||||
var fbs = std.io.fixedBufferStream(buf);
|
||||
try self.format("", .{}, fbs.writer());
|
||||
}
|
||||
|
||||
/// Formatter for logging
|
||||
pub fn format(
|
||||
self: Feature,
|
||||
comptime layout: []const u8,
|
||||
opts: std.fmt.FormatOptions,
|
||||
writer: anytype,
|
||||
) !void {
|
||||
_ = layout;
|
||||
_ = opts;
|
||||
if (self.value <= 1) {
|
||||
// Format boolean options as "+tag" for on and "-tag" for off.
|
||||
try std.fmt.format(writer, "{c}{s}", .{
|
||||
"-+"[self.value],
|
||||
self.tag,
|
||||
});
|
||||
} else {
|
||||
// Format non-boolean tags as "tag=value".
|
||||
try std.fmt.format(writer, "{s}={d}", .{
|
||||
self.tag,
|
||||
self.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// A list of font feature settings (see `Feature` for more documentation).
|
||||
pub const FeatureList = struct {
|
||||
features: std.ArrayListUnmanaged(Feature) = .{},
|
||||
|
||||
pub fn deinit(self: *FeatureList, alloc: Allocator) void {
|
||||
self.features.deinit(alloc);
|
||||
}
|
||||
|
||||
/// Parse a comma separated list of features.
|
||||
/// See `Feature.fromReader` for more docs.
|
||||
pub fn fromString(alloc: Allocator, str: []const u8) !FeatureList {
|
||||
var self: FeatureList = .{};
|
||||
try self.appendFromString(alloc, str);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Append features to this list from a string with a comma separated list.
|
||||
/// See `Feature.fromReader` for more docs.
|
||||
pub fn appendFromString(
|
||||
self: *FeatureList,
|
||||
alloc: Allocator,
|
||||
str: []const u8,
|
||||
) !void {
|
||||
var fbs = std.io.fixedBufferStream(str);
|
||||
const reader = fbs.reader();
|
||||
while (fbs.pos < fbs.buffer.len) {
|
||||
const i = fbs.pos;
|
||||
if (Feature.fromReader(reader)) |feature| {
|
||||
try self.features.append(alloc, feature);
|
||||
} else log.warn(
|
||||
"failed to parse font feature setting: \"{s}\"",
|
||||
.{fbs.buffer[i..fbs.pos]},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatter for logging
|
||||
pub fn format(
|
||||
self: FeatureList,
|
||||
comptime layout: []const u8,
|
||||
opts: std.fmt.FormatOptions,
|
||||
writer: anytype,
|
||||
) !void {
|
||||
for (self.features.items, 0..) |feature, i| {
|
||||
try feature.format(layout, opts, writer);
|
||||
if (i != std.features.items.len - 1) try writer.writeAll(", ");
|
||||
}
|
||||
if (self.value <= 1) {
|
||||
// Format boolean options as "+tag" for on and "-tag" for off.
|
||||
try std.fmt.format(writer, "{c}{s}", .{
|
||||
"-+"[self.value],
|
||||
self.tag,
|
||||
});
|
||||
} else {
|
||||
// Format non-boolean tags as "tag=value".
|
||||
try std.fmt.format(writer, "{s}={d}", .{
|
||||
self.tag,
|
||||
self.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// These features are hardcoded to always be on by default. Users
|
||||
/// can turn them off by setting the features to "-liga" for example.
|
||||
pub const default_features = [_]Feature{
|
||||
.{ .tag = "dlig".*, .value = 1 },
|
||||
.{ .tag = "liga".*, .value = 1 },
|
||||
};
|
||||
|
||||
test "Feature.fromString" {
|
||||
const testing = std.testing;
|
||||
|
||||
// This is not *complete* coverage of every possible
|
||||
// combination of syntax, but it covers quite a few.
|
||||
|
||||
// Boolean settings (on)
|
||||
const kern_on = Feature{ .tag = "kern".*, .value = 1 };
|
||||
try testing.expectEqual(kern_on, Feature.fromString("kern"));
|
||||
try testing.expectEqual(kern_on, Feature.fromString("kern, "));
|
||||
try testing.expectEqual(kern_on, Feature.fromString("kern on"));
|
||||
try testing.expectEqual(kern_on, Feature.fromString("kern on, "));
|
||||
try testing.expectEqual(kern_on, Feature.fromString("+kern"));
|
||||
try testing.expectEqual(kern_on, Feature.fromString("+kern, "));
|
||||
try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1"));
|
||||
try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1, "));
|
||||
|
||||
// Boolean settings (off)
|
||||
const kern_off = Feature{ .tag = "kern".*, .value = 0 };
|
||||
try testing.expectEqual(kern_off, Feature.fromString("kern off"));
|
||||
try testing.expectEqual(kern_off, Feature.fromString("kern off, "));
|
||||
try testing.expectEqual(kern_off, Feature.fromString("-'kern'"));
|
||||
try testing.expectEqual(kern_off, Feature.fromString("-'kern', "));
|
||||
try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0"));
|
||||
try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0, "));
|
||||
|
||||
// Non-boolean settings
|
||||
const aalt_2 = Feature{ .tag = "aalt".*, .value = 2 };
|
||||
try testing.expectEqual(aalt_2, Feature.fromString("aalt=2"));
|
||||
try testing.expectEqual(aalt_2, Feature.fromString("aalt=2, "));
|
||||
try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2"));
|
||||
try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2, "));
|
||||
|
||||
// Various ambiguous/error cases which should be null
|
||||
try testing.expectEqual(null, Feature.fromString("aalt=2x")); // bad number
|
||||
try testing.expectEqual(null, Feature.fromString("toolong")); // tag too long
|
||||
try testing.expectEqual(null, Feature.fromString("sht")); // tag too short
|
||||
try testing.expectEqual(null, Feature.fromString("-kern 1")); // redundant/conflicting
|
||||
try testing.expectEqual(null, Feature.fromString("-kern on")); // redundant/conflicting
|
||||
try testing.expectEqual(null, Feature.fromString("aalt=o,")); // bad keyword
|
||||
try testing.expectEqual(null, Feature.fromString("aalt=ofn,")); // bad keyword
|
||||
}
|
||||
|
||||
test "FeatureList.fromString" {
|
||||
const testing = std.testing;
|
||||
|
||||
const str =
|
||||
" kern, kern on , +kern, \"kern\" = 1," ++ // Boolean settings (on)
|
||||
"kern off, -'kern' , \"kern\"=0," ++ // Boolean settings (off)
|
||||
"aalt=2, 'aalt'\t2," ++ // Non-boolean settings
|
||||
"aalt=2x, toolong, sht, -kern 1, -kern on, aalt=o, aalt=ofn," ++ // Invalid cases
|
||||
"last"; // To ensure final element is included correctly.
|
||||
var feats = try FeatureList.fromString(testing.allocator, str);
|
||||
defer feats.deinit(testing.allocator);
|
||||
try testing.expectEqualSlices(
|
||||
Feature,
|
||||
&(.{Feature{ .tag = "kern".*, .value = 1 }} ** 4 ++
|
||||
.{Feature{ .tag = "kern".*, .value = 0 }} ** 3 ++
|
||||
.{Feature{ .tag = "aalt".*, .value = 2 }} ** 2 ++
|
||||
.{Feature{ .tag = "last".*, .value = 1 }}),
|
||||
feats.features.items,
|
||||
);
|
||||
}
|
@ -3,6 +3,10 @@ const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const harfbuzz = @import("harfbuzz");
|
||||
const font = @import("../main.zig");
|
||||
const terminal = @import("../../terminal/main.zig");
|
||||
const Feature = font.shape.Feature;
|
||||
const FeatureList = font.shape.FeatureList;
|
||||
const default_features = font.shape.default_features;
|
||||
const Face = font.Face;
|
||||
const Collection = font.Collection;
|
||||
const DeferredFace = font.DeferredFace;
|
||||
@ -10,7 +14,6 @@ const Library = font.Library;
|
||||
const SharedGrid = font.SharedGrid;
|
||||
const Style = font.Style;
|
||||
const Presentation = font.Presentation;
|
||||
const terminal = @import("../../terminal/main.zig");
|
||||
|
||||
const log = std.log.scoped(.font_shaper);
|
||||
|
||||
@ -27,38 +30,37 @@ pub const Shaper = struct {
|
||||
cell_buf: CellBuf,
|
||||
|
||||
/// The features to use for shaping.
|
||||
hb_feats: FeatureList,
|
||||
hb_feats: []harfbuzz.Feature,
|
||||
|
||||
const CellBuf = std.ArrayListUnmanaged(font.shape.Cell);
|
||||
const FeatureList = std.ArrayListUnmanaged(harfbuzz.Feature);
|
||||
|
||||
// These features are hardcoded to always be on by default. Users
|
||||
// can turn them off by setting the features to "-liga" for example.
|
||||
const hardcoded_features = [_][]const u8{ "dlig", "liga" };
|
||||
|
||||
/// The cell_buf argument is the buffer to use for storing shaped results.
|
||||
/// This should be at least the number of columns in the terminal.
|
||||
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
|
||||
// Parse all the features we want to use. We use
|
||||
var hb_feats = hb_feats: {
|
||||
var list = try FeatureList.initCapacity(alloc, opts.features.len + hardcoded_features.len);
|
||||
errdefer list.deinit(alloc);
|
||||
|
||||
for (hardcoded_features) |name| {
|
||||
if (harfbuzz.Feature.fromString(name)) |feat| {
|
||||
try list.append(alloc, feat);
|
||||
} else log.warn("failed to parse font feature: {s}", .{name});
|
||||
// Parse all the features we want to use.
|
||||
const hb_feats = hb_feats: {
|
||||
var feature_list: FeatureList = .{};
|
||||
defer feature_list.deinit(alloc);
|
||||
try feature_list.features.appendSlice(alloc, &default_features);
|
||||
for (opts.features) |feature_str| {
|
||||
try feature_list.appendFromString(alloc, feature_str);
|
||||
}
|
||||
|
||||
for (opts.features) |name| {
|
||||
if (harfbuzz.Feature.fromString(name)) |feat| {
|
||||
try list.append(alloc, feat);
|
||||
} else log.warn("failed to parse font feature: {s}", .{name});
|
||||
var list = try alloc.alloc(harfbuzz.Feature, feature_list.features.items.len);
|
||||
errdefer alloc.free(list);
|
||||
|
||||
for (feature_list.features.items, 0..) |feature, i| {
|
||||
list[i] = .{
|
||||
.tag = std.mem.nativeToBig(u32, @bitCast(feature.tag)),
|
||||
.value = feature.value,
|
||||
.start = harfbuzz.c.HB_FEATURE_GLOBAL_START,
|
||||
.end = harfbuzz.c.HB_FEATURE_GLOBAL_END,
|
||||
};
|
||||
}
|
||||
|
||||
break :hb_feats list;
|
||||
};
|
||||
errdefer hb_feats.deinit(alloc);
|
||||
errdefer alloc.free(hb_feats);
|
||||
|
||||
return Shaper{
|
||||
.alloc = alloc,
|
||||
@ -71,7 +73,7 @@ pub const Shaper = struct {
|
||||
pub fn deinit(self: *Shaper) void {
|
||||
self.hb_buf.destroy();
|
||||
self.cell_buf.deinit(self.alloc);
|
||||
self.hb_feats.deinit(self.alloc);
|
||||
self.alloc.free(self.hb_feats);
|
||||
}
|
||||
|
||||
pub fn endFrame(self: *const Shaper) void {
|
||||
@ -125,10 +127,10 @@ pub const Shaper = struct {
|
||||
// If we are disabling default font features we just offset
|
||||
// our features by the hardcoded items because always
|
||||
// add those at the beginning.
|
||||
break :i hardcoded_features.len;
|
||||
break :i default_features.len;
|
||||
};
|
||||
|
||||
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats.items[i..]);
|
||||
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..]);
|
||||
}
|
||||
|
||||
// If our buffer is empty, we short-circuit the rest of the work
|
||||
|
@ -27,6 +27,7 @@ pub const GlobalState = struct {
|
||||
alloc: std.mem.Allocator,
|
||||
action: ?cli.Action,
|
||||
logging: Logging,
|
||||
rlimits: ResourceLimits = .{},
|
||||
|
||||
/// The app resources directory, equivalent to zig-out/share when we build
|
||||
/// from source. This is null if we can't detect it.
|
||||
@ -56,6 +57,7 @@ pub const GlobalState = struct {
|
||||
.alloc = undefined,
|
||||
.action = null,
|
||||
.logging = .{ .stderr = {} },
|
||||
.rlimits = .{},
|
||||
.resources_dir = null,
|
||||
};
|
||||
errdefer self.deinit();
|
||||
@ -123,8 +125,8 @@ pub const GlobalState = struct {
|
||||
std.log.info("renderer={}", .{renderer.Renderer});
|
||||
std.log.info("libxev backend={}", .{xev.backend});
|
||||
|
||||
// First things first, we fix our file descriptors
|
||||
internal_os.fixMaxFiles();
|
||||
// As early as possible, initialize our resource limits.
|
||||
self.rlimits = ResourceLimits.init();
|
||||
|
||||
// Initialize our crash reporting.
|
||||
crash.init(self.alloc) catch |err| {
|
||||
@ -174,3 +176,21 @@ pub const GlobalState = struct {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Maintains the Unix resource limits that we set for our process. This
|
||||
/// can be used to restore the limits to their original values.
|
||||
pub const ResourceLimits = struct {
|
||||
nofile: ?internal_os.rlimit = null,
|
||||
|
||||
pub fn init() ResourceLimits {
|
||||
return .{
|
||||
// Maximize the number of file descriptors we can have open
|
||||
// because we can consume a lot of them if we make many terminals.
|
||||
.nofile = internal_os.fixMaxFiles(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn restore(self: *const ResourceLimits) void {
|
||||
if (self.nofile) |lim| internal_os.restoreMaxFiles(lim);
|
||||
}
|
||||
};
|
||||
|
@ -36,6 +36,11 @@ pub const Flags = packed struct {
|
||||
/// and not just while Ghostty is focused. This may not work on all platforms.
|
||||
/// See the keybind config documentation for more information.
|
||||
global: bool = false,
|
||||
|
||||
/// True if this binding should only be triggered if the action can be
|
||||
/// performed. If the action can't be performed then the binding acts as
|
||||
/// if it doesn't exist.
|
||||
performable: bool = false,
|
||||
};
|
||||
|
||||
/// Full binding parser. The binding parser is implemented as an iterator
|
||||
@ -90,6 +95,9 @@ pub const Parser = struct {
|
||||
} else if (std.mem.eql(u8, prefix, "unconsumed")) {
|
||||
if (!flags.consumed) return Error.InvalidFormat;
|
||||
flags.consumed = false;
|
||||
} else if (std.mem.eql(u8, prefix, "performable")) {
|
||||
if (flags.performable) return Error.InvalidFormat;
|
||||
flags.performable = true;
|
||||
} else {
|
||||
// If we don't recognize the prefix then we're done.
|
||||
// There are trigger-specific prefixes like "physical:" so
|
||||
@ -185,10 +193,29 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool {
|
||||
if (rhs.trigger.mods.alt) count += 1;
|
||||
break :blk count;
|
||||
};
|
||||
if (lhs_count == rhs_count)
|
||||
|
||||
if (lhs_count != rhs_count)
|
||||
return lhs_count > rhs_count;
|
||||
|
||||
if (lhs.trigger.mods.int() != rhs.trigger.mods.int())
|
||||
return lhs.trigger.mods.int() > rhs.trigger.mods.int();
|
||||
|
||||
return lhs_count > rhs_count;
|
||||
const lhs_key: c_int = blk: {
|
||||
switch (lhs.trigger.key) {
|
||||
.translated => break :blk @intFromEnum(lhs.trigger.key.translated),
|
||||
.physical => break :blk @intFromEnum(lhs.trigger.key.physical),
|
||||
.unicode => break :blk @intCast(lhs.trigger.key.unicode),
|
||||
}
|
||||
};
|
||||
const rhs_key: c_int = blk: {
|
||||
switch (rhs.trigger.key) {
|
||||
.translated => break :blk @intFromEnum(rhs.trigger.key.translated),
|
||||
.physical => break :blk @intFromEnum(rhs.trigger.key.physical),
|
||||
.unicode => break :blk @intCast(rhs.trigger.key.unicode),
|
||||
}
|
||||
};
|
||||
|
||||
return lhs_key < rhs_key;
|
||||
}
|
||||
|
||||
/// The set of actions that a keybinding can take.
|
||||
@ -311,17 +338,17 @@ pub const Action = union(enum) {
|
||||
toggle_tab_overview: void,
|
||||
|
||||
/// Create a new split in the given direction. The new split will appear in
|
||||
/// the direction given.
|
||||
/// the direction given. For example `new_split:up`. Valid values are left, right, up, down and auto.
|
||||
new_split: SplitDirection,
|
||||
|
||||
/// Focus on a split in a given direction.
|
||||
/// Focus on a split in a given direction. For example `goto_split:top`. Valid values are top, bottom, left, right, previous and next.
|
||||
goto_split: SplitFocusDirection,
|
||||
|
||||
/// zoom/unzoom the current split.
|
||||
toggle_split_zoom: void,
|
||||
|
||||
/// Resize the current split by moving the split divider in the given
|
||||
/// direction
|
||||
/// direction. For example `resize_split:left,10`. The valid directions are up, down, left and right.
|
||||
resize_split: SplitResizeParameter,
|
||||
|
||||
/// Equalize all splits in the current window
|
||||
@ -478,10 +505,42 @@ pub const Action = union(enum) {
|
||||
previous,
|
||||
next,
|
||||
|
||||
top,
|
||||
up,
|
||||
left,
|
||||
bottom,
|
||||
down,
|
||||
right,
|
||||
|
||||
pub fn parse(input: []const u8) !SplitFocusDirection {
|
||||
return std.meta.stringToEnum(SplitFocusDirection, input) orelse {
|
||||
// For backwards compatibility we map "top" and "bottom" onto the enum
|
||||
// values "up" and "down"
|
||||
if (std.mem.eql(u8, input, "top")) {
|
||||
return .up;
|
||||
} else if (std.mem.eql(u8, input, "bottom")) {
|
||||
return .down;
|
||||
} else {
|
||||
return Error.InvalidFormat;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test "parse" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqual(.previous, try SplitFocusDirection.parse("previous"));
|
||||
try testing.expectEqual(.next, try SplitFocusDirection.parse("next"));
|
||||
|
||||
try testing.expectEqual(.up, try SplitFocusDirection.parse("up"));
|
||||
try testing.expectEqual(.left, try SplitFocusDirection.parse("left"));
|
||||
try testing.expectEqual(.down, try SplitFocusDirection.parse("down"));
|
||||
try testing.expectEqual(.right, try SplitFocusDirection.parse("right"));
|
||||
|
||||
try testing.expectEqual(.up, try SplitFocusDirection.parse("top"));
|
||||
try testing.expectEqual(.down, try SplitFocusDirection.parse("bottom"));
|
||||
|
||||
try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse(""));
|
||||
try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("green"));
|
||||
}
|
||||
};
|
||||
|
||||
pub const SplitResizeDirection = enum {
|
||||
@ -524,7 +583,16 @@ pub const Action = union(enum) {
|
||||
comptime field: std.builtin.Type.UnionField,
|
||||
param: []const u8,
|
||||
) !field.type {
|
||||
return switch (@typeInfo(field.type)) {
|
||||
const field_info = @typeInfo(field.type);
|
||||
|
||||
// Fields can provide a custom "parse" function
|
||||
if (field_info == .Struct or field_info == .Union or field_info == .Enum) {
|
||||
if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .Fn) {
|
||||
return field.type.parse(param);
|
||||
}
|
||||
}
|
||||
|
||||
return switch (field_info) {
|
||||
.Enum => try parseEnum(field.type, param),
|
||||
.Int => try parseInt(field.type, param),
|
||||
.Float => try parseFloat(field.type, param),
|
||||
@ -1019,6 +1087,14 @@ pub const Trigger = struct {
|
||||
const cp = it.nextCodepoint() orelse break :unicode;
|
||||
if (it.nextCodepoint() != null) break :unicode;
|
||||
|
||||
// If this is ASCII and we have a translated key, set that.
|
||||
if (std.math.cast(u8, cp)) |ascii| {
|
||||
if (key.Key.fromASCII(ascii)) |k| {
|
||||
result.key = .{ .translated = k };
|
||||
continue :loop;
|
||||
}
|
||||
}
|
||||
|
||||
result.key = .{ .unicode = cp };
|
||||
continue :loop;
|
||||
}
|
||||
@ -1554,6 +1630,19 @@ test "parse: triggers" {
|
||||
try parseSingle("a=ignore"),
|
||||
);
|
||||
|
||||
// unicode keys that map to translated
|
||||
try testing.expectEqual(Binding{
|
||||
.trigger = .{ .key = .{ .translated = .one } },
|
||||
.action = .{ .ignore = {} },
|
||||
}, try parseSingle("1=ignore"));
|
||||
try testing.expectEqual(Binding{
|
||||
.trigger = .{
|
||||
.mods = .{ .super = true },
|
||||
.key = .{ .translated = .period },
|
||||
},
|
||||
.action = .{ .ignore = {} },
|
||||
}, try parseSingle("cmd+.=ignore"));
|
||||
|
||||
// single modifier
|
||||
try testing.expectEqual(Binding{
|
||||
.trigger = .{
|
||||
@ -1626,6 +1715,16 @@ test "parse: triggers" {
|
||||
.flags = .{ .consumed = false },
|
||||
}, try parseSingle("unconsumed:physical:a+shift=ignore"));
|
||||
|
||||
// performable keys
|
||||
try testing.expectEqual(Binding{
|
||||
.trigger = .{
|
||||
.mods = .{ .shift = true },
|
||||
.key = .{ .translated = .a },
|
||||
},
|
||||
.action = .{ .ignore = {} },
|
||||
.flags = .{ .performable = true },
|
||||
}, try parseSingle("performable:shift+a=ignore"));
|
||||
|
||||
// invalid key
|
||||
try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore"));
|
||||
|
||||
|
@ -729,7 +729,9 @@ pub const Key = enum(c_int) {
|
||||
.{ '\t', .tab },
|
||||
|
||||
// Keypad entries. We just assume keypad with the kp_ prefix
|
||||
// so that has some special meaning. These must also always be last.
|
||||
// so that has some special meaning. These must also always be last,
|
||||
// so that our `fromASCII` function doesn't accidentally map them
|
||||
// over normal numerics and other keys.
|
||||
.{ '0', .kp_0 },
|
||||
.{ '1', .kp_1 },
|
||||
.{ '2', .kp_2 },
|
||||
|
@ -14,6 +14,7 @@ const input = @import("../input.zig");
|
||||
const renderer = @import("../renderer.zig");
|
||||
const terminal = @import("../terminal/main.zig");
|
||||
const inspector = @import("main.zig");
|
||||
const utils = @import("utils.zig");
|
||||
|
||||
/// The window names. These are used with docking so we need to have access.
|
||||
const window_cell = "Cell";
|
||||
@ -285,10 +286,6 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void {
|
||||
cimgui.c.igDockBuilderFinish(dock_id_main);
|
||||
}
|
||||
|
||||
fn bytesToKb(bytes: usize) usize {
|
||||
return bytes / 1024;
|
||||
}
|
||||
|
||||
fn renderScreenWindow(self: *Inspector) void {
|
||||
// Start our window. If we're collapsed we do nothing.
|
||||
defer cimgui.c.igEnd();
|
||||
@ -444,7 +441,7 @@ fn renderScreenWindow(self: *Inspector) void {
|
||||
}
|
||||
{
|
||||
_ = cimgui.c.igTableSetColumnIndex(1);
|
||||
cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_bytes, bytesToKb(kitty_images.total_bytes));
|
||||
cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_bytes, utils.toKiloBytes(kitty_images.total_bytes));
|
||||
}
|
||||
}
|
||||
|
||||
@ -456,7 +453,7 @@ fn renderScreenWindow(self: *Inspector) void {
|
||||
}
|
||||
{
|
||||
_ = cimgui.c.igTableSetColumnIndex(1);
|
||||
cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_limit, bytesToKb(kitty_images.total_limit));
|
||||
cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_limit, utils.toKiloBytes(kitty_images.total_limit));
|
||||
}
|
||||
}
|
||||
|
||||
@ -522,7 +519,7 @@ fn renderScreenWindow(self: *Inspector) void {
|
||||
}
|
||||
{
|
||||
_ = cimgui.c.igTableSetColumnIndex(1);
|
||||
cimgui.c.igText("%d bytes (%d KB)", pages.page_size, bytesToKb(pages.page_size));
|
||||
cimgui.c.igText("%d bytes (%d KB)", pages.page_size, utils.toKiloBytes(pages.page_size));
|
||||
}
|
||||
}
|
||||
|
||||
@ -534,7 +531,7 @@ fn renderScreenWindow(self: *Inspector) void {
|
||||
}
|
||||
{
|
||||
_ = cimgui.c.igTableSetColumnIndex(1);
|
||||
cimgui.c.igText("%d bytes (%d KB)", pages.maxSize(), bytesToKb(pages.maxSize()));
|
||||
cimgui.c.igText("%d bytes (%d KB)", pages.maxSize(), utils.toKiloBytes(pages.maxSize()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -728,7 +725,7 @@ fn renderSizeWindow(self: *Inspector) void {
|
||||
{
|
||||
_ = cimgui.c.igTableSetColumnIndex(1);
|
||||
cimgui.c.igText(
|
||||
"%d pt",
|
||||
"%.2f pt",
|
||||
self.surface.font_size.points,
|
||||
);
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const cimgui = @import("cimgui");
|
||||
const terminal = @import("../terminal/main.zig");
|
||||
const inspector = @import("main.zig");
|
||||
const utils = @import("utils.zig");
|
||||
|
||||
pub fn render(page: *const terminal.Page) void {
|
||||
cimgui.c.igPushID_Ptr(page);
|
||||
@ -25,7 +27,7 @@ pub fn render(page: *const terminal.Page) void {
|
||||
}
|
||||
{
|
||||
_ = cimgui.c.igTableSetColumnIndex(1);
|
||||
cimgui.c.igText("%d bytes", page.memory.len);
|
||||
cimgui.c.igText("%d bytes (%d Kb)", page.memory.len, utils.toKiloBytes(page.memory.len));
|
||||
cimgui.c.igText("%d VM pages", page.memory.len / std.mem.page_size);
|
||||
}
|
||||
}
|
||||
|
3
src/inspector/utils.zig
Normal file
3
src/inspector/utils.zig
Normal file
@ -0,0 +1,3 @@
|
||||
pub fn toKiloBytes(bytes: usize) usize {
|
||||
return bytes / 1024;
|
||||
}
|
@ -49,7 +49,8 @@ pub fn main() !MainReturn {
|
||||
|
||||
error.InvalidAction => try stderr.print(
|
||||
"Error: unknown CLI action specified. CLI actions are specified with\n" ++
|
||||
"the '+' character.\n",
|
||||
"the '+' character.\n\n" ++
|
||||
"All valid CLI actions can be listed with `ghostty +help`\n",
|
||||
.{},
|
||||
),
|
||||
|
||||
|
@ -59,3 +59,29 @@ pub fn launchedFromDesktop() bool {
|
||||
else => @compileError("unsupported platform"),
|
||||
};
|
||||
}
|
||||
|
||||
pub const DesktopEnvironment = enum {
|
||||
gnome,
|
||||
macos,
|
||||
other,
|
||||
windows,
|
||||
};
|
||||
|
||||
/// Detect what desktop environment we are running under. This is mainly used on
|
||||
/// Linux to enable or disable GTK client-side decorations but there may be more
|
||||
/// uses in the future.
|
||||
pub fn desktopEnvironment() DesktopEnvironment {
|
||||
return switch (comptime builtin.os.tag) {
|
||||
.macos => .macos,
|
||||
.windows => .windows,
|
||||
.linux => de: {
|
||||
if (@inComptime()) @compileError("Checking for the desktop environment on Linux must be done at runtime.");
|
||||
// use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux
|
||||
// https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop=
|
||||
const de = posix.getenv("XDG_SESSION_DESKTOP") orelse break :de .other;
|
||||
if (std.ascii.eqlIgnoreCase("gnome", de)) break :de .gnome;
|
||||
break :de .other;
|
||||
},
|
||||
else => .other,
|
||||
};
|
||||
}
|
||||
|
@ -4,24 +4,27 @@ const posix = std.posix;
|
||||
|
||||
const log = std.log.scoped(.os);
|
||||
|
||||
pub const rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {};
|
||||
|
||||
/// This maximizes the number of file descriptors we can have open. We
|
||||
/// need to do this because each window consumes at least a handful of fds.
|
||||
/// This is extracted from the Zig compiler source code.
|
||||
pub fn fixMaxFiles() void {
|
||||
if (!@hasDecl(posix.system, "rlimit")) return;
|
||||
pub fn fixMaxFiles() ?rlimit {
|
||||
if (!@hasDecl(posix.system, "rlimit")) return null;
|
||||
|
||||
var lim = posix.getrlimit(.NOFILE) catch {
|
||||
const old = posix.getrlimit(.NOFILE) catch {
|
||||
log.warn("failed to query file handle limit, may limit max windows", .{});
|
||||
return; // Oh well; we tried.
|
||||
return null; // Oh well; we tried.
|
||||
};
|
||||
|
||||
// If we're already at the max, we're done.
|
||||
if (lim.cur >= lim.max) {
|
||||
log.debug("file handle limit already maximized value={}", .{lim.cur});
|
||||
return;
|
||||
if (old.cur >= old.max) {
|
||||
log.debug("file handle limit already maximized value={}", .{old.cur});
|
||||
return old;
|
||||
}
|
||||
|
||||
// Do a binary search for the limit.
|
||||
var lim = old;
|
||||
var min: posix.rlim_t = lim.cur;
|
||||
var max: posix.rlim_t = 1 << 20;
|
||||
// But if there's a defined upper bound, don't search, just set it.
|
||||
@ -41,6 +44,12 @@ pub fn fixMaxFiles() void {
|
||||
}
|
||||
|
||||
log.debug("file handle limit raised value={}", .{lim.cur});
|
||||
return old;
|
||||
}
|
||||
|
||||
pub fn restoreMaxFiles(lim: rlimit) void {
|
||||
if (!@hasDecl(posix.system, "rlimit")) return;
|
||||
posix.setrlimit(.NOFILE, lim) catch {};
|
||||
}
|
||||
|
||||
/// Return the recommended path for temporary files.
|
||||
|
@ -12,7 +12,7 @@ const Error = error{
|
||||
|
||||
/// Determine the home directory for the currently executing user. This
|
||||
/// is generally an expensive process so the value should be cached.
|
||||
pub inline fn home(buf: []u8) !?[]u8 {
|
||||
pub inline fn home(buf: []u8) !?[]const u8 {
|
||||
return switch (builtin.os.tag) {
|
||||
inline .linux, .macos => try homeUnix(buf),
|
||||
.windows => try homeWindows(buf),
|
||||
@ -24,7 +24,7 @@ pub inline fn home(buf: []u8) !?[]u8 {
|
||||
};
|
||||
}
|
||||
|
||||
fn homeUnix(buf: []u8) !?[]u8 {
|
||||
fn homeUnix(buf: []u8) !?[]const u8 {
|
||||
// First: if we have a HOME env var, then we use that.
|
||||
if (posix.getenv("HOME")) |result| {
|
||||
if (buf.len < result.len) return Error.BufferTooSmall;
|
||||
@ -77,7 +77,7 @@ fn homeUnix(buf: []u8) !?[]u8 {
|
||||
return null;
|
||||
}
|
||||
|
||||
fn homeWindows(buf: []u8) !?[]u8 {
|
||||
fn homeWindows(buf: []u8) !?[]const u8 {
|
||||
const drive_len = blk: {
|
||||
var fba_instance = std.heap.FixedBufferAllocator.init(buf);
|
||||
const fba = fba_instance.allocator();
|
||||
@ -110,6 +110,68 @@ fn trimSpace(input: []const u8) []const u8 {
|
||||
return std.mem.trim(u8, input, " \n\t");
|
||||
}
|
||||
|
||||
pub const ExpandError = error{
|
||||
HomeDetectionFailed,
|
||||
BufferTooSmall,
|
||||
};
|
||||
|
||||
/// Expands a path that starts with a tilde (~) to the home directory of
|
||||
/// the current user.
|
||||
///
|
||||
/// Errors if `home` fails or if the size of the expanded path is larger
|
||||
/// than `buf.len`.
|
||||
pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 {
|
||||
return switch (builtin.os.tag) {
|
||||
.linux, .macos => try expandHomeUnix(path, buf),
|
||||
.ios => return path,
|
||||
else => @compileError("unimplemented"),
|
||||
};
|
||||
}
|
||||
|
||||
fn expandHomeUnix(path: []const u8, buf: []u8) ExpandError![]const u8 {
|
||||
if (!std.mem.startsWith(u8, path, "~/")) return path;
|
||||
const home_dir: []const u8 = if (home(buf)) |home_|
|
||||
home_ orelse return error.HomeDetectionFailed
|
||||
else |_|
|
||||
return error.HomeDetectionFailed;
|
||||
const rest = path[1..]; // Skip the ~
|
||||
const expanded_len = home_dir.len + rest.len;
|
||||
|
||||
if (expanded_len > buf.len) return Error.BufferTooSmall;
|
||||
@memcpy(buf[home_dir.len..expanded_len], rest);
|
||||
|
||||
return buf[0..expanded_len];
|
||||
}
|
||||
|
||||
test "expandHomeUnix" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const home_dir = try expandHomeUnix("~/", &buf);
|
||||
// Joining the home directory `~` with the path `/`
|
||||
// the result should end with a separator here. (e.g. `/home/user/`)
|
||||
try testing.expect(home_dir[home_dir.len - 1] == std.fs.path.sep);
|
||||
|
||||
const downloads = try expandHomeUnix("~/Downloads/shader.glsl", &buf);
|
||||
const expected_downloads = try std.mem.concat(allocator, u8, &[_][]const u8{ home_dir, "Downloads/shader.glsl" });
|
||||
defer allocator.free(expected_downloads);
|
||||
try testing.expectEqualStrings(expected_downloads, downloads);
|
||||
|
||||
try testing.expectEqualStrings("~", try expandHomeUnix("~", &buf));
|
||||
try testing.expectEqualStrings("~abc/", try expandHomeUnix("~abc/", &buf));
|
||||
try testing.expectEqualStrings("/home/user", try expandHomeUnix("/home/user", &buf));
|
||||
try testing.expectEqualStrings("", try expandHomeUnix("", &buf));
|
||||
|
||||
// Expect an error if the buffer is large enough to hold the home directory,
|
||||
// but not the expanded path
|
||||
var small_buf = try allocator.alloc(u8, home_dir.len);
|
||||
defer allocator.free(small_buf);
|
||||
try testing.expectError(error.BufferTooSmall, expandHomeUnix(
|
||||
"~/Downloads",
|
||||
small_buf[0..],
|
||||
));
|
||||
}
|
||||
|
||||
test {
|
||||
const testing = std.testing;
|
||||
|
||||
|
121
src/os/macos.zig
121
src/os/macos.zig
@ -24,42 +24,27 @@ pub const AppSupportDirError = Allocator.Error || error{AppleAPIFailed};
|
||||
pub fn appSupportDir(
|
||||
alloc: Allocator,
|
||||
sub_path: []const u8,
|
||||
) AppSupportDirError![]u8 {
|
||||
comptime assert(builtin.target.isDarwin());
|
||||
|
||||
const NSFileManager = objc.getClass("NSFileManager").?;
|
||||
const manager = NSFileManager.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("defaultManager"),
|
||||
.{},
|
||||
) AppSupportDirError![]const u8 {
|
||||
return try commonDir(
|
||||
alloc,
|
||||
.NSApplicationSupportDirectory,
|
||||
&.{ build_config.bundle_id, sub_path },
|
||||
);
|
||||
}
|
||||
|
||||
const url = manager.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
|
||||
.{
|
||||
NSSearchPathDirectory.NSApplicationSupportDirectory,
|
||||
NSSearchPathDomainMask.NSUserDomainMask,
|
||||
@as(?*anyopaque, null),
|
||||
true,
|
||||
@as(?*anyopaque, null),
|
||||
},
|
||||
pub const CacheDirError = Allocator.Error || error{AppleAPIFailed};
|
||||
|
||||
/// Return the path to the system cache directory with the given sub path joined.
|
||||
/// This allocates the result using the given allocator.
|
||||
pub fn cacheDir(
|
||||
alloc: Allocator,
|
||||
sub_path: []const u8,
|
||||
) CacheDirError![]const u8 {
|
||||
return try commonDir(
|
||||
alloc,
|
||||
.NSCachesDirectory,
|
||||
&.{ build_config.bundle_id, sub_path },
|
||||
);
|
||||
|
||||
// I don't think this is possible but just in case.
|
||||
if (url.value == null) return error.AppleAPIFailed;
|
||||
|
||||
// Get the UTF-8 string from the URL.
|
||||
const path = url.getProperty(objc.Object, "path");
|
||||
const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse
|
||||
return error.AppleAPIFailed;
|
||||
const app_support_dir = std.mem.sliceTo(c_str, 0);
|
||||
|
||||
return try std.fs.path.join(alloc, &.{
|
||||
app_support_dir,
|
||||
build_config.bundle_id,
|
||||
sub_path,
|
||||
});
|
||||
}
|
||||
|
||||
pub const SetQosClassError = error{
|
||||
@ -110,9 +95,79 @@ pub const NSOperatingSystemVersion = extern struct {
|
||||
};
|
||||
|
||||
pub const NSSearchPathDirectory = enum(c_ulong) {
|
||||
NSCachesDirectory = 13,
|
||||
NSApplicationSupportDirectory = 14,
|
||||
};
|
||||
|
||||
pub const NSSearchPathDomainMask = enum(c_ulong) {
|
||||
NSUserDomainMask = 1,
|
||||
};
|
||||
|
||||
fn commonDir(
|
||||
alloc: Allocator,
|
||||
directory: NSSearchPathDirectory,
|
||||
sub_paths: []const []const u8,
|
||||
) (error{AppleAPIFailed} || Allocator.Error)![]const u8 {
|
||||
comptime assert(builtin.target.isDarwin());
|
||||
|
||||
const NSFileManager = objc.getClass("NSFileManager").?;
|
||||
const manager = NSFileManager.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("defaultManager"),
|
||||
.{},
|
||||
);
|
||||
|
||||
const url = manager.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
|
||||
.{
|
||||
directory,
|
||||
NSSearchPathDomainMask.NSUserDomainMask,
|
||||
@as(?*anyopaque, null),
|
||||
true,
|
||||
@as(?*anyopaque, null),
|
||||
},
|
||||
);
|
||||
|
||||
if (url.value == null) return error.AppleAPIFailed;
|
||||
|
||||
const path = url.getProperty(objc.Object, "path");
|
||||
const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse
|
||||
return error.AppleAPIFailed;
|
||||
const base_dir = std.mem.sliceTo(c_str, 0);
|
||||
|
||||
// Create a new array with base_dir as the first element
|
||||
var paths = try alloc.alloc([]const u8, sub_paths.len + 1);
|
||||
paths[0] = base_dir;
|
||||
@memcpy(paths[1..], sub_paths);
|
||||
defer alloc.free(paths);
|
||||
|
||||
return try std.fs.path.join(alloc, paths);
|
||||
}
|
||||
|
||||
test "cacheDir paths" {
|
||||
if (!builtin.target.isDarwin()) return;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Test base path
|
||||
{
|
||||
const cache_path = try cacheDir(alloc, "");
|
||||
defer alloc.free(cache_path);
|
||||
try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null);
|
||||
}
|
||||
|
||||
// Test with subdir
|
||||
{
|
||||
const cache_path = try cacheDir(alloc, "test");
|
||||
defer alloc.free(cache_path);
|
||||
try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null);
|
||||
|
||||
const bundle_path = try std.fmt.allocPrint(alloc, "{s}/test", .{build_config.bundle_id});
|
||||
defer alloc.free(bundle_path);
|
||||
try testing.expect(std.mem.indexOf(u8, cache_path, bundle_path) != null);
|
||||
}
|
||||
}
|
||||
|
@ -32,14 +32,19 @@ pub const getenv = env.getenv;
|
||||
pub const setenv = env.setenv;
|
||||
pub const unsetenv = env.unsetenv;
|
||||
pub const launchedFromDesktop = desktop.launchedFromDesktop;
|
||||
pub const desktopEnvironment = desktop.desktopEnvironment;
|
||||
pub const rlimit = file.rlimit;
|
||||
pub const fixMaxFiles = file.fixMaxFiles;
|
||||
pub const restoreMaxFiles = file.restoreMaxFiles;
|
||||
pub const allocTmpDir = file.allocTmpDir;
|
||||
pub const freeTmpDir = file.freeTmpDir;
|
||||
pub const isFlatpak = flatpak.isFlatpak;
|
||||
pub const FlatpakHostCommand = flatpak.FlatpakHostCommand;
|
||||
pub const home = homedir.home;
|
||||
pub const expandHome = homedir.expandHome;
|
||||
pub const ensureLocale = locale.ensureLocale;
|
||||
pub const clickInterval = mouse.clickInterval;
|
||||
pub const open = openpkg.open;
|
||||
pub const OpenType = openpkg.Type;
|
||||
pub const pipe = pipepkg.pipe;
|
||||
pub const resourcesDir = resourcesdir.resourcesDir;
|
||||
|
@ -2,25 +2,50 @@ const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// The type of the data at the URL to open. This is used as a hint
|
||||
/// to potentially open the URL in a different way.
|
||||
pub const Type = enum {
|
||||
text,
|
||||
unknown,
|
||||
};
|
||||
|
||||
/// Open a URL in the default handling application.
|
||||
///
|
||||
/// Any output on stderr is logged as a warning in the application logs.
|
||||
/// Output on stdout is ignored.
|
||||
pub fn open(alloc: Allocator, url: []const u8) !void {
|
||||
// Some opener commands terminate after opening (macOS open) and some do not
|
||||
// (xdg-open). For those which do not terminate, we do not want to wait for
|
||||
// the process to exit to collect stderr.
|
||||
const argv, const wait = switch (builtin.os.tag) {
|
||||
.linux => .{ &.{ "xdg-open", url }, false },
|
||||
.macos => .{ &.{ "open", url }, true },
|
||||
.windows => .{ &.{ "rundll32", "url.dll,FileProtocolHandler", url }, false },
|
||||
pub fn open(
|
||||
alloc: Allocator,
|
||||
typ: Type,
|
||||
url: []const u8,
|
||||
) !void {
|
||||
const cmd: OpenCommand = switch (builtin.os.tag) {
|
||||
.linux => .{ .child = std.process.Child.init(
|
||||
&.{ "xdg-open", url },
|
||||
alloc,
|
||||
) },
|
||||
|
||||
.windows => .{ .child = std.process.Child.init(
|
||||
&.{ "rundll32", "url.dll,FileProtocolHandler", url },
|
||||
alloc,
|
||||
) },
|
||||
|
||||
.macos => .{
|
||||
.child = std.process.Child.init(
|
||||
switch (typ) {
|
||||
.text => &.{ "open", "-t", url },
|
||||
.unknown => &.{ "open", url },
|
||||
},
|
||||
alloc,
|
||||
),
|
||||
.wait = true,
|
||||
},
|
||||
|
||||
.ios => return error.Unimplemented,
|
||||
else => @compileError("unsupported OS"),
|
||||
};
|
||||
|
||||
var exe = std.process.Child.init(argv, alloc);
|
||||
|
||||
if (comptime wait) {
|
||||
var exe = cmd.child;
|
||||
if (cmd.wait) {
|
||||
// Pipe stdout/stderr so we can collect output from the command
|
||||
exe.stdout_behavior = .Pipe;
|
||||
exe.stderr_behavior = .Pipe;
|
||||
@ -28,7 +53,7 @@ pub fn open(alloc: Allocator, url: []const u8) !void {
|
||||
|
||||
try exe.spawn();
|
||||
|
||||
if (comptime wait) {
|
||||
if (cmd.wait) {
|
||||
// 50 KiB is the default value used by std.process.Child.run
|
||||
const output_max_size = 50 * 1024;
|
||||
|
||||
@ -47,3 +72,8 @@ pub fn open(alloc: Allocator, url: []const u8) !void {
|
||||
if (stderr.items.len > 0) std.log.err("open stderr={s}", .{stderr.items});
|
||||
}
|
||||
}
|
||||
|
||||
const OpenCommand = struct {
|
||||
child: std.process.Child,
|
||||
wait: bool = false,
|
||||
};
|
||||
|
@ -143,6 +143,32 @@ test {
|
||||
}
|
||||
}
|
||||
|
||||
test "cache directory paths" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
const mock_home = "/Users/test";
|
||||
|
||||
// Test when XDG_CACHE_HOME is not set
|
||||
{
|
||||
// Test base path
|
||||
{
|
||||
const cache_path = try cache(alloc, .{ .home = mock_home });
|
||||
defer alloc.free(cache_path);
|
||||
try testing.expectEqualStrings("/Users/test/.cache", cache_path);
|
||||
}
|
||||
|
||||
// Test with subdir
|
||||
{
|
||||
const cache_path = try cache(alloc, .{
|
||||
.home = mock_home,
|
||||
.subdir = "ghostty",
|
||||
});
|
||||
defer alloc.free(cache_path);
|
||||
try testing.expectEqualStrings("/Users/test/.cache/ghostty", cache_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test parseTerminalExec {
|
||||
const testing = std.testing;
|
||||
|
||||
|
@ -146,7 +146,7 @@ image_bg_end: u32 = 0,
|
||||
image_text_end: u32 = 0,
|
||||
image_virtual: bool = false,
|
||||
|
||||
/// Defererred OpenGL operation to update the screen size.
|
||||
/// Deferred OpenGL operation to update the screen size.
|
||||
const SetScreenSize = struct {
|
||||
size: renderer.Size,
|
||||
|
||||
|
@ -68,6 +68,34 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
|
||||
builtin unset ghostty_bash_inject rcfile
|
||||
fi
|
||||
|
||||
# Sudo
|
||||
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" && -n "$TERMINFO" ]]; then
|
||||
# Wrap `sudo` command to ensure Ghostty terminfo is preserved.
|
||||
#
|
||||
# This approach supports wrapping a `sudo` alias, but the alias definition
|
||||
# must come _after_ this function is defined. Otherwise, the alias expansion
|
||||
# will take precedence over this function, and it won't be wrapped.
|
||||
function sudo {
|
||||
builtin local sudo_has_sudoedit_flags="no"
|
||||
for arg in "$@"; do
|
||||
# Check if argument is '-e' or '--edit' (sudoedit flags)
|
||||
if [[ "$arg" == "-e" || $arg == "--edit" ]]; then
|
||||
sudo_has_sudoedit_flags="yes"
|
||||
builtin break
|
||||
fi
|
||||
# Check if argument is neither an option nor a key-value pair
|
||||
if [[ "$arg" != -* && "$arg" != *=* ]]; then
|
||||
builtin break
|
||||
fi
|
||||
done
|
||||
if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then
|
||||
builtin command sudo "$@";
|
||||
else
|
||||
builtin command sudo TERMINFO="$TERMINFO" "$@";
|
||||
fi
|
||||
}
|
||||
fi
|
||||
|
||||
# Import bash-preexec, safe to do multiple times
|
||||
builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh"
|
||||
|
||||
@ -109,31 +137,6 @@ function __ghostty_precmd() {
|
||||
PS0=$PS0'\[\e[0 q\]'
|
||||
fi
|
||||
|
||||
# Sudo
|
||||
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" ]] && [[ -n "$TERMINFO" ]]; then
|
||||
# Wrap `sudo` command to ensure Ghostty terminfo is preserved
|
||||
# shellcheck disable=SC2317
|
||||
sudo() {
|
||||
builtin local sudo_has_sudoedit_flags="no"
|
||||
for arg in "$@"; do
|
||||
# Check if argument is '-e' or '--edit' (sudoedit flags)
|
||||
if [[ "$arg" == "-e" || $arg == "--edit" ]]; then
|
||||
sudo_has_sudoedit_flags="yes"
|
||||
builtin break
|
||||
fi
|
||||
# Check if argument is neither an option nor a key-value pair
|
||||
if [[ "$arg" != -* && "$arg" != *=* ]]; then
|
||||
builtin break
|
||||
fi
|
||||
done
|
||||
if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then
|
||||
builtin command sudo "$@";
|
||||
else
|
||||
builtin command sudo TERMINFO="$TERMINFO" "$@";
|
||||
fi
|
||||
}
|
||||
fi
|
||||
|
||||
if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then
|
||||
# Command and working directory
|
||||
# shellcheck disable=SC2016
|
||||
|
@ -3413,6 +3413,16 @@ pub const Pin = struct {
|
||||
direction: Direction,
|
||||
limit: ?Pin,
|
||||
) PageIterator {
|
||||
if (build_config.slow_runtime_safety) {
|
||||
if (limit) |l| {
|
||||
// Check the order according to the iteration direction.
|
||||
switch (direction) {
|
||||
.right_down => assert(self.eql(l) or self.before(l)),
|
||||
.left_up => assert(self.eql(l) or l.before(self)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.row = self,
|
||||
.limit = if (limit) |p| .{ .row = p } else .{ .none = {} },
|
||||
|
@ -382,6 +382,7 @@ fn encodeError(r: *Response, err: EncodeableError) void {
|
||||
error.DecompressionFailed => r.message = "EINVAL: decompression failed",
|
||||
error.FilePathTooLong => r.message = "EINVAL: file path too long",
|
||||
error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir",
|
||||
error.TemporaryFileNotNamedCorrectly => r.message = "EINVAL: temporary file not named correctly",
|
||||
error.UnsupportedFormat => r.message = "EINVAL: unsupported format",
|
||||
error.UnsupportedMedium => r.message = "EINVAL: unsupported medium",
|
||||
error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth",
|
||||
|
@ -220,6 +220,9 @@ pub const LoadingImage = struct {
|
||||
// Temporary file logic
|
||||
if (medium == .temporary_file) {
|
||||
if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir;
|
||||
if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) {
|
||||
return error.TemporaryFileNotNamedCorrectly;
|
||||
}
|
||||
}
|
||||
defer if (medium == .temporary_file) {
|
||||
posix.unlink(path) catch |err| {
|
||||
@ -469,6 +472,7 @@ pub const Image = struct {
|
||||
DimensionsTooLarge,
|
||||
FilePathTooLong,
|
||||
TemporaryFileNotInTempDir,
|
||||
TemporaryFileNotNamedCorrectly,
|
||||
UnsupportedFormat,
|
||||
UnsupportedMedium,
|
||||
UnsupportedDepth,
|
||||
@ -682,7 +686,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk"
|
||||
try testing.expect(img.compression == .none);
|
||||
}
|
||||
|
||||
test "image load: rgb, not compressed, temporary file" {
|
||||
test "image load: temporary file without correct path" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@ -697,6 +701,39 @@ test "image load: rgb, not compressed, temporary file" {
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const path = try tmp_dir.dir.realpath("image.data", &buf);
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.medium = .temporary_file,
|
||||
.compression = .none,
|
||||
.width = 20,
|
||||
.height = 15,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try alloc.dupe(u8, path),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd));
|
||||
|
||||
// Temporary file should still be there
|
||||
try tmp_dir.dir.access(path, .{});
|
||||
}
|
||||
|
||||
test "image load: rgb, not compressed, temporary file" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var tmp_dir = try internal_os.TempDir.init();
|
||||
defer tmp_dir.deinit();
|
||||
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
||||
try tmp_dir.dir.writeFile(.{
|
||||
.sub_path = "tty-graphics-protocol-image.data",
|
||||
.data = data,
|
||||
});
|
||||
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf);
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
@ -762,12 +799,12 @@ test "image load: png, not compressed, regular file" {
|
||||
defer tmp_dir.deinit();
|
||||
const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
|
||||
try tmp_dir.dir.writeFile(.{
|
||||
.sub_path = "image.data",
|
||||
.sub_path = "tty-graphics-protocol-image.data",
|
||||
.data = data,
|
||||
});
|
||||
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const path = try tmp_dir.dir.realpath("image.data", &buf);
|
||||
const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf);
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
|
@ -189,11 +189,20 @@ pub const Parser = struct {
|
||||
.@"8_fg" = @enumFromInt(slice[0] - 30),
|
||||
},
|
||||
|
||||
38 => if (slice.len >= 5 and slice[1] == 2) {
|
||||
38 => if (slice.len >= 2) switch (slice[1]) {
|
||||
// `2` indicates direct-color (r, g, b).
|
||||
// We need at least 3 more params for this to make sense.
|
||||
2 => if (slice.len >= 5) {
|
||||
self.idx += 4;
|
||||
|
||||
// In the 6-len form, ignore the 3rd param.
|
||||
const rgb = slice[2..5];
|
||||
// When a colon separator is used, there may or may not be
|
||||
// a color space identifier as the third param, which we
|
||||
// need to ignore (it has no standardized behavior).
|
||||
const rgb = if (slice.len == 5 or !self.colon)
|
||||
slice[2..5]
|
||||
else rgb: {
|
||||
self.idx += 1;
|
||||
break :rgb slice[3..6];
|
||||
};
|
||||
|
||||
// We use @truncate because the value should be 0 to 255. If
|
||||
// it isn't, the behavior is undefined so we just... truncate it.
|
||||
@ -204,12 +213,16 @@ pub const Parser = struct {
|
||||
.b = @truncate(rgb[2]),
|
||||
},
|
||||
};
|
||||
} else if (slice.len >= 3 and slice[1] == 5) {
|
||||
},
|
||||
// `5` indicates indexed color.
|
||||
5 => if (slice.len >= 3) {
|
||||
self.idx += 2;
|
||||
return Attribute{
|
||||
.@"256_fg" = @truncate(slice[2]),
|
||||
};
|
||||
},
|
||||
else => {},
|
||||
},
|
||||
|
||||
39 => return Attribute{ .reset_fg = {} },
|
||||
|
||||
@ -217,11 +230,20 @@ pub const Parser = struct {
|
||||
.@"8_bg" = @enumFromInt(slice[0] - 40),
|
||||
},
|
||||
|
||||
48 => if (slice.len >= 5 and slice[1] == 2) {
|
||||
48 => if (slice.len >= 2) switch (slice[1]) {
|
||||
// `2` indicates direct-color (r, g, b).
|
||||
// We need at least 3 more params for this to make sense.
|
||||
2 => if (slice.len >= 5) {
|
||||
self.idx += 4;
|
||||
|
||||
// We only support the 5-len form.
|
||||
const rgb = slice[2..5];
|
||||
// When a colon separator is used, there may or may not be
|
||||
// a color space identifier as the third param, which we
|
||||
// need to ignore (it has no standardized behavior).
|
||||
const rgb = if (slice.len == 5 or !self.colon)
|
||||
slice[2..5]
|
||||
else rgb: {
|
||||
self.idx += 1;
|
||||
break :rgb slice[3..6];
|
||||
};
|
||||
|
||||
// We use @truncate because the value should be 0 to 255. If
|
||||
// it isn't, the behavior is undefined so we just... truncate it.
|
||||
@ -232,24 +254,33 @@ pub const Parser = struct {
|
||||
.b = @truncate(rgb[2]),
|
||||
},
|
||||
};
|
||||
} else if (slice.len >= 3 and slice[1] == 5) {
|
||||
},
|
||||
// `5` indicates indexed color.
|
||||
5 => if (slice.len >= 3) {
|
||||
self.idx += 2;
|
||||
return Attribute{
|
||||
.@"256_bg" = @truncate(slice[2]),
|
||||
};
|
||||
},
|
||||
else => {},
|
||||
},
|
||||
|
||||
49 => return Attribute{ .reset_bg = {} },
|
||||
|
||||
53 => return Attribute{ .overline = {} },
|
||||
55 => return Attribute{ .reset_overline = {} },
|
||||
|
||||
58 => if (slice.len >= 5 and slice[1] == 2) {
|
||||
58 => if (slice.len >= 2) switch (slice[1]) {
|
||||
// `2` indicates direct-color (r, g, b).
|
||||
// We need at least 3 more params for this to make sense.
|
||||
2 => if (slice.len >= 5) {
|
||||
self.idx += 4;
|
||||
|
||||
// In the 6-len form, ignore the 3rd param. Otherwise, use it.
|
||||
const rgb = if (slice.len == 5) slice[2..5] else rgb: {
|
||||
// Consume one more element
|
||||
// When a colon separator is used, there may or may not be
|
||||
// a color space identifier as the third param, which we
|
||||
// need to ignore (it has no standardized behavior).
|
||||
const rgb = if (slice.len == 5 or !self.colon)
|
||||
slice[2..5]
|
||||
else rgb: {
|
||||
self.idx += 1;
|
||||
break :rgb slice[3..6];
|
||||
};
|
||||
@ -263,12 +294,16 @@ pub const Parser = struct {
|
||||
.b = @truncate(rgb[2]),
|
||||
},
|
||||
};
|
||||
} else if (slice.len >= 3 and slice[1] == 5) {
|
||||
},
|
||||
// `5` indicates indexed color.
|
||||
5 => if (slice.len >= 3) {
|
||||
self.idx += 2;
|
||||
return Attribute{
|
||||
.@"256_underline_color" = @truncate(slice[2]),
|
||||
};
|
||||
},
|
||||
else => {},
|
||||
},
|
||||
|
||||
59 => return Attribute{ .reset_underline_color = {} },
|
||||
|
||||
@ -566,3 +601,59 @@ test "sgr: direct color bg missing color" {
|
||||
var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false };
|
||||
while (p.next()) |_| {}
|
||||
}
|
||||
|
||||
test "sgr: direct fg/bg/underline ignore optional color space" {
|
||||
// These behaviors have been verified against xterm.
|
||||
|
||||
// Colon version should skip the optional color space identifier
|
||||
{
|
||||
// 3 8 : 2 : Pi : Pr : Pg : Pb
|
||||
const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
|
||||
try testing.expect(v == .direct_color_fg);
|
||||
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
|
||||
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
|
||||
try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b);
|
||||
}
|
||||
{
|
||||
// 4 8 : 2 : Pi : Pr : Pg : Pb
|
||||
const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
|
||||
try testing.expect(v == .direct_color_bg);
|
||||
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r);
|
||||
try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g);
|
||||
try testing.expectEqual(@as(u8, 3), v.direct_color_bg.b);
|
||||
}
|
||||
{
|
||||
// 5 8 : 2 : Pi : Pr : Pg : Pb
|
||||
const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
|
||||
try testing.expect(v == .underline_color);
|
||||
try testing.expectEqual(@as(u8, 1), v.underline_color.r);
|
||||
try testing.expectEqual(@as(u8, 2), v.underline_color.g);
|
||||
try testing.expectEqual(@as(u8, 3), v.underline_color.b);
|
||||
}
|
||||
|
||||
// Semicolon version should not parse optional color space identifier
|
||||
{
|
||||
// 3 8 ; 2 ; Pr ; Pg ; Pb
|
||||
const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
|
||||
try testing.expect(v == .direct_color_fg);
|
||||
try testing.expectEqual(@as(u8, 0), v.direct_color_fg.r);
|
||||
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.g);
|
||||
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.b);
|
||||
}
|
||||
{
|
||||
// 4 8 ; 2 ; Pr ; Pg ; Pb
|
||||
const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
|
||||
try testing.expect(v == .direct_color_bg);
|
||||
try testing.expectEqual(@as(u8, 0), v.direct_color_bg.r);
|
||||
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.g);
|
||||
try testing.expectEqual(@as(u8, 2), v.direct_color_bg.b);
|
||||
}
|
||||
{
|
||||
// 5 8 ; 2 ; Pr ; Pg ; Pb
|
||||
const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
|
||||
try testing.expect(v == .underline_color);
|
||||
try testing.expectEqual(@as(u8, 0), v.underline_color.r);
|
||||
try testing.expectEqual(@as(u8, 1), v.underline_color.g);
|
||||
try testing.expectEqual(@as(u8, 2), v.underline_color.b);
|
||||
}
|
||||
}
|
||||
|
@ -380,7 +380,8 @@ pub fn Stream(comptime Handler: type) type {
|
||||
fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void {
|
||||
switch (input.final) {
|
||||
// CUU - Cursor Up
|
||||
'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
|
||||
'A', 'k' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -392,8 +393,15 @@ pub fn Stream(comptime Handler: type) type {
|
||||
false,
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI A with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// CUD - Cursor Down
|
||||
'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
|
||||
'B' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -405,8 +413,15 @@ pub fn Stream(comptime Handler: type) type {
|
||||
false,
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI B with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// CUF - Cursor Right
|
||||
'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight(
|
||||
'C' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -417,8 +432,15 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI C with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// CUB - Cursor Left
|
||||
'D', 'j' => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft(
|
||||
'D', 'j' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -429,8 +451,15 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI D with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// CNL - Cursor Next Line
|
||||
'E' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
|
||||
'E' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -442,8 +471,15 @@ pub fn Stream(comptime Handler: type) type {
|
||||
true,
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI E with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// CPL - Cursor Previous Line
|
||||
'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
|
||||
'F' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -455,25 +491,46 @@ pub fn Stream(comptime Handler: type) type {
|
||||
true,
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI F with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// HPA - Cursor Horizontal Position Absolute
|
||||
// TODO: test
|
||||
'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) {
|
||||
'G', '`' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) {
|
||||
0 => try self.handler.setCursorCol(1),
|
||||
1 => try self.handler.setCursorCol(input.params[0]),
|
||||
else => log.warn("invalid HPA command: {}", .{input}),
|
||||
} else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI G with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// CUP - Set Cursor Position.
|
||||
// TODO: test
|
||||
'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) {
|
||||
'H', 'f' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) {
|
||||
0 => try self.handler.setCursorPos(1, 1),
|
||||
1 => try self.handler.setCursorPos(input.params[0], 1),
|
||||
2 => try self.handler.setCursorPos(input.params[0], input.params[1]),
|
||||
else => log.warn("invalid CUP command: {}", .{input}),
|
||||
} else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI H with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// CHT - Cursor Horizontal Tabulation
|
||||
'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab(
|
||||
'I' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -484,6 +541,12 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI I with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// Erase Display
|
||||
'J' => if (@hasDecl(T, "eraseDisplay")) {
|
||||
const protected_: ?bool = switch (input.intermediates.len) {
|
||||
@ -540,22 +603,37 @@ pub fn Stream(comptime Handler: type) type {
|
||||
|
||||
// IL - Insert Lines
|
||||
// TODO: test
|
||||
'L' => if (@hasDecl(T, "insertLines")) switch (input.params.len) {
|
||||
'L' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "insertLines")) switch (input.params.len) {
|
||||
0 => try self.handler.insertLines(1),
|
||||
1 => try self.handler.insertLines(input.params[0]),
|
||||
else => log.warn("invalid IL command: {}", .{input}),
|
||||
} else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI L with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// DL - Delete Lines
|
||||
// TODO: test
|
||||
'M' => if (@hasDecl(T, "deleteLines")) switch (input.params.len) {
|
||||
'M' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "deleteLines")) switch (input.params.len) {
|
||||
0 => try self.handler.deleteLines(1),
|
||||
1 => try self.handler.deleteLines(input.params[0]),
|
||||
else => log.warn("invalid DL command: {}", .{input}),
|
||||
} else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI M with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// Delete Character (DCH)
|
||||
'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars(
|
||||
'P' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -566,6 +644,12 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI P with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// Scroll Up (SD)
|
||||
|
||||
'S' => switch (input.intermediates.len) {
|
||||
@ -587,7 +671,8 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
|
||||
// Scroll Down (SD)
|
||||
'T' => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown(
|
||||
'T' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -598,27 +683,31 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
// Cursor Tabulation Control
|
||||
'W' => {
|
||||
switch (input.params.len) {
|
||||
0 => if (@hasDecl(T, "tabSet"))
|
||||
try self.handler.tabSet()
|
||||
else
|
||||
log.warn("unimplemented tab set callback: {}", .{input}),
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI T with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
1 => if (input.intermediates.len == 1 and input.intermediates[0] == '?') {
|
||||
if (input.params[0] == 5) {
|
||||
if (@hasDecl(T, "tabReset"))
|
||||
try self.handler.tabReset()
|
||||
else
|
||||
log.warn("unimplemented tab reset callback: {}", .{input});
|
||||
} else log.warn("invalid cursor tabulation control: {}", .{input});
|
||||
} else {
|
||||
switch (input.params[0]) {
|
||||
0 => if (@hasDecl(T, "tabSet"))
|
||||
// Cursor Tabulation Control
|
||||
'W' => switch (input.intermediates.len) {
|
||||
0 => {
|
||||
if (input.params.len == 0 or
|
||||
(input.params.len == 1 and input.params[0] == 0))
|
||||
{
|
||||
if (@hasDecl(T, "tabSet"))
|
||||
try self.handler.tabSet()
|
||||
else
|
||||
log.warn("unimplemented tab set callback: {}", .{input}),
|
||||
log.warn("unimplemented tab set callback: {}", .{input});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (input.params.len) {
|
||||
0 => unreachable,
|
||||
|
||||
1 => switch (input.params[0]) {
|
||||
0 => unreachable,
|
||||
|
||||
2 => if (@hasDecl(T, "tabClear"))
|
||||
try self.handler.tabClear(.current)
|
||||
@ -631,7 +720,6 @@ pub fn Stream(comptime Handler: type) type {
|
||||
log.warn("unimplemented tab clear callback: {}", .{input}),
|
||||
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
|
||||
else => {},
|
||||
@ -641,8 +729,22 @@ pub fn Stream(comptime Handler: type) type {
|
||||
return;
|
||||
},
|
||||
|
||||
1 => if (input.intermediates[0] == '?' and input.params[0] == 5) {
|
||||
if (@hasDecl(T, "tabReset"))
|
||||
try self.handler.tabReset()
|
||||
else
|
||||
log.warn("unimplemented tab reset callback: {}", .{input});
|
||||
} else log.warn("invalid cursor tabulation control: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI W with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// Erase Characters (ECH)
|
||||
'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars(
|
||||
'X' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -653,8 +755,15 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI X with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// CHT - Cursor Horizontal Tabulation Back
|
||||
'Z' => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack(
|
||||
'Z' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -665,8 +774,15 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI Z with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// HPR - Cursor Horizontal Position Relative
|
||||
'a' => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative(
|
||||
'a' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -677,8 +793,15 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI a with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// Repeat Previous Char (REP)
|
||||
'b' => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat(
|
||||
'b' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -689,6 +812,12 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI b with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// c - Device Attributes (DA1)
|
||||
'c' => if (@hasDecl(T, "deviceAttributes")) {
|
||||
const req: ansi.DeviceAttributeReq = switch (input.intermediates.len) {
|
||||
@ -708,7 +837,8 @@ pub fn Stream(comptime Handler: type) type {
|
||||
} else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
// VPA - Cursor Vertical Position Absolute
|
||||
'd' => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow(
|
||||
'd' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -719,8 +849,15 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI d with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// VPR - Cursor Vertical Position Relative
|
||||
'e' => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative(
|
||||
'e' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative(
|
||||
switch (input.params.len) {
|
||||
0 => 1,
|
||||
1 => input.params[0],
|
||||
@ -731,9 +868,16 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI e with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// TBC - Tab Clear
|
||||
// TODO: test
|
||||
'g' => if (@hasDecl(T, "tabClear")) try self.handler.tabClear(
|
||||
'g' => switch (input.intermediates.len) {
|
||||
0 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear(
|
||||
switch (input.params.len) {
|
||||
1 => @enumFromInt(input.params[0]),
|
||||
else => {
|
||||
@ -743,6 +887,12 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
) else log.warn("unimplemented CSI callback: {}", .{input}),
|
||||
|
||||
else => log.warn(
|
||||
"ignoring unimplemented CSI g with intermediates: {s}",
|
||||
.{input.intermediates},
|
||||
),
|
||||
},
|
||||
|
||||
// SM - Set Mode
|
||||
'h' => if (@hasDecl(T, "setMode")) mode: {
|
||||
const ansi_mode = ansi: {
|
||||
@ -1564,10 +1714,13 @@ pub fn Stream(comptime Handler: type) type {
|
||||
} else log.warn("unimplemented ESC callback: {}", .{action}),
|
||||
|
||||
// HTS - Horizontal Tab Set
|
||||
'H' => if (@hasDecl(T, "tabSet"))
|
||||
try self.handler.tabSet()
|
||||
else
|
||||
log.warn("unimplemented tab set callback: {}", .{action}),
|
||||
'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) {
|
||||
0 => try self.handler.tabSet(),
|
||||
else => {
|
||||
log.warn("invalid tab set command: {}", .{action});
|
||||
return;
|
||||
},
|
||||
} else log.warn("unimplemented tab set callback: {}", .{action}),
|
||||
|
||||
// RI - Reverse Index
|
||||
'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) {
|
||||
@ -1597,17 +1750,17 @@ pub fn Stream(comptime Handler: type) type {
|
||||
} else log.warn("unimplemented invokeCharset: {}", .{action}),
|
||||
|
||||
// SPA - Start of Guarded Area
|
||||
'V' => if (@hasDecl(T, "setProtectedMode")) {
|
||||
'V' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) {
|
||||
try self.handler.setProtectedMode(ansi.ProtectedMode.iso);
|
||||
} else log.warn("unimplemented ESC callback: {}", .{action}),
|
||||
|
||||
// EPA - End of Guarded Area
|
||||
'W' => if (@hasDecl(T, "setProtectedMode")) {
|
||||
'W' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) {
|
||||
try self.handler.setProtectedMode(ansi.ProtectedMode.off);
|
||||
} else log.warn("unimplemented ESC callback: {}", .{action}),
|
||||
|
||||
// DECID
|
||||
'Z' => if (@hasDecl(T, "deviceAttributes")) {
|
||||
'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) {
|
||||
try self.handler.deviceAttributes(.primary, &.{});
|
||||
} else log.warn("unimplemented ESC callback: {}", .{action}),
|
||||
|
||||
@ -1666,12 +1819,12 @@ pub fn Stream(comptime Handler: type) type {
|
||||
} else log.warn("unimplemented invokeCharset: {}", .{action}),
|
||||
|
||||
// Set application keypad mode
|
||||
'=' => if (@hasDecl(T, "setMode")) {
|
||||
'=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
|
||||
try self.handler.setMode(.keypad_keys, true);
|
||||
} else log.warn("unimplemented setMode: {}", .{action}),
|
||||
|
||||
// Reset application keypad mode
|
||||
'>' => if (@hasDecl(T, "setMode")) {
|
||||
'>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
|
||||
try self.handler.setMode(.keypad_keys, false);
|
||||
} else log.warn("unimplemented setMode: {}", .{action}),
|
||||
|
||||
@ -1753,6 +1906,10 @@ test "stream: cursor right (CUF)" {
|
||||
s.handler.amount = 0;
|
||||
try s.nextSlice("\x1B[5;4C");
|
||||
try testing.expectEqual(@as(u16, 0), s.handler.amount);
|
||||
|
||||
s.handler.amount = 0;
|
||||
try s.nextSlice("\x1b[?3C");
|
||||
try testing.expectEqual(@as(u16, 0), s.handler.amount);
|
||||
}
|
||||
|
||||
test "stream: dec set mode (SM) and reset mode (RM)" {
|
||||
@ -1770,6 +1927,10 @@ test "stream: dec set mode (SM) and reset mode (RM)" {
|
||||
|
||||
try s.nextSlice("\x1B[?6l");
|
||||
try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode);
|
||||
|
||||
s.handler.mode = @as(modes.Mode, @enumFromInt(1));
|
||||
try s.nextSlice("\x1B[6 h");
|
||||
try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode);
|
||||
}
|
||||
|
||||
test "stream: ansi set mode (SM) and reset mode (RM)" {
|
||||
@ -1788,6 +1949,10 @@ test "stream: ansi set mode (SM) and reset mode (RM)" {
|
||||
|
||||
try s.nextSlice("\x1B[4l");
|
||||
try testing.expect(s.handler.mode == null);
|
||||
|
||||
s.handler.mode = null;
|
||||
try s.nextSlice("\x1B[>5h");
|
||||
try testing.expect(s.handler.mode == null);
|
||||
}
|
||||
|
||||
test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" {
|
||||
@ -1937,6 +2102,12 @@ test "stream: DECED, DECSED" {
|
||||
try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?);
|
||||
try testing.expect(!s.handler.protected.?);
|
||||
}
|
||||
{
|
||||
// Invalid and ignored by the handler
|
||||
for ("\x1B[>0J") |c| try s.next(c);
|
||||
try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?);
|
||||
try testing.expect(!s.handler.protected.?);
|
||||
}
|
||||
}
|
||||
|
||||
test "stream: DECEL, DECSEL" {
|
||||
@ -1997,6 +2168,12 @@ test "stream: DECEL, DECSEL" {
|
||||
try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?);
|
||||
try testing.expect(!s.handler.protected.?);
|
||||
}
|
||||
{
|
||||
// Invalid and ignored by the handler
|
||||
for ("\x1B[<1K") |c| try s.next(c);
|
||||
try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?);
|
||||
try testing.expect(!s.handler.protected.?);
|
||||
}
|
||||
}
|
||||
|
||||
test "stream: DECSCUSR" {
|
||||
@ -2014,6 +2191,10 @@ test "stream: DECSCUSR" {
|
||||
|
||||
try s.nextSlice("\x1B[1 q");
|
||||
try testing.expect(s.handler.style.? == .blinking_block);
|
||||
|
||||
// Invalid and ignored by the handler
|
||||
try s.nextSlice("\x1B[?0 q");
|
||||
try testing.expect(s.handler.style.? == .blinking_block);
|
||||
}
|
||||
|
||||
test "stream: DECSCUSR without space" {
|
||||
@ -2054,6 +2235,10 @@ test "stream: XTSHIFTESCAPE" {
|
||||
|
||||
try s.nextSlice("\x1B[>1s");
|
||||
try testing.expect(s.handler.escape.? == true);
|
||||
|
||||
// Invalid and ignored by the handler
|
||||
try s.nextSlice("\x1B[1 s");
|
||||
try testing.expect(s.handler.escape.? == true);
|
||||
}
|
||||
|
||||
test "stream: change window title with invalid utf-8" {
|
||||
@ -2374,6 +2559,14 @@ test "stream CSI W tab set" {
|
||||
s.handler.called = false;
|
||||
try s.nextSlice("\x1b[0W");
|
||||
try testing.expect(s.handler.called);
|
||||
|
||||
s.handler.called = false;
|
||||
try s.nextSlice("\x1b[>W");
|
||||
try testing.expect(!s.handler.called);
|
||||
|
||||
s.handler.called = false;
|
||||
try s.nextSlice("\x1b[99W");
|
||||
try testing.expect(!s.handler.called);
|
||||
}
|
||||
|
||||
test "stream CSI ? W reset tab stops" {
|
||||
@ -2392,4 +2585,8 @@ test "stream CSI ? W reset tab stops" {
|
||||
|
||||
try s.nextSlice("\x1b[?5W");
|
||||
try testing.expect(s.handler.reset);
|
||||
|
||||
// Invalid and ignored by the handler
|
||||
try s.nextSlice("\x1b[?1;2;3W");
|
||||
try testing.expect(s.handler.reset);
|
||||
}
|
||||
|
@ -466,6 +466,9 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void {
|
||||
// for alt screen, we do nothing.
|
||||
if (self.terminal.active_screen == .alternate) return;
|
||||
|
||||
// Clear our selection
|
||||
self.terminal.screen.clearSelection();
|
||||
|
||||
// Clear our scrollback
|
||||
if (history) self.terminal.eraseDisplay(.scrollback, false);
|
||||
|
||||
|
@ -42,6 +42,7 @@ wdth = "wdth"
|
||||
Strat = "Strat"
|
||||
grey = "gray"
|
||||
greyscale = "grayscale"
|
||||
DECID = "DECID"
|
||||
|
||||
[type.swift.extend-words]
|
||||
inout = "inout"
|
||||
|
Reference in New Issue
Block a user