Merge branch 'ghostty-org:main' into main

This commit is contained in:
plyght
2024-11-24 18:39:49 -05:00
committed by GitHub
134 changed files with 6697 additions and 2756 deletions

View File

@ -158,6 +158,9 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_16.0.app
# Setup Sparkle
- name: Setup Sparkle
env:
@ -187,7 +190,6 @@ jobs:
- name: Build Ghostty.app
run: |
cd macos
sudo xcode-select -s /Applications/Xcode_16.0.app
xcodebuild -target Ghostty -configuration Release
# We inject the "build number" as simply the number of commits since HEAD.
@ -342,6 +344,9 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_16.0.app
# Setup Sparkle
- name: Setup Sparkle
env:
@ -371,7 +376,6 @@ jobs:
- name: Build Ghostty.app
run: |
cd macos
sudo xcode-select -s /Applications/Xcode_16.0.app
xcodebuild -target Ghostty -configuration Release
# We inject the "build number" as simply the number of commits since HEAD.
@ -514,6 +518,9 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_16.0.app
# Setup Sparkle
- name: Setup Sparkle
env:
@ -543,7 +550,6 @@ jobs:
- name: Build Ghostty.app
run: |
cd macos
sudo xcode-select -s /Applications/Xcode_16.0.app
xcodebuild -target Ghostty -configuration Release
# We inject the "build number" as simply the number of commits since HEAD.

View File

@ -196,6 +196,9 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_16.0.app
- name: Test All
run: |
# OpenGL
@ -324,10 +327,10 @@ jobs:
run: nix develop -c zig build -Dapp-runtime=none test
- name: Test GTK Build
run: nix develop -c zig build -Dapp-runtime=gtk -Dgtk-libadwaita=true -Demit-docs
run: nix develop -c zig build -Dapp-runtime=gtk -Dgtk-adwaita=true -Demit-docs
- name: Test GTK Build (No Libadwaita)
run: nix develop -c zig build -Dapp-runtime=gtk -Dgtk-libadwaita=false -Demit-docs
run: nix develop -c zig build -Dapp-runtime=gtk -Dgtk-adwaita=false -Demit-docs
- name: Test GLFW Build
run: nix develop -c zig build -Dapp-runtime=glfw
@ -352,6 +355,9 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_16.0.app
- name: test
run: nix develop -c zig build test

79
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,79 @@
# Ghostty Development Process
This document describes the development process for Ghostty. It is intended for
anyone considering opening an **issue** or **pull request**. If in doubt,
please open a [discussion](https://github.com/ghostty-org/ghostty/discussions);
we can always convert that to an issue later.
> [!NOTE]
>
> I'm sorry for the wall of text. I'm not trying to be difficult and I do
> appreciate your contributions. Ghostty is a personal project for me that
> I maintain in my free time. If you're expecting me to dedicate my personal
> time to fixing bugs, maintaining features, and reviewing code, I do kindly
> ask you spend a few minutes reading this document. Thank you. ❤️
## Quick Guide
**I'd like to contribute!**
All issues are actionable. Pick one and start working on it. Thank you.
If you need help or guidance, comment on the issue. Issues that are extra
friendly to new contributors are tagged with "contributor friendly".
**I have a bug!**
1. Search the issue tracker and discussions for similar issues.
2. If you don't have steps to reproduce, open a discussion.
3. If you have steps to reproduce, open an issue.
**I have an idea for a feature!**
1. Open a discussion.
**I've implemented a feature!**
1. If there is an issue for the feature, open a pull request.
2. If there is no issue, open a discussion and link to your branch.
3. If you want to live dangerously, open a pull request and hope for the best.
**I have a question!**
1. Open a discussion or use Discord.
## General Patterns
### Issues are Actionable
The Ghostty [issue tracker](https://github.com/ghostty-org/ghostty/issues)
is for _actionable items_.
Unlike some other projects, Ghostty **does not use the issue tracker for
discussion or feature requests**. Instead, we use GitHub
[discussions](https://github.com/ghostty-org/ghostty/discussions) for that.
Once a discussion reaches a point where a well-understood, actionable
item is identified, it is moved to the issue tracker. **This pattern
makes it easier for maintainers or contributors to find issues to work on
since _every issue_ is ready to be worked on.**
If you are experiencing a bug and have clear steps to reproduce it, please
open an issue. If you are experiencing a bug but you are not sure how to
reproduce it or aren't sure if it's a bug, please open a discussion.
If you have an idea for a feature, please open a discussion.
### Pull Requests Implement an Issue
Pull requests should be associated with a previously accepted issue.
**If you open a pull request for something that wasn't previously discussed,**
it may be closed or remain stale for an indefinite period of time. I'm not
saying it will never be accepted, but the odds are stacked against you.
Issues tagged with "feature" represent accepted, well-scoped feature requests.
If you implement an issue tagged with feature as described in the issue, your
pull request will be accepted with a high degree of certainty.
> [!NOTE]
>
> **Pull requests are NOT a place to discuss feature design.** Please do
> not open a WIP pull request to discuss a feature. Instead, use a discussion
> and link to your branch.

View File

@ -80,7 +80,9 @@ relevant to package maintainers:
- `--system`: The path to the offline cache directory. This disables
any package fetching from the internet. This flag also triggers all
dependencies to be dynamically linked by default.
dependencies to be dynamically linked by default. This flag also makes
the binary a PIE (Position Independent Executable) by default (override
with `-Dpie`).
- `-Doptimize=ReleaseFast`: Build with optimizations enabled and safety checks
disabled. This is the recommended build mode for distribution. I'd prefer
@ -91,3 +93,8 @@ relevant to package maintainers:
- `-Dcpu=baseline`: Build for the "baseline" CPU of the target architecture.
This avoids building for newer CPU features that may not be available on
all target machines.
- `-Dtarget=$arch-$os-$abi`: Build for a specific target triple. This is
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.

View File

@ -63,7 +63,7 @@ placed at `$XDG_CONFIG_HOME/ghostty/config`, which defaults to
The file format is documented below as an example:
```
```ini
# The syntax is "key = value". The whitespace around the equals doesn't matter.
background = 282c34
foreground= ffffff
@ -375,9 +375,9 @@ test cases.
We believe Ghostty is one of the most compliant terminal emulators available.
Terminal behavior is partially a dejour standard
Terminal behavior is partially a de jure standard
(i.e. [ECMA-48](https://ecma-international.org/publications-and-standards/standards/ecma-48/))
but mostly a defacto standard as defined by popular terminal emulators
but mostly a de facto standard as defined by popular terminal emulators
worldwide. Ghostty takes the approach that our behavior is defined by
(1) standards, if available, (2) xterm, if the feature exists, (3)
other popular terminals, in that order. This defines what the Ghostty project
@ -789,7 +789,14 @@ Below is an example:
#
# Instead, either run `nix flake update` or `nixos-rebuild build`
# as the current user, and then run `sudo nixos-rebuild switch`.
ghostty.url = "git+ssh://git@github.com/ghostty-org/ghostty";
ghostty = {
url = "git+ssh://git@github.com/ghostty-org/ghostty";
# NOTE: The below 2 lines are only required on nixos-unstable,
# if you're on stable, they may break your build
inputs.nixpkgs-stable.follows = "nixpkgs";
inputs.nixpkgs-unstable.follows = "nixpkgs";
};
};
outputs = { nixpkgs, ghostty, ... }: {

View File

@ -19,4 +19,3 @@ Mac:
Major Features:
- Bell
- Sixels: https://saitoha.github.io/libsixel/

View File

@ -10,6 +10,7 @@ const font = @import("src/font/main.zig");
const renderer = @import("src/renderer.zig");
const terminfo = @import("src/terminfo/main.zig");
const config_vim = @import("src/config/vim.zig");
const config_sublime_syntax = @import("src/config/sublime_syntax.zig");
const fish_completions = @import("src/build/fish_completions.zig");
const build_config = @import("src/build_config.zig");
const BuildConfig = build_config.BuildConfig;
@ -56,6 +57,11 @@ pub fn build(b: *std.Build) !void {
break :target result;
};
// This is set to true when we're building a system package. For now
// this is trivially detected using the "system_package_mode" bool
// but we may want to make this more sophisticated in the future.
const system_package: bool = b.graph.system_package_mode;
const wasm_target: WasmTarget = .browser;
// We use env vars throughout the build so we grab them immediately here.
@ -91,12 +97,18 @@ pub fn build(b: *std.Build) !void {
"The app runtime to use. Not all values supported on all platforms.",
) orelse renderer.Impl.default(target.result, wasm_target);
config.libadwaita = b.option(
config.adwaita = b.option(
bool,
"gtk-libadwaita",
"Enables the use of libadwaita when using the gtk rendering backend.",
"gtk-adwaita",
"Enables the use of Adwaita when using the GTK rendering backend.",
) orelse true;
const pie = b.option(
bool,
"pie",
"Build a Position Independent Executable. Default true for system packages.",
) orelse system_package;
const conformance = b.option(
[]const u8,
"conformance",
@ -129,6 +141,9 @@ pub fn build(b: *std.Build) !void {
// If we are emitting any other artifacts then we default to false.
if (emit_bench or emit_test_exe or emit_helpgen) break :emit_docs false;
// We always emit docs in system package mode.
if (system_package) break :emit_docs true;
// We only default to true if we can find pandoc.
const path = Command.expandPath(b.allocator, "pandoc") catch
break :emit_docs false;
@ -281,6 +296,9 @@ pub fn build(b: *std.Build) !void {
// Exe
if (exe_) |exe| {
// Set PIE if requested
if (pie) exe.pie = true;
// Add the shared dependencies
_ = try addDeps(b, exe, config);
@ -499,6 +517,38 @@ pub fn build(b: *std.Build) !void {
});
}
// Neovim plugin
// This is just a copy-paste of the Vim plugin, but using a Neovim subdir.
// By default, Neovim doesn't look inside share/vim/vimfiles. Some distros
// configure it to do that however. Fedora, does not as a counterexample.
{
const wf = b.addWriteFiles();
_ = wf.add("syntax/ghostty.vim", config_vim.syntax);
_ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect);
_ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin);
b.installDirectory(.{
.source_dir = wf.getDirectory(),
.install_dir = .prefix,
.install_subdir = "share/nvim/site",
});
}
// Sublime syntax highlighting for bat cli tool
// NOTE: The current implementation requires symlinking the generated
// 'ghostty.sublime-syntax' file from zig-out to the '~.config/bat/syntaxes'
// directory. The syntax then needs to be mapped to the correct language in
// the config file within the '~.config/bat' directory
// (ex: --map-syntax "/Users/user/.config/ghostty/config:Ghostty Config").
{
const wf = b.addWriteFiles();
_ = wf.add("ghostty.sublime-syntax", config_sublime_syntax.syntax);
b.installDirectory(.{
.source_dir = wf.getDirectory(),
.install_dir = .prefix,
.install_subdir = "share/bat/syntaxes",
});
}
// Documentation
if (emit_docs) {
try buildDocumentation(b, config);
@ -975,7 +1025,7 @@ fn addDeps(
if (b.systemIntegrationOption("freetype", .{})) {
step.linkSystemLibrary2("bzip2", dynamic_link_opts);
step.linkSystemLibrary2("freetype", dynamic_link_opts);
step.linkSystemLibrary2("freetype2", dynamic_link_opts);
} else {
step.linkLibrary(freetype_dep.artifact("freetype"));
try static_libs.append(freetype_dep.artifact("freetype").getEmittedBin());
@ -1054,7 +1104,8 @@ fn addDeps(
});
step.root_module.addImport("oniguruma", oniguruma_dep.module("oniguruma"));
if (b.systemIntegrationOption("oniguruma", .{})) {
step.linkSystemLibrary2("oniguruma", dynamic_link_opts);
// Oniguruma is compiled and distributed as libonig.so
step.linkSystemLibrary2("onig", dynamic_link_opts);
} else {
step.linkLibrary(oniguruma_dep.artifact("oniguruma"));
try static_libs.append(oniguruma_dep.artifact("oniguruma").getEmittedBin());
@ -1068,6 +1119,7 @@ fn addDeps(
step.root_module.addImport("glslang", glslang_dep.module("glslang"));
if (b.systemIntegrationOption("glslang", .{})) {
step.linkSystemLibrary2("glslang", dynamic_link_opts);
step.linkSystemLibrary2("glslang-default-resource-limits", dynamic_link_opts);
} else {
step.linkLibrary(glslang_dep.artifact("glslang"));
try static_libs.append(glslang_dep.artifact("glslang").getEmittedBin());
@ -1190,8 +1242,6 @@ fn addDeps(
step.root_module.addImport("vaxis", b.dependency("vaxis", .{
.target = target,
.optimize = optimize,
.libxev = false,
.images = false,
}).module("vaxis"));
step.root_module.addImport("wuffs", b.dependency("wuffs", .{
.target = target,
@ -1213,6 +1263,7 @@ fn addDeps(
step.root_module.addImport("zf", b.dependency("zf", .{
.target = target,
.optimize = optimize,
.with_tui = false,
}).module("zf"));
// Mac Stuff
@ -1226,14 +1277,7 @@ fn addDeps(
.optimize = optimize,
});
// This is a bit of a hack that should probably be fixed upstream
// in zig-objc, but we need to add the apple SDK paths to the
// zig-objc module so that it can find the objc runtime headers.
const module = objc_dep.module("objc");
module.resolved_target = step.root_module.resolved_target;
try @import("apple_sdk").addPaths(b, module);
step.root_module.addImport("objc", module);
step.root_module.addImport("objc", objc_dep.module("objc"));
step.root_module.addImport("macos", macos_dep.module("macos"));
step.linkLibrary(macos_dep.artifact("macos"));
try static_libs.append(macos_dep.artifact("macos").getEmittedBin());
@ -1294,7 +1338,7 @@ fn addDeps(
.gtk => {
step.linkSystemLibrary2("gtk4", dynamic_link_opts);
if (config.libadwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts);
if (config.adwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts);
{
const gresource = @import("src/apprt/gtk/gresource.zig");

View File

@ -14,8 +14,8 @@
.lazy = true,
},
.zig_objc = .{
.url = "https://github.com/mitchellh/zig-objc/archive/fe5ac419530cf800294369d996133fe9cd067aec.tar.gz",
.hash = "122034b3e15d582d8d101a7713e5f13c872b8b8eb6d9cb47515b8e34ee75e122630d",
.url = "https://github.com/mitchellh/zig-objc/archive/9b8ba849b0f58fe207ecd6ab7c147af55b17556e.tar.gz",
.hash = "1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634",
},
.zig_js = .{
.url = "https://github.com/mitchellh/zig-js/archive/d0b8b0a57c52fbc89f9d9fecba75ca29da7dd7d1.tar.gz",
@ -49,16 +49,16 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b4a9c4d.tar.gz",
.hash = "122056fbb29863ec1678b7954fb76b1533ad8c581a34577c1b2efe419e29e05596df",
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/80543b14552b7c9fef88fad826552e6ac5632abe.tar.gz",
.hash = "1220217ae916146a4c598f8ba5bfff0ff940335d00572e337f20b4accf24fa2ca4fc",
},
.vaxis = .{
.url = "git+https://github.com/rockorager/libvaxis?ref=main#a1b43d24653670d612b91f0855b165e6c987b809",
.hash = "1220e4d6fc82c487178339422887fdfd5094b15c242fe31ad596c4b2fcdc60ef667f",
.url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b",
.hash = "12200df4ebeaed45de26cb2c9f3b6f3746d8013b604e035dae658f86f586c8c91d2f",
},
.zf = .{
.url = "git+https://github.com/natecraddock/zf.git?ref=main#bb27a917c3513785c6a91f0b1c10002a5029cacc",
.hash = "1220a74107c7f153a2f809e41c7fa7e8dbf75c91043e39fad998247804e5edac2cc8",
.url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd",
.hash = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8",
},
.z2d = .{
.url = "git+https://github.com/vancluever/z2d?ref=main#285a796eb9c25a2389f087d008f0e60faf0b8eda",

View File

@ -430,6 +430,11 @@ typedef struct {
const char* title;
} ghostty_action_set_title_s;
// apprt.action.Pwd.C
typedef struct {
const char* pwd;
} ghostty_action_pwd_s;
// terminal.MouseShape
typedef enum {
GHOSTTY_MOUSE_SHAPE_DEFAULT,
@ -512,6 +517,31 @@ typedef struct {
ghostty_input_trigger_s trigger;
} ghostty_action_key_sequence_s;
// apprt.action.ColorKind
typedef enum {
GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,
GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2,
GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3,
} ghostty_action_color_kind_e;
// apprt.action.ColorChange
typedef struct {
ghostty_action_color_kind_e kind;
uint8_t r;
uint8_t g;
uint8_t b;
} ghostty_action_color_change_s;
// apprt.action.ConfigChange
typedef struct {
ghostty_config_t config;
} ghostty_action_config_change_s;
// apprt.action.ReloadConfig
typedef struct {
bool soft;
} ghostty_action_reload_config_s;
// apprt.Action.Key
typedef enum {
GHOSTTY_ACTION_NEW_WINDOW,
@ -537,6 +567,7 @@ typedef enum {
GHOSTTY_ACTION_RENDER_INSPECTOR,
GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
GHOSTTY_ACTION_SET_TITLE,
GHOSTTY_ACTION_PWD,
GHOSTTY_ACTION_MOUSE_SHAPE,
GHOSTTY_ACTION_MOUSE_VISIBILITY,
GHOSTTY_ACTION_MOUSE_OVER_LINK,
@ -545,6 +576,9 @@ typedef enum {
GHOSTTY_ACTION_QUIT_TIMER,
GHOSTTY_ACTION_SECURE_INPUT,
GHOSTTY_ACTION_KEY_SEQUENCE,
GHOSTTY_ACTION_COLOR_CHANGE,
GHOSTTY_ACTION_RELOAD_CONFIG,
GHOSTTY_ACTION_CONFIG_CHANGE,
} ghostty_action_tag_e;
typedef union {
@ -560,6 +594,7 @@ typedef union {
ghostty_action_inspector_e inspector;
ghostty_action_desktop_notification_s desktop_notification;
ghostty_action_set_title_s set_title;
ghostty_action_pwd_s pwd;
ghostty_action_mouse_shape_e mouse_shape;
ghostty_action_mouse_visibility_e mouse_visibility;
ghostty_action_mouse_over_link_s mouse_over_link;
@ -567,6 +602,9 @@ typedef union {
ghostty_action_quit_timer_e quit_timer;
ghostty_action_secure_input_e secure_input;
ghostty_action_key_sequence_s key_sequence;
ghostty_action_color_change_s color_change;
ghostty_action_reload_config_s reload_config;
ghostty_action_config_change_s config_change;
} ghostty_action_u;
typedef struct {
@ -575,7 +613,6 @@ typedef struct {
} ghostty_action_s;
typedef void (*ghostty_runtime_wakeup_cb)(void*);
typedef const ghostty_config_t (*ghostty_runtime_reload_config_cb)(void*);
typedef void (*ghostty_runtime_read_clipboard_cb)(void*,
ghostty_clipboard_e,
void*);
@ -598,7 +635,6 @@ typedef struct {
bool supports_selection_clipboard;
ghostty_runtime_wakeup_cb wakeup_cb;
ghostty_runtime_action_cb action_cb;
ghostty_runtime_reload_config_cb reload_config_cb;
ghostty_runtime_read_clipboard_cb read_clipboard_cb;
ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb;
ghostty_runtime_write_clipboard_cb write_clipboard_cb;
@ -614,6 +650,7 @@ ghostty_info_s ghostty_info(void);
ghostty_config_t ghostty_config_new();
void ghostty_config_free(ghostty_config_t);
ghostty_config_t ghostty_config_clone(ghostty_config_t);
void ghostty_config_load_cli_args(ghostty_config_t);
void ghostty_config_load_default_files(ghostty_config_t);
void ghostty_config_load_recursive_files(ghostty_config_t);
@ -635,9 +672,10 @@ void ghostty_app_set_focus(ghostty_app_t, bool);
bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);
void ghostty_app_keyboard_changed(ghostty_app_t);
void ghostty_app_open_config(ghostty_app_t);
void ghostty_app_reload_config(ghostty_app_t);
void ghostty_app_update_config(ghostty_app_t, ghostty_config_t);
bool ghostty_app_needs_confirm_quit(ghostty_app_t);
bool ghostty_app_has_global_keybinds(ghostty_app_t);
void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e);
ghostty_surface_config_s ghostty_surface_config_new();
@ -646,6 +684,7 @@ void ghostty_surface_free(ghostty_surface_t);
void* ghostty_surface_userdata(ghostty_surface_t);
ghostty_app_t ghostty_surface_app(ghostty_surface_t);
ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t);
void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
void ghostty_surface_refresh(ghostty_surface_t);
void ghostty_surface_draw(ghostty_surface_t);
@ -688,7 +727,6 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
const char*,
void*,
bool);
uintptr_t ghostty_surface_pwd(ghostty_surface_t, char*, uintptr_t);
bool ghostty_surface_has_selection(ghostty_surface_t);
uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t);

View File

@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; };
55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; };
552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; };
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
@ -59,6 +60,7 @@
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; };
A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */; };
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; };
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; };
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
@ -95,6 +97,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
29C15B1C2CDC3B2000520DD4 /* bat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = bat; path = "../zig-out/share/bat"; sourceTree = "<group>"; };
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; };
55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; };
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
@ -139,6 +142,7 @@
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = "<group>"; };
A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = "<group>"; };
A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = "<group>"; };
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = "<group>"; };
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = "<group>"; };
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = "<group>"; };
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; };
@ -247,6 +251,7 @@
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
@ -366,6 +371,7 @@
A5A1F8862A489D7400D1E8BC /* Resources */ = {
isa = PBXGroup;
children = (
29C15B1C2CDC3B2000520DD4 /* bat */,
55154BDF2B33911F001622DC /* ghostty */,
552964E52B34A9B400030505 /* vim */,
A586167B2B7703CC009BDB1D /* fish */,
@ -495,7 +501,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1520;
LastUpgradeCheck = 1600;
LastUpgradeCheck = 1610;
TargetAttributes = {
A5B30530299BEAAA0047F10C = {
CreatedOnToolsVersion = 14.2;
@ -538,6 +544,7 @@
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */,
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */,
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */,
29C15B1D2CDC3B2900520DD4 /* bat in Resources */,
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */,
A586167C2B7703CC009BDB1D /* fish in Resources */,
55154BE02B33911F001622DC /* ghostty in Resources */,
@ -607,6 +614,7 @@
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */,
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */,
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */,
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
@ -648,6 +656,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@ -754,6 +763,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@ -815,6 +825,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
LastUpgradeVersion = "1610"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -69,6 +69,9 @@ class AppDelegate: NSObject,
/// seconds since the process was launched.
private var applicationLaunchTime: TimeInterval = 0
/// This is the current configuration from the Ghostty configuration that we need.
private var derivedConfig: DerivedConfig = DerivedConfig()
/// The ghostty global state. Only one per process.
let ghostty: Ghostty.App = Ghostty.App()
@ -92,6 +95,9 @@ class AppDelegate: NSObject,
/// makes our logic very easy.
private var isVisible: Bool = true
/// The observer for the app appearance.
private var appearanceObserver: NSKeyValueObservation? = nil
override init() {
terminalManager = TerminalManager(ghostty)
updaterController = SPUStandardUpdaterController(
@ -138,7 +144,7 @@ class AppDelegate: NSObject,
menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:))
// Initial config loading
configDidReload(ghostty)
ghosttyConfigDidChange(config: ghostty.config)
// Start our update checker.
updaterController.startUpdater()
@ -162,6 +168,12 @@ class AppDelegate: NSObject,
name: .quickTerminalDidChangeVisibility,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil
)
// Configure user notifications
let actions = [
@ -178,6 +190,23 @@ class AppDelegate: NSObject,
)
])
center.delegate = self
// Observe our appearance so we can report the correct value to libghostty.
self.appearanceObserver = NSApplication.shared.observe(
\.effectiveAppearance,
options: [.new, .initial]
) { _, change in
guard let appearance = change.newValue else { return }
guard let app = self.ghostty.app else { return }
let scheme: ghostty_color_scheme_e
if (appearance.isDark) {
scheme = GHOSTTY_COLOR_SCHEME_DARK
} else {
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
}
ghostty_app_set_color_scheme(app, scheme)
}
}
func applicationDidBecomeActive(_ notification: Notification) {
@ -188,13 +217,13 @@ class AppDelegate: NSObject,
// is possible to have other windows in a few scenarios:
// - if we're opening a URL since `application(_:openFile:)` is called before this.
// - if we're restoring from persisted state
if terminalManager.windows.count == 0 && ghostty.config.initialWindow {
if terminalManager.windows.count == 0 && derivedConfig.initialWindow {
terminalManager.newWindow()
}
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return ghostty.config.shouldQuitAfterLastWindowClosed
return derivedConfig.shouldQuitAfterLastWindowClosed
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
@ -300,52 +329,52 @@ class AppDelegate: NSObject,
}
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
private func syncMenuShortcuts() {
private func syncMenuShortcuts(_ config: Ghostty.Config) {
guard ghostty.readiness == .ready else { return }
syncMenuShortcut(action: "open_config", menuItem: self.menuOpenConfig)
syncMenuShortcut(action: "reload_config", menuItem: self.menuReloadConfig)
syncMenuShortcut(action: "quit", menuItem: self.menuQuit)
syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig)
syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig)
syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit)
syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(action: "close_surface", menuItem: self.menuClose)
syncMenuShortcut(action: "close_window", menuItem: self.menuCloseWindow)
syncMenuShortcut(action: "close_all_windows", menuItem: self.menuCloseAllWindows)
syncMenuShortcut(action: "new_split:right", menuItem: self.menuSplitRight)
syncMenuShortcut(action: "new_split:down", menuItem: self.menuSplitDown)
syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose)
syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow)
syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows)
syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight)
syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
syncMenuShortcut(action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit)
syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit)
syncMenuShortcut(action: "goto_split:top", menuItem: self.menuSelectSplitAbove)
syncMenuShortcut(action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow)
syncMenuShortcut(action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
syncMenuShortcut(action: "goto_split:right", menuItem: self.menuSelectSplitRight)
syncMenuShortcut(action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
syncMenuShortcut(action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown)
syncMenuShortcut(action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight)
syncMenuShortcut(action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft)
syncMenuShortcut(action: "equalize_splits", menuItem: self.menuEqualizeSplits)
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:left", menuItem: self.menuSelectSplitLeft)
syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown)
syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight)
syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft)
syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits)
syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize)
syncMenuShortcut(action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
syncMenuShortcut(action: "toggle_visibility", menuItem: self.menuToggleVisibility)
syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector)
syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize)
syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector)
syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput)
syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput)
// This menu item is NOT synced with the configuration because it disables macOS
// global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue
// to work but it won't be reflected in the menu item.
//
// syncMenuShortcut(action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
// syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
// Dock menu
reloadDockMenu()
@ -353,9 +382,9 @@ class AppDelegate: NSObject,
/// Syncs a single menu shortcut for the given action. The action string is the same
/// action string used for the Ghostty configuration.
private func syncMenuShortcut(action: String, menuItem: NSMenuItem?) {
private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) {
guard let menu = menuItem else { return }
guard let equiv = ghostty.config.keyEquivalent(for: action) else {
guard let equiv = config.keyEquivalent(for: action) else {
// No shortcut, clear the menu item
menu.keyEquivalent = ""
menu.keyEquivalentModifierMask = []
@ -422,6 +451,98 @@ class AppDelegate: NSObject,
self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off }
}
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a surface one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
ghosttyConfigDidChange(config: config)
}
private func ghosttyConfigDidChange(config: Ghostty.Config) {
// Update the config we need to store
self.derivedConfig = DerivedConfig(config)
// Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows
// configuration. This is the only way to carefully control whether macOS invokes the
// state restoration system.
switch (config.windowSaveState) {
case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows")
case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows")
case "default": fallthrough
default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows")
}
// Sync our auto-update settings
updaterController.updater.automaticallyChecksForUpdates =
config.autoUpdate == .check || config.autoUpdate == .download
updaterController.updater.automaticallyDownloadsUpdates =
config.autoUpdate == .download
// Config could change keybindings, so update everything that depends on that
syncMenuShortcuts(config)
terminalManager.relabelAllTabs()
// Config could change window appearance. We wrap this in an async queue because when
// this is called as part of application launch it can deadlock with an internal
// AppKit mutex on the appearance.
DispatchQueue.main.async { self.syncAppearance(config: config) }
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController.sharedInstance
c.errors = config.errors
if (c.errors.count > 0) {
if (c.window == nil || !c.window!.isVisible) {
c.showWindow(self)
}
}
// We need to handle our global event tap depending on if there are global
// events that we care about in Ghostty.
if (ghostty_app_has_global_keybinds(ghostty.app!)) {
if (timeSinceLaunch > 5) {
// If the process has been running for awhile we enable right away
// because no windows are likely to pop up.
GlobalEventTap.shared.enable()
} else {
// If the process just started, we wait a couple seconds to allow
// the initial windows and so on to load so our permissions dialog
// doesn't get buried.
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
GlobalEventTap.shared.enable()
}
}
} else {
GlobalEventTap.shared.disable()
}
}
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance(config: Ghostty.Config) {
guard let theme = config.windowTheme else { return }
switch (theme) {
case "dark":
let appearance = NSAppearance(named: .darkAqua)
NSApplication.shared.appearance = appearance
case "light":
let appearance = NSAppearance(named: .aqua)
NSApplication.shared.appearance = appearance
case "auto":
let color = OSColor(config.backgroundColor)
let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua)
NSApplication.shared.appearance = appearance
default:
NSApplication.shared.appearance = nil
}
}
//MARK: - Restorable State
/// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways.
@ -470,88 +591,6 @@ class AppDelegate: NSObject,
return nil
}
func configDidReload(_ state: Ghostty.App) {
// Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows
// configuration. This is the only way to carefully control whether macOS invokes the
// state restoration system.
switch (ghostty.config.windowSaveState) {
case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows")
case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows")
case "default": fallthrough
default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows")
}
// Sync our auto-update settings
updaterController.updater.automaticallyChecksForUpdates =
ghostty.config.autoUpdate == .check || ghostty.config.autoUpdate == .download
updaterController.updater.automaticallyDownloadsUpdates =
ghostty.config.autoUpdate == .download
// Config could change keybindings, so update everything that depends on that
syncMenuShortcuts()
terminalManager.relabelAllTabs()
// Config could change window appearance. We wrap this in an async queue because when
// this is called as part of application launch it can deadlock with an internal
// AppKit mutex on the appearance.
DispatchQueue.main.async { self.syncAppearance() }
// Update all of our windows
terminalManager.windows.forEach { window in
window.controller.configDidReload()
}
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController.sharedInstance
c.errors = state.config.errors
if (c.errors.count > 0) {
if (c.window == nil || !c.window!.isVisible) {
c.showWindow(self)
}
}
// We need to handle our global event tap depending on if there are global
// events that we care about in Ghostty.
if (ghostty_app_has_global_keybinds(ghostty.app!)) {
if (timeSinceLaunch > 5) {
// If the process has been running for awhile we enable right away
// because no windows are likely to pop up.
GlobalEventTap.shared.enable()
} else {
// If the process just started, we wait a couple seconds to allow
// the initial windows and so on to load so our permissions dialog
// doesn't get buried.
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
GlobalEventTap.shared.enable()
}
}
} else {
GlobalEventTap.shared.disable()
}
}
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance() {
guard let theme = ghostty.config.windowTheme else { return }
switch (theme) {
case "dark":
let appearance = NSAppearance(named: .darkAqua)
NSApplication.shared.appearance = appearance
case "light":
let appearance = NSAppearance(named: .aqua)
NSApplication.shared.appearance = appearance
case "auto":
let color = OSColor(ghostty.config.backgroundColor)
let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua)
NSApplication.shared.appearance = appearance
default:
NSApplication.shared.appearance = nil
}
}
//MARK: - Dock Menu
private func reloadDockMenu() {
@ -629,7 +668,7 @@ class AppDelegate: NSObject,
if quickController == nil {
quickController = QuickTerminalController(
ghostty,
position: ghostty.config.quickTerminalPosition
position: derivedConfig.quickTerminalPosition
)
}
@ -655,4 +694,22 @@ class AppDelegate: NSObject,
isVisible.toggle()
}
private struct DerivedConfig {
let initialWindow: Bool
let shouldQuitAfterLastWindowClosed: Bool
let quickTerminalPosition: QuickTerminalPosition
init() {
self.initialWindow = true
self.shouldQuitAfterLastWindowClosed = false
self.quickTerminalPosition = .top
}
init(_ config: Ghostty.Config) {
self.initialWindow = config.initialWindow
self.shouldQuitAfterLastWindowClosed = config.shouldQuitAfterLastWindowClosed
self.quickTerminalPosition = config.quickTerminalPosition
}
}
}

View File

@ -3,7 +3,7 @@ import SwiftUI
struct AboutView: View {
@Environment(\.openURL) var openURL
private let githubLink = URL(string: "https://github.com/ghostty-org/ghostty")
private let githubURL = URL(string: "https://github.com/ghostty-org/ghostty")
/// Read the commit from the bundle.
private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String }
@ -11,25 +11,6 @@ struct AboutView: View {
private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String }
private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String }
private var properties: [KeyValue<String>] {
let list: [KeyValue<String?>] = [
.init(key: "Version", value: version),
.init(key: "Build", value: build),
.init(key: "Commit", value: commit == "" ? nil : commit)
]
return list.compactMap {
guard let value = $0.value else { return nil }
return .init(key: $0.key, value: value)
}
}
private struct KeyValue<Value: Equatable>: Identifiable {
var id = UUID()
public let key: LocalizedStringResource
public let value: Value
}
#if os(macOS)
// This creates a background style similar to the Apple "About My Mac" Window
private struct VisualEffectBackground: NSViewRepresentable {
@ -80,27 +61,23 @@ struct AboutView: View {
.opacity(0.8)
}
.textSelection(.enabled)
VStack(spacing: 2) {
ForEach(properties) { item in
HStack(spacing: 4) {
Text(item.key)
.frame(width: 126, alignment: .trailing)
.padding(.trailing, 2)
Text(item.value)
.frame(width: 125, alignment: .leading)
.padding(.leading, 2)
.tint(.secondary)
.opacity(0.8)
}
.font(.callout)
.textSelection(.enabled)
.frame(maxWidth: .infinity)
if let version {
PropertyRow(label: "Version", text: version)
}
if let build {
PropertyRow(label: "Build", text: build)
}
if let commit, commit != "",
let url = githubURL?.appendingPathComponent("/commits/\(commit)") {
PropertyRow(label: "Commit", text: commit, url: url)
}
}
.frame(maxWidth: .infinity)
HStack(spacing: 8) {
if let url = githubLink {
if let url = githubURL {
Button("GitHub") {
openURL(url)
}
@ -127,6 +104,45 @@ struct AboutView: View {
.background(VisualEffectBackground(material: .underWindowBackground).ignoresSafeArea())
#endif
}
private struct PropertyRow: View {
private let label: String
private let text: String
private let url: URL?
init(label: String, text: String, url: URL? = nil) {
self.label = label
self.text = text
self.url = url
}
@ViewBuilder private var textView: some View {
Text(text)
.frame(width: 125, alignment: .leading)
.padding(.leading, 2)
.tint(.secondary)
.opacity(0.8)
.monospaced()
}
var body: some View {
HStack(spacing: 4) {
Text(label)
.frame(width: 126, alignment: .trailing)
.padding(.trailing, 2)
if let url {
Link(destination: url) {
textView
}
} else {
textView
}
}
.font(.callout)
.textSelection(.enabled)
.frame(maxWidth: .infinity)
}
}
}
struct AboutView_Previews: PreviewProvider {

View File

@ -18,12 +18,16 @@ class QuickTerminalController: BaseTerminalController {
/// application to the front.
private var previousApp: NSRunningApplication? = nil
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
init(_ ghostty: Ghostty.App,
position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: Ghostty.SplitNode? = nil
) {
self.position = position
self.derivedConfig = DerivedConfig(ghostty.config)
super.init(ghostty, baseConfig: base, surfaceTree: tree)
// Setup our notifications for behaviors
@ -35,8 +39,8 @@ class QuickTerminalController: BaseTerminalController {
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidReloadConfig),
name: Ghostty.Notification.ghosttyDidReloadConfig,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil)
}
@ -64,7 +68,7 @@ class QuickTerminalController: BaseTerminalController {
window.isRestorable = false
// Setup our configured appearance that we support.
syncAppearance()
syncAppearance(ghostty.config)
// Setup our initial size based on our configured position
position.setLoaded(window)
@ -186,7 +190,7 @@ class QuickTerminalController: BaseTerminalController {
}
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = ghostty.config.quickTerminalScreen.screen else { return }
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
// Move our window off screen to the top
position.setInitial(in: window, on: screen)
@ -197,7 +201,7 @@ class QuickTerminalController: BaseTerminalController {
// Run the animation that moves our window into the proper place and makes
// it visible.
NSAnimationContext.runAnimationGroup({ context in
context.duration = ghostty.config.quickTerminalAnimationDuration
context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn)
position.setFinal(in: window.animator(), on: screen)
}, completionHandler: {
@ -287,7 +291,7 @@ class QuickTerminalController: BaseTerminalController {
}
NSAnimationContext.runAnimationGroup({ context in
context.duration = ghostty.config.quickTerminalAnimationDuration
context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn)
position.setInitial(in: window.animator(), on: screen)
}, completionHandler: {
@ -297,7 +301,7 @@ class QuickTerminalController: BaseTerminalController {
})
}
private func syncAppearance() {
private func syncAppearance(_ config: Ghostty.Config) {
guard let window else { return }
// If our window is not visible, then delay this. This is possible specifically
@ -306,12 +310,25 @@ class QuickTerminalController: BaseTerminalController {
// 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() }
DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) }
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) {
case "display-p3":
window.colorSpace = .displayP3
case "srgb":
fallthrough
default:
window.colorSpace = .sRGB
}
// If we have window transparency then set it transparent. Otherwise set it opaque.
if (ghostty.config.backgroundOpacity < 1) {
if (config.backgroundOpacity < 1) {
window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that
@ -358,8 +375,35 @@ class QuickTerminalController: BaseTerminalController {
toggleFullscreen(mode: .nonNative)
}
@objc private func ghosttyDidReloadConfig(notification: SwiftUI.Notification) {
syncAppearance()
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a
// surface-specific one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// Update our derived config
self.derivedConfig = DerivedConfig(config)
syncAppearance(config)
}
private struct DerivedConfig {
let quickTerminalScreen: QuickTerminalScreen
let quickTerminalAnimationDuration: Double
init() {
self.quickTerminalScreen = .main
self.quickTerminalAnimationDuration = 0.2
}
init(_ config: Ghostty.Config) {
self.quickTerminalScreen = config.quickTerminalScreen
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
}
}
}

View File

@ -60,6 +60,9 @@ class BaseTerminalController: NSWindowController,
/// The previous frame information from the window
private var savedFrame: SavedFrame? = nil
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
struct SavedFrame {
let window: NSRect
let screen: NSRect
@ -74,6 +77,7 @@ class BaseTerminalController: NSWindowController,
surfaceTree tree: Ghostty.SplitNode? = nil
) {
self.ghostty = ghostty
self.derivedConfig = DerivedConfig(ghostty.config)
super.init(window: nil)
@ -93,6 +97,11 @@ class BaseTerminalController: NSWindowController,
selector: #selector(didChangeScreenParametersNotification),
name: NSApplication.didChangeScreenParametersNotification,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChangeBase(_:)),
name: .ghosttyConfigDidChange,
object: nil)
// Listen for local events that we need to know of outside of
// single surface handlers.
@ -146,14 +155,18 @@ class BaseTerminalController: NSWindowController,
}
// MARK: Notifications
@objc private func didChangeScreenParametersNotification(_ notification: Notification) {
// If we have a window that is visible and it is outside the bounds of the
// screen then we clamp it back to within the screen.
guard let window else { return }
guard window.isVisible else { return }
guard let screen = window.screen else { return }
// We ignore fullscreen windows because macOS automatically resizes
// those back to the fullscreen bounds.
guard !window.styleMask.contains(.fullScreen) else { return }
guard let screen = window.screen else { return }
let visibleFrame = screen.visibleFrame
var newFrame = window.frame
@ -187,6 +200,20 @@ class BaseTerminalController: NSWindowController,
window.setFrame(newFrame, display: true)
}
@objc private func ghosttyConfigDidChangeBase(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a
// surface-specific one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// Update our derived config
self.derivedConfig = DerivedConfig(config)
}
// MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
@ -241,7 +268,7 @@ class BaseTerminalController: NSWindowController,
func pwdDidChange(to: URL?) {
guard let window else { return }
if ghostty.config.macosTitlebarProxyIcon == .visible {
if derivedConfig.macosTitlebarProxyIcon == .visible {
// Use the 'to' URL directly
window.representedURL = to
} else {
@ -251,7 +278,7 @@ class BaseTerminalController: NSWindowController,
func cellSizeDidChange(to: NSSize) {
guard ghostty.config.windowStepResize else { return }
guard derivedConfig.windowStepResize else { return }
self.window?.contentResizeIncrements = to
}
@ -559,4 +586,19 @@ class BaseTerminalController: NSWindowController,
guard let surface = focusedSurface?.surface else { return }
ghostty.resetTerminal(surface: surface)
}
private struct DerivedConfig {
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
let windowStepResize: Bool
init() {
self.macosTitlebarProxyIcon = .visible
self.windowStepResize = false
}
init(_ config: Ghostty.Config) {
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
self.windowStepResize = config.windowStepResize
}
}
}

View File

@ -1,6 +1,7 @@
import Foundation
import Cocoa
import SwiftUI
import Combine
import GhosttyKit
/// A classic, tabbed terminal experience.
@ -20,6 +21,12 @@ class TerminalController: BaseTerminalController {
/// For example, terminals executing custom scripts are not restorable.
private var restorable: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
/// The notification cancellable for focused surface property changes.
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
init(_ ghostty: Ghostty.App,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: Ghostty.SplitNode? = nil
@ -31,6 +38,9 @@ class TerminalController: BaseTerminalController {
// restoration.
self.restorable = (base?.command ?? "") == ""
// Setup our initial derived config based on the current app config
self.derivedConfig = DerivedConfig(ghostty.config)
super.init(ghostty, baseConfig: base, surfaceTree: tree)
// Setup our notifications for behaviors
@ -50,6 +60,12 @@ class TerminalController: BaseTerminalController {
selector: #selector(onGotoTab),
name: Ghostty.Notification.ghosttyGotoTab,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil
)
center.addObserver(
self,
selector: #selector(onFrameDidChange),
@ -80,10 +96,38 @@ class TerminalController: BaseTerminalController {
//MARK: - Methods
func configDidReload() {
guard let window = window as? TerminalWindow else { return }
window.focusFollowsMouse = ghostty.config.focusFollowsMouse
syncAppearance()
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// If this is an app-level config update then we update some things.
if (notification.object == nil) {
// 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
if surfaceTree == nil {
syncAppearance(.init(config))
}
return
}
// This is a surface-level config update. If we have the surface, we
// update our appearance based on it.
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
// We can't use surfaceView.derivedConfig because it may not be updated
// yet since it also responds to notifications.
syncAppearance(.init(config))
}
/// Update the accessory view of each tab according to the keyboard
@ -144,28 +188,23 @@ class TerminalController: BaseTerminalController {
self.relabelTabs()
}
private func syncAppearance() {
private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
guard let window = self.window as? TerminalWindow 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() }
return
}
// If our window is not visible, then we do nothing. Some things such as blurring
// have no effect if the window is not visible. Ultimately, we'll have this called
// at some point when a surface becomes focused.
guard window.isVisible else { return }
// Set the font for the window and tab titles.
if let titleFontName = ghostty.config.windowTitleFontFamily {
if let titleFontName = surfaceConfig.windowTitleFontFamily {
window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize)
} else {
window.titlebarFont = nil
}
// If we have window transparency then set it transparent. Otherwise set it opaque.
if (ghostty.config.backgroundOpacity < 1) {
if (surfaceConfig.backgroundOpacity < 1) {
window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that
@ -179,14 +218,26 @@ class TerminalController: BaseTerminalController {
window.backgroundColor = .windowBackgroundColor
}
window.hasShadow = ghostty.config.macosWindowShadow
window.hasShadow = surfaceConfig.macosWindowShadow
guard window.hasStyledTabs else { return }
// The titlebar is always updated. We don't need to worry about opacity
// because we handle it here.
let backgroundColor = OSColor(ghostty.config.backgroundColor)
window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity)
// Our background color depends on if our focused surface borders the top or not.
// If it does, we match the focused surface. If it doesn't, we use the app
// configuration.
let backgroundColor: OSColor
if let surfaceTree {
if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) {
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor)
} 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.
backgroundColor = OSColor(surfaceTree.topLeft().backgroundColor ?? derivedConfig.backgroundColor)
}
} else {
backgroundColor = OSColor(self.derivedConfig.backgroundColor)
}
window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity)
if (window.isOpaque) {
// Bg color is only synced if we have no transparency. This is because
@ -210,6 +261,12 @@ class TerminalController: BaseTerminalController {
override func windowDidLoad() {
guard let window = window as? TerminalWindow else { return }
// I copy this because we may change the source in the future but also because
// I regularly audit our codebase for "ghostty.config" access because generally
// you shouldn't use it. Its safe in this case because for a new window we should
// use whatever the latest app-level config is.
let config = ghostty.config
// Setting all three of these is required for restoration to work.
window.isRestorable = restorable
if (restorable) {
@ -218,13 +275,13 @@ class TerminalController: BaseTerminalController {
}
// If window decorations are disabled, remove our title
if (!ghostty.config.windowDecorations) { window.styleMask.remove(.titled) }
if (!config.windowDecorations) { window.styleMask.remove(.titled) }
// 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 (ghostty.config.windowColorspace) {
switch (config.windowColorspace) {
case "display-p3":
window.colorSpace = .displayP3
case "srgb":
@ -256,30 +313,30 @@ class TerminalController: BaseTerminalController {
window.center()
// Make sure our theme is set on the window so styling is correct.
if let windowTheme = ghostty.config.windowTheme {
if let windowTheme = config.windowTheme {
window.windowTheme = .init(rawValue: windowTheme)
}
// Handle titlebar tabs config option. Something about what we do while setting up the
// titlebar tabs interferes with the window restore process unless window.tabbingMode
// is set to .preferred, so we set it, and switch back to automatic as soon as we can.
if (ghostty.config.macosTitlebarStyle == "tabs") {
if (config.macosTitlebarStyle == "tabs") {
window.tabbingMode = .preferred
window.titlebarTabs = true
DispatchQueue.main.async {
window.tabbingMode = .automatic
}
} else if (ghostty.config.macosTitlebarStyle == "transparent") {
} else if (config.macosTitlebarStyle == "transparent") {
window.transparentTabs = true
}
if window.hasStyledTabs {
// Set the background color of the window
let backgroundColor = NSColor(ghostty.config.backgroundColor)
let backgroundColor = NSColor(config.backgroundColor)
window.backgroundColor = backgroundColor
// This makes sure our titlebar renders correctly when there is a transparent background
window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity)
window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity)
}
// Initialize our content view to the SwiftUI root
@ -290,7 +347,7 @@ class TerminalController: BaseTerminalController {
))
// If our titlebar style is "hidden" we adjust the style appropriately
if (ghostty.config.macosTitlebarStyle == "hidden") {
if (config.macosTitlebarStyle == "hidden") {
window.styleMask = [
// We need `titled` in the mask to get the normal window frame
.titled,
@ -345,10 +402,12 @@ class TerminalController: BaseTerminalController {
}
}
window.focusFollowsMouse = ghostty.config.focusFollowsMouse
window.focusFollowsMouse = config.focusFollowsMouse
// Apply any additional appearance-related properties to the new window.
syncAppearance()
// 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).
syncAppearance(.init(config))
}
// Shows the "+" button in the tab bar, responds to that click.
@ -464,7 +523,7 @@ class TerminalController: BaseTerminalController {
// Custom toolbar-based title used when titlebar tabs are enabled.
if let toolbar = window.toolbar as? TerminalToolbar {
if (window.titlebarTabs || ghostty.config.macosTitlebarStyle == "hidden") {
if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") {
// Updating the title text as above automatically reveals the
// native title view in macOS 15.0 and above. Since we're using
// a custom view instead, we need to re-hide it.
@ -485,6 +544,36 @@ class TerminalController: BaseTerminalController {
window.surfaceIsZoomed = to
}
override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
super.focusedSurfaceDidChange(to: to)
// We always cancel our event listener
surfaceAppearanceCancellables.removeAll()
// When our focus changes, we update our window appearance based on the
// currently focused surface.
guard let focusedSurface else { return }
syncAppearance(focusedSurface.derivedConfig)
// We also want to get notified of certain changes to update our appearance.
focusedSurface.$derivedConfig
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
.store(in: &surfaceAppearanceCancellables)
focusedSurface.$backgroundColor
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
.store(in: &surfaceAppearanceCancellables)
}
private func syncAppearanceOnPropertyChange(_ surface: Ghostty.SurfaceView?) {
guard let surface else { return }
DispatchQueue.main.async { [weak self, weak surface] in
guard let surface else { return }
guard let self else { return }
guard self.focusedSurface == surface else { return }
self.syncAppearance(surface.derivedConfig)
}
}
//MARK: - Notifications
@objc private func onMoveTab(notification: SwiftUI.Notification) {
@ -593,4 +682,19 @@ class TerminalController: BaseTerminalController {
toggleFullscreen(mode: fullscreenMode)
}
private struct DerivedConfig {
let backgroundColor: Color
let macosTitlebarStyle: String
init() {
self.backgroundColor = Color(NSColor.windowBackgroundColor)
self.macosTitlebarStyle = "system"
}
init(_ config: Ghostty.Config) {
self.backgroundColor = config.backgroundColor
self.macosTitlebarStyle = config.macosTitlebarStyle
}
}
}

View File

@ -37,8 +37,12 @@ class TerminalManager {
return windows.last
}
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
init(_ ghostty: Ghostty.App) {
self.ghostty = ghostty
self.derivedConfig = DerivedConfig(ghostty.config)
let center = NotificationCenter.default
center.addObserver(
@ -51,6 +55,11 @@ class TerminalManager {
selector: #selector(onNewWindow),
name: Ghostty.Notification.ghosttyNewWindow,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil)
}
deinit {
@ -70,8 +79,8 @@ class TerminalManager {
if let parent = focusedSurface?.window,
parent.styleMask.contains(.fullScreen) {
window.toggleFullScreen(nil)
} else if ghostty.config.windowFullscreen {
switch (ghostty.config.windowFullscreenMode) {
} else if derivedConfig.windowFullscreen {
switch (derivedConfig.windowFullscreenMode) {
case .native:
// Native has to be done immediately so that our stylemask contains
// fullscreen for the logic later in this method.
@ -81,7 +90,7 @@ class TerminalManager {
// If we're non-native then we have to do it on a later loop
// so that the content view is setup.
DispatchQueue.main.async {
c.toggleFullscreen(mode: self.ghostty.config.windowFullscreenMode)
c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode)
}
}
}
@ -159,9 +168,9 @@ class TerminalManager {
// If we have the "hidden" titlebar style we want to create new
// tabs as windows instead, so just skip adding it to the parent.
if (ghostty.config.macosTitlebarStyle != "hidden") {
if (derivedConfig.macosTitlebarStyle != "hidden") {
// Add the window to the tab group and show it.
switch ghostty.config.windowNewTabPosition {
switch derivedConfig.windowNewTabPosition {
case "end":
// If we already have a tab group and we want the new tab to open at the end,
// then we use the last window in the tab group as the parent.
@ -227,7 +236,19 @@ class TerminalManager {
// are closing a tabbed window, we want to set the cascade point to be
// the next cascade point from this window.
if focusedWindow != controller.window {
// The cascadeTopLeft call below should NOT move the window. Starting with
// macOS 15, we found that specifically when used with the new window snapping
// features of macOS 15, this WOULD move the frame. So we keep track of the
// old frame and restore it if necessary. Issue:
// https://github.com/ghostty-org/ghostty/issues/2565
let oldFrame = focusedWindow.frame
Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
if focusedWindow.frame != oldFrame {
focusedWindow.setFrame(oldFrame, display: true)
}
return
}
@ -313,4 +334,39 @@ class TerminalManager {
self.newTab(to: window, withBaseConfig: config)
}
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a
// surface-specific one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// Update our derived config
self.derivedConfig = DerivedConfig(config)
}
private struct DerivedConfig {
let windowFullscreen: Bool
let windowFullscreenMode: FullscreenMode
let macosTitlebarStyle: String
let windowNewTabPosition: String
init() {
self.windowFullscreen = false
self.windowFullscreenMode = .native
self.macosTitlebarStyle = "transparent"
self.windowNewTabPosition = ""
}
init(_ config: Ghostty.Config) {
self.windowFullscreen = config.windowFullscreen
self.windowFullscreenMode = config.windowFullscreenMode
self.macosTitlebarStyle = config.macosTitlebarStyle
self.windowNewTabPosition = config.windowNewTabPosition
}
}
}

View File

@ -65,8 +65,10 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
}
// If our configuration is "never" then we never restore the state
// no matter what.
if (appDelegate.terminalManager.ghostty.config.windowSaveState == "never") {
// no matter what. Note its safe to use "ghostty.config" directly here
// because window restoration is only ever invoked on app start so we
// don't have to deal with config reloads.
if (appDelegate.ghostty.config.windowSaveState == "never") {
completionHandler(nil, nil)
return
}

View File

@ -50,6 +50,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
// Various state values sent back up from the currently focused terminals.
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle
@FocusedValue(\.ghosttySurfacePwd) private var surfacePwd
@FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
@ -65,14 +66,11 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
return title
}
// The proxy icon URL for our window
private var proxyIconURL: URL? {
guard let proxyURLString = focusedSurface?.pwd else {
return nil
}
// Use fileURLWithPath initializer for file paths
return URL(fileURLWithPath: proxyURLString)
// The pwd of the focused surface as a URL
private var pwdURL: URL? {
guard let surfacePwd else { return nil }
return URL(fileURLWithPath: surfacePwd)
}
var body: some View {
@ -88,7 +86,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) {
DebugBuildWarningView()
}
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
.environmentObject(ghostty)
.focused($focused)
@ -99,7 +97,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
.onChange(of: title) { newValue in
self.delegate?.titleDidChange(to: newValue)
}
.onChange(of: proxyIconURL) { newValue in
.onChange(of: pwdURL) { newValue in
self.delegate?.pwdDidChange(to: newValue)
}
.onChange(of: cellSize) { newValue in

View File

@ -1,3 +1,4 @@
import SwiftUI
import GhosttyKit
extension Ghostty {
@ -5,6 +6,33 @@ extension Ghostty {
}
extension Ghostty.Action {
struct ColorChange {
let kind: Kind
let color: Color
enum Kind {
case foreground
case background
case cursor
case palette(index: UInt8)
}
init(c: ghostty_action_color_change_s) {
switch (c.kind) {
case GHOSTTY_ACTION_COLOR_KIND_FOREGROUND:
self.kind = .foreground
case GHOSTTY_ACTION_COLOR_KIND_BACKGROUND:
self.kind = .background
case GHOSTTY_ACTION_COLOR_KIND_CURSOR:
self.kind = .cursor
default:
self.kind = .palette(index: UInt8(c.kind.rawValue))
}
self.color = Color(red: Double(c.r) / 255, green: Double(c.g) / 255, blue: Double(c.b) / 255)
}
}
struct MoveTab {
let amount: Int

View File

@ -3,9 +3,6 @@ import UserNotifications
import GhosttyKit
protocol GhosttyAppDelegate: AnyObject {
/// Called when the configuration did finish reloading.
func configDidReload(_ app: Ghostty.App)
#if os(macOS)
/// Called when a callback needs access to a specific surface. This should return nil
/// when the surface is no longer valid.
@ -68,7 +65,6 @@ extension Ghostty {
supports_selection_clipboard: false,
wakeup_cb: { userdata in App.wakeup(userdata) },
action_cb: { app, target, action in App.action(app!, target: target, action: action) },
reload_config_cb: { userdata in App.reloadConfig(userdata) },
read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) },
confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) },
write_clipboard_cb: { userdata, str, loc, confirm in App.writeClipboard(userdata, string: str, location: loc, confirm: confirm) },
@ -145,9 +141,47 @@ extension Ghostty {
ghostty_app_open_config(app)
}
func reloadConfig() {
/// Reload the configuration.
func reloadConfig(soft: Bool = false) {
guard let app = self.app else { return }
ghostty_app_reload_config(app)
// Soft updates just call with our existing config
if (soft) {
ghostty_app_update_config(app, config.config!)
return
}
// Hard or full updates have to reload the full configuration
let newConfig = Config()
guard newConfig.loaded else {
Ghostty.logger.warning("failed to reload configuration")
return
}
ghostty_app_update_config(app, newConfig.config!)
// We can only set our config after updating it so that we don't free
// memory that may still be in use
self.config = newConfig
}
func reloadConfig(surface: ghostty_surface_t, soft: Bool = false) {
// Soft updates just call with our existing config
if (soft) {
ghostty_surface_update_config(surface, config.config!)
return
}
// Hard or full updates have to reload the full configuration.
// NOTE: We never set this on self.config because this is a surface-only
// config. We free it after the call.
let newConfig = Config()
guard newConfig.loaded else {
Ghostty.logger.warning("failed to reload configuration")
return
}
ghostty_surface_update_config(surface, newConfig.config!)
}
/// Request that the given surface is closed. This will trigger the full normal surface close event
@ -240,7 +274,6 @@ extension Ghostty {
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {}
static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) {}
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? { return nil }
static func readClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
@ -368,31 +401,6 @@ extension Ghostty {
)
}
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? {
let newConfig = Config()
guard newConfig.loaded else {
AppDelegate.logger.warning("failed to reload configuration")
return nil
}
// Assign the new config. This will automatically free the old config.
// It is safe to free the old config from within this function call.
let state = Unmanaged<Self>.fromOpaque(userdata!).takeUnretainedValue()
state.config = newConfig
// If we have a delegate, notify.
if let delegate = state.delegate {
delegate.configDidReload(state)
}
// Send an event out
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyDidReloadConfig,
object: nil)
return newConfig.config
}
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {
let state = Unmanaged<App>.fromOpaque(userdata!).takeUnretainedValue()
@ -488,6 +496,9 @@ extension Ghostty {
case GHOSTTY_ACTION_SET_TITLE:
setTitle(app, target: target, v: action.action.set_title)
case GHOSTTY_ACTION_PWD:
pwdChanged(app, target: target, v: action.action.pwd)
case GHOSTTY_ACTION_OPEN_CONFIG:
ghostty_config_open()
@ -521,6 +532,15 @@ extension Ghostty {
case GHOSTTY_ACTION_KEY_SEQUENCE:
keySequence(app, target: target, v: action.action.key_sequence)
case GHOSTTY_ACTION_CONFIG_CHANGE:
configChange(app, target: target, v: action.action.config_change)
case GHOSTTY_ACTION_RELOAD_CONFIG:
configReload(app, target: target, v: action.action.reload_config)
case GHOSTTY_ACTION_COLOR_CHANGE:
colorChange(app, target: target, change: action.action.color_change)
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
fallthrough
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
@ -941,6 +961,26 @@ extension Ghostty {
}
}
private static func pwdChanged(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_pwd_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("pwd change does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let pwd = String(cString: v.pwd!, encoding: .utf8) else { return }
surfaceView.pwd = pwd
default:
assertionFailure()
}
}
private static func setMouseShape(
_ app: ghostty_app_t,
target: ghostty_target_s,
@ -1134,6 +1174,104 @@ extension Ghostty {
}
}
private static func configReload(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_reload_config_s)
{
logger.info("config reload notification")
guard let app_ud = ghostty_app_userdata(app) else { return }
let ghostty = Unmanaged<App>.fromOpaque(app_ud).takeUnretainedValue()
switch (target.tag) {
case GHOSTTY_TARGET_APP:
ghostty.reloadConfig(soft: v.soft)
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
ghostty.reloadConfig(surface: surface, soft: v.soft)
default:
assertionFailure()
}
}
private static func configChange(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_config_change_s) {
logger.info("config change notification")
// Clone the config so we own the memory. It'd be nicer to not have to do
// this but since we async send the config out below we have to own the lifetime.
// A future improvement might be to add reference counting to config or
// something so apprt's do not have to do this.
let config = Config(clone: v.config)
switch (target.tag) {
case GHOSTTY_TARGET_APP:
// Notify the world that the app config changed
NotificationCenter.default.post(
name: .ghosttyConfigDidChange,
object: nil,
userInfo: [
SwiftUI.Notification.Name.GhosttyConfigChangeKey: config,
]
)
// We also REPLACE our app-level config when this happens. This lets
// all the various things that depend on this but are still theme specific
// such as split border color work.
guard let app_ud = ghostty_app_userdata(app) else { return }
let ghostty = Unmanaged<App>.fromOpaque(app_ud).takeUnretainedValue()
ghostty.config = config
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
NotificationCenter.default.post(
name: .ghosttyConfigDidChange,
object: surfaceView,
userInfo: [
SwiftUI.Notification.Name.GhosttyConfigChangeKey: config,
]
)
default:
assertionFailure()
}
}
private static func colorChange(
_ app: ghostty_app_t,
target: ghostty_target_s,
change: ghostty_action_color_change_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("color change does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
NotificationCenter.default.post(
name: .ghosttyColorDidChange,
object: surfaceView,
userInfo: [
SwiftUI.Notification.Name.GhosttyColorChangeKey: Action.ColorChange(c: change)
]
)
default:
assertionFailure()
}
}
// MARK: User Notifications
/// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user

View File

@ -39,6 +39,10 @@ extension Ghostty {
}
}
init(clone config: ghostty_config_t) {
self.config = ghostty_config_clone(config)
}
deinit {
self.config = nil
}

View File

@ -38,6 +38,16 @@ extension Ghostty {
}
}
func topLeft() -> SurfaceView {
switch (self) {
case .leaf(let leaf):
return leaf.surface
case .split(let container):
return container.topLeft.topLeft()
}
}
/// 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.
@ -136,6 +146,24 @@ extension Ghostty {
}
}
/// Returns true if the surface borders the top. Assumes the view is in the tree.
func doesBorderTop(view: SurfaceView) -> Bool {
switch (self) {
case .leaf(let leaf):
return leaf.surface == view
case .split(let container):
switch (container.direction) {
case .vertical:
return container.topLeft.doesBorderTop(view: view)
case .horizontal:
return container.topLeft.doesBorderTop(view: view) ||
container.bottomRight.doesBorderTop(view: view)
}
}
}
// MARK: - Sequence
func makeIterator() -> IndexingIterator<[Leaf]> {

View File

@ -206,6 +206,14 @@ extension Ghostty {
// MARK: Surface Notification
extension Notification.Name {
/// Configuration change. If the object is nil then it is app-wide. Otherwise its surface-specific.
static let ghosttyConfigDidChange = Notification.Name("com.mitchellh.ghostty.configDidChange")
static let GhosttyConfigChangeKey = ghosttyConfigDidChange.rawValue
/// Color change. Object is the surface changing.
static let ghosttyColorDidChange = Notification.Name("com.mitchellh.ghostty.ghosttyColorDidChange")
static let GhosttyColorChangeKey = ghosttyColorDidChange.rawValue
/// Goto tab. Has tab index in the userinfo.
static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab")
static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue
@ -217,9 +225,6 @@ extension Ghostty.Notification {
/// Used to pass a configuration along when creating a new tab/window/split.
static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig"
/// Posted when the application configuration is reloaded.
static let ghosttyDidReloadConfig = Notification.Name("com.mitchellh.ghostty.didReloadConfig")
/// Posted when a new split is requested. The sending object will be the surface that had focus. The
/// userdata has one key "direction" with the direction to split to.
static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit")

View File

@ -59,23 +59,6 @@ extension Ghostty {
@EnvironmentObject private var ghostty: Ghostty.App
#if canImport(AppKit)
// The visibility state of the mouse pointer
private var pointerVisibility: BackportVisibility {
// If our window or surface loses focus we always bring it back
if (!windowFocus || !surfaceFocus) {
return .visible
}
// If we have window focus then it depends on surface state
if (surfaceView.pointerVisible) {
return .visible
} else {
return .hidden
}
}
#endif
var body: some View {
let center = NotificationCenter.default
@ -92,10 +75,10 @@ extension Ghostty {
Surface(view: surfaceView, size: geo.size)
.focused($surfaceFocus)
.focusedValue(\.ghosttySurfaceTitle, surfaceView.title)
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
.focusedValue(\.ghosttySurfaceView, surfaceView)
.focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize)
#if canImport(AppKit)
.backport.pointerVisibility(pointerVisibility)
.backport.pointerStyle(surfaceView.pointerStyle)
.onReceive(pubBecomeKey) { notification in
guard let window = notification.object as? NSWindow else { return }
@ -171,7 +154,8 @@ extension Ghostty {
// If we have a URL from hovering a link, we show that.
if let url = surfaceView.hoverUrl {
let padding: CGFloat = 3
let padding: CGFloat = 5
let cornerRadius: CGFloat = 9
ZStack {
HStack {
Spacer()
@ -180,7 +164,10 @@ extension Ghostty {
Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(.background)
.background(
UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius))
.fill(.background)
)
.lineLimit(1)
.truncationMode(.middle)
.opacity(isHoveringURLLeft ? 1 : 0)
@ -193,7 +180,10 @@ extension Ghostty {
Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(.background)
.background(
UnevenRoundedRectangle(cornerRadii: .init(topTrailing: cornerRadius))
.fill(.background)
)
.lineLimit(1)
.truncationMode(.middle)
.opacity(isHoveringURLLeft ? 0 : 1)
@ -512,9 +502,7 @@ extension FocusedValues {
struct FocusedGhosttySurface: FocusedValueKey {
typealias Value = Ghostty.SurfaceView
}
}
extension FocusedValues {
var ghosttySurfaceTitle: String? {
get { self[FocusedGhosttySurfaceTitle.self] }
set { self[FocusedGhosttySurfaceTitle.self] = newValue }
@ -523,9 +511,16 @@ extension FocusedValues {
struct FocusedGhosttySurfaceTitle: FocusedValueKey {
typealias Value = String
}
}
extension FocusedValues {
var ghosttySurfacePwd: String? {
get { self[FocusedGhosttySurfacePwd.self] }
set { self[FocusedGhosttySurfacePwd.self] = newValue }
}
struct FocusedGhosttySurfacePwd: FocusedValueKey {
typealias Value = String
}
var ghosttySurfaceZoomed: Bool? {
get { self[FocusedGhosttySurfaceZoomed.self] }
set { self[FocusedGhosttySurfaceZoomed.self] = newValue }
@ -534,9 +529,7 @@ extension FocusedValues {
struct FocusedGhosttySurfaceZoomed: FocusedValueKey {
typealias Value = Bool
}
}
extension FocusedValues {
var ghosttySurfaceCellSize: OSSize? {
get { self[FocusedGhosttySurfaceCellSize.self] }
set { self[FocusedGhosttySurfaceCellSize.self] = newValue }

View File

@ -14,6 +14,10 @@ extension Ghostty {
// to the app level and it is set from there.
@Published var title: String = "👻"
// The current pwd of the surface as defined by the pty. This can be
// changed with escape codes.
@Published var pwd: String? = nil
// The cell size of this surface. This is set by the core when the
// surface is first created and any time the cell size changes (i.e.
// when the font size changes). This is used to allow windows to be
@ -42,9 +46,15 @@ extension Ghostty {
@Published var surfaceSize: ghostty_surface_size_s? = nil
// Whether the pointer should be visible or not
@Published private(set) var pointerVisible: Bool = true
@Published private(set) var pointerStyle: BackportPointerStyle = .default
/// The configuration derived from the Ghostty config so we don't need to rely on references.
@Published private(set) var derivedConfig: DerivedConfig
/// The background color within the color palette of the surface. This is only set if it is
/// dynamically updated. Otherwise, the background color is the default background color.
@Published private(set) var backgroundColor: Color? = nil
// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
var initialSize: NSSize? = nil
@ -71,17 +81,6 @@ extension Ghostty {
return ghostty_surface_needs_confirm_quit(surface)
}
/// Returns the pwd of the surface if it has one.
var pwd: String? {
guard let surface = self.surface else { return nil }
let v = String(unsafeUninitializedCapacity: 1024) {
Int(ghostty_surface_pwd(surface, $0.baseAddress, UInt($0.count)))
}
if (v.count == 0) { return nil }
return v
}
// Returns the inspector instance for this surface, or nil if the
// surface has been closed.
var inspector: ghostty_inspector_t? {
@ -122,6 +121,13 @@ extension Ghostty {
self.markedText = NSMutableAttributedString()
self.uuid = uuid ?? .init()
// Our initial config always is our application wide config.
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
self.derivedConfig = DerivedConfig(appDelegate.ghostty.config)
} else {
self.derivedConfig = DerivedConfig()
}
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING.
@ -145,6 +151,16 @@ extension Ghostty {
selector: #selector(ghosttyDidEndKeySequence),
name: Ghostty.Notification.didEndKeySequence,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyColorDidChange(_:)),
name: .ghosttyColorDidChange,
object: self)
center.addObserver(
self,
selector: #selector(windowDidChangeScreen),
@ -316,7 +332,11 @@ extension Ghostty {
}
func setCursorVisibility(_ visible: Bool) {
pointerVisible = visible
// Technically this action could be called anytime we want to
// change the mouse visibility but at the time of writing this
// mouse-hide-while-typing is the only use case so this is the
// preferred method.
NSCursor.setHiddenUntilMouseMoves(!visible)
}
// MARK: - Notifications
@ -337,6 +357,31 @@ extension Ghostty {
keySequence = []
}
@objc private func ghosttyConfigDidChange(_ notification: SwiftUI.Notification) {
// Get our managed configuration object out
guard let config = notification.userInfo?[
SwiftUI.Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
// Update our derived config
self.derivedConfig = DerivedConfig(config)
}
@objc private func ghosttyColorDidChange(_ notification: SwiftUI.Notification) {
guard let change = notification.userInfo?[
SwiftUI.Notification.Name.GhosttyColorChangeKey
] as? Ghostty.Action.ColorChange else { return }
switch (change.kind) {
case .background:
self.backgroundColor = change.color
default:
// We don't do anything for the other colors yet.
break
}
}
@objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
guard let window = self.window else { return }
guard let object = notification.object as? NSWindow, window == object else { return }
@ -704,12 +749,6 @@ extension Ghostty {
/// Special case handling for some control keys
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Only process keys when Control is the only modifier
if (!event.modifierFlags.contains(.control) ||
!event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) {
return false
}
// Only process key down events
if (event.type != .keyDown) {
return false
@ -722,11 +761,23 @@ extension Ghostty {
return false
}
// Only process keys when Control is active. All known issues we're
// resolving happen only in this scenario. This probably isn't fully robust
// but we can broaden the scope as we find more cases.
if (!event.modifierFlags.contains(.control)) {
return false
}
let equivalent: String
switch (event.charactersIgnoringModifiers) {
case "/":
// Treat C-/ as C-_. We do this because C-/ makes macOS make a beep
// sound and we don't like the beep sound.
if (!event.modifierFlags.contains(.control) ||
!event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) {
return false
}
equivalent = "_"
case "\r":
@ -742,7 +793,7 @@ extension Ghostty {
let newEvent = NSEvent.keyEvent(
with: .keyDown,
location: event.locationInWindow,
modifierFlags: .control,
modifierFlags: event.modifierFlags,
timestamp: event.timestamp,
windowNumber: event.windowNumber,
context: nil,
@ -1023,6 +1074,27 @@ extension Ghostty {
Ghostty.moveFocus(to: self)
}
}
struct DerivedConfig {
let backgroundColor: Color
let backgroundOpacity: Double
let macosWindowShadow: Bool
let windowTitleFontFamily: String?
init() {
self.backgroundColor = Color(NSColor.windowBackgroundColor)
self.backgroundOpacity = 1
self.macosWindowShadow = true
self.windowTitleFontFamily = nil
}
init(_ config: Ghostty.Config) {
self.backgroundColor = config.backgroundColor
self.backgroundOpacity = config.backgroundOpacity
self.macosWindowShadow = config.macosWindowShadow
self.windowTitleFontFamily = config.windowTitleFontFamily
}
}
}
}

View File

@ -12,6 +12,9 @@ extension Ghostty {
// to the app level and it is set from there.
@Published var title: String = "👻"
// The current pwd of the surface.
@Published var pwd: String? = nil
// The cell size of this surface. This is set by the core when the
// surface is first created and any time the cell size changes (i.e.
// when the font size changes). This is used to allow windows to be

View File

@ -0,0 +1,8 @@
import Cocoa
extension NSAppearance {
/// Returns true if the appearance is some kind of dark.
var isDark: Bool {
return name.rawValue.lowercased().contains("dark")
}
}

View File

@ -21,6 +21,32 @@ extension OSColor {
return (0.299 * r) + (0.587 * g) + (0.114 * b)
}
var hexString: String? {
#if canImport(AppKit)
guard let rgb = usingColorSpace(.deviceRGB) else { return nil }
let red = Int(rgb.redComponent * 255)
let green = Int(rgb.greenComponent * 255)
let blue = Int(rgb.blueComponent * 255)
return String(format: "#%02X%02X%02X", red, green, blue)
#elseif canImport(UIKit)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return nil
}
// Convert to 0255 range
let r = Int(red * 255)
let g = Int(green * 255)
let b = Int(blue * 255)
// Format to hexadecimal
return String(format: "#%02X%02X%02X", r, g, b)
#endif
}
func darken(by amount: CGFloat) -> OSColor {
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
self.getHue(&h, saturation: &s, brightness: &b, alpha: &a)

View File

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

View File

@ -32,7 +32,7 @@ pub fn build(b: *std.Build) !void {
};
if (b.systemIntegrationOption("freetype", .{})) {
lib.linkSystemLibrary2("freetype", dynamic_link_opts);
lib.linkSystemLibrary2("freetype2", dynamic_link_opts);
} else {
const freetype = b.dependency("freetype", .{
.target = target,

View File

@ -76,7 +76,8 @@ pub fn build(b: *std.Build) !void {
});
if (b.systemIntegrationOption("freetype", .{})) {
lib.linkSystemLibrary2("freetype", dynamic_link_opts);
lib.linkSystemLibrary2("freetype2", dynamic_link_opts);
module.linkSystemLibrary("freetype2", dynamic_link_opts);
} else {
lib.linkLibrary(freetype.artifact("freetype"));
module.addIncludePath(freetype.builder.dependency("freetype", .{}).path("include"));

View File

@ -12,8 +12,9 @@ const apprt = @import("apprt.zig");
const Surface = @import("Surface.zig");
const tracy = @import("tracy");
const input = @import("input.zig");
const Config = @import("config.zig").Config;
const BlockingQueue = @import("./blocking_queue.zig").BlockingQueue;
const configpkg = @import("config.zig");
const Config = configpkg.Config;
const BlockingQueue = @import("datastruct/main.zig").BlockingQueue;
const renderer = @import("renderer.zig");
const font = @import("font/main.zig");
const internal_os = @import("os/main.zig");
@ -66,6 +67,16 @@ font_grid_set: font.SharedGridSet,
last_notification_time: ?std.time.Instant = null,
last_notification_digest: u64 = 0,
/// The conditional state of the configuration. See the equivalent field
/// in the Surface struct for more information. In this case, this applies
/// to the app-level config and as a default for new surfaces.
config_conditional_state: configpkg.ConditionalState,
/// Set to false once we've created at least one surface. This
/// never goes true again. This can be used by surfaces to determine
/// if they are the first surface.
first: bool = true,
pub const CreateError = Allocator.Error || font.SharedGridSet.InitError;
/// Initialize the main app instance. This creates the main window, sets
@ -89,6 +100,7 @@ pub fn create(
.mailbox = .{},
.quit = false,
.font_grid_set = font_grid_set,
.config_conditional_state = .{},
};
errdefer app.surfaces.deinit(alloc);
@ -142,11 +154,31 @@ pub fn tick(self: *App, rt_app: *apprt.App) !bool {
/// Update the configuration associated with the app. This can only be
/// called from the main thread. The caller owns the config memory. The
/// memory can be freed immediately when this returns.
pub fn updateConfig(self: *App, config: *const Config) !void {
pub fn updateConfig(self: *App, rt_app: *apprt.App, config: *const Config) !void {
// Go through and update all of the surface configurations.
for (self.surfaces.items) |surface| {
try surface.core_surface.handleMessage(.{ .change_config = config });
}
// Apply our conditional state. If we fail to apply the conditional state
// then we log and attempt to move forward with the old config.
// We only apply this to the app-level config because the surface
// config applies its own conditional state.
var applied_: ?configpkg.Config = config.changeConditionalState(
self.config_conditional_state,
) catch |err| err: {
log.warn("failed to apply conditional state to config err={}", .{err});
break :err null;
};
defer if (applied_) |*c| c.deinit();
const applied: *const configpkg.Config = if (applied_) |*c| c else config;
// Notify the apprt that the app has changed configuration.
try rt_app.performAction(
.app,
.config_change,
.{ .config = applied },
);
}
/// Add an initialized surface. This is really only for the runtime
@ -227,7 +259,6 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
while (self.mailbox.pop()) |message| {
log.debug("mailbox message={s}", .{@tagName(message)});
switch (message) {
.reload_config => try self.reloadConfig(rt_app),
.open_config => try self.performAction(rt_app, .open_config),
.new_window => |msg| try self.newWindow(rt_app, msg),
.close => |surface| self.closeSurface(surface),
@ -248,14 +279,6 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
}
}
pub fn reloadConfig(self: *App, rt_app: *apprt.App) !void {
log.debug("reloading configuration", .{});
if (try rt_app.reloadConfig()) |new| {
log.debug("new configuration received, applying", .{});
try self.updateConfig(new);
}
}
pub fn closeSurface(self: *App, surface: *Surface) void {
if (!self.hasSurface(surface)) return;
surface.close();
@ -376,6 +399,33 @@ pub fn keyEvent(
return true;
}
/// Call to notify Ghostty that the color scheme for the app has changed.
/// "Color scheme" in this case refers to system themes such as "light/dark".
pub fn colorSchemeEvent(
self: *App,
rt_app: *apprt.App,
scheme: apprt.ColorScheme,
) !void {
const new_scheme: configpkg.ConditionalState.Theme = switch (scheme) {
.light => .light,
.dark => .dark,
};
// If our scheme didn't change, then we don't do anything.
if (self.config_conditional_state.theme == new_scheme) return;
// Setup our conditional state which has the current color theme.
self.config_conditional_state.theme = new_scheme;
// Request our configuration be reloaded because the new scheme may
// impact the colors of the app.
try rt_app.performAction(
.app,
.reload_config,
.{ .soft = true },
);
}
/// Perform a binding action. This only accepts actions that are scoped
/// to the app. Callers can use performAllAction to perform any action
/// and any non-app-scoped actions will be performed on all surfaces.
@ -390,7 +440,7 @@ pub fn performAction(
.quit => self.setQuit(),
.new_window => try self.newWindow(rt_app, .{ .parent = null }),
.open_config => try rt_app.performAction(.app, .open_config, {}),
.reload_config => try self.reloadConfig(rt_app),
.reload_config => try rt_app.performAction(.app, .reload_config, .{}),
.close_all_windows => try rt_app.performAction(.app, .close_all_windows, {}),
.toggle_quick_terminal => try rt_app.performAction(.app, .toggle_quick_terminal, {}),
.toggle_visibility => try rt_app.performAction(.app, .toggle_visibility, {}),
@ -450,10 +500,6 @@ fn hasSurface(self: *const App, surface: *const Surface) bool {
/// The message types that can be sent to the app thread.
pub const Message = union(enum) {
/// Reload the configuration for the entire app and propagate it to
/// all the active surfaces.
reload_config: void,
// Open the configuration file
open_config: void,

View File

@ -94,11 +94,6 @@ keyboard: Keyboard,
/// less important.
pressed_key: ?input.KeyEvent = null,
/// The current color scheme of the GUI element containing this surface.
/// This will default to light until the apprt sends us the actual color
/// scheme. This is used by mode 3031 and CSI 996 n.
color_scheme: apprt.ColorScheme = .light,
/// The hash value of the last keybinding trigger that we performed. This
/// is only set if the last key input matched a keybinding, consumed it,
/// and performed it. This is used to prevent sending release/repeat events
@ -113,23 +108,29 @@ io_thr: std.Thread,
/// Terminal inspector
inspector: ?*inspector.Inspector = null,
/// All the cached sizes since we need them at various times.
screen_size: renderer.ScreenSize,
grid_size: renderer.GridSize,
cell_size: renderer.CellSize,
/// Explicit padding due to configuration
padding: renderer.Padding,
/// All our sizing information.
size: renderer.Size,
/// The configuration derived from the main config. We "derive" it so that
/// we don't have a shared pointer hanging around that we need to worry about
/// the lifetime of. This makes updating config at runtime easier.
config: DerivedConfig,
/// The conditional state of the configuration. This can affect
/// how certain configurations take effect such as light/dark mode.
/// This is managed completely by Ghostty core but an apprt action
/// is sent whenever this changes.
config_conditional_state: configpkg.ConditionalState,
/// This is set to true if our IO thread notifies us our child exited.
/// This is used to determine if we need to confirm, hold open, etc.
child_exited: bool = false,
/// We maintain our focus state and assume we're focused by default.
/// If we're not initially focused then apprts can call focusCallback
/// to let us know.
focused: bool = true,
/// The effect of an input event. This can be used by callers to take
/// the appropriate action after an input event. For example, key
/// input can be forwarded to the OS for further processing if it
@ -324,6 +325,32 @@ const DerivedConfig = struct {
for (self.links) |*link| link.regex.deinit();
self.arena.deinit();
}
fn scaledPadding(self: *const DerivedConfig, x_dpi: f32, y_dpi: f32) renderer.Padding {
const padding_top: u32 = padding_top: {
const padding_top: f32 = @floatFromInt(self.window_padding_top);
break :padding_top @intFromFloat(@floor(padding_top * y_dpi / 72));
};
const padding_bottom: u32 = padding_bottom: {
const padding_bottom: f32 = @floatFromInt(self.window_padding_bottom);
break :padding_bottom @intFromFloat(@floor(padding_bottom * y_dpi / 72));
};
const padding_left: u32 = padding_left: {
const padding_left: f32 = @floatFromInt(self.window_padding_left);
break :padding_left @intFromFloat(@floor(padding_left * x_dpi / 72));
};
const padding_right: u32 = padding_right: {
const padding_right: f32 = @floatFromInt(self.window_padding_right);
break :padding_right @intFromFloat(@floor(padding_right * x_dpi / 72));
};
return .{
.top = padding_top,
.bottom = padding_bottom,
.left = padding_left,
.right = padding_right,
};
}
};
/// Create a new surface. This must be called from the main thread. The
@ -332,11 +359,31 @@ const DerivedConfig = struct {
pub fn init(
self: *Surface,
alloc: Allocator,
config: *const configpkg.Config,
config_original: *const configpkg.Config,
app: *App,
rt_app: *apprt.runtime.App,
rt_surface: *apprt.runtime.Surface,
) !void {
// Apply our conditional state. If we fail to apply the conditional state
// then we log and attempt to move forward with the old config.
var config_: ?configpkg.Config = config_original.changeConditionalState(
app.config_conditional_state,
) catch |err| err: {
log.warn("failed to apply conditional state to config err={}", .{err});
break :err null;
};
defer if (config_) |*c| c.deinit();
// We want a config pointer for everything so we get that either
// based on our conditional state or the original config.
const config: *const configpkg.Config = if (config_) |*c| config: {
// We want to preserve our original working directory. We
// don't need to dupe memory here because termio will derive
// it. We preserve this so directory inheritance works.
c.@"working-directory" = config_original.@"working-directory";
break :config c;
} else config_original;
// Get our configuration
var derived_config = try DerivedConfig.init(alloc, config);
errdefer derived_config.deinit();
@ -373,28 +420,32 @@ pub fn init(
// Pre-calculate our initial cell size ourselves.
const cell_size = font_grid.cellSize();
// Convert our padding from points to pixels
const padding_top: u32 = padding_top: {
const padding_top: f32 = @floatFromInt(derived_config.window_padding_top);
break :padding_top @intFromFloat(@floor(padding_top * y_dpi / 72));
};
const padding_bottom: u32 = padding_bottom: {
const padding_bottom: f32 = @floatFromInt(derived_config.window_padding_bottom);
break :padding_bottom @intFromFloat(@floor(padding_bottom * y_dpi / 72));
};
const padding_left: u32 = padding_left: {
const padding_left: f32 = @floatFromInt(derived_config.window_padding_left);
break :padding_left @intFromFloat(@floor(padding_left * x_dpi / 72));
};
const padding_right: u32 = padding_right: {
const padding_right: f32 = @floatFromInt(derived_config.window_padding_right);
break :padding_right @intFromFloat(@floor(padding_right * x_dpi / 72));
};
const padding: renderer.Padding = .{
.top = padding_top,
.bottom = padding_bottom,
.left = padding_left,
.right = padding_right,
// Build our size struct which has all the sizes we need.
const size: renderer.Size = size: {
var size: renderer.Size = .{
.screen = screen: {
const surface_size = try rt_surface.getSize();
break :screen .{
.width = surface_size.width,
.height = surface_size.height,
};
},
.cell = font_grid.cellSize(),
.padding = .{},
};
const explicit: renderer.Padding = derived_config.scaledPadding(
x_dpi,
y_dpi,
);
if (derived_config.window_padding_balance) {
size.balancePadding(explicit);
} else {
size.padding = explicit;
}
break :size size;
};
// Create our terminal grid with the initial size
@ -402,26 +453,12 @@ pub fn init(
var renderer_impl = try Renderer.init(alloc, .{
.config = try Renderer.DerivedConfig.init(alloc, config),
.font_grid = font_grid,
.padding = .{
.explicit = padding,
.balance = config.@"window-padding-balance",
},
.size = size,
.surface_mailbox = .{ .surface = self, .app = app_mailbox },
.rt_surface = rt_surface,
});
errdefer renderer_impl.deinit();
// Calculate our grid size based on known dimensions.
const surface_size = try rt_surface.getSize();
const screen_size: renderer.ScreenSize = .{
.width = surface_size.width,
.height = surface_size.height,
};
const grid_size = renderer.GridSize.init(
screen_size.subPadding(padding),
cell_size,
);
// The mutex used to protect our renderer state.
const mutex = try alloc.create(std.Thread.Mutex);
mutex.* = .{};
@ -462,20 +499,27 @@ pub fn init(
.io = undefined,
.io_thread = io_thread,
.io_thr = undefined,
.screen_size = .{ .width = 0, .height = 0 },
.grid_size = .{},
.cell_size = cell_size,
.padding = padding,
.size = size,
.config = derived_config,
// Our conditional state is initialized to the app state. This
// lets us get the most likely correct color theme and so on.
.config_conditional_state = app.config_conditional_state,
};
// The command we're going to execute
const command: ?[]const u8 = if (app.first)
config.@"initial-command" orelse config.command
else
config.command;
// Start our IO implementation
// This separate block ({}) is important because our errdefers must
// be scoped here to be valid.
{
// Initialize our IO backend
var io_exec = try termio.Exec.init(alloc, .{
.command = config.command,
.command = command,
.shell_integration = config.@"shell-integration",
.shell_integration_features = config.@"shell-integration-features",
.working_directory = config.@"working-directory",
@ -499,10 +543,7 @@ pub fn init(
errdefer io_mailbox.deinit(alloc);
try termio.Termio.init(&self.io, alloc, .{
.grid_size = grid_size,
.cell_size = cell_size,
.screen_size = screen_size,
.padding = padding,
.size = size,
.full_config = config,
.config = try termio.Termio.DerivedConfig.init(alloc, config),
.backend = .{ .exec = io_exec },
@ -521,7 +562,7 @@ pub fn init(
try rt_app.performAction(
.{ .surface = self },
.cell_size,
.{ .width = cell_size.width, .height = cell_size.height },
.{ .width = size.cell.width, .height = size.cell.height },
);
// Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app
@ -530,8 +571,8 @@ pub fn init(
.{ .surface = self },
.size_limit,
.{
.min_width = cell_size.width * 10,
.min_height = cell_size.height * 4,
.min_width = size.cell.width * 10,
.min_height = size.cell.height * 4,
// No max:
.max_width = 0,
.max_height = 0,
@ -543,7 +584,7 @@ pub fn init(
// init stuff we should get rid of this. But this is required because
// sizeCallback does retina-aware stuff we don't do here and don't want
// to duplicate.
try self.sizeCallback(surface_size);
try self.resize(self.size.screen);
// Give the renderer one more opportunity to finalize any surface
// setup on the main thread prior to spinning up the rendering thread.
@ -583,12 +624,12 @@ pub fn init(
// account for the padding so we get the exact correct grid size.
const final_width: u32 =
@as(u32, @intFromFloat(@ceil(width_f32 / scale.x))) +
padding.left +
padding.right;
size.padding.left +
size.padding.right;
const final_height: u32 =
@as(u32, @intFromFloat(@ceil(height_f32 / scale.y))) +
padding.top +
padding.bottom;
size.padding.top +
size.padding.bottom;
rt_app.performAction(
.{ .surface = self },
@ -613,9 +654,9 @@ pub fn init(
// For xdg-terminal-exec execution we special-case and set the window
// title to the command being executed. This allows window managers
// to set custom styling based on the command being executed.
const command = config.command orelse break :xdg;
if (command.len > 0) {
const title = alloc.dupeZ(u8, command) catch |err| {
const v = command orelse break :xdg;
if (v.len > 0) {
const title = alloc.dupeZ(u8, v) catch |err| {
log.warn(
"error copying command for title, title will not be set err={}",
.{err},
@ -630,6 +671,9 @@ pub fn init(
);
}
}
// We are no longer the first surface
app.first = false;
}
pub fn deinit(self: *Surface) void {
@ -758,7 +802,7 @@ pub fn needsConfirmQuit(self: *Surface) bool {
/// surface.
pub fn handleMessage(self: *Surface, msg: Message) !void {
switch (msg) {
.change_config => |config| try self.changeConfig(config),
.change_config => |config| try self.updateConfig(config),
.set_title => |*v| {
// We ignore the message in case the title was set via config.
@ -799,6 +843,29 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
}, .unlocked);
},
.color_change => |change| {
// On any color change, we have to report for mode 2031
// if it is enabled.
self.reportColorScheme(false);
// Notify our apprt
try self.rt_app.performAction(
.{ .surface = self },
.color_change,
.{
.kind = switch (change.kind) {
.background => .background,
.foreground => .foreground,
.cursor => .cursor,
.palette => |v| @enumFromInt(v),
},
.r = change.color.r,
.g = change.color.g,
.b = change.color.b,
},
);
},
.set_mouse_shape => |shape| {
log.debug("changing mouse shape: {}", .{shape});
try self.rt_app.performAction(
@ -826,6 +893,20 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
},
},
.pwd_change => |w| {
defer w.deinit();
// We always allocate for this because we need to null-terminate.
const str = try self.alloc.dupeZ(u8, w.slice());
defer self.alloc.free(str);
try self.rt_app.performAction(
.{ .surface = self },
.pwd,
.{ .pwd = str },
);
},
.close => self.close(),
// Close without confirmation.
@ -847,7 +928,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.renderer_health => |health| self.updateRendererHealth(health),
.report_color_scheme => try self.reportColorScheme(),
.report_color_scheme => |force| self.reportColorScheme(force),
.present_surface => try self.presentSurface(),
@ -884,9 +965,19 @@ fn passwordInput(self: *Surface, v: bool) !void {
try self.queueRender();
}
/// Sends a DSR response for the current color scheme to the pty.
fn reportColorScheme(self: *Surface) !void {
const output = switch (self.color_scheme) {
/// Sends a DSR response for the current color scheme to the pty. If
/// force is false then we only send the response if the terminal mode
/// 2031 is enabled.
fn reportColorScheme(self: *Surface, force: bool) void {
if (!force) {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
if (!self.renderer_state.terminal.modes.get(.report_color_scheme)) {
return;
}
}
const output = switch (self.config_conditional_state.theme) {
.light => "\x1B[?997;2n",
.dark => "\x1B[?997;1n",
};
@ -1009,8 +1100,39 @@ fn updateRendererHealth(self: *Surface, health: renderer.Health) void {
};
}
/// Update our configuration at runtime.
fn changeConfig(self: *Surface, config: *const configpkg.Config) !void {
/// This should be called anytime `config_conditional_state` changes
/// so that the apprt can reload the configuration.
fn notifyConfigConditionalState(self: *Surface) void {
self.rt_app.performAction(
.{ .surface = self },
.reload_config,
.{ .soft = true },
) catch |err| {
log.warn("failed to notify app of config state change err={}", .{err});
};
}
/// Update our configuration at runtime. This can be called by the apprt
/// to set a surface-specific configuration that differs from the app
/// or other surfaces.
pub fn updateConfig(
self: *Surface,
original: *const configpkg.Config,
) !void {
// Apply our conditional state. If we fail to apply the conditional state
// then we log and attempt to move forward with the old config.
var config_: ?configpkg.Config = original.changeConditionalState(
self.config_conditional_state,
) catch |err| err: {
log.warn("failed to apply conditional state to config err={}", .{err});
break :err null;
};
defer if (config_) |*c| c.deinit();
// We want a config pointer for everything so we get that either
// based on our conditional state or the original config.
const config: *const configpkg.Config = if (config_) |*c| c else original;
// Update our new derived config immediately
const derived = DerivedConfig.init(self.alloc, config) catch |err| {
// If the derivation fails then we just log and return. We don't
@ -1059,6 +1181,13 @@ fn changeConfig(self: *Surface, config: *const configpkg.Config) !void {
self.queueRender() catch |err| {
log.warn("failed to notify renderer of config change err={}", .{err});
};
// Notify the window
try self.rt_app.performAction(
.{ .surface = self },
.config_change,
.{ .config = config },
);
}
/// Returns true if the terminal has a selection.
@ -1108,18 +1237,12 @@ pub fn selectionInfo(self: *const Surface) ?apprt.Selection {
// Our sizes are all scaled so we need to send the unscaled values back.
const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 };
// We need to account for padding as well.
const pad = if (self.config.window_padding_balance)
renderer.Padding.balanced(self.screen_size, self.grid_size, self.cell_size)
else
self.padding;
const x: f64 = x: {
// Simple x * cell width gives the left
var x: f64 = @floatFromInt(tl_coord.x * self.cell_size.width);
var x: f64 = @floatFromInt(tl_coord.x * self.size.cell.width);
// Add padding
x += @floatFromInt(pad.left);
x += @floatFromInt(self.size.padding.left);
// Scale
x /= content_scale.x;
@ -1129,14 +1252,14 @@ pub fn selectionInfo(self: *const Surface) ?apprt.Selection {
const y: f64 = y: {
// Simple y * cell height gives the top
var y: f64 = @floatFromInt(tl_coord.y * self.cell_size.height);
var y: f64 = @floatFromInt(tl_coord.y * self.size.cell.height);
// We want the text baseline
y += @floatFromInt(self.cell_size.height);
y += @floatFromInt(self.size.cell.height);
y -= @floatFromInt(self.font_metrics.cell_baseline);
// Add padding
y += @floatFromInt(pad.top);
y += @floatFromInt(self.size.padding.top);
// Scale
y /= content_scale.y;
@ -1177,10 +1300,10 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos {
const x: f64 = x: {
// Simple x * cell width gives the top-left corner
var x: f64 = @floatFromInt(cursor.x * self.cell_size.width);
var x: f64 = @floatFromInt(cursor.x * self.size.cell.width);
// We want the midpoint
x += @as(f64, @floatFromInt(self.cell_size.width)) / 2;
x += @as(f64, @floatFromInt(self.size.cell.width)) / 2;
// And scale it
x /= content_scale.x;
@ -1190,10 +1313,10 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos {
const y: f64 = y: {
// Simple x * cell width gives the top-left corner
var y: f64 = @floatFromInt(cursor.y * self.cell_size.height);
var y: f64 = @floatFromInt(cursor.y * self.size.cell.height);
// We want the bottom
y += @floatFromInt(self.cell_size.height);
y += @floatFromInt(self.size.cell.height);
// And scale it
y /= content_scale.y;
@ -1319,24 +1442,12 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void {
/// Change the cell size for the terminal grid. This can happen as
/// a result of changing the font size at runtime.
fn setCellSize(self: *Surface, size: renderer.CellSize) !void {
// Update our new cell size for future calcs
self.cell_size = size;
// Update our grid_size
self.grid_size = renderer.GridSize.init(
self.screen_size.subPadding(self.padding),
self.cell_size,
);
// Update our cell size within our size struct
self.size.cell = size;
self.balancePaddingIfNeeded();
// Notify the terminal
self.io.queueMessage(.{
.resize = .{
.grid_size = self.grid_size,
.cell_size = self.cell_size,
.screen_size = self.screen_size,
.padding = self.padding,
},
}, .unlocked);
self.io.queueMessage(.{ .resize = self.size }, .unlocked);
// Notify the window
try self.rt_app.performAction(
@ -1407,41 +1518,41 @@ pub fn sizeCallback(self: *Surface, size: apprt.SurfaceSize) !void {
// Update our screen size, but only if it actually changed. And if
// the screen size didn't change, then our grid size could not have
// changed, so we just return.
if (self.screen_size.equals(new_screen_size)) return;
if (self.size.screen.equals(new_screen_size)) return;
try self.resize(new_screen_size);
}
fn resize(self: *Surface, size: renderer.ScreenSize) !void {
// Save our screen size
self.screen_size = size;
self.size.screen = size;
self.balancePaddingIfNeeded();
// Recalculate our grid size. Because Ghostty supports fluid resizing,
// its possible the grid doesn't change at all even if the screen size changes.
// We have to update the IO thread no matter what because we send
// pixel-level sizing to the subprocess.
self.grid_size = renderer.GridSize.init(
self.screen_size.subPadding(self.padding),
self.cell_size,
);
if (self.grid_size.columns < 5 and (self.padding.left > 0 or self.padding.right > 0)) {
const grid_size = self.size.grid();
if (grid_size.columns < 5 and (self.size.padding.left > 0 or self.size.padding.right > 0)) {
log.warn("WARNING: very small terminal grid detected with padding " ++
"set. Is your padding reasonable?", .{});
}
if (self.grid_size.rows < 2 and (self.padding.top > 0 or self.padding.bottom > 0)) {
if (grid_size.rows < 2 and (self.size.padding.top > 0 or self.size.padding.bottom > 0)) {
log.warn("WARNING: very small terminal grid detected with padding " ++
"set. Is your padding reasonable?", .{});
}
// Mail the IO thread
self.io.queueMessage(.{
.resize = .{
.grid_size = self.grid_size,
.cell_size = self.cell_size,
.screen_size = self.screen_size,
.padding = self.padding,
},
}, .unlocked);
self.io.queueMessage(.{ .resize = self.size }, .unlocked);
}
/// Recalculate the balanced padding if needed.
fn balancePaddingIfNeeded(self: *Surface) void {
if (!self.config.window_padding_balance) return;
const content_scale = try self.rt_surface.getContentScale();
const x_dpi = content_scale.x * font.face.default_dpi;
const y_dpi = content_scale.y * font.face.default_dpi;
self.size.balancePadding(self.config.scaledPadding(x_dpi, y_dpi));
}
/// Called to set the preedit state for character input. Preedit is used
@ -1972,6 +2083,10 @@ pub fn focusCallback(self: *Surface, focused: bool) !void {
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// If our focus state is the same we do nothing.
if (self.focused == focused) return;
self.focused = focused;
// Notify our render thread of the new state
_ = self.renderer_thread.mailbox.push(.{
.focus = focused,
@ -2028,6 +2143,12 @@ pub fn focusCallback(self: *Surface, focused: bool) !void {
// Schedule render which also drains our mailbox
try self.queueRender();
// Whenever our focus changes we unhide the mouse. The mouse will be
// hidden again if the user starts typing. This helps alleviate some
// buggy behavior upstream in macOS with the mouse never becoming visible
// again when tabbing between programs (see #2525).
self.showMouse();
// Update the focus state and notify the terminal
{
self.renderer_state.mutex.lock();
@ -2090,7 +2211,8 @@ pub fn scrollCallback(
if (!scroll_mods.precision) {
// Calculate our magnitude of scroll. This is constant (not
// dependent on yoff).
const grid_rows_f64: f64 = @floatFromInt(self.grid_size.rows);
const grid_size = self.size.grid();
const grid_rows_f64: f64 = @floatFromInt(grid_size.rows);
const y_delta_f64: f64 = @round((grid_rows_f64 * self.config.mouse_scroll_multiplier) / 15.0);
const y_delta_usize: usize = @max(1, @as(usize, @intFromFloat(y_delta_f64)));
@ -2117,7 +2239,7 @@ pub fn scrollCallback(
// If the new offset is less than a single unit of scroll, we save
// the new pending value and do not scroll yet.
const cell_size: f64 = @floatFromInt(self.cell_size.height);
const cell_size: f64 = @floatFromInt(self.size.cell.height);
if (@abs(poff) < cell_size) {
self.mouse.pending_scroll_y = poff;
break :y .{};
@ -2147,7 +2269,7 @@ pub fn scrollCallback(
const xoff_adjusted: f64 = xoff * self.config.mouse_scroll_multiplier;
const poff: f64 = self.mouse.pending_scroll_x + xoff_adjusted;
const cell_size: f64 = @floatFromInt(self.cell_size.width);
const cell_size: f64 = @floatFromInt(self.size.cell.width);
if (@abs(poff) < cell_size) {
self.mouse.pending_scroll_x = poff;
break :x .{};
@ -2272,36 +2394,15 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) !
try self.setFontSize(size);
// Update our padding which is dependent on DPI.
self.padding = padding: {
const padding_top: u32 = padding_top: {
const padding_top: f32 = @floatFromInt(self.config.window_padding_top);
break :padding_top @intFromFloat(@floor(padding_top * y_dpi / 72));
};
const padding_bottom: u32 = padding_bottom: {
const padding_bottom: f32 = @floatFromInt(self.config.window_padding_bottom);
break :padding_bottom @intFromFloat(@floor(padding_bottom * y_dpi / 72));
};
const padding_left: u32 = padding_left: {
const padding_left: f32 = @floatFromInt(self.config.window_padding_left);
break :padding_left @intFromFloat(@floor(padding_left * x_dpi / 72));
};
const padding_right: u32 = padding_right: {
const padding_right: f32 = @floatFromInt(self.config.window_padding_right);
break :padding_right @intFromFloat(@floor(padding_right * x_dpi / 72));
};
break :padding .{
.top = padding_top,
.bottom = padding_bottom,
.left = padding_left,
.right = padding_right,
};
};
// Update our padding which is dependent on DPI. We only do this for
// unbalanced padding since balanced padding is not dependent on DPI.
if (!self.config.window_padding_balance) {
self.size.padding = self.config.scaledPadding(x_dpi, y_dpi);
}
// Force a resize event because the change in padding will affect
// pixel-level changes to the renderer and viewport.
try self.resize(self.screen_size);
try self.resize(self.size.screen);
}
/// The type of action to report for a mouse event.
@ -2340,8 +2441,8 @@ fn mouseReport(
// We always report release events no matter where they happen.
if (action != .release) {
const pos_out_viewport = pos_out_viewport: {
const max_x: f32 = @floatFromInt(self.screen_size.width);
const max_y: f32 = @floatFromInt(self.screen_size.height);
const max_x: f32 = @floatFromInt(self.size.screen.width);
const max_y: f32 = @floatFromInt(self.size.screen.height);
break :pos_out_viewport pos.x < 0 or pos.y < 0 or
pos.x > max_x or pos.y > max_y;
};
@ -2500,15 +2601,22 @@ fn mouseReport(
.sgr_pixels => {
// Final character to send in the CSI
const final: u8 = if (action == .release) 'm' else 'M';
const adjusted = self.posAdjusted(pos.x, pos.y);
// The position has to be adjusted to the terminal space.
const coord: renderer.Coordinate.Terminal = (renderer.Coordinate{
.surface = .{
.x = pos.x,
.y = pos.y,
},
}).convert(.terminal, self.size).terminal;
// Response always is at least 4 chars, so this leaves the
// remainder for numbers which are very large...
var data: termio.Message.WriteReq.Small.Array = undefined;
const resp = try std.fmt.bufPrint(&data, "\x1B[<{d};{d};{d}{c}", .{
button_code,
@as(i32, @intFromFloat(@round(adjusted.x))),
@as(i32, @intFromFloat(@round(adjusted.y))),
@as(i32, @intFromFloat(@round(coord.x))),
@as(i32, @intFromFloat(@round(coord.y))),
final,
});
@ -2713,12 +2821,20 @@ pub fn mouseButtonCallback(
}
// For left button click release we check if we are moving our cursor.
if (button == .left and action == .release and mods.alt) click_move: {
if (button == .left and
action == .release and
mods.alt)
click_move: {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
// If we have a selection then we do not do click to move because
// it means that we moved our cursor while pressing the mouse button.
if (self.io.terminal.screen.selection != null) break :click_move;
// Moving always resets the click count so that we don't highlight.
self.mouse.left_click_count = 0;
const pin = self.mouse.left_click_pin orelse break :click_move;
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
try self.clickMoveCursor(pin.*);
return true;
}
@ -2755,7 +2871,7 @@ pub fn mouseButtonCallback(
// If we move our cursor too much between clicks then we reset
// the multi-click state.
if (self.mouse.left_click_count > 0) {
const max_distance: f64 = @floatFromInt(self.cell_size.width);
const max_distance: f64 = @floatFromInt(self.size.cell.width);
const distance = @sqrt(
std.math.pow(f64, pos.x - self.mouse.left_click_xpos, 2) +
std.math.pow(f64, pos.y - self.mouse.left_click_ypos, 2),
@ -3067,7 +3183,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
/// if there is no hyperlink.
fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 {
_ = self;
const page = &pin.page.data;
const page = &pin.node.data;
const cell = pin.rowAndCell().cell;
const link_id = page.lookupHyperlink(cell) orelse return null;
const entry = page.hyperlink_set.get(page.memory, link_id);
@ -3255,8 +3371,8 @@ pub fn cursorPosCallback(
// We allow for a 1 pixel buffer at the top and bottom to detect
// scroll even in full screen windows.
// Note: one day, we can change this from distance to time based if we want.
//log.warn("CURSOR POS: {} {}", .{ pos, self.screen_size });
const max_y: f32 = @floatFromInt(self.screen_size.height);
//log.warn("CURSOR POS: {} {}", .{ pos, self.size.screen });
const max_y: f32 = @floatFromInt(self.size.screen.height);
if (pos.y <= 1 or pos.y > max_y - 1) {
const delta: isize = if (pos.y < 0) -1 else 1;
try self.io.terminal.scrollViewport(.{ .delta = delta });
@ -3415,11 +3531,11 @@ fn dragLeftClickSingle(
const click_pin = self.mouse.left_click_pin.?.*;
// the boundary point at which we consider selection or non-selection
const cell_width_f64: f64 = @floatFromInt(self.cell_size.width);
const cell_width_f64: f64 = @floatFromInt(self.size.cell.width);
const cell_xboundary = cell_width_f64 * 0.6;
// first xpos of the clicked cell adjusted for padding
const left_padding_f64: f64 = @as(f64, @floatFromInt(self.padding.left));
const left_padding_f64: f64 = @as(f64, @floatFromInt(self.size.padding.left));
const cell_xstart = @as(f64, @floatFromInt(click_pin.x)) * cell_width_f64;
const cell_start_xpos = self.mouse.left_click_xpos - cell_xstart - left_padding_f64;
@ -3437,7 +3553,7 @@ fn dragLeftClickSingle(
try self.setSelection(if (selected) terminal.Selection.init(
drag_pin,
drag_pin,
self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt,
SurfaceMouse.isRectangleSelectState(self.mouse.mods),
) else null);
return;
@ -3472,7 +3588,7 @@ fn dragLeftClickSingle(
try self.setSelection(terminal.Selection.init(
start,
drag_pin,
self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt,
SurfaceMouse.isRectangleSelectState(self.mouse.mods),
));
return;
}
@ -3554,58 +3670,27 @@ pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void {
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// If our scheme didn't change, then we don't do anything.
if (self.color_scheme == scheme) return;
const new_scheme: configpkg.ConditionalState.Theme = switch (scheme) {
.light => .light,
.dark => .dark,
};
// Set our new scheme
self.color_scheme = scheme;
// If our scheme didn't change, then we don't do anything.
if (self.config_conditional_state.theme == new_scheme) return;
// Setup our conditional state which has the current color theme.
self.config_conditional_state.theme = new_scheme;
self.notifyConfigConditionalState();
// If mode 2031 is on, then we report the change live.
const report = report: {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
break :report self.renderer_state.terminal.modes.get(.report_color_scheme);
};
if (report) try self.reportColorScheme();
}
pub fn posAdjusted(self: Surface, xpos: f64, ypos: f64) struct { x: f64, y: f64 } {
const pad = if (self.config.window_padding_balance)
renderer.Padding.balanced(self.screen_size, self.grid_size, self.cell_size)
else
self.padding;
return .{
.x = xpos - @as(f64, @floatFromInt(pad.left)),
.y = ypos - @as(f64, @floatFromInt(pad.top)),
};
self.reportColorScheme(false);
}
pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordinate {
// xpos/ypos need to be adjusted for window padding
// (i.e. "window-padding-*" settings.
const adjusted = self.posAdjusted(xpos, ypos);
// adjusted.x and adjusted.y can be negative if while dragging, the user moves the
// mouse off the surface. Likewise, they can be larger than our surface
// width if the user drags out of the surface positively.
return .{
.x = if (adjusted.x < 0) 0 else x: {
// Our cell is the mouse divided by cell width
const cell_width: f64 = @floatFromInt(self.cell_size.width);
const x: usize = @intFromFloat(adjusted.x / cell_width);
// Can be off the screen if the user drags it out, so max
// it out on our available columns
break :x @min(x, self.grid_size.columns - 1);
},
.y = if (adjusted.y < 0) 0 else y: {
const cell_height: f64 = @floatFromInt(self.cell_size.height);
const y: usize = @intFromFloat(adjusted.y / cell_height);
break :y @min(y, self.grid_size.rows - 1);
},
};
// Get our grid cell
const coord: renderer.Coordinate = .{ .surface = .{ .x = xpos, .y = ypos } };
const grid = coord.convert(.grid, self.size).grid;
return .{ .x = grid.x, .y = grid.y };
}
/// Scroll to the bottom of the viewport.
@ -3843,21 +3928,21 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
.scroll_page_up => {
const rows: isize = @intCast(self.grid_size.rows);
const rows: isize = @intCast(self.size.grid().rows);
self.io.queueMessage(.{
.scroll_viewport = .{ .delta = -1 * rows },
}, .unlocked);
},
.scroll_page_down => {
const rows: isize = @intCast(self.grid_size.rows);
const rows: isize = @intCast(self.size.grid().rows);
self.io.queueMessage(.{
.scroll_viewport = .{ .delta = rows },
}, .unlocked);
},
.scroll_page_fractional => |fraction| {
const rows: f32 = @floatFromInt(self.grid_size.rows);
const rows: f32 = @floatFromInt(self.size.grid().rows);
const delta: isize = @intFromFloat(@trunc(fraction * rows));
self.io.queueMessage(.{
.scroll_viewport = .{ .delta = delta },
@ -3927,7 +4012,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.left => .left,
.down => .down,
.up => .up,
.auto => if (self.screen_size.width > self.screen_size.height)
.auto => if (self.size.screen.width > self.size.screen.height)
.right
else
.down,

View File

@ -1,6 +1,7 @@
const std = @import("std");
const assert = std.debug.assert;
const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig");
const input = @import("../input.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
@ -151,6 +152,9 @@ pub const Action = union(Key) {
/// Set the title of the target.
set_title: SetTitle,
/// The current working directory has changed for the target terminal.
pwd: Pwd,
/// Set the mouse cursor shape.
mouse_shape: terminal.MouseShape,
@ -186,6 +190,33 @@ pub const Action = union(Key) {
/// key mode because other input may be ignored.
key_sequence: KeySequence,
/// A terminal color was changed programmatically through things
/// such as OSC 10/11.
color_change: ColorChange,
/// A request to reload the configuration. The reload request can be
/// from a user or for some internal reason. The reload request may
/// request it is a soft reload or a full reload. See the struct for
/// more documentation.
///
/// The configuration should be passed to updateConfig either at the
/// app or surface level depending on the target.
reload_config: ReloadConfig,
/// The configuration has changed. The value is a pointer to the new
/// configuration. The pointer is only valid for the duration of the
/// action and should not be stored.
///
/// This should be used by apprts to update any internal state that
/// depends on configuration for the given target (i.e. headerbar colors).
/// The apprt should copy any data it needs since the memory lifetime
/// is only valid for the duration of the action.
///
/// This allows an apprt to have config-dependent state reactively
/// change without having to store the entire configuration or poll
/// for changes.
config_change: ConfigChange,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
new_window,
@ -211,6 +242,7 @@ pub const Action = union(Key) {
render_inspector,
desktop_notification,
set_title,
pwd,
mouse_shape,
mouse_visibility,
mouse_over_link,
@ -219,6 +251,9 @@ pub const Action = union(Key) {
quit_timer,
secure_input,
key_sequence,
color_change,
reload_config,
config_change,
};
/// Sync with: ghostty_action_u
@ -412,6 +447,21 @@ pub const SetTitle = struct {
}
};
pub const Pwd = struct {
pwd: [:0]const u8,
// Sync with: ghostty_action_set_pwd_s
pub const C = extern struct {
pwd: [*:0]const u8,
};
pub fn cval(self: Pwd) C {
return .{
.pwd = self.pwd.ptr,
};
}
};
/// The desktop notification to show.
pub const DesktopNotification = struct {
title: [:0]const u8,
@ -448,3 +498,42 @@ pub const KeySequence = union(enum) {
};
}
};
pub const ColorChange = extern struct {
kind: ColorKind,
r: u8,
g: u8,
b: u8,
};
pub const ColorKind = enum(c_int) {
// Negative numbers indicate some named kind
foreground = -1,
background = -2,
cursor = -3,
// 0+ values indicate a palette index
_,
};
pub const ReloadConfig = extern struct {
/// A soft reload means that the configuration doesn't need to be
/// read off disk, but libghostty needs the full config again so call
/// updateConfig with it.
soft: bool = false,
};
pub const ConfigChange = struct {
config: *const configpkg.Config,
// Sync with: ghostty_action_config_change_s
pub const C = extern struct {
config: *const configpkg.Config,
};
pub fn cval(self: ConfigChange) C {
return .{
.config = self.config,
};
}
};

View File

@ -47,11 +47,6 @@ pub const App = struct {
/// Callback called to handle an action.
action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.C) void,
/// Reload the configuration and return the new configuration.
/// The old configuration can be freed immediately when this is
/// called.
reload_config: *const fn (AppUD) callconv(.C) ?*const Config,
/// Read the clipboard value. The return value must be preserved
/// by the host until the next call. If there is no valid clipboard
/// value then this should return null.
@ -90,26 +85,38 @@ pub const App = struct {
};
core_app: *CoreApp,
config: *const Config,
opts: Options,
keymap: input.Keymap,
/// The configuration for the app. This is owned by this structure.
config: Config,
/// The keymap state is used for global keybinds only. Each surface
/// also has its own keymap state for focused keybinds.
keymap_state: input.Keymap.State,
pub fn init(core_app: *CoreApp, config: *const Config, opts: Options) !App {
pub fn init(
core_app: *CoreApp,
config: *const Config,
opts: Options,
) !App {
// We have to clone the config.
const alloc = core_app.alloc;
var config_clone = try config.clone(alloc);
errdefer config_clone.deinit();
return .{
.core_app = core_app,
.config = config,
.config = config_clone,
.opts = opts,
.keymap = try input.Keymap.init(),
.keymap_state = .{},
};
}
pub fn terminate(self: App) void {
pub fn terminate(self: *App) void {
self.keymap.deinit();
self.config.deinit();
}
/// Returns true if there are any global keybinds in the configuration.
@ -375,21 +382,11 @@ pub const App = struct {
}
}
pub fn reloadConfig(self: *App) !?*const Config {
// Reload
if (self.opts.reload_config(self.opts.userdata)) |new| {
self.config = new;
return self.config;
}
return null;
}
pub fn wakeup(self: App) void {
pub fn wakeup(self: *const App) void {
self.opts.wakeup(self.opts.userdata);
}
pub fn wait(self: App) !void {
pub fn wait(self: *const App) !void {
_ = self;
}
@ -430,6 +427,28 @@ pub const App = struct {
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
) !void {
// Special case certain actions before they are sent to the
// embedded apprt.
self.performPreAction(target, action, value);
log.debug("dispatching action target={s} action={} value={}", .{
@tagName(target),
action,
value,
});
self.opts.action(
self,
target.cval(),
@unionInit(apprt.Action, @tagName(action), value).cval(),
);
}
fn performPreAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
) void {
// Special case certain actions before they are sent to the embedder
switch (action) {
.set_title => switch (target) {
@ -443,19 +462,21 @@ pub const App = struct {
},
},
.config_change => switch (target) {
.surface => {},
// For app updates, we update our core config. We need to
// clone it because the caller owns the param.
.app => if (value.config.clone(self.core_app.alloc)) |config| {
self.config.deinit();
self.config = config;
} else |err| {
log.err("error updating app config err={}", .{err});
},
},
else => {},
}
log.debug("dispatching action target={s} action={} value={}", .{
@tagName(target),
action,
value,
});
self.opts.action(
self,
target.cval(),
@unionInit(apprt.Action, @tagName(action), value).cval(),
);
}
};
@ -577,7 +598,7 @@ pub const Surface = struct {
errdefer app.core_app.deleteSurface(self);
// Shallow copy the config so that we can modify it.
var config = try apprt.surface.newConfig(app.core_app, app.config);
var config = try apprt.surface.newConfig(app.core_app, &app.config);
defer config.deinit();
// If we have a working directory from the options then we set it.
@ -1339,10 +1360,14 @@ pub const CAPI = struct {
};
}
/// Reload the configuration.
export fn ghostty_app_reload_config(v: *App) void {
_ = v.core_app.reloadConfig(v) catch |err| {
log.err("error reloading config err={}", .{err});
/// Update the configuration to the provided config. This will propagate
/// to all surfaces as well.
export fn ghostty_app_update_config(
v: *App,
config: *const Config,
) void {
v.core_app.updateConfig(v, config) catch |err| {
log.err("error updating config err={}", .{err});
return;
};
}
@ -1357,6 +1382,22 @@ pub const CAPI = struct {
return v.hasGlobalKeybinds();
}
/// Update the color scheme of the app.
export fn ghostty_app_set_color_scheme(v: *App, scheme_raw: c_int) void {
const scheme = std.meta.intToEnum(apprt.ColorScheme, scheme_raw) catch {
log.warn(
"invalid color scheme to ghostty_surface_set_color_scheme value={}",
.{scheme_raw},
);
return;
};
v.core_app.colorSchemeEvent(v, scheme) catch |err| {
log.err("error setting color scheme err={}", .{err});
return;
};
}
/// Returns initial surface options.
export fn ghostty_surface_config_new() apprt.Surface.Options {
return .{};
@ -1399,6 +1440,17 @@ pub const CAPI = struct {
return surface.newSurfaceOptions();
}
/// Update the configuration to the provided config for only this surface.
export fn ghostty_surface_update_config(
surface: *Surface,
config: *const Config,
) void {
surface.core_surface.updateConfig(config) catch |err| {
log.err("error updating config err={}", .{err});
return;
};
}
/// Returns true if the surface needs to confirm quitting.
export fn ghostty_surface_needs_confirm_quit(surface: *Surface) bool {
return surface.core_surface.needsConfirmQuit();
@ -1428,25 +1480,6 @@ pub const CAPI = struct {
return selection.len;
}
/// Copies the surface working directory into the provided buffer and
/// returns the copied size. If the buffer is too small, there is no pwd,
/// or there is an error, then 0 is returned.
export fn ghostty_surface_pwd(surface: *Surface, buf: [*]u8, cap: usize) usize {
const pwd_ = surface.core_surface.pwd(global.alloc) catch |err| {
log.warn("error getting pwd err={}", .{err});
return 0;
};
const pwd = pwd_ orelse return 0;
defer global.alloc.free(pwd);
// If the buffer is too small, return no pwd.
if (pwd.len > cap) return 0;
// Copy into the buffer and return the length
@memcpy(buf[0..pwd.len], pwd);
return pwd.len;
}
/// Tell the surface that it needs to schedule a render
export fn ghostty_surface_refresh(surface: *Surface) void {
surface.refresh();
@ -1466,13 +1499,14 @@ pub const CAPI = struct {
/// Return the size information a surface has.
export fn ghostty_surface_size(surface: *Surface) SurfaceSize {
const grid_size = surface.core_surface.size.grid();
return .{
.columns = surface.core_surface.grid_size.columns,
.rows = surface.core_surface.grid_size.rows,
.width_px = surface.core_surface.screen_size.width,
.height_px = surface.core_surface.screen_size.height,
.cell_width_px = surface.core_surface.cell_size.width,
.cell_height_px = surface.core_surface.cell_size.height,
.columns = grid_size.columns,
.rows = grid_size.rows,
.width_px = surface.core_surface.size.screen.width,
.height_px = surface.core_surface.size.screen.height,
.cell_width_px = surface.core_surface.size.cell.width,
.cell_height_px = surface.core_surface.size.cell.height,
};
}
@ -1822,7 +1856,7 @@ pub const CAPI = struct {
// This is only supported on macOS
if (comptime builtin.target.os.tag != .macos) return;
const config = app.config;
const config = &app.config;
// Do nothing if we don't have background transparency enabled
if (config.@"background-opacity" >= 1.0) return;

View File

@ -200,6 +200,8 @@ pub const App = struct {
}),
},
.reload_config => try self.reloadConfig(target, value),
// Unimplemented
.new_split,
.goto_split,
@ -223,6 +225,9 @@ pub const App = struct {
.mouse_over_link,
.cell_size,
.renderer_health,
.color_change,
.pwd,
.config_change,
=> log.info("unimplemented action={}", .{action}),
}
}
@ -232,16 +237,34 @@ pub const App = struct {
/// successful return.
///
/// The returned pointer value is only valid for a stable self pointer.
pub fn reloadConfig(self: *App) !?*const Config {
fn reloadConfig(
self: *App,
target: apprt.action.Target,
opts: apprt.action.ReloadConfig,
) !void {
if (opts.soft) {
switch (target) {
.app => try self.app.updateConfig(self, &self.config),
.surface => |core_surface| try core_surface.updateConfig(
&self.config,
),
}
return;
}
// Load our configuration
var config = try Config.load(self.app.alloc);
errdefer config.deinit();
// Call into our app to update
switch (target) {
.app => try self.app.updateConfig(self, &config),
.surface => |core_surface| try core_surface.updateConfig(&config),
}
// Update the existing config, be sure to clean up the old one.
self.config.deinit();
self.config = config;
return &self.config;
}
/// Toggle the window to fullscreen mode.

View File

@ -462,21 +462,24 @@ pub fn performAction(
.equalize_splits => self.equalizeSplits(target),
.goto_split => self.gotoSplit(target, value),
.open_config => try configpkg.edit.open(self.core_app.alloc),
.config_change => self.configChange(value.config),
.reload_config => try self.reloadConfig(target, value),
.inspector => self.controlInspector(target, value),
.desktop_notification => self.showDesktopNotification(target, value),
.set_title => try self.setTitle(target, value),
.pwd => try self.setPwd(target, value),
.present_terminal => self.presentTerminal(target),
.initial_size => try self.setInitialSize(target, value),
.mouse_visibility => self.setMouseVisibility(target, value),
.mouse_shape => try self.setMouseShape(target, value),
.mouse_over_link => self.setMouseOverLink(target, value),
.toggle_tab_overview => self.toggleTabOverview(target),
.toggle_split_zoom => self.toggleSplitZoom(target),
.toggle_window_decorations => self.toggleWindowDecorations(target),
.quit_timer => self.quitTimer(value),
// Unimplemented
.close_all_windows,
.toggle_split_zoom,
.toggle_quick_terminal,
.toggle_visibility,
.size_limit,
@ -485,6 +488,7 @@ pub fn performAction(
.key_sequence,
.render_inspector,
.renderer_health,
.color_change,
=> log.warn("unimplemented action={}", .{action}),
}
}
@ -670,6 +674,13 @@ fn toggleTabOverview(_: *App, target: apprt.Target) void {
}
}
fn toggleSplitZoom(_: *App, target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |surface| surface.rt_surface.toggleSplitZoom(),
}
}
fn toggleWindowDecorations(
_: *App,
target: apprt.Target,
@ -708,6 +719,17 @@ fn setTitle(
}
}
fn setPwd(
_: *App,
target: apprt.Target,
pwd: apprt.action.Pwd,
) !void {
switch (target) {
.app => {},
.surface => |v| try v.rt_surface.setPwd(pwd.pwd),
}
}
fn setMouseVisibility(
_: *App,
target: apprt.Target,
@ -796,19 +818,9 @@ fn showDesktopNotification(
c.g_application_send_notification(g_app, n.body.ptr, notification);
}
/// Reload the configuration. This should return the new configuration.
/// The old value can be freed immediately at this point assuming a
/// successful return.
///
/// The returned pointer value is only valid for a stable self pointer.
pub fn reloadConfig(self: *App) !?*const Config {
// Load our configuration
var config = try Config.load(self.core_app.alloc);
errdefer config.deinit();
fn configChange(self: *App, new_config: *const Config) void {
_ = new_config;
// Update the existing config, be sure to clean up the old one.
self.config.deinit();
self.config = config;
self.syncConfigChanges() catch |err| {
log.warn("error handling configuration changes err={}", .{err});
};
@ -819,8 +831,36 @@ pub fn reloadConfig(self: *App) !?*const Config {
if (surface.container.window()) |window| window.onConfigReloaded();
}
}
}
return &self.config;
pub fn reloadConfig(
self: *App,
target: apprt.action.Target,
opts: apprt.action.ReloadConfig,
) !void {
if (opts.soft) {
switch (target) {
.app => try self.core_app.updateConfig(self, &self.config),
.surface => |core_surface| try core_surface.updateConfig(
&self.config,
),
}
return;
}
// Load our configuration
var config = try Config.load(self.core_app.alloc);
errdefer config.deinit();
// Call into our app to update
switch (target) {
.app => try self.core_app.updateConfig(self, &config),
.surface => |core_surface| try core_surface.updateConfig(&config),
}
// Update the existing config, be sure to clean up the old one.
self.config.deinit();
self.config = config;
}
/// Call this anytime the configuration changes.
@ -928,6 +968,13 @@ fn loadRuntimeCss(
\\ --headerbar-bg-color: rgb({d},{d},{d});
\\ --headerbar-backdrop-color: oklab(from var(--headerbar-bg-color) calc(l * 0.9) a b / alpha);
\\}}
\\windowhandle {{
\\ background-color: var(--headerbar-bg-color);
\\ color: var(--headerbar-fg-color);
\\}}
\\windowhandle:backdrop {{
\\ background-color: var(--headerbar-backdrop-color);
\\}}
, .{
headerbar_foreground.r,
headerbar_foreground.g,
@ -1381,9 +1428,9 @@ fn gtkActionReloadConfig(
ud: ?*anyopaque,
) callconv(.C) void {
const self: *App = @ptrCast(@alignCast(ud orelse return));
_ = self.core_app.mailbox.push(.{
.reload_config = {},
}, .{ .forever = {} });
self.reloadConfig(.app, .{}) catch |err| {
log.err("error reloading configuration: {}", .{err});
};
}
fn gtkActionQuit(

View File

@ -203,7 +203,7 @@ const ButtonsView = struct {
fn gtkReloadClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
const self: *ConfigErrors = @ptrCast(@alignCast(ud));
_ = self.app.reloadConfig() catch |err| {
self.app.reloadConfig(.app, .{}) catch |err| {
log.warn("error reloading config error={}", .{err});
return;
};

View File

@ -94,13 +94,14 @@ fn gtkUpdate(ud: ?*anyopaque) callconv(.C) c.gboolean {
return c.FALSE;
};
const grid_size = surface.core_surface.size.grid();
var buf: [32]u8 = undefined;
const text = std.fmt.bufPrintZ(
&buf,
"{d}c {d}r",
.{
surface.core_surface.grid_size.columns,
surface.core_surface.grid_size.rows,
grid_size.columns,
grid_size.rows,
},
) catch |err| {
log.err("unable to format text: {}", .{err});

View File

@ -77,6 +77,7 @@ pub fn init(
});
errdefer surface.destroy(alloc);
sibling.dimSurface();
sibling.setSplitZoom(false);
// Create the actual GTKPaned, attach the proper children.
const orientation: c_uint = switch (direction) {
@ -258,7 +259,7 @@ pub fn grabFocus(self: *Split) void {
/// Update the paned children to represent the current state.
/// This should be called anytime the top/left or bottom/right
/// element is changed.
fn updateChildren(self: *const Split) void {
pub fn updateChildren(self: *const Split) void {
// We have to set both to null. If we overwrite the pane with
// the same value, then GTK bugs out (the GL area unrealizes
// and never rerealizes).
@ -372,7 +373,15 @@ fn directionNext(self: *const Split, from: Side) ?struct {
}
}
fn removeChildren(self: *const Split) void {
c.gtk_paned_set_start_child(@ptrCast(self.paned), null);
c.gtk_paned_set_end_child(@ptrCast(self.paned), null);
pub fn detachTopLeft(self: *const Split) void {
c.gtk_paned_set_start_child(self.paned, null);
}
pub fn detachBottomRight(self: *const Split) void {
c.gtk_paned_set_end_child(self.paned, null);
}
fn removeChildren(self: *const Split) void {
self.detachTopLeft();
self.detachBottomRight();
}

View File

@ -308,8 +308,8 @@ pub const URLWidget = struct {
/// surface has been initialized.
realized: bool = false,
/// True if this surface had a parent to start with.
parent_surface: bool = false,
/// The config to use to initialize a surface.
init_config: InitConfig,
/// The GUI container that this surface has been attached to. This
/// dictates some behaviors such as new splits, etc.
@ -330,6 +330,9 @@ url_widget: ?URLWidget = null,
/// The overlay that shows resizing information.
resize_overlay: ResizeOverlay = .{},
/// Whether or not the current surface is zoomed in (see `toggle_split_zoom`).
zoomed_in: bool = false,
/// If non-null this is the widget on the overlay which dims the surface when it is unfocused
unfocused_widget: ?*c.GtkWidget = null,
@ -367,6 +370,36 @@ im_len: u7 = 0,
/// details on what this is.
cgroup_path: ?[]const u8 = null,
/// Configuration used for initializing the surface. We have to copy some
/// data since initialization is delayed with GTK (on realize).
pub const InitConfig = struct {
parent: bool = false,
pwd: ?[]const u8 = null,
pub fn init(
alloc: Allocator,
app: *App,
opts: Options,
) Allocator.Error!InitConfig {
const parent = opts.parent orelse return .{};
const pwd: ?[]const u8 = if (app.config.@"window-inherit-working-directory")
try parent.pwd(alloc)
else
null;
errdefer if (pwd) |p| alloc.free(p);
return .{
.parent = true,
.pwd = pwd,
};
}
pub fn deinit(self: *InitConfig, alloc: Allocator) void {
if (self.pwd) |pwd| alloc.free(pwd);
}
};
pub fn create(alloc: Allocator, app: *App, opts: Options) !*Surface {
var surface = try alloc.create(Surface);
errdefer alloc.destroy(surface);
@ -491,6 +524,10 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
};
errdefer if (cgroup_path) |path| app.core_app.alloc.free(path);
// Build our initialization config
const init_config = try InitConfig.init(app.core_app.alloc, app, opts);
errdefer init_config.deinit(app.core_app.alloc);
// Build our result
self.* = .{
.app = app,
@ -501,7 +538,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
.title_text = null,
.core_surface = undefined,
.font_size = font_size,
.parent_surface = opts.parent != null,
.init_config = init_config,
.size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 },
.im_context = im_context,
@ -552,7 +589,11 @@ fn realize(self: *Surface) !void {
// Get our new surface config
var config = try apprt.surface.newConfig(self.app.core_app, &self.app.config);
defer config.deinit();
if (!self.parent_surface) {
if (self.init_config.pwd) |pwd| {
// If we have a working directory we want, then we force that.
config.@"working-directory" = pwd;
} else if (!self.init_config.parent) {
// A hack, see the "parent_surface" field for more information.
config.@"working-directory" = self.app.config.@"working-directory";
}
@ -580,6 +621,7 @@ fn realize(self: *Surface) !void {
}
pub fn deinit(self: *Surface) void {
self.init_config.deinit(self.app.core_app.alloc);
if (self.title_text) |title| self.app.core_app.alloc.free(title);
// We don't allocate anything if we aren't realized.
@ -643,6 +685,8 @@ pub fn redraw(self: *Surface) void {
/// Close this surface.
pub fn close(self: *Surface, processActive: bool) void {
self.setSplitZoom(false);
// If we're not part of a window hierarchy, we never confirm
// so we can just directly remove ourselves and exit.
const window = self.container.window() orelse {
@ -791,7 +835,16 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void
}
pub fn grabFocus(self: *Surface) void {
if (self.container.tab()) |tab| tab.focus_child = self;
if (self.container.tab()) |tab| {
// If any other surface was focused and zoomed in, set it to non zoomed in
// so that self can grab focus.
if (tab.focus_child) |focus_child| {
if (focus_child.zoomed_in and focus_child != self) {
focus_child.setSplitZoom(false);
}
}
tab.focus_child = self;
}
const widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
_ = c.gtk_widget_grab_focus(widget);
@ -801,7 +854,7 @@ pub fn grabFocus(self: *Surface) void {
fn updateTitleLabels(self: *Surface) void {
// If we have no title, then we have nothing to update.
const title = self.title_text orelse return;
const title = self.getTitle() orelse return;
// If we have a tab and are the focused child, then we have to update the tab
if (self.container.tab()) |tab| {
@ -822,9 +875,19 @@ fn updateTitleLabels(self: *Surface) void {
}
}
const zoom_title_prefix = "🔍 ";
pub fn setTitle(self: *Surface, slice: [:0]const u8) !void {
const alloc = self.app.core_app.alloc;
const copy = try alloc.dupeZ(u8, slice);
// Always allocate with the "🔍 " at the beginning and slice accordingly
// is the surface is zoomed in or not.
const copy: [:0]const u8 = copy: {
const new_title = try alloc.allocSentinel(u8, zoom_title_prefix.len + slice.len, 0);
@memcpy(new_title[0..zoom_title_prefix.len], zoom_title_prefix);
@memcpy(new_title[zoom_title_prefix.len..], slice);
break :copy new_title;
};
errdefer alloc.free(copy);
if (self.title_text) |old| alloc.free(old);
@ -834,7 +897,21 @@ pub fn setTitle(self: *Surface, slice: [:0]const u8) !void {
}
pub fn getTitle(self: *Surface) ?[:0]const u8 {
return self.title_text;
if (self.title_text) |title_text| {
return if (self.zoomed_in)
title_text
else
title_text[zoom_title_prefix.len..];
}
return null;
}
pub fn setPwd(self: *Surface, pwd: [:0]const u8) !void {
// If we have a tab and are the focused child, then we have to update the tab
if (self.container.tab()) |tab| {
tab.setTooltipText(pwd);
}
}
pub fn setMouseShape(
@ -1875,3 +1952,41 @@ pub fn present(self: *Surface) void {
self.grabFocus();
}
fn detachFromSplit(self: *Surface) void {
const split = self.container.split() orelse return;
switch (self.container.splitSide() orelse unreachable) {
.top_left => split.detachTopLeft(),
.bottom_right => split.detachBottomRight(),
}
}
fn attachToSplit(self: *Surface) void {
const split = self.container.split() orelse return;
split.updateChildren();
}
pub fn setSplitZoom(self: *Surface, new_split_zoom: bool) void {
if (new_split_zoom == self.zoomed_in) return;
const tab = self.container.tab() orelse return;
const tab_widget = tab.elem.widget();
const surface_widget = self.primaryWidget();
if (new_split_zoom) {
self.detachFromSplit();
c.gtk_box_remove(tab.box, tab_widget);
c.gtk_box_append(tab.box, surface_widget);
} else {
c.gtk_box_remove(tab.box, surface_widget);
self.attachToSplit();
c.gtk_box_append(tab.box, tab_widget);
}
self.zoomed_in = new_split_zoom;
self.grabFocus();
}
pub fn toggleSplitZoom(self: *Surface) void {
self.setSplitZoom(!self.zoomed_in);
}

View File

@ -112,6 +112,10 @@ pub fn setLabelText(self: *Tab, title: [:0]const u8) void {
self.window.notebook.setTabLabel(self, title);
}
pub fn setTooltipText(self: *Tab, tooltip: [:0]const u8) void {
self.window.notebook.setTabTooltip(self, tooltip);
}
/// Remove this tab from the window.
pub fn remove(self: *Tab) void {
self.window.closeTab(self);

View File

@ -92,13 +92,9 @@ pub fn init(self: *Window, app: *App) !void {
break :window window;
}
};
errdefer c.gtk_window_destroy(@ptrCast(window));
const gtk_window: *c.GtkWindow = @ptrCast(window);
errdefer if (self.isAdwWindow()) {
c.adw_application_window_destroy(window);
} else {
c.gtk_application_window_destroy(gtk_window);
};
self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty");
c.gtk_window_set_default_size(gtk_window, 1000, 600);

View File

@ -12,7 +12,7 @@ const Config = @import("../../config.zig").Config;
/// This must be `inline` so that the comptime check noops conditional
/// paths that are not enabled.
pub inline fn enabled(config: *const Config) bool {
return build_options.libadwaita and
return build_options.adwaita and
config.@"gtk-adwaita";
}
@ -30,7 +30,7 @@ pub fn versionAtLeast(
comptime minor: u16,
comptime micro: u16,
) bool {
if (comptime !build_options.libadwaita) return false;
if (comptime !build_options.adwaita) return false;
// If our header has lower versions than the given version,
// we can return false immediately. This prevents us from

View File

@ -1,7 +1,7 @@
/// Imported C API directly from header files
pub const c = @cImport({
@cInclude("gtk/gtk.h");
if (@import("build_options").libadwaita) {
if (@import("build_options").adwaita) {
@cInclude("libadwaita-1/adwaita.h");
}

View File

@ -223,6 +223,17 @@ pub const Notebook = union(enum) {
}
}
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),
}
}
fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int {
const numPages = self.nPages();
return switch (tab.window.app.config.@"window-new-tab-position") {

View File

@ -58,8 +58,10 @@ pub const Message = union(enum) {
/// Health status change for the renderer.
renderer_health: renderer.Health,
/// Report the color scheme
report_color_scheme: void,
/// Report the color scheme. The bool parameter is whether to force or not.
/// If force is true, the color scheme should be reported even if mode
/// 2031 is not set.
report_color_scheme: bool,
/// Tell the surface to present itself to the user. This may require raising
/// a window and switching tabs.
@ -70,6 +72,15 @@ pub const Message = union(enum) {
/// unless the surface exits.
password_input: bool,
/// A terminal color was changed using OSC sequences.
color_change: struct {
kind: terminal.osc.Command.ColorKind,
color: terminal.color.RGB,
},
/// The terminal has reported a change in the working directory.
pwd_change: WriteReq,
pub const ReportTitleStyle = enum {
csi_21_t,

View File

@ -12,7 +12,7 @@ pub const fish_completions = comptimeGenerateFishCompletions();
fn comptimeGenerateFishCompletions() []const u8 {
comptime {
@setEvalBranchQuota(17000);
@setEvalBranchQuota(18000);
var counter = std.io.countingWriter(std.io.null_writer);
try writeFishCompletions(&counter.writer());
@ -53,7 +53,7 @@ fn writeFishCompletions(writer: anytype) !void {
if (std.mem.startsWith(u8, field.name, "font-family"))
try writer.writeAll(" -f -a \"(ghostty +list-fonts | grep '^[A-Z]')\"")
else if (std.mem.eql(u8, "theme", field.name))
try writer.writeAll(" -f -a \"(ghostty +list-themes)\"")
try writer.writeAll(" -f -a \"(ghostty +list-themes | sed -E 's/^(.*) \\(.*\\$/\\1/')\"")
else if (std.mem.eql(u8, "working-directory", field.name))
try writer.writeAll(" -f -k -a \"(__fish_complete_directories)\"")
else {

View File

@ -26,7 +26,7 @@ pub fn genConfig(writer: anytype, cli: bool) !void {
\\
);
@setEvalBranchQuota(2000);
@setEvalBranchQuota(3000);
inline for (@typeInfo(Config).Struct.fields) |field| {
if (field.name[0] == '_') continue;

View File

@ -21,7 +21,7 @@ const WasmTarget = @import("os/wasm/target.zig").Target;
pub const BuildConfig = struct {
version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 },
flatpak: bool = false,
libadwaita: bool = false,
adwaita: bool = false,
app_runtime: apprt.Runtime = .none,
renderer: rendererpkg.Impl = .opengl,
font_backend: font.Backend = .freetype,
@ -40,7 +40,7 @@ pub const BuildConfig = struct {
// We need to break these down individual because addOption doesn't
// support all types.
step.addOption(bool, "flatpak", self.flatpak);
step.addOption(bool, "libadwaita", self.libadwaita);
step.addOption(bool, "adwaita", self.adwaita);
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);
@ -67,7 +67,7 @@ pub const BuildConfig = struct {
return .{
.version = options.app_version,
.flatpak = options.flatpak,
.libadwaita = options.libadwaita,
.adwaita = options.adwaita,
.app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?,
.font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?,
.renderer = std.meta.stringToEnum(rendererpkg.Impl, @tagName(options.renderer)).?,

View File

@ -71,6 +71,13 @@ pub const Action = enum {
var pending_help: bool = false;
var pending: ?Action = null;
while (iter.next()) |arg| {
// If we see a "-e" and we haven't seen a command yet, then
// we are done looking for commands. This special case enables
// `ghostty -e ghostty +command`. If we've seen a command we
// still want to keep looking because
// `ghostty +command -e +command` is invalid.
if (std.mem.eql(u8, arg, "-e") and pending == null) return null;
// Special case, --version always outputs the version no
// matter what, no matter what other args exist.
if (std.mem.eql(u8, arg, "--version")) return .version;
@ -240,3 +247,30 @@ test "parse action plus" {
try testing.expect(action.? == .version);
}
}
test "parse action plus ignores -e" {
const testing = std.testing;
const alloc = testing.allocator;
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--a=42 -e +version",
);
defer iter.deinit();
const action = try Action.detectIter(&iter);
try testing.expect(action == null);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+list-fonts --a=42 -e +version",
);
defer iter.deinit();
try testing.expectError(
Action.Error.MultipleActions,
Action.detectIter(&iter),
);
}
}

View File

@ -13,7 +13,7 @@ const DiagnosticList = diags.DiagnosticList;
// `--long value`? Not currently allowed.
// For trimming
const whitespace = " \t";
pub const whitespace = " \t";
/// The base errors for arg parsing. Additional errors can be returned due
/// to type-specific parsing but these are always possible.
@ -132,8 +132,8 @@ pub fn parse(
// track more error messages.
error.OutOfMemory => return err,
error.InvalidField => "unknown field",
error.ValueRequired => "value required",
error.InvalidValue => "invalid value",
error.ValueRequired => formatValueRequired(T, arena_alloc, key) catch "value required",
error.InvalidValue => formatInvalidValue(T, arena_alloc, key, value) catch "invalid value",
else => try std.fmt.allocPrintZ(
arena_alloc,
"unknown error {}",
@ -151,6 +151,54 @@ pub fn parse(
}
}
fn formatValueRequired(
comptime T: type,
arena_alloc: std.mem.Allocator,
key: []const u8,
) std.mem.Allocator.Error![:0]const u8 {
var buf = std.ArrayList(u8).init(arena_alloc);
errdefer buf.deinit();
const writer = buf.writer();
try writer.print("value required", .{});
try formatValues(T, key, writer);
try writer.writeByte(0);
return buf.items[0 .. buf.items.len - 1 :0];
}
fn formatInvalidValue(
comptime T: type,
arena_alloc: std.mem.Allocator,
key: []const u8,
value: ?[]const u8,
) std.mem.Allocator.Error![:0]const u8 {
var buf = std.ArrayList(u8).init(arena_alloc);
errdefer buf.deinit();
const writer = buf.writer();
try writer.print("invalid value \"{?s}\"", .{value});
try formatValues(T, key, writer);
try writer.writeByte(0);
return buf.items[0 .. buf.items.len - 1 :0];
}
fn formatValues(comptime T: type, key: []const u8, writer: anytype) std.mem.Allocator.Error!void {
const typeinfo = @typeInfo(T);
inline for (typeinfo.Struct.fields) |f| {
if (std.mem.eql(u8, key, f.name)) {
switch (@typeInfo(f.type)) {
.Enum => |e| {
try writer.print(", valid values are: ", .{});
inline for (e.fields, 0..) |field, i| {
if (i != 0) try writer.print(", ", .{});
try writer.print("{s}", .{field.name});
}
},
else => {},
}
break;
}
}
}
/// Returns true if this type can track diagnostics.
fn canTrackDiags(comptime T: type) bool {
return @hasField(T, "_diagnostics");
@ -161,7 +209,7 @@ fn canTrackDiags(comptime T: type) bool {
/// This may result in allocations. The allocations can only be freed by freeing
/// all the memory associated with alloc. It is expected that alloc points to
/// an arena.
fn parseIntoField(
pub fn parseIntoField(
comptime T: type,
alloc: Allocator,
dst: *T,
@ -202,10 +250,46 @@ fn parseIntoField(
1 => @field(dst, field.name) = try Field.parseCLI(value),
// 2 arg = (self, input) => void
2 => try @field(dst, field.name).parseCLI(value),
2 => switch (@typeInfo(field.type)) {
.Struct,
.Union,
.Enum,
=> try @field(dst, field.name).parseCLI(value),
// If the field is optional and set, then we use
// the pointer value directly into it. If its not
// set we need to create a new instance.
.Optional => if (@field(dst, field.name)) |*v| {
try v.parseCLI(value);
} else {
// Note: you cannot do @field(dst, name) = undefined
// because this causes the value to be "null"
// in ReleaseFast modes.
var tmp: Field = undefined;
try tmp.parseCLI(value);
@field(dst, field.name) = tmp;
},
else => @compileError("unexpected field type"),
},
// 3 arg = (self, alloc, input) => void
3 => try @field(dst, field.name).parseCLI(alloc, value),
3 => switch (@typeInfo(field.type)) {
.Struct,
.Union,
.Enum,
=> try @field(dst, field.name).parseCLI(alloc, value),
.Optional => if (@field(dst, field.name)) |*v| {
try v.parseCLI(alloc, value);
} else {
var tmp: Field = undefined;
try tmp.parseCLI(alloc, value);
@field(dst, field.name) = tmp;
},
else => @compileError("unexpected field type"),
},
else => @compileError("parseCLI invalid argument count"),
}
@ -262,8 +346,9 @@ fn parseIntoField(
value orelse return error.ValueRequired,
) orelse return error.InvalidValue,
.Struct => try parsePackedStruct(
.Struct => try parseStruct(
Field,
alloc,
value orelse return error.ValueRequired,
),
@ -330,9 +415,79 @@ fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
return error.InvalidValue;
}
fn parseStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
return switch (@typeInfo(T).Struct.layout) {
.auto => parseAutoStruct(T, alloc, v),
.@"packed" => parsePackedStruct(T, v),
else => @compileError("unsupported struct layout"),
};
}
pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
const info = @typeInfo(T).Struct;
comptime assert(info.layout == .auto);
// We start our result as undefined so we don't get an error for required
// fields. We track required fields below and we validate that we set them
// all at the bottom of this function (in addition to setting defaults for
// optionals).
var result: T = undefined;
// Keep track of which fields were set so we can error if a required
// field was not set.
const FieldSet = std.StaticBitSet(info.fields.len);
var fields_set: FieldSet = FieldSet.initEmpty();
// We split each value by ","
var iter = std.mem.splitSequence(u8, v, ",");
loop: while (iter.next()) |entry| {
// Find the key/value, trimming whitespace. The value may be quoted
// which we strip the quotes from.
const idx = mem.indexOf(u8, entry, ":") orelse return error.InvalidValue;
const key = std.mem.trim(u8, entry[0..idx], whitespace);
const value = value: {
var value = std.mem.trim(u8, entry[idx + 1 ..], whitespace);
// Detect a quoted string.
if (value.len >= 2 and
value[0] == '"' and
value[value.len - 1] == '"')
{
// Trim quotes since our CLI args processor expects
// quotes to already be gone.
value = value[1 .. value.len - 1];
}
break :value value;
};
inline for (info.fields, 0..) |field, i| {
if (std.mem.eql(u8, field.name, key)) {
try parseIntoField(T, alloc, &result, key, value);
fields_set.set(i);
continue :loop;
}
}
// No field matched
return error.InvalidValue;
}
// Ensure all required fields are set
inline for (info.fields, 0..) |field, i| {
if (!fields_set.isSet(i)) {
const default_ptr = field.default_value orelse return error.InvalidValue;
const typed_ptr: *const field.type = @alignCast(@ptrCast(default_ptr));
@field(result, field.name) = typed_ptr.*;
}
}
return result;
}
fn parsePackedStruct(comptime T: type, v: []const u8) !T {
const info = @typeInfo(T).Struct;
assert(info.layout == .@"packed");
comptime assert(info.layout == .@"packed");
var result: T = .{};
@ -799,6 +954,62 @@ test "parseIntoField: struct with parse func" {
try testing.expectEqual(@as([]const u8, "HELLO!"), data.a.v);
}
test "parseIntoField: optional struct with parse func" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var data: struct {
a: ?struct {
const Self = @This();
v: []const u8,
pub fn parseCLI(self: *Self, _: Allocator, value: ?[]const u8) !void {
_ = value;
self.* = .{ .v = "HELLO!" };
}
} = null,
} = .{};
try parseIntoField(@TypeOf(data), alloc, &data, "a", "42");
try testing.expectEqual(@as([]const u8, "HELLO!"), data.a.?.v);
}
test "parseIntoField: struct with basic fields" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var data: struct {
value: struct {
a: []const u8,
b: u32,
c: u8 = 12,
} = undefined,
} = .{};
// Set required fields
try parseIntoField(@TypeOf(data), alloc, &data, "value", "a:hello,b:42");
try testing.expectEqualStrings("hello", data.value.a);
try testing.expectEqual(42, data.value.b);
try testing.expectEqual(12, data.value.c);
// Set all fields
try parseIntoField(@TypeOf(data), alloc, &data, "value", "a:world,b:84,c:24");
try testing.expectEqualStrings("world", data.value.a);
try testing.expectEqual(84, data.value.b);
try testing.expectEqual(24, data.value.c);
// Missing require dfield
try testing.expectError(
error.InvalidValue,
parseIntoField(@TypeOf(data), alloc, &data, "value", "a:hello"),
);
}
test "parseIntoField: tagged union" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);

View File

@ -111,7 +111,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
// align things nicely
var iter = keybinds.set.bindings.iterator();
var bindings = std.ArrayList(Binding).init(alloc);
var widest_key: usize = 0;
var widest_key: u16 = 0;
var buf: [64]u8 = undefined;
while (iter.next()) |bind| {
const action = switch (bind.value_ptr.*) {
@ -134,7 +134,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
const alt_style: vaxis.Style = .{ .fg = .{ .index = 3 } };
const shift_style: vaxis.Style = .{ .fg = .{ .index = 4 } };
var longest_col: usize = 0;
var longest_col: u16 = 0;
// Print the list
for (bindings.items) |bind| {
@ -143,20 +143,20 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false };
const trigger = bind.trigger;
if (trigger.mods.super) {
result = try win.printSegment(.{ .text = "super", .style = super_style }, .{ .col_offset = result.col });
result = try win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = "super", .style = super_style }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
}
if (trigger.mods.ctrl) {
result = try win.printSegment(.{ .text = "ctrl ", .style = ctrl_style }, .{ .col_offset = result.col });
result = try win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = "ctrl ", .style = ctrl_style }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
}
if (trigger.mods.alt) {
result = try win.printSegment(.{ .text = "alt ", .style = alt_style }, .{ .col_offset = result.col });
result = try win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = "alt ", .style = alt_style }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
}
if (trigger.mods.shift) {
result = try win.printSegment(.{ .text = "shift", .style = shift_style }, .{ .col_offset = result.col });
result = try win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = "shift", .style = shift_style }, .{ .col_offset = result.col });
result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
}
const key = switch (trigger.key) {
@ -166,20 +166,20 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
};
// We don't track the key print because we index the action off the *widest* key so we get
// nice alignment no matter what was printed for mods
_ = try win.printSegment(.{ .text = key }, .{ .col_offset = result.col });
_ = win.printSegment(.{ .text = key }, .{ .col_offset = result.col });
if (longest_col < result.col) longest_col = result.col;
const action = try std.fmt.allocPrint(alloc, "{}", .{bind.action});
// If our action has an argument, we print the argument in a different color
if (std.mem.indexOfScalar(u8, action, ':')) |idx| {
_ = try win.print(&.{
_ = win.print(&.{
.{ .text = action[0..idx] },
.{ .text = action[idx .. idx + 1], .style = .{ .dim = true } },
.{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } },
}, .{ .col_offset = longest_col + widest_key + 2 });
} else {
_ = try win.printSegment(.{ .text = action }, .{ .col_offset = longest_col + widest_key + 2 });
_ = win.printSegment(.{ .text = action }, .{ .col_offset = longest_col + widest_key + 2 });
}
try vx.prettyPrint(writer);
}

File diff suppressed because it is too large Load Diff

View File

@ -42,7 +42,7 @@ pub fn run(alloc: Allocator) !u8 {
gtk.gtk_get_minor_version(),
gtk.gtk_get_micro_version(),
});
if (comptime build_options.libadwaita) {
if (comptime build_options.adwaita) {
try stdout.print(" - libadwaita : enabled\n", .{});
try stdout.print(" build : {s}\n", .{
gtk.ADW_VERSION_S,

View File

@ -2,10 +2,12 @@ const builtin = @import("builtin");
const formatter = @import("config/formatter.zig");
pub const Config = @import("config/Config.zig");
pub const conditional = @import("config/conditional.zig");
pub const string = @import("config/string.zig");
pub const edit = @import("config/edit.zig");
pub const url = @import("config/url.zig");
pub const ConditionalState = conditional.State;
pub const FileFormatter = formatter.FileFormatter;
pub const entryFormatter = formatter.entryFormatter;
pub const formatEntry = formatter.formatEntry;
@ -16,6 +18,7 @@ pub const CopyOnSelect = Config.CopyOnSelect;
pub const CustomShaderAnimation = Config.CustomShaderAnimation;
pub const FontSyntheticStyle = Config.FontSyntheticStyle;
pub const FontStyle = Config.FontStyle;
pub const FreetypeLoadFlags = Config.FreetypeLoadFlags;
pub const Keybinds = Config.Keybinds;
pub const MouseShiftCapture = Config.MouseShiftCapture;
pub const NonNativeFullscreen = Config.NonNativeFullscreen;

View File

@ -19,6 +19,7 @@ export fn ghostty_config_new() ?*Config {
result.* = Config.default(global.alloc) catch |err| {
log.err("error creating config err={}", .{err});
global.alloc.destroy(result);
return null;
};
@ -32,6 +33,22 @@ export fn ghostty_config_free(ptr: ?*Config) void {
}
}
/// Deep clone the configuration.
export fn ghostty_config_clone(self: *Config) ?*Config {
const result = global.alloc.create(Config) catch |err| {
log.err("error allocating config err={}", .{err});
return null;
};
result.* = self.clone(global.alloc) catch |err| {
log.err("error cloning config err={}", .{err});
global.alloc.destroy(result);
return null;
};
return result;
}
/// Load the configuration from the CLI args.
export fn ghostty_config_load_cli_args(self: *Config) void {
self.loadCliArgs(global.alloc) catch |err| {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,95 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
/// Conditionals in Ghostty configuration are based on a static, typed
/// state of the world instead of a dynamic key-value set. This simplifies
/// the implementation, allows for better type checking, and enables a
/// typed C API.
pub const State = struct {
/// The theme of the underlying OS desktop environment.
theme: Theme = .light,
/// The target OS of the current build.
os: std.Target.Os.Tag = builtin.target.os.tag,
pub const Theme = enum { light, dark };
/// Tests the conditional against the state and returns true if it matches.
pub fn match(self: State, cond: Conditional) bool {
switch (cond.key) {
inline else => |tag| {
// The raw value of the state field.
const raw = @field(self, @tagName(tag));
// Since all values are enums currently then we can just
// do this. If we introduce non-enum state values then this
// will be a compile error and we should fix here.
const value: []const u8 = @tagName(raw);
return switch (cond.op) {
.eq => std.mem.eql(u8, value, cond.value),
.ne => !std.mem.eql(u8, value, cond.value),
};
},
}
}
};
/// An enum of the available conditional configuration keys.
pub const Key = key: {
const stateInfo = @typeInfo(State).Struct;
var fields: [stateInfo.fields.len]std.builtin.Type.EnumField = undefined;
for (stateInfo.fields, 0..) |field, i| fields[i] = .{
.name = field.name,
.value = i,
};
break :key @Type(.{ .Enum = .{
.tag_type = std.math.IntFittingRange(0, fields.len - 1),
.fields = &fields,
.decls = &.{},
.is_exhaustive = true,
} });
};
/// A single conditional that can be true or false.
pub const Conditional = struct {
key: Key,
op: Op,
value: []const u8,
pub const Op = enum { eq, ne };
pub fn clone(
self: Conditional,
alloc: Allocator,
) Allocator.Error!Conditional {
return .{
.key = self.key,
.op = self.op,
.value = try alloc.dupe(u8, self.value),
};
}
};
test "conditional enum match" {
const testing = std.testing;
const state: State = .{ .theme = .dark };
try testing.expect(state.match(.{
.key = .theme,
.op = .eq,
.value = "dark",
}));
try testing.expect(!state.match(.{
.key = .theme,
.op = .ne,
.value = "dark",
}));
try testing.expect(state.match(.{
.key = .theme,
.op = .ne,
.value = "light",
}));
}

View File

@ -0,0 +1,51 @@
const std = @import("std");
const Config = @import("Config.zig");
const Template = struct {
const header =
\\%YAML 1.2
\\---
\\# See http://www.sublimetext.com/docs/syntax.html
\\name: Ghostty Config
\\file_extensions:
\\ - ghostty
\\scope: source.ghostty
\\
\\contexts:
\\ main:
\\ # Comments
\\ - match: '^\s*#.*$'
\\ scope: comment.line.number-sign.ghostty
\\
\\ # Keywords
\\ - match: '\b(
;
const footer =
\\)\b'
\\ scope: keyword.other.ghostty
\\
;
};
/// Check if a field is internal (starts with underscore)
fn isInternal(name: []const u8) bool {
return name.len > 0 and name[0] == '_';
}
/// Generate keywords from Config fields
fn generateKeywords() []const u8 {
@setEvalBranchQuota(5000);
var keywords: []const u8 = "";
const config_fields = @typeInfo(Config).Struct.fields;
for (config_fields) |field| {
if (isInternal(field.name)) continue;
if (keywords.len > 0) keywords = keywords ++ "|";
keywords = keywords ++ field.name;
}
return keywords;
}
/// Complete Sublime syntax file content
pub const syntax = Template.header ++ generateKeywords() ++ Template.footer;

1
src/config/testdata/theme_dark vendored Normal file
View File

@ -0,0 +1 @@
background = #EEEEEE

1
src/config/testdata/theme_light vendored Normal file
View File

@ -0,0 +1 @@
background = #FFFFFF

2
src/config/testdata/theme_simple vendored Normal file
View File

@ -0,0 +1,2 @@
# A simple theme
background = #123ABC

View File

@ -112,7 +112,6 @@ pub fn open(
path: []const u8,
file: std.fs.File,
} {
// Absolute themes are loaded a different path.
if (std.fs.path.isAbsolute(theme)) {
const file: std.fs.File = try openAbsolute(

View File

@ -24,7 +24,7 @@ const oni = @import("oniguruma");
/// handling them well requires a non-regex approach.
pub const regex =
"(?:" ++ url_schemes ++
\\)(?:[\w\-.~:/?#\[\]@!$&*+,;=%]+(?:\(\w*\))?)+(?<!\.)
\\)(?:[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])
;
const url_schemes =
\\https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:
@ -70,6 +70,10 @@ test "url regex" {
.input = "Link period https://example.com. More text.",
.expect = "https://example.com",
},
.{
.input = "Link trailing colon https://example.com, more text.",
.expect = "https://example.com",
},
.{
.input = "Link in double quotes \"https://example.com\" and more",
.expect = "https://example.com",
@ -112,6 +116,11 @@ test "url regex" {
.input = "square brackets https://example.com/[foo] and more",
.expect = "https://example.com/[foo]",
},
// square bracket following url
.{
.input = "[13]:TooManyStatements: TempFile#assign_temp_file_to_entity has approx 7 statements [https://example.com/docs/Too-Many-Statements.md]",
.expect = "https://example.com/docs/Too-Many-Statements.md",
},
// remaining URL schemes tests
.{
.input = "match ftp://example.com ftp links",

View File

@ -70,7 +70,7 @@ fn writeSyntax(writer: anytype) !void {
try writer.writeAll(
\\
\\
\\syn match ghosttyConfigComment /#.*/ contains=@Spell
\\syn match ghosttyConfigComment /^\s*#.*/ contains=@Spell
\\
\\hi def link ghosttyConfigComment Comment
\\hi def link ghosttyConfigKeyword Keyword

View File

@ -177,29 +177,30 @@ fn beforeSend(
const obj = sentry.Value.initObject();
errdefer obj.decref();
const surface = thr_state.surface;
const grid_size = surface.size.grid();
obj.set(
"screen-width",
sentry.Value.initInt32(std.math.cast(i32, surface.screen_size.width) orelse -1),
sentry.Value.initInt32(std.math.cast(i32, surface.size.screen.width) orelse -1),
);
obj.set(
"screen-height",
sentry.Value.initInt32(std.math.cast(i32, surface.screen_size.height) orelse -1),
sentry.Value.initInt32(std.math.cast(i32, surface.size.screen.height) orelse -1),
);
obj.set(
"grid-columns",
sentry.Value.initInt32(std.math.cast(i32, surface.grid_size.columns) orelse -1),
sentry.Value.initInt32(std.math.cast(i32, grid_size.columns) orelse -1),
);
obj.set(
"grid-rows",
sentry.Value.initInt32(std.math.cast(i32, surface.grid_size.rows) orelse -1),
sentry.Value.initInt32(std.math.cast(i32, grid_size.rows) orelse -1),
);
obj.set(
"cell-width",
sentry.Value.initInt32(std.math.cast(i32, surface.cell_size.width) orelse -1),
sentry.Value.initInt32(std.math.cast(i32, surface.size.cell.width) orelse -1),
);
obj.set(
"cell-height",
sentry.Value.initInt32(std.math.cast(i32, surface.cell_size.height) orelse -1),
sentry.Value.initInt32(std.math.cast(i32, surface.size.cell.height) orelse -1),
);
contexts.set("Dimensions", obj);

View File

@ -1,4 +1,4 @@
const fastmem = @import("./fastmem.zig");
const fastmem = @import("../fastmem.zig");
const std = @import("std");
const assert = std.debug.assert;

View File

@ -1,7 +1,7 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const fastmem = @import("fastmem.zig");
const fastmem = @import("../fastmem.zig");
/// Returns a circular buffer containing type T.
pub fn CircBuf(comptime T: type, comptime default: T) type {

View File

@ -0,0 +1,181 @@
const std = @import("std");
const testing = std.testing;
/// An intrusive doubly-linked list. The type T must have a "next" and "prev"
/// field pointing to itself.
///
/// This is an adaptation of the DoublyLinkedList from the Zig standard
/// library, which is MIT licensed. I've removed functionality that I don't
/// need.
pub fn DoublyLinkedList(comptime T: type) type {
return struct {
const Self = @This();
/// The type of the node in the list. This makes it easy to get the
/// node type from the list type.
pub const Node = T;
first: ?*Node = null,
last: ?*Node = null,
/// Insert a new node after an existing one.
///
/// Arguments:
/// node: Pointer to a node in the list.
/// new_node: Pointer to the new node to insert.
pub fn insertAfter(list: *Self, node: *Node, new_node: *Node) void {
new_node.prev = node;
if (node.next) |next_node| {
// Intermediate node.
new_node.next = next_node;
next_node.prev = new_node;
} else {
// Last element of the list.
new_node.next = null;
list.last = new_node;
}
node.next = new_node;
}
/// Insert a new node before an existing one.
///
/// Arguments:
/// node: Pointer to a node in the list.
/// new_node: Pointer to the new node to insert.
pub fn insertBefore(list: *Self, node: *Node, new_node: *Node) void {
new_node.next = node;
if (node.prev) |prev_node| {
// Intermediate node.
new_node.prev = prev_node;
prev_node.next = new_node;
} else {
// First element of the list.
new_node.prev = null;
list.first = new_node;
}
node.prev = new_node;
}
/// Insert a new node at the end of the list.
///
/// Arguments:
/// new_node: Pointer to the new node to insert.
pub fn append(list: *Self, new_node: *Node) void {
if (list.last) |last| {
// Insert after last.
list.insertAfter(last, new_node);
} else {
// Empty list.
list.prepend(new_node);
}
}
/// Insert a new node at the beginning of the list.
///
/// Arguments:
/// new_node: Pointer to the new node to insert.
pub fn prepend(list: *Self, new_node: *Node) void {
if (list.first) |first| {
// Insert before first.
list.insertBefore(first, new_node);
} else {
// Empty list.
list.first = new_node;
list.last = new_node;
new_node.prev = null;
new_node.next = null;
}
}
/// Remove a node from the list.
///
/// Arguments:
/// node: Pointer to the node to be removed.
pub fn remove(list: *Self, node: *Node) void {
if (node.prev) |prev_node| {
// Intermediate node.
prev_node.next = node.next;
} else {
// First element of the list.
list.first = node.next;
}
if (node.next) |next_node| {
// Intermediate node.
next_node.prev = node.prev;
} else {
// Last element of the list.
list.last = node.prev;
}
}
/// Remove and return the last node in the list.
///
/// Returns:
/// A pointer to the last node in the list.
pub fn pop(list: *Self) ?*Node {
const last = list.last orelse return null;
list.remove(last);
return last;
}
/// Remove and return the first node in the list.
///
/// Returns:
/// A pointer to the first node in the list.
pub fn popFirst(list: *Self) ?*Node {
const first = list.first orelse return null;
list.remove(first);
return first;
}
};
}
test "basic DoublyLinkedList test" {
const Node = struct {
data: u32,
prev: ?*@This() = null,
next: ?*@This() = null,
};
const L = DoublyLinkedList(Node);
var list: L = .{};
var one: Node = .{ .data = 1 };
var two: Node = .{ .data = 2 };
var three: Node = .{ .data = 3 };
var four: Node = .{ .data = 4 };
var five: Node = .{ .data = 5 };
list.append(&two); // {2}
list.append(&five); // {2, 5}
list.prepend(&one); // {1, 2, 5}
list.insertBefore(&five, &four); // {1, 2, 4, 5}
list.insertAfter(&two, &three); // {1, 2, 3, 4, 5}
// Traverse forwards.
{
var it = list.first;
var index: u32 = 1;
while (it) |node| : (it = node.next) {
try testing.expect(node.data == index);
index += 1;
}
}
// Traverse backwards.
{
var it = list.last;
var index: u32 = 1;
while (it) |node| : (it = node.prev) {
try testing.expect(node.data == (6 - index));
index += 1;
}
}
_ = list.popFirst(); // {2, 3, 4, 5}
_ = list.pop(); // {2, 3, 4}
list.remove(&three); // {2, 4}
try testing.expect(list.first.?.data == 2);
try testing.expect(list.last.?.data == 4);
}

19
src/datastruct/main.zig Normal file
View File

@ -0,0 +1,19 @@
//! The datastruct package contains data structures or anything closely
//! related to data structures.
const blocking_queue = @import("blocking_queue.zig");
const cache_table = @import("cache_table.zig");
const circ_buf = @import("circ_buf.zig");
const intrusive_linked_list = @import("intrusive_linked_list.zig");
const segmented_pool = @import("segmented_pool.zig");
pub const lru = @import("lru.zig");
pub const BlockingQueue = blocking_queue.BlockingQueue;
pub const CacheTable = cache_table.CacheTable;
pub const CircBuf = circ_buf.CircBuf;
pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList;
pub const SegmentedPool = segmented_pool.SegmentedPool;
test {
@import("std").testing.refAllDecls(@This());
}

View File

@ -452,6 +452,12 @@ pub const LoadOptions = struct {
/// for this is owned by the user and is not freed by the collection.
metric_modifiers: Metrics.ModifierSet = .{},
/// Freetype Load Flags to use when loading glyphs. This is a list of
/// bitfield constants that controls operations to perform during glyph
/// loading. Only a subset is exposed for configuration, for the whole set
/// of flags see `pkg.freetype.face.LoadFlags`.
freetype_load_flags: font.face.FreetypeLoadFlags = font.face.freetype_load_flags_default,
pub fn deinit(self: *LoadOptions, alloc: Allocator) void {
_ = self;
_ = alloc;
@ -462,6 +468,7 @@ pub const LoadOptions = struct {
return .{
.size = self.size,
.metric_modifiers = &self.metric_modifiers,
.freetype_load_flags = self.freetype_load_flags,
};
}
};

View File

@ -57,6 +57,11 @@ pub const CoreText = struct {
/// The initialized font
font: *macos.text.Font,
/// Variations to apply to this font. We apply the variations to the
/// search descriptor but sometimes when the font collection is
/// made the variation axes are reset so we have to reapply them.
variations: []const font.face.Variation,
pub fn deinit(self: *CoreText) void {
self.font.release();
self.* = undefined;
@ -194,7 +199,10 @@ fn loadCoreText(
) !Face {
_ = lib;
const ct = self.ct.?;
return try Face.initFontCopy(ct.font, opts);
var face = try Face.initFontCopy(ct.font, opts);
errdefer face.deinit();
try face.setVariations(ct.variations, opts);
return face;
}
fn loadCoreTextFreetype(
@ -236,43 +244,7 @@ fn loadCoreTextFreetype(
//std.log.warn("path={s}", .{path_slice});
var face = try Face.initFile(lib, buf[0..path_slice.len :0], 0, opts);
errdefer face.deinit();
// If our ct font has variations, apply them to the face.
if (ct.font.copyAttribute(.variation)) |variations| vars: {
defer variations.release();
if (variations.getCount() == 0) break :vars;
// This configuration is just used for testing so we don't want to
// have to pass a full allocator through so use the stack. We
// shouldn't have a lot of variations and if we do we should use
// another mechanism.
//
// On macOS the default stack size for a thread is 512KB and the main
// thread gets megabytes so 16KB is a safe stack allocation.
var data: [1024 * 16]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&data);
const alloc = fba.allocator();
var face_vars = std.ArrayList(font.face.Variation).init(alloc);
const kav = try variations.getKeysAndValues(alloc);
for (kav.keys, kav.values) |key, value| {
const num: *const macos.foundation.Number = @ptrCast(key.?);
const val: *const macos.foundation.Number = @ptrCast(value.?);
var num_i32: i32 = undefined;
if (!num.getValue(.sint32, &num_i32)) continue;
var val_f64: f64 = undefined;
if (!val.getValue(.float64, &val_f64)) continue;
try face_vars.append(.{
.id = @bitCast(num_i32),
.value = val_f64,
});
}
try face.setVariations(face_vars.items, opts);
}
try face.setVariations(ct.variations, opts);
return face;
}

View File

@ -168,6 +168,7 @@ fn collection(
.library = self.font_lib,
.size = size,
.metric_modifiers = key.metric_modifiers,
.freetype_load_flags = key.freetype_load_flags,
};
var c = Collection.init();
@ -427,6 +428,7 @@ pub const DerivedConfig = struct {
@"adjust-strikethrough-position": ?Metrics.Modifier,
@"adjust-strikethrough-thickness": ?Metrics.Modifier,
@"adjust-cursor-thickness": ?Metrics.Modifier,
@"freetype-load-flags": font.face.FreetypeLoadFlags,
/// Initialize a DerivedConfig. The config should be either a
/// config.Config or another DerivedConfig to clone from.
@ -461,6 +463,7 @@ pub const DerivedConfig = struct {
.@"adjust-strikethrough-position" = config.@"adjust-strikethrough-position",
.@"adjust-strikethrough-thickness" = config.@"adjust-strikethrough-thickness",
.@"adjust-cursor-thickness" = config.@"adjust-cursor-thickness",
.@"freetype-load-flags" = if (font.face.FreetypeLoadFlags != void) config.@"freetype-load-flags" else {},
// This must be last so the arena contains all our allocations
// from above since Zig does assignment in order.
@ -500,6 +503,10 @@ pub const Key = struct {
/// font grid.
font_size: DesiredSize = .{ .points = 12 },
/// The freetype load flags configuration, only non-void if the
/// freetype backend is enabled.
freetype_load_flags: font.face.FreetypeLoadFlags = font.face.freetype_load_flags_default,
const style_offsets_len = std.enums.directEnumArrayLen(Style, 0);
const StyleOffsets = [style_offsets_len]usize;
@ -618,6 +625,10 @@ pub const Key = struct {
.codepoint_map = codepoint_map,
.metric_modifiers = metric_modifiers,
.font_size = font_size,
.freetype_load_flags = if (font.face.FreetypeLoadFlags != void)
config.@"freetype-load-flags"
else
font.face.freetype_load_flags_default,
};
}
@ -647,6 +658,7 @@ pub const Key = struct {
for (self.descriptors) |d| d.hash(hasher);
self.codepoint_map.hash(hasher);
autoHash(hasher, self.metric_modifiers.count());
autoHash(hasher, self.freetype_load_flags);
if (self.metric_modifiers.count() > 0) {
inline for (@typeInfo(Metrics.Key).Enum.fields) |field| {
const key = @field(Metrics.Key, field.name);

View File

@ -79,7 +79,7 @@ pub const Descriptor = struct {
// This is not correct, but we don't currently depend on the
// hash value being different based on decimal values of variations.
autoHash(hasher, @as(u64, @intFromFloat(variation.value)));
autoHash(hasher, @as(i64, @intFromFloat(variation.value)));
}
}
@ -235,21 +235,7 @@ pub const Descriptor = struct {
);
}
// Build our descriptor from attrs
var desc = try macos.text.FontDescriptor.createWithAttributes(@ptrCast(attrs));
errdefer desc.release();
// Variations are built by copying the descriptor. I don't know a way
// to set it on attrs directly.
for (self.variations) |v| {
const id = try macos.foundation.Number.create(.int, @ptrCast(&v.id));
defer id.release();
const next = try desc.createCopyWithVariation(id, v.value);
desc.release();
desc = next;
}
return desc;
return try macos.text.FontDescriptor.createWithAttributes(@ptrCast(attrs));
}
};
@ -384,6 +370,7 @@ pub const CoreText = struct {
return DiscoverIterator{
.alloc = alloc,
.list = zig_list,
.variations = desc.variations,
.i = 0,
};
}
@ -420,6 +407,7 @@ pub const CoreText = struct {
return DiscoverIterator{
.alloc = alloc,
.list = list,
.variations = desc.variations,
.i = 0,
};
}
@ -443,6 +431,7 @@ pub const CoreText = struct {
return DiscoverIterator{
.alloc = alloc,
.list = list,
.variations = desc.variations,
.i = 0,
};
}
@ -682,30 +671,29 @@ pub const CoreText = struct {
break :style .unmatched;
defer style.release();
// Get our style string
var buf: [128]u8 = undefined;
const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched;
// If we have a specific desired style, attempt to search for that.
if (desc.style) |desired_style| {
var buf: [128]u8 = undefined;
const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched;
// Matching style string gets highest score
if (std.mem.eql(u8, desired_style, style_str)) break :style .match;
// Otherwise the score is based on the length of the style string.
// Shorter styles are scored higher.
break :style @enumFromInt(100 -| style_str.len);
} else if (!desc.bold and !desc.italic) {
// If we do not, and we have no symbolic traits, then we try
// to find "regular" (or no style). If we have symbolic traits
// we do nothing but we can improve scoring by taking that into
// account, too.
if (std.mem.eql(u8, "Regular", style_str)) {
break :style .match;
}
}
// If we do not, and we have no symbolic traits, then we try
// to find "regular" (or no style). If we have symbolic traits
// we do nothing but we can improve scoring by taking that into
// account, too.
if (!desc.bold and !desc.italic) {
var buf: [128]u8 = undefined;
const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched;
if (std.mem.eql(u8, "Regular", style_str)) break :style .match;
}
break :style .unmatched;
// Otherwise the score is based on the length of the style string.
// Shorter styles are scored higher. This is a heuristic that
// if we don't have a desired style then shorter tends to be
// more often the "regular" style.
break :style @enumFromInt(100 -| style_str.len);
};
score_acc.traits = traits: {
@ -721,6 +709,7 @@ pub const CoreText = struct {
pub const DiscoverIterator = struct {
alloc: Allocator,
list: []const *macos.text.FontDescriptor,
variations: []const Variation,
i: usize,
pub fn deinit(self: *DiscoverIterator) void {
@ -756,7 +745,10 @@ pub const CoreText = struct {
defer self.i += 1;
return DeferredFace{
.ct = .{ .font = font },
.ct = .{
.font = font,
.variations = self.variations,
},
};
}
};

View File

@ -14,6 +14,7 @@ pub const emoji = @embedFile("res/NotoColorEmoji.ttf");
pub const emoji_text = @embedFile("res/NotoEmoji-Regular.ttf");
/// Fonts with general properties
pub const arabic = @embedFile("res/KawkabMono-Regular.ttf");
pub const variable = @embedFile("res/Lilex-VF.ttf");
/// Font with nerd fonts embedded.

View File

@ -2,6 +2,7 @@ const std = @import("std");
const builtin = @import("builtin");
const options = @import("main.zig").options;
pub const Metrics = @import("face/Metrics.zig");
const config = @import("../config.zig");
const freetype = @import("face/freetype.zig");
const coretext = @import("face/coretext.zig");
pub const web_canvas = @import("face/web_canvas.zig");
@ -26,10 +27,19 @@ pub const Face = switch (options.backend) {
/// using whatever platform method you can.
pub const default_dpi = if (builtin.os.tag == .macos) 72 else 96;
/// These are the flags to customize how freetype loads fonts. This is
/// only non-void if the freetype backend is enabled.
pub const FreetypeLoadFlags = if (options.backend.hasFreetype())
config.FreetypeLoadFlags
else
void;
pub const freetype_load_flags_default = if (FreetypeLoadFlags != void) .{} else {};
/// Options for initializing a font face.
pub const Options = struct {
size: DesiredSize,
metric_modifiers: ?*const Metrics.ModifierSet = null,
freetype_load_flags: FreetypeLoadFlags = freetype_load_flags_default,
};
/// The desired size for loading a font.

View File

@ -229,6 +229,9 @@ pub const Face = struct {
vs: []const font.face.Variation,
opts: font.face.Options,
) !void {
// If we have no variations, we don't need to do anything.
if (vs.len == 0) return;
// Create a new font descriptor with all the variations set.
var desc = self.font.copyDescriptor();
defer desc.release();

View File

@ -18,10 +18,16 @@ const Library = font.Library;
const convert = @import("freetype_convert.zig");
const fastmem = @import("../../fastmem.zig");
const quirks = @import("../../quirks.zig");
const config = @import("../../config.zig");
const log = std.log.scoped(.font_face);
pub const Face = struct {
comptime {
// If we have the freetype backend, we should have load flags.
assert(font.face.FreetypeLoadFlags != void);
}
/// Our freetype library
lib: freetype.Library,
@ -34,6 +40,9 @@ pub const Face = struct {
/// Metrics for this font face. These are useful for renderers.
metrics: font.face.Metrics,
/// Freetype load flags for this font face.
load_flags: font.face.FreetypeLoadFlags,
/// Set quirks.disableDefaultFontFeatures
quirks_disable_default_font_features: bool = false,
@ -77,6 +86,7 @@ pub const Face = struct {
.face = face,
.hb_font = hb_font,
.metrics = calcMetrics(face, opts.metric_modifiers),
.load_flags = opts.freetype_load_flags,
};
result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result);
@ -319,6 +329,12 @@ pub const Face = struct {
// 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",
.monochrome = !self.load_flags.monochrome,
.no_autohint = !self.load_flags.autohint,
});
const glyph = self.face.handle.*.glyph;

Binary file not shown.

19
src/font/res/MIT.txt Normal file
View File

@ -0,0 +1,19 @@
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

91
src/font/res/OFL.txt Normal file
View File

@ -0,0 +1,91 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

32
src/font/res/README.md Normal file
View File

@ -0,0 +1,32 @@
# Fonts and Licenses
This project uses several fonts which fall under the SIL Open Font License (OFL-1.1) and MIT License:
- Code New Roman (OFL-1.1)
- [© 2014 Sam Radian. All Rights Reserved.](https://github.com/chrissimpkins/codeface/blob/master/fonts/code-new-roman/license.txt)
- Geist Mono (OFL-1.1)
- [Copyright (c) 2023 Vercel, in collaboration with basement.studio](https://github.com/vercel/geist-font/blob/main/LICENSE.txt)
- Inconsolata (OFL-1.1)
- [Copyright 2006 The Inconsolata Project Authors](https://github.com/google/fonts/blob/main/ofl/inconsolata/OFL.txt)
- JetBrains Mono (OFL-1.1)
- [Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)](https://github.com/JetBrains/JetBrainsMono/blob/master/OFL.txt)
- JuliaMono (OFL-1.1)
- [Copyright (c) 2020 - 2023, cormullion
with Reserved Font Name JuliaMono.](https://github.com/cormullion/juliamono/blob/master/LICENSE)
- Kawkab Mono (OFL-1.1)
- [Copyright (c) 2015, Abdullah Arif (abdullah.a@gmail.com). Copyright 2010, 2012, 2014 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
](https://github.com/aiaf/kawkab-mono/blob/master/OFL.txt)
- Lilex (OFL-1.1)
- [Copyright 2019 The Lilex Project Authors (https://github.com/mishamyrt/Lilex)](https://github.com/mishamyrt/Lilex/blob/master/OFL.txt)
- Monaspace Neon (OFL-1.1)
- [Copyright (c) 2023, GitHub https://github.com/githubnext/monaspace
with Reserved Font Name "Monaspace", including subfamilies: "Argon", "Neon", "Xenon", "Radon", and "Krypton"](https://github.com/githubnext/monaspace/blob/main/LICENSE)
- Noto Emoji (OFL-1.1)
- [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)
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/>.
A full copy of the MIT license can be found at [MIT.txt](./MIT.txt).

View File

@ -14,7 +14,7 @@ const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const font = @import("../main.zig");
const CacheTable = @import("../../cache_table.zig").CacheTable;
const CacheTable = @import("../../datastruct/main.zig").CacheTable;
const log = std.log.scoped(.font_shaper_cache);

View File

@ -190,6 +190,11 @@ pub const Shaper = struct {
// Reset the buffer for our current run
self.shaper.hb_buf.reset();
self.shaper.hb_buf.setContentType(.unicode);
// We don't support RTL text because RTL in terminals is messy.
// Its something we want to improve. For now, we force LTR because
// our renderers assume a strictly increasing X value.
self.shaper.hb_buf.setDirection(.ltr);
}
pub fn addCodepoint(self: RunIteratorHook, cp: u32, cluster: u32) !void {
@ -453,6 +458,46 @@ test "shape monaspace ligs" {
}
}
// Ghostty doesn't currently support RTL and our renderers assume
// that cells are in strict LTR order. This means that we need to
// force RTL text to be LTR for rendering. This test ensures that
// we are correctly forcing RTL text to be LTR.
test "shape arabic forced LTR" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaperWithFont(alloc, .arabic);
defer testdata.deinit();
var screen = try terminal.Screen.init(alloc, 120, 30, 0);
defer screen.deinit();
try screen.testWriteString(@embedFile("testdata/arabic.txt"));
var shaper = &testdata.shaper;
var it = shaper.runIterator(
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
null,
null,
);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
try testing.expectEqual(@as(usize, 25), run.cells);
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 25), cells.len);
var x: u16 = cells[0].x;
for (cells[1..]) |cell| {
try testing.expectEqual(x + 1, cell.x);
x = cell.x;
}
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape emoji width" {
const testing = std.testing;
const alloc = testing.allocator;
@ -1146,6 +1191,7 @@ const TestShaper = struct {
const TestFont = enum {
inconsolata,
monaspace_neon,
arabic,
};
/// Helper to return a fully initialized shaper.
@ -1159,6 +1205,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
const testFont = switch (font_req) {
.inconsolata => font.embedded.inconsolata,
.monaspace_neon => font.embedded.monaspace_neon,
.arabic => font.embedded.arabic,
};
var lib = try Library.init();

3
src/font/shaper/testdata/arabic.txt vendored Normal file
View File

@ -0,0 +1,3 @@
غريبه لاني عربي أبا عن جد
واتكلم الانجليزية بطلاقة اكثر من ٢٥ سنه
ومع هذا اجد العربيه افضل لان فيها الكثير من المفردات الاكثر دقه بالوصف

View File

@ -79,6 +79,17 @@ const Quads = packed struct(u4) {
br: bool = false,
};
/// Specification of a branch drawing node, which consists of a
/// circle which is either empty or filled, and lines connecting
/// optionally between the circle and each of the 4 edges.
const BranchNode = packed struct(u5) {
up: bool = false,
right: bool = false,
down: bool = false,
left: bool = false,
filled: bool = false,
};
/// Alignment of a figure within a cell
const Alignment = struct {
horizontal: enum {
@ -474,14 +485,14 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
// '╬'
0x256c => self.draw_lines(canvas, .{ .up = .double, .down = .double, .left = .double, .right = .double }),
// '╭'
0x256d => try self.draw_light_arc(canvas, .br),
0x256d => try self.draw_arc(canvas, .br, .light),
// '╮'
0x256e => try self.draw_light_arc(canvas, .bl),
0x256e => try self.draw_arc(canvas, .bl, .light),
// '╯'
0x256f => try self.draw_light_arc(canvas, .tl),
0x256f => try self.draw_arc(canvas, .tl, .light),
// '╰'
0x2570 => try self.draw_light_arc(canvas, .tr),
0x2570 => try self.draw_arc(canvas, .tr, .light),
// ''
0x2571 => self.draw_light_diagonal_upper_right_to_lower_left(canvas),
// '╲'
@ -1302,6 +1313,330 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
// '🯯'
0x1fbef => self.draw_circle(canvas, Alignment.top_left, true),
// (Below:)
// Branch drawing character set, used for drawing git-like
// graphs in the terminal. Originally implemented in Kitty.
// Ref:
// - https://github.com/kovidgoyal/kitty/pull/7681
// - https://github.com/kovidgoyal/kitty/pull/7805
// NOTE: Kitty is GPL licensed, and its code was not referenced
// for these characters, only the loose specification of
// the character set in the pull request descriptions.
//
// TODO(qwerasd): This should be in another file, but really the
// general organization of the sprite font code
// needs to be reworked eventually.
//
//
//
//
//
// ''
0x0f5d0 => self.hline_middle(canvas, .light),
// ''
0x0f5d1 => self.vline_middle(canvas, .light),
// ''
0x0f5d2 => self.draw_fading_line(canvas, .right, .light),
// ''
0x0f5d3 => self.draw_fading_line(canvas, .left, .light),
// ''
0x0f5d4 => self.draw_fading_line(canvas, .bottom, .light),
// ''
0x0f5d5 => self.draw_fading_line(canvas, .top, .light),
// ''
0x0f5d6 => try self.draw_arc(canvas, .br, .light),
// ''
0x0f5d7 => try self.draw_arc(canvas, .bl, .light),
// ''
0x0f5d8 => try self.draw_arc(canvas, .tr, .light),
// ''
0x0f5d9 => try self.draw_arc(canvas, .tl, .light),
// ''
0x0f5da => {
self.vline_middle(canvas, .light);
try self.draw_arc(canvas, .tr, .light);
},
// ''
0x0f5db => {
self.vline_middle(canvas, .light);
try self.draw_arc(canvas, .br, .light);
},
// ''
0x0f5dc => {
try self.draw_arc(canvas, .tr, .light);
try self.draw_arc(canvas, .br, .light);
},
// ''
0x0f5dd => {
self.vline_middle(canvas, .light);
try self.draw_arc(canvas, .tl, .light);
},
// ''
0x0f5de => {
self.vline_middle(canvas, .light);
try self.draw_arc(canvas, .bl, .light);
},
// ''
0x0f5df => {
try self.draw_arc(canvas, .tl, .light);
try self.draw_arc(canvas, .bl, .light);
},
// ''
0x0f5e0 => {
try self.draw_arc(canvas, .bl, .light);
self.hline_middle(canvas, .light);
},
// ''
0x0f5e1 => {
try self.draw_arc(canvas, .br, .light);
self.hline_middle(canvas, .light);
},
// ''
0x0f5e2 => {
try self.draw_arc(canvas, .br, .light);
try self.draw_arc(canvas, .bl, .light);
},
// ''
0x0f5e3 => {
try self.draw_arc(canvas, .tl, .light);
self.hline_middle(canvas, .light);
},
// ''
0x0f5e4 => {
try self.draw_arc(canvas, .tr, .light);
self.hline_middle(canvas, .light);
},
// ''
0x0f5e5 => {
try self.draw_arc(canvas, .tr, .light);
try self.draw_arc(canvas, .tl, .light);
},
// ''
0x0f5e6 => {
self.vline_middle(canvas, .light);
try self.draw_arc(canvas, .tl, .light);
try self.draw_arc(canvas, .tr, .light);
},
// ''
0x0f5e7 => {
self.vline_middle(canvas, .light);
try self.draw_arc(canvas, .bl, .light);
try self.draw_arc(canvas, .br, .light);
},
// ''
0x0f5e8 => {
self.hline_middle(canvas, .light);
try self.draw_arc(canvas, .bl, .light);
try self.draw_arc(canvas, .tl, .light);
},
// ''
0x0f5e9 => {
self.hline_middle(canvas, .light);
try self.draw_arc(canvas, .tr, .light);
try self.draw_arc(canvas, .br, .light);
},
// ''
0x0f5ea => {
self.vline_middle(canvas, .light);
try self.draw_arc(canvas, .tl, .light);
try self.draw_arc(canvas, .br, .light);
},
// ''
0x0f5eb => {
self.vline_middle(canvas, .light);
try self.draw_arc(canvas, .tr, .light);
try self.draw_arc(canvas, .bl, .light);
},
// ''
0x0f5ec => {
self.hline_middle(canvas, .light);
try self.draw_arc(canvas, .tl, .light);
try self.draw_arc(canvas, .br, .light);
},
// ''
0x0f5ed => {
self.hline_middle(canvas, .light);
try self.draw_arc(canvas, .tr, .light);
try self.draw_arc(canvas, .bl, .light);
},
// ''
0x0f5ee => self.draw_branch_node(canvas, .{ .filled = true }, .light),
// ''
0x0f5ef => self.draw_branch_node(canvas, .{}, .light),
// ''
0x0f5f0 => self.draw_branch_node(canvas, .{
.right = true,
.filled = true,
}, .light),
// ''
0x0f5f1 => self.draw_branch_node(canvas, .{
.right = true,
}, .light),
// ''
0x0f5f2 => self.draw_branch_node(canvas, .{
.left = true,
.filled = true,
}, .light),
// ''
0x0f5f3 => self.draw_branch_node(canvas, .{
.left = true,
}, .light),
// ''
0x0f5f4 => self.draw_branch_node(canvas, .{
.left = true,
.right = true,
.filled = true,
}, .light),
// ''
0x0f5f5 => self.draw_branch_node(canvas, .{
.left = true,
.right = true,
}, .light),
// ''
0x0f5f6 => self.draw_branch_node(canvas, .{
.down = true,
.filled = true,
}, .light),
// ''
0x0f5f7 => self.draw_branch_node(canvas, .{
.down = true,
}, .light),
// ''
0x0f5f8 => self.draw_branch_node(canvas, .{
.up = true,
.filled = true,
}, .light),
// ''
0x0f5f9 => self.draw_branch_node(canvas, .{
.up = true,
}, .light),
// ''
0x0f5fa => self.draw_branch_node(canvas, .{
.up = true,
.down = true,
.filled = true,
}, .light),
// ''
0x0f5fb => self.draw_branch_node(canvas, .{
.up = true,
.down = true,
}, .light),
// ''
0x0f5fc => self.draw_branch_node(canvas, .{
.right = true,
.down = true,
.filled = true,
}, .light),
// ''
0x0f5fd => self.draw_branch_node(canvas, .{
.right = true,
.down = true,
}, .light),
// ''
0x0f5fe => self.draw_branch_node(canvas, .{
.left = true,
.down = true,
.filled = true,
}, .light),
// ''
0x0f5ff => self.draw_branch_node(canvas, .{
.left = true,
.down = true,
}, .light),
// ''
0x0f600 => self.draw_branch_node(canvas, .{
.up = true,
.right = true,
.filled = true,
}, .light),
// ''
0x0f601 => self.draw_branch_node(canvas, .{
.up = true,
.right = true,
}, .light),
// ''
0x0f602 => self.draw_branch_node(canvas, .{
.up = true,
.left = true,
.filled = true,
}, .light),
// ''
0x0f603 => self.draw_branch_node(canvas, .{
.up = true,
.left = true,
}, .light),
// ''
0x0f604 => self.draw_branch_node(canvas, .{
.up = true,
.down = true,
.right = true,
.filled = true,
}, .light),
// ''
0x0f605 => self.draw_branch_node(canvas, .{
.up = true,
.down = true,
.right = true,
}, .light),
// ''
0x0f606 => self.draw_branch_node(canvas, .{
.up = true,
.down = true,
.left = true,
.filled = true,
}, .light),
// ''
0x0f607 => self.draw_branch_node(canvas, .{
.up = true,
.down = true,
.left = true,
}, .light),
// ''
0x0f608 => self.draw_branch_node(canvas, .{
.down = true,
.left = true,
.right = true,
.filled = true,
}, .light),
// ''
0x0f609 => self.draw_branch_node(canvas, .{
.down = true,
.left = true,
.right = true,
}, .light),
// ''
0x0f60a => self.draw_branch_node(canvas, .{
.up = true,
.left = true,
.right = true,
.filled = true,
}, .light),
// ''
0x0f60b => self.draw_branch_node(canvas, .{
.up = true,
.left = true,
.right = true,
}, .light),
// ''
0x0f60c => self.draw_branch_node(canvas, .{
.up = true,
.down = true,
.left = true,
.right = true,
.filled = true,
}, .light),
// ''
0x0f60d => self.draw_branch_node(canvas, .{
.up = true,
.down = true,
.left = true,
.right = true,
}, .light),
// Not official box characters but special characters we hide
// in the high bits of a unicode codepoint.
@intFromEnum(Sprite.cursor_rect) => self.draw_cursor_rect(canvas),
@ -1793,6 +2128,135 @@ fn draw_cell_diagonal(
) catch {};
}
fn draw_fading_line(
self: Box,
canvas: *font.sprite.Canvas,
comptime to: Edge,
comptime thickness: Thickness,
) void {
const thick_px = thickness.height(self.thickness);
const float_width: f64 = @floatFromInt(self.width);
const float_height: f64 = @floatFromInt(self.height);
// Top of horizontal strokes
const h_top = (self.height -| thick_px) / 2;
// Bottom of horizontal strokes
const h_bottom = h_top +| thick_px;
// Left of vertical strokes
const v_left = (self.width -| thick_px) / 2;
// Right of vertical strokes
const v_right = v_left +| thick_px;
// If we're fading to the top or left, we start with 0.0
// and increment up as we progress, otherwise we start
// at 255.0 and increment down (negative).
var color: f64 = switch (to) {
.top, .left => 0.0,
.bottom, .right => 255.0,
};
const inc: f64 = 255.0 / switch (to) {
.top => float_height,
.bottom => -float_height,
.left => float_width,
.right => -float_width,
};
switch (to) {
.top, .bottom => {
for (0..self.height) |y| {
for (v_left..v_right) |x| {
canvas.pixel(
@intCast(x),
@intCast(y),
@enumFromInt(@as(u8, @intFromFloat(@round(color)))),
);
}
color += inc;
}
},
.left, .right => {
for (0..self.width) |x| {
for (h_top..h_bottom) |y| {
canvas.pixel(
@intCast(x),
@intCast(y),
@enumFromInt(@as(u8, @intFromFloat(@round(color)))),
);
}
color += inc;
}
},
}
}
fn draw_branch_node(
self: Box,
canvas: *font.sprite.Canvas,
node: BranchNode,
comptime thickness: Thickness,
) void {
const thick_px = thickness.height(self.thickness);
const float_width: f64 = @floatFromInt(self.width);
const float_height: f64 = @floatFromInt(self.height);
const float_thick: f64 = @floatFromInt(thick_px);
// Top of horizontal strokes
const h_top = (self.height -| thick_px) / 2;
// Bottom of horizontal strokes
const h_bottom = h_top +| thick_px;
// Left of vertical strokes
const v_left = (self.width -| thick_px) / 2;
// Right of vertical strokes
const v_right = v_left +| thick_px;
// We calculate the center of the circle this way
// to ensure it aligns with box drawing characters
// since the lines are sometimes off center to
// make sure they aren't split between pixels.
const cx: f64 = @as(f64, @floatFromInt(v_left)) + float_thick / 2;
const cy: f64 = @as(f64, @floatFromInt(h_top)) + float_thick / 2;
// The radius needs to be the smallest distance from the center to an edge.
const r: f64 = @min(
@min(cx, cy),
@min(float_width - cx, float_height - cy),
);
var ctx: z2d.Context = .{
.surface = canvas.sfc,
.pattern = .{
.opaque_pattern = .{
.pixel = .{ .alpha8 = .{ .a = @intFromEnum(Shade.on) } },
},
},
.line_width = float_thick,
};
var path = z2d.Path.init(canvas.alloc);
defer path.deinit();
// These @intFromFloat casts shouldn't ever fail since r can never
// be greater than cx or cy, so when subtracting it from them the
// result can never be negative.
if (node.up)
self.rect(canvas, v_left, 0, v_right, @intFromFloat(@ceil(cy - r)));
if (node.right)
self.rect(canvas, @intFromFloat(@floor(cx + r)), h_top, self.width, h_bottom);
if (node.down)
self.rect(canvas, v_left, @intFromFloat(@floor(cy + r)), v_right, self.height);
if (node.left)
self.rect(canvas, 0, h_top, @intFromFloat(@ceil(cx - r)), h_bottom);
if (node.filled) {
path.arc(cx, cy, r, 0, std.math.pi * 2, false, null) catch return;
path.close() catch return;
ctx.fill(canvas.alloc, path) catch return;
} else {
path.arc(cx, cy, r - float_thick / 2, 0, std.math.pi * 2, false, null) catch return;
path.close() catch return;
ctx.stroke(canvas.alloc, path) catch return;
}
}
fn draw_circle(
self: Box,
canvas: *font.sprite.Canvas,
@ -2118,12 +2582,13 @@ fn draw_edge_triangle(
try ctx.fill(canvas.alloc, path);
}
fn draw_light_arc(
fn draw_arc(
self: Box,
canvas: *font.sprite.Canvas,
comptime corner: Corner,
comptime thickness: Thickness,
) !void {
const thick_px = Thickness.light.height(self.thickness);
const thick_px = thickness.height(self.thickness);
const float_width: f64 = @floatFromInt(self.width);
const float_height: f64 = @floatFromInt(self.height);
const float_thick: f64 = @floatFromInt(thick_px);
@ -2534,6 +2999,32 @@ fn testRenderAll(self: Box, alloc: Allocator, atlas: *font.Atlas) !void {
else => {},
}
}
// Branch drawing character set, used for drawing git-like
// graphs in the terminal. Originally implemented in Kitty.
// Ref:
// - https://github.com/kovidgoyal/kitty/pull/7681
// - https://github.com/kovidgoyal/kitty/pull/7805
// NOTE: Kitty is GPL licensed, and its code was not referenced
// for these characters, only the loose specification of
// the character set in the pull request descriptions.
//
// TODO(qwerasd): This should be in another file, but really the
// general organization of the sprite font code
// needs to be reworked eventually.
//
//
//
//
//
cp = 0xf5d0;
while (cp <= 0xf60d) : (cp += 1) {
_ = try self.renderGlyph(
alloc,
atlas,
cp,
);
}
}
test "render all sprites" {

View File

@ -263,6 +263,21 @@ const Kind = enum {
0x1FBCE...0x1FBEF,
=> .box,
// Branch drawing character set, used for drawing git-like
// graphs in the terminal. Originally implemented in Kitty.
// Ref:
// - https://github.com/kovidgoyal/kitty/pull/7681
// - https://github.com/kovidgoyal/kitty/pull/7805
// NOTE: Kitty is GPL licensed, and its code was not referenced
// for these characters, only the loose specification of
// the character set in the pull request descriptions.
//
//
//
//
//
0xF5D0...0xF60D => .box,
// Powerline fonts
0xE0B0,
0xE0B4,

Binary file not shown.

View File

@ -317,8 +317,7 @@ pub const Action = union(enum) {
/// Focus on a split in a given direction.
goto_split: SplitFocusDirection,
/// zoom/unzoom the current split. This is currently only supported
/// on macOS. Contributions welcome for other platforms.
/// zoom/unzoom the current split.
toggle_split_zoom: void,
/// Resize the current split by moving the split divider in the given
@ -422,6 +421,8 @@ pub const Action = union(enum) {
///
crash: CrashThread,
pub const Key = @typeInfo(Action).Union.tag_type.?;
pub const CrashThread = enum {
main,
io,
@ -431,6 +432,16 @@ pub const Action = union(enum) {
pub const CursorKey = struct {
normal: []const u8,
application: []const u8,
pub fn clone(
self: CursorKey,
alloc: Allocator,
) Allocator.Error!CursorKey {
return .{
.normal = try alloc.dupe(u8, self.normal),
.application = try alloc.dupe(u8, self.application),
};
}
};
pub const AdjustSelection = enum {
@ -774,6 +785,50 @@ pub const Action = union(enum) {
}
}
/// Clone this action with the given allocator. The allocator
/// should be an arena-style allocator since fine-grained
/// deallocation is not possible.
pub fn clone(self: Action, alloc: Allocator) Allocator.Error!Action {
return switch (self) {
inline else => |value, tag| @unionInit(
Action,
@tagName(tag),
try cloneValue(alloc, value),
),
};
}
fn cloneValue(
alloc: Allocator,
value: anytype,
) Allocator.Error!@TypeOf(value) {
return switch (@typeInfo(@TypeOf(value))) {
.Void,
.Int,
.Float,
.Enum,
=> value,
.Pointer => |info| slice: {
comptime assert(info.size == .Slice);
break :slice try alloc.dupe(
info.child,
value,
);
},
.Struct => |info| if (info.is_tuple)
value
else
try value.clone(alloc),
else => {
@compileLog(@TypeOf(value));
@compileError("unexpected type");
},
};
}
/// Returns a hash code that can be used to uniquely identify this
/// action.
pub fn hash(self: Action) u64 {
@ -1102,6 +1157,16 @@ pub const Set = struct {
action: Action,
flags: Flags,
pub fn clone(
self: Leaf,
alloc: Allocator,
) Allocator.Error!Leaf {
return .{
.action = try self.action.clone(alloc),
.flags = self.flags,
};
}
pub fn hash(self: Leaf) u64 {
var hasher = std.hash.Wyhash.init(0);
self.action.hash(&hasher);
@ -1391,8 +1456,9 @@ pub const Set = struct {
// If we have any leaders we need to clone them.
var it = result.bindings.iterator();
while (it.next()) |entry| switch (entry.value_ptr.*) {
// No data to clone
.leaf => {},
// Leaves could have data to clone (i.e. text actions
// contain allocated strings).
.leaf => |*s| s.* = try s.clone(alloc),
// Must be deep cloned.
.leader => |*s| {
@ -2129,3 +2195,22 @@ test "set: consumed state" {
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed);
}
test "Action: clone" {
const testing = std.testing;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
{
var a: Action = .ignore;
const b = try a.clone(alloc);
try testing.expect(b == .ignore);
}
{
var a: Action = .{ .text = "foo" };
const b = try a.clone(alloc);
try testing.expect(b == .text);
}
}

View File

@ -11,6 +11,7 @@ const cimgui = @import("cimgui");
const Surface = @import("../Surface.zig");
const font = @import("../font/main.zig");
const input = @import("../input.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
const inspector = @import("main.zig");
@ -641,8 +642,8 @@ fn renderSizeWindow(self: *Inspector) void {
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText(
"%dpx x %dpx",
self.surface.screen_size.width,
self.surface.screen_size.height,
self.surface.size.screen.width,
self.surface.size.screen.height,
);
}
}
@ -656,10 +657,11 @@ fn renderSizeWindow(self: *Inspector) void {
}
{
_ = cimgui.c.igTableSetColumnIndex(1);
const grid_size = self.surface.size.grid();
cimgui.c.igText(
"%dc x %dr",
self.surface.grid_size.columns,
self.surface.grid_size.rows,
grid_size.columns,
grid_size.rows,
);
}
}
@ -675,8 +677,8 @@ fn renderSizeWindow(self: *Inspector) void {
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText(
"%dpx x %dpx",
self.surface.cell_size.width,
self.surface.cell_size.height,
self.surface.size.cell.width,
self.surface.size.cell.height,
);
}
}
@ -692,10 +694,10 @@ fn renderSizeWindow(self: *Inspector) void {
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText(
"T=%d B=%d L=%d R=%d px",
self.surface.padding.top,
self.surface.padding.bottom,
self.surface.padding.left,
self.surface.padding.right,
self.surface.size.padding.top,
self.surface.size.padding.bottom,
self.surface.size.padding.left,
self.surface.size.padding.right,
);
}
}
@ -785,7 +787,13 @@ fn renderSizeWindow(self: *Inspector) void {
}
{
const adjusted = self.surface.posAdjusted(self.mouse.last_xpos, self.mouse.last_ypos);
const coord: renderer.Coordinate.Terminal = (renderer.Coordinate{
.surface = .{
.x = self.mouse.last_xpos,
.y = self.mouse.last_ypos,
},
}).convert(.terminal, self.surface.size).terminal;
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
{
_ = cimgui.c.igTableSetColumnIndex(0);
@ -795,8 +803,8 @@ fn renderSizeWindow(self: *Inspector) void {
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText(
"(%dpx, %dpx)",
@as(i64, @intFromFloat(adjusted.x)),
@as(i64, @intFromFloat(adjusted.y)),
@as(i64, @intFromFloat(coord.x)),
@as(i64, @intFromFloat(coord.y)),
);
}
}

View File

@ -1,7 +1,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const input = @import("../input.zig");
const CircBuf = @import("../circ_buf.zig").CircBuf;
const CircBuf = @import("../datastruct/main.zig").CircBuf;
const cimgui = @import("cimgui");
/// Circular buffer of key events.

View File

@ -2,7 +2,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const cimgui = @import("cimgui");
const terminal = @import("../terminal/main.zig");
const CircBuf = @import("../circ_buf.zig").CircBuf;
const CircBuf = @import("../datastruct/main.zig").CircBuf;
const Surface = @import("../Surface.zig");
/// The stream handler for our inspector.

View File

@ -168,7 +168,6 @@ pub const std_options: std.Options = .{
};
test {
_ = @import("circ_buf.zig");
_ = @import("pty.zig");
_ = @import("Command.zig");
_ = @import("font/main.zig");
@ -180,17 +179,11 @@ test {
_ = @import("surface_mouse.zig");
// Libraries
_ = @import("segmented_pool.zig");
_ = @import("crash/main.zig");
_ = @import("datastruct/main.zig");
_ = @import("inspector/main.zig");
_ = @import("terminal/main.zig");
_ = @import("terminfo/main.zig");
_ = @import("simd/main.zig");
_ = @import("unicode/main.zig");
// TODO
_ = @import("blocking_queue.zig");
_ = @import("cache_table.zig");
_ = @import("config.zig");
_ = @import("lru.zig");
}

View File

@ -9,7 +9,7 @@ const builtin = @import("builtin");
const xev = @import("xev");
const macos = @import("macos");
const BlockingQueue = @import("../blocking_queue.zig").BlockingQueue;
const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue;
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.cf_release_thread);

View File

@ -3,12 +3,6 @@ const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const posix = std.posix;
/// The separator used in environment variables such as PATH.
pub const PATH_SEP = switch (builtin.os.tag) {
.windows => ";",
else => ":",
};
/// Append a value to an environment variable such as PATH.
/// The returned value is always allocated so it must be freed.
pub fn appendEnv(
@ -33,9 +27,9 @@ pub fn appendEnvAlways(
current: []const u8,
value: []const u8,
) ![]u8 {
return try std.fmt.allocPrint(alloc, "{s}{s}{s}", .{
return try std.fmt.allocPrint(alloc, "{s}{c}{s}", .{
current,
PATH_SEP,
std.fs.path.delimiter,
value,
});
}

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