Merge branch 'main' into fix_2271

This commit is contained in:
Christian Kugler
2024-09-29 14:15:56 +02:00
committed by GitHub
88 changed files with 7733 additions and 2365 deletions

View File

@ -11,13 +11,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.1.7
uses: namespacelabs/nscloud-cache-action@v1.1.8
with:
path: |
/nix
/zig
- name: Setup Nix
uses: cachix/install-nix-action@V28
uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15

View File

@ -57,7 +57,7 @@ jobs:
fetch-depth: 0
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -92,7 +92,10 @@ jobs:
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
# Nix breaks xcodebuild so this has to be run outside.
- name: Build Ghostty.app
run: cd macos && xcodebuild -target Ghostty -configuration Release
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.
# This will be a monotonically always increasing build number that we use.
@ -205,7 +208,7 @@ jobs:
fetch-depth: 0
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -240,7 +243,10 @@ jobs:
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
# Nix breaks xcodebuild so this has to be run outside.
- name: Build Ghostty.app
run: cd macos && xcodebuild -target Ghostty -configuration Release
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.
# This will be a monotonically always increasing build number that we use.

View File

@ -105,7 +105,7 @@ jobs:
fetch-depth: 0
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -140,7 +140,10 @@ jobs:
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
# Nix breaks xcodebuild so this has to be run outside.
- name: Build Ghostty.app
run: cd macos && xcodebuild -target Ghostty -configuration Release
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.
# This will be a monotonically always increasing build number that we use.
@ -286,7 +289,7 @@ jobs:
fetch-depth: 0
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -321,7 +324,10 @@ jobs:
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
# Nix breaks xcodebuild so this has to be run outside.
- name: Build Ghostty.app
run: cd macos && xcodebuild -target Ghostty -configuration Release
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.
# This will be a monotonically always increasing build number that we use.
@ -455,7 +461,7 @@ jobs:
fetch-depth: 0
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -490,7 +496,10 @@ jobs:
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
# Nix breaks xcodebuild so this has to be run outside.
- name: Build Ghostty.app
run: cd macos && xcodebuild -target Ghostty -configuration Release
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.
# This will be a monotonically always increasing build number that we use.

View File

@ -32,14 +32,14 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.1.7
uses: namespacelabs/nscloud-cache-action@v1.1.8
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -64,14 +64,14 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.1.7
uses: namespacelabs/nscloud-cache-action@v1.1.8
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -93,14 +93,14 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.1.7
uses: namespacelabs/nscloud-cache-action@v1.1.8
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -126,14 +126,14 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.1.7
uses: namespacelabs/nscloud-cache-action@v1.1.8
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -152,7 +152,7 @@ jobs:
uses: actions/checkout@v4
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -160,6 +160,9 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_16.0.app
# GhosttyKit is the framework that is built from Zig for our native
# Mac app to access.
- name: Build GhosttyKit
@ -185,7 +188,7 @@ jobs:
uses: actions/checkout@v4
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -302,14 +305,14 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.1.7
uses: namespacelabs/nscloud-cache-action@v1.1.8
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -340,7 +343,7 @@ jobs:
uses: actions/checkout@v4
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -361,12 +364,12 @@ jobs:
steps:
- uses: actions/checkout@v4 # Check out repo so we can lint it
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.1.7
uses: namespacelabs/nscloud-cache-action@v1.1.8
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -388,12 +391,12 @@ jobs:
steps:
- uses: actions/checkout@v4 # Check out repo so we can lint it
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.1.7
uses: namespacelabs/nscloud-cache-action@v1.1.8
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
@ -415,12 +418,12 @@ jobs:
steps:
- uses: actions/checkout@v4 # Check out repo so we can lint it
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.1.7
uses: namespacelabs/nscloud-cache-action@v1.1.8
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@V28
- uses: cachix/install-nix-action@v29
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15

View File

@ -1038,12 +1038,15 @@ fn addDeps(
.optimize = optimize,
.libxev = false,
.images = false,
.text_input = false,
});
const wuffs_dep = b.dependency("wuffs", .{
.target = target,
.optimize = optimize,
});
const zf_dep = b.dependency("zf", .{
.target = target,
.optimize = optimize,
});
// Wasm we do manually since it is such a different build.
if (step.rootModuleTarget().cpu.arch == .wasm32) {
@ -1130,6 +1133,7 @@ fn addDeps(
step.root_module.addImport("ziglyph", ziglyph_dep.module("ziglyph"));
step.root_module.addImport("vaxis", vaxis_dep.module("vaxis"));
step.root_module.addImport("wuffs", wuffs_dep.module("wuffs"));
step.root_module.addImport("zf", zf_dep.module("zf"));
// Mac Stuff
if (step.rootModuleTarget().isDarwin()) {

View File

@ -5,8 +5,8 @@
.dependencies = .{
// Zig libs
.libxev = .{
.url = "https://github.com/mitchellh/libxev/archive/43c7e4b3308f359e5b758db2d824d7c447f4ed3f.tar.gz",
.hash = "1220aec83b6367c6bc64ca781828e0ad817fb38e7fca7331bd6d736b6896910f6637",
.url = "https://github.com/mitchellh/libxev/archive/b8d1d93e5c899b27abbaa7df23b496c3e6a178c7.tar.gz",
.hash = "1220612bc023c21d75234882ec9a8c6a1cbd9d642da3dfb899297f14bb5bd7b6cd78",
},
.mach_glfw = .{
.url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz",
@ -54,8 +54,12 @@
.hash = "122056fbb29863ec1678b7954fb76b1533ad8c581a34577c1b2efe419e29e05596df",
},
.vaxis = .{
.url = "git+https://github.com/rockorager/libvaxis?ref=main#a8baf9ce371b89a84383130c82549bb91401d15a",
.hash = "12207f53d7dddd3e5ca6577fcdd137dcf1fa32c9f22cbb0911ad0701cde4095a1c4c",
.url = "git+https://github.com/rockorager/libvaxis?ref=main#1961712c1f0cf46b235dd31418dc1b52442abbd5",
.hash = "12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133",
},
.zf = .{
.url = "git+https://github.com/natecraddock/zf.git?ref=main#bb27a917c3513785c6a91f0b1c10002a5029cacc",
.hash = "1220a74107c7f153a2f809e41c7fa7e8dbf75c91043e39fad998247804e5edac2cc8",
},
},
}

View File

@ -30,15 +30,13 @@
pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
in {
devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix {
inherit (pkgs-unstable) tracy;
zig = zig.packages.${system}."0.13.0";
wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {};
};
packages.${system} = let
mkArgs = optimize: {
inherit (pkgs-unstable) zig_0_13;
inherit (pkgs-unstable) zig_0_13 stdenv;
inherit optimize;
revision = self.shortRev or self.dirtyShortRev or "dirty";

View File

@ -30,7 +30,9 @@ typedef void* ghostty_config_t;
typedef void* ghostty_surface_t;
typedef void* ghostty_inspector_t;
// Enums are up top so we can reference them later.
// All the types below are fully defined and must be kept in sync with
// their Zig counterparts. Any changes to these types MUST have an associated
// Zig change.
typedef enum {
GHOSTTY_PLATFORM_INVALID,
GHOSTTY_PLATFORM_MACOS,
@ -48,33 +50,6 @@ typedef enum {
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,
} ghostty_clipboard_request_e;
typedef enum {
GHOSTTY_SPLIT_RIGHT,
GHOSTTY_SPLIT_DOWN
} ghostty_split_direction_e;
typedef enum {
GHOSTTY_SPLIT_FOCUS_PREVIOUS,
GHOSTTY_SPLIT_FOCUS_NEXT,
GHOSTTY_SPLIT_FOCUS_TOP,
GHOSTTY_SPLIT_FOCUS_LEFT,
GHOSTTY_SPLIT_FOCUS_BOTTOM,
GHOSTTY_SPLIT_FOCUS_RIGHT,
} ghostty_split_focus_direction_e;
typedef enum {
GHOSTTY_SPLIT_RESIZE_UP,
GHOSTTY_SPLIT_RESIZE_DOWN,
GHOSTTY_SPLIT_RESIZE_LEFT,
GHOSTTY_SPLIT_RESIZE_RIGHT,
} ghostty_split_resize_direction_e;
typedef enum {
GHOSTTY_INSPECTOR_TOGGLE,
GHOSTTY_INSPECTOR_SHOW,
GHOSTTY_INSPECTOR_HIDE,
} ghostty_inspector_mode_e;
typedef enum {
GHOSTTY_MOUSE_RELEASE,
GHOSTTY_MOUSE_PRESS,
@ -97,55 +72,6 @@ typedef enum {
GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN,
} ghostty_input_mouse_momentum_e;
typedef enum {
GHOSTTY_MOUSE_SHAPE_DEFAULT,
GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU,
GHOSTTY_MOUSE_SHAPE_HELP,
GHOSTTY_MOUSE_SHAPE_POINTER,
GHOSTTY_MOUSE_SHAPE_PROGRESS,
GHOSTTY_MOUSE_SHAPE_WAIT,
GHOSTTY_MOUSE_SHAPE_CELL,
GHOSTTY_MOUSE_SHAPE_CROSSHAIR,
GHOSTTY_MOUSE_SHAPE_TEXT,
GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT,
GHOSTTY_MOUSE_SHAPE_ALIAS,
GHOSTTY_MOUSE_SHAPE_COPY,
GHOSTTY_MOUSE_SHAPE_MOVE,
GHOSTTY_MOUSE_SHAPE_NO_DROP,
GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED,
GHOSTTY_MOUSE_SHAPE_GRAB,
GHOSTTY_MOUSE_SHAPE_GRABBING,
GHOSTTY_MOUSE_SHAPE_ALL_SCROLL,
GHOSTTY_MOUSE_SHAPE_COL_RESIZE,
GHOSTTY_MOUSE_SHAPE_ROW_RESIZE,
GHOSTTY_MOUSE_SHAPE_N_RESIZE,
GHOSTTY_MOUSE_SHAPE_E_RESIZE,
GHOSTTY_MOUSE_SHAPE_S_RESIZE,
GHOSTTY_MOUSE_SHAPE_W_RESIZE,
GHOSTTY_MOUSE_SHAPE_NE_RESIZE,
GHOSTTY_MOUSE_SHAPE_NW_RESIZE,
GHOSTTY_MOUSE_SHAPE_SE_RESIZE,
GHOSTTY_MOUSE_SHAPE_SW_RESIZE,
GHOSTTY_MOUSE_SHAPE_EW_RESIZE,
GHOSTTY_MOUSE_SHAPE_NS_RESIZE,
GHOSTTY_MOUSE_SHAPE_NESW_RESIZE,
GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE,
GHOSTTY_MOUSE_SHAPE_ZOOM_IN,
GHOSTTY_MOUSE_SHAPE_ZOOM_OUT,
} ghostty_mouse_shape_e;
typedef enum {
GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE,
GHOSTTY_NON_NATIVE_FULLSCREEN_TRUE,
GHOSTTY_NON_NATIVE_FULLSCREEN_VISIBLE_MENU,
} ghostty_non_native_fullscreen_e;
typedef enum {
GHOSTTY_TAB_PREVIOUS = -1,
GHOSTTY_TAB_NEXT = -2,
GHOSTTY_TAB_LAST = -3,
} ghostty_tab_e;
typedef enum {
GHOSTTY_COLOR_SCHEME_LIGHT = 0,
GHOSTTY_COLOR_SCHEME_DARK = 1,
@ -357,14 +283,6 @@ typedef enum {
GHOSTTY_BUILD_MODE_RELEASE_SMALL,
} ghostty_build_mode_e;
typedef enum {
GHOSTTY_RENDERER_HEALTH_OK,
GHOSTTY_RENDERER_HEALTH_UNHEALTHY,
} ghostty_renderer_health_e;
// Fully defined types. This MUST be kept in sync with equivalent Zig
// structs. To find the Zig struct, grep for this type name. The documentation
// for all of these types is available in the Zig source.
typedef struct {
ghostty_build_mode_e build_mode;
const char* version;
@ -414,13 +332,231 @@ typedef struct {
uint32_t cell_height_px;
} ghostty_surface_size_s;
// apprt.Target.Key
typedef enum {
GHOSTTY_TARGET_APP,
GHOSTTY_TARGET_SURFACE,
} ghostty_target_tag_e;
typedef union {
ghostty_surface_t surface;
} ghostty_target_u;
typedef struct {
ghostty_target_tag_e tag;
ghostty_target_u target;
} ghostty_target_s;
// apprt.action.SplitDirection
typedef enum {
GHOSTTY_SPLIT_DIRECTION_RIGHT,
GHOSTTY_SPLIT_DIRECTION_DOWN,
} ghostty_action_split_direction_e;
// apprt.action.GotoSplit
typedef enum {
GHOSTTY_GOTO_SPLIT_PREVIOUS,
GHOSTTY_GOTO_SPLIT_NEXT,
GHOSTTY_GOTO_SPLIT_TOP,
GHOSTTY_GOTO_SPLIT_LEFT,
GHOSTTY_GOTO_SPLIT_BOTTOM,
GHOSTTY_GOTO_SPLIT_RIGHT,
} ghostty_action_goto_split_e;
// apprt.action.ResizeSplit.Direction
typedef enum {
GHOSTTY_RESIZE_SPLIT_UP,
GHOSTTY_RESIZE_SPLIT_DOWN,
GHOSTTY_RESIZE_SPLIT_LEFT,
GHOSTTY_RESIZE_SPLIT_RIGHT,
} ghostty_action_resize_split_direction_e;
// apprt.action.ResizeSplit
typedef struct {
uint16_t amount;
ghostty_action_resize_split_direction_e direction;
} ghostty_action_resize_split_s;
// apprt.action.GotoTab
typedef enum {
GHOSTTY_GOTO_TAB_PREVIOUS = -1,
GHOSTTY_GOTO_TAB_NEXT = -2,
GHOSTTY_GOTO_TAB_LAST = -3,
} ghostty_action_goto_tab_e;
// apprt.action.Fullscreen
typedef enum {
GHOSTTY_FULLSCREEN_NATIVE,
GHOSTTY_FULLSCREEN_NON_NATIVE,
GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU,
} ghostty_action_fullscreen_e;
// apprt.action.SecureInput
typedef enum {
GHOSTTY_SECURE_INPUT_ON,
GHOSTTY_SECURE_INPUT_OFF,
GHOSTTY_SECURE_INPUT_TOGGLE,
} ghostty_action_secure_input_e;
// apprt.action.Inspector
typedef enum {
GHOSTTY_INSPECTOR_TOGGLE,
GHOSTTY_INSPECTOR_SHOW,
GHOSTTY_INSPECTOR_HIDE,
} ghostty_action_inspector_e;
// apprt.action.QuitTimer
typedef enum {
GHOSTTY_QUIT_TIMER_START,
GHOSTTY_QUIT_TIMER_STOP,
} ghostty_action_quit_timer_e;
// apprt.action.DesktopNotification.C
typedef struct {
const char* title;
const char* body;
} ghostty_action_desktop_notification_s;
// apprt.action.SetTitle.C
typedef struct {
const char* title;
} ghostty_action_set_title_s;
// terminal.MouseShape
typedef enum {
GHOSTTY_MOUSE_SHAPE_DEFAULT,
GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU,
GHOSTTY_MOUSE_SHAPE_HELP,
GHOSTTY_MOUSE_SHAPE_POINTER,
GHOSTTY_MOUSE_SHAPE_PROGRESS,
GHOSTTY_MOUSE_SHAPE_WAIT,
GHOSTTY_MOUSE_SHAPE_CELL,
GHOSTTY_MOUSE_SHAPE_CROSSHAIR,
GHOSTTY_MOUSE_SHAPE_TEXT,
GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT,
GHOSTTY_MOUSE_SHAPE_ALIAS,
GHOSTTY_MOUSE_SHAPE_COPY,
GHOSTTY_MOUSE_SHAPE_MOVE,
GHOSTTY_MOUSE_SHAPE_NO_DROP,
GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED,
GHOSTTY_MOUSE_SHAPE_GRAB,
GHOSTTY_MOUSE_SHAPE_GRABBING,
GHOSTTY_MOUSE_SHAPE_ALL_SCROLL,
GHOSTTY_MOUSE_SHAPE_COL_RESIZE,
GHOSTTY_MOUSE_SHAPE_ROW_RESIZE,
GHOSTTY_MOUSE_SHAPE_N_RESIZE,
GHOSTTY_MOUSE_SHAPE_E_RESIZE,
GHOSTTY_MOUSE_SHAPE_S_RESIZE,
GHOSTTY_MOUSE_SHAPE_W_RESIZE,
GHOSTTY_MOUSE_SHAPE_NE_RESIZE,
GHOSTTY_MOUSE_SHAPE_NW_RESIZE,
GHOSTTY_MOUSE_SHAPE_SE_RESIZE,
GHOSTTY_MOUSE_SHAPE_SW_RESIZE,
GHOSTTY_MOUSE_SHAPE_EW_RESIZE,
GHOSTTY_MOUSE_SHAPE_NS_RESIZE,
GHOSTTY_MOUSE_SHAPE_NESW_RESIZE,
GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE,
GHOSTTY_MOUSE_SHAPE_ZOOM_IN,
GHOSTTY_MOUSE_SHAPE_ZOOM_OUT,
} ghostty_action_mouse_shape_e;
// apprt.action.MouseVisibility
typedef enum {
GHOSTTY_MOUSE_VISIBLE,
GHOSTTY_MOUSE_HIDDEN,
} ghostty_action_mouse_visibility_e;
// apprt.action.MouseOverLink
typedef struct {
const char* url;
size_t len;
} ghostty_action_mouse_over_link_s;
// apprt.action.SizeLimit
typedef struct {
uint32_t min_width;
uint32_t min_height;
uint32_t max_width;
uint32_t max_height;
} ghostty_action_size_limit_s;
// apprt.action.InitialSize
typedef struct {
uint32_t width;
uint32_t height;
} ghostty_action_initial_size_s;
// apprt.action.CellSize
typedef struct {
uint32_t width;
uint32_t height;
} ghostty_action_cell_size_s;
// renderer.Health
typedef enum {
GHOSTTY_RENDERER_HEALTH_OK,
GHOSTTY_RENDERER_HEALTH_UNHEALTHY,
} ghostty_action_renderer_health_e;
// apprt.Action.Key
typedef enum {
GHOSTTY_ACTION_NEW_WINDOW,
GHOSTTY_ACTION_NEW_TAB,
GHOSTTY_ACTION_NEW_SPLIT,
GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
GHOSTTY_ACTION_TOGGLE_FULLSCREEN,
GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,
GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,
GHOSTTY_ACTION_GOTO_TAB,
GHOSTTY_ACTION_GOTO_SPLIT,
GHOSTTY_ACTION_RESIZE_SPLIT,
GHOSTTY_ACTION_EQUALIZE_SPLITS,
GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM,
GHOSTTY_ACTION_PRESENT_TERMINAL,
GHOSTTY_ACTION_SIZE_LIMIT,
GHOSTTY_ACTION_INITIAL_SIZE,
GHOSTTY_ACTION_CELL_SIZE,
GHOSTTY_ACTION_INSPECTOR,
GHOSTTY_ACTION_RENDER_INSPECTOR,
GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
GHOSTTY_ACTION_SET_TITLE,
GHOSTTY_ACTION_MOUSE_SHAPE,
GHOSTTY_ACTION_MOUSE_VISIBILITY,
GHOSTTY_ACTION_MOUSE_OVER_LINK,
GHOSTTY_ACTION_RENDERER_HEALTH,
GHOSTTY_ACTION_OPEN_CONFIG,
GHOSTTY_ACTION_QUIT_TIMER,
GHOSTTY_ACTION_SECURE_INPUT,
} ghostty_action_tag_e;
typedef union {
ghostty_action_split_direction_e new_split;
ghostty_action_fullscreen_e toggle_fullscreen;
ghostty_action_goto_tab_e goto_tab;
ghostty_action_goto_split_e goto_split;
ghostty_action_resize_split_s resize_split;
ghostty_action_size_limit_s size_limit;
ghostty_action_initial_size_s initial_size;
ghostty_action_cell_size_s cell_size;
ghostty_action_inspector_e inspector;
ghostty_action_desktop_notification_s desktop_notification;
ghostty_action_set_title_s set_title;
ghostty_action_mouse_shape_e mouse_shape;
ghostty_action_mouse_visibility_e mouse_visibility;
ghostty_action_mouse_over_link_s mouse_over_link;
ghostty_action_renderer_health_e renderer_health;
ghostty_action_quit_timer_e quit_timer;
ghostty_action_secure_input_e secure_input;
} ghostty_action_u;
typedef struct {
ghostty_action_tag_e tag;
ghostty_action_u action;
} 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_open_config_cb)(void*);
typedef void (*ghostty_runtime_set_title_cb)(void*, const char*);
typedef void (*ghostty_runtime_set_mouse_shape_cb)(void*,
ghostty_mouse_shape_e);
typedef void (*ghostty_runtime_set_mouse_visibility_cb)(void*, bool);
typedef void (*ghostty_runtime_read_clipboard_cb)(void*,
ghostty_clipboard_e,
void*);
@ -433,67 +569,21 @@ typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
const char*,
ghostty_clipboard_e,
bool);
typedef void (*ghostty_runtime_new_split_cb)(void*,
ghostty_split_direction_e,
ghostty_surface_config_s);
typedef void (*ghostty_runtime_new_tab_cb)(void*, ghostty_surface_config_s);
typedef void (*ghostty_runtime_new_window_cb)(void*, ghostty_surface_config_s);
typedef void (*ghostty_runtime_control_inspector_cb)(void*,
ghostty_inspector_mode_e);
typedef void (*ghostty_runtime_close_surface_cb)(void*, bool);
typedef void (*ghostty_runtime_focus_split_cb)(void*,
ghostty_split_focus_direction_e);
typedef void (*ghostty_runtime_resize_split_cb)(
void*,
ghostty_split_resize_direction_e,
uint16_t);
typedef void (*ghostty_runtime_equalize_splits_cb)(void*);
typedef void (*ghostty_runtime_toggle_split_zoom_cb)(void*);
typedef void (*ghostty_runtime_goto_tab_cb)(void*, int32_t);
typedef void (*ghostty_runtime_toggle_fullscreen_cb)(
void*,
ghostty_non_native_fullscreen_e);
typedef void (*ghostty_runtime_set_initial_window_size_cb)(void*,
uint32_t,
uint32_t);
typedef void (*ghostty_runtime_render_inspector_cb)(void*);
typedef void (*ghostty_runtime_set_cell_size_cb)(void*, uint32_t, uint32_t);
typedef void (*ghostty_runtime_show_desktop_notification_cb)(void*,
const char*,
const char*);
typedef void (
*ghostty_runtime_update_renderer_health)(void*, ghostty_renderer_health_e);
typedef void (*ghostty_runtime_mouse_over_link_cb)(void*, const char*, size_t);
typedef void (*ghostty_runtime_action_cb)(ghostty_app_t,
ghostty_target_s,
ghostty_action_s);
typedef struct {
void* userdata;
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_open_config_cb open_config_cb;
ghostty_runtime_set_title_cb set_title_cb;
ghostty_runtime_set_mouse_shape_cb set_mouse_shape_cb;
ghostty_runtime_set_mouse_visibility_cb set_mouse_visibility_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;
ghostty_runtime_new_split_cb new_split_cb;
ghostty_runtime_new_tab_cb new_tab_cb;
ghostty_runtime_new_window_cb new_window_cb;
ghostty_runtime_control_inspector_cb control_inspector_cb;
ghostty_runtime_close_surface_cb close_surface_cb;
ghostty_runtime_focus_split_cb focus_split_cb;
ghostty_runtime_resize_split_cb resize_split_cb;
ghostty_runtime_equalize_splits_cb equalize_splits_cb;
ghostty_runtime_toggle_split_zoom_cb toggle_split_zoom_cb;
ghostty_runtime_goto_tab_cb goto_tab_cb;
ghostty_runtime_toggle_fullscreen_cb toggle_fullscreen_cb;
ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb;
ghostty_runtime_render_inspector_cb render_inspector_cb;
ghostty_runtime_set_cell_size_cb set_cell_size_cb;
ghostty_runtime_show_desktop_notification_cb show_desktop_notification_cb;
ghostty_runtime_update_renderer_health update_renderer_health_cb;
ghostty_runtime_mouse_over_link_cb mouse_over_link_cb;
} ghostty_runtime_config_s;
//-------------------------------------------------------------------
@ -523,16 +613,20 @@ ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
void ghostty_app_free(ghostty_app_t);
bool ghostty_app_tick(ghostty_app_t);
void* ghostty_app_userdata(ghostty_app_t);
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);
bool ghostty_app_needs_confirm_quit(ghostty_app_t);
bool ghostty_app_has_global_keybinds(ghostty_app_t);
ghostty_surface_config_s ghostty_surface_config_new();
ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*);
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);
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
void ghostty_surface_refresh(ghostty_surface_t);
void ghostty_surface_draw(ghostty_surface_t);
@ -552,7 +646,10 @@ bool ghostty_surface_mouse_button(ghostty_surface_t,
ghostty_input_mouse_state_e,
ghostty_input_mouse_button_e,
ghostty_input_mods_e);
void ghostty_surface_mouse_pos(ghostty_surface_t, double, double);
void ghostty_surface_mouse_pos(ghostty_surface_t,
double,
double,
ghostty_input_mods_e);
void ghostty_surface_mouse_scroll(ghostty_surface_t,
double,
double,
@ -560,11 +657,11 @@ void ghostty_surface_mouse_scroll(ghostty_surface_t,
void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double);
void ghostty_surface_ime_point(ghostty_surface_t, double*, double*);
void ghostty_surface_request_close(ghostty_surface_t);
void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e);
void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e);
void ghostty_surface_split_focus(ghostty_surface_t,
ghostty_split_focus_direction_e);
ghostty_action_goto_split_e);
void ghostty_surface_split_resize(ghostty_surface_t,
ghostty_split_resize_direction_e,
ghostty_action_resize_split_direction_e,
uint16_t);
void ghostty_surface_split_equalize(ghostty_surface_t);
bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t);

View File

@ -21,6 +21,7 @@
A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; };
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */; };
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */; };
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */; };
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */; };
A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */; };
A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */; };
@ -34,6 +35,7 @@
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; };
A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; };
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; };
@ -41,6 +43,7 @@
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; };
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */; };
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; };
A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; };
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630962AEE163600D64628 /* HostingWindow.swift */; };
@ -57,6 +60,16 @@
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; };
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; };
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; };
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; };
A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */; };
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */; };
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */; };
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */; };
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; };
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; };
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; };
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; };
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */; };
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */; };
@ -91,6 +104,7 @@
A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = "<group>"; };
A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateDelegate.swift; sourceTree = "<group>"; };
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Input.swift; sourceTree = "<group>"; };
A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalScreen.swift; sourceTree = "<group>"; };
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_UIKit.swift; sourceTree = "<group>"; };
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossKit.swift; sourceTree = "<group>"; };
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_AppKit.swift; sourceTree = "<group>"; };
@ -98,6 +112,7 @@
A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; };
A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; };
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = "<group>"; };
@ -105,6 +120,7 @@
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Shell.swift; sourceTree = "<group>"; };
A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = "<group>"; };
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = "<group>"; };
A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = "<group>"; };
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = "<group>"; };
@ -122,6 +138,15 @@
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; };
A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; };
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = "<group>"; };
A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalController.swift; sourceTree = "<group>"; };
A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalWindow.swift; sourceTree = "<group>"; };
A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalPosition.swift; sourceTree = "<group>"; };
A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = "<group>"; };
A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = "<group>"; };
A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = "<group>"; };
A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = "<group>"; };
A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsController.swift; sourceTree = "<group>"; };
A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsView.swift; sourceTree = "<group>"; };
@ -188,9 +213,12 @@
A53426362A7DC53000EBB7A2 /* Features */ = {
isa = PBXGroup;
children = (
A5CBD0672CA2704E0017A1AE /* Global Keybinds */,
A56D58872ACDE6BE00508D2C /* Services */,
A59630982AEE1C4400D64628 /* Terminal */,
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
A57D79252C9C8782001D522E /* Secure Input */,
A534263E2A7DCC5800EBB7A2 /* Settings */,
A51BFC1C2B2FB5AB00E92F16 /* About */,
A51BFC292B30F69F00E92F16 /* Update */,
@ -203,14 +231,17 @@
children = (
A5CEAFFE29C2410700646FDA /* Backport.swift */,
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */,
A5CBD0572C9F30860017A1AE /* Cursor.swift */,
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */,
A5CEAFDA29B8005900646FDA /* SplitView */,
@ -295,6 +326,15 @@
path = Services;
sourceTree = "<group>";
};
A57D79252C9C8782001D522E /* Secure Input */ = {
isa = PBXGroup;
children = (
A57D79262C9C8798001D522E /* SecureInput.swift */,
A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */,
);
path = "Secure Input";
sourceTree = "<group>";
};
A59630982AEE1C4400D64628 /* Terminal */ = {
isa = PBXGroup;
children = (
@ -306,6 +346,7 @@
A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */,
AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */,
A535B9D9299C569B0017E2E4 /* ErrorView.swift */,
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */,
);
path = Terminal;
sourceTree = "<group>";
@ -346,6 +387,26 @@
name = Products;
sourceTree = "<group>";
};
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = {
isa = PBXGroup;
children = (
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */,
A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */,
A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */,
A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */,
A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */,
);
path = QuickTerminal;
sourceTree = "<group>";
};
A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = {
isa = PBXGroup;
children = (
A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */,
);
path = "Global Keybinds";
sourceTree = "<group>";
};
A5CEAFDA29B8005900646FDA /* SplitView */ = {
isa = PBXGroup;
children = (
@ -422,7 +483,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1520;
LastUpgradeCheck = 1420;
LastUpgradeCheck = 1600;
TargetAttributes = {
A5B30530299BEAAA0047F10C = {
CreatedOnToolsVersion = 14.2;
@ -471,6 +532,7 @@
A5985CE62C33060F00C57AD3 /* man in Resources */,
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */,
552964E62B34A9B400030505 /* vim in Resources */,
A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -491,20 +553,28 @@
files = (
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */,
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
@ -516,10 +586,13 @@
A5FEB3002ABB69450068369E /* main.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */,
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */,
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */,
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */,
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */,
@ -538,6 +611,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */,
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
@ -587,6 +661,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@ -619,6 +694,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
@ -678,6 +754,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@ -738,6 +815,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@ -770,6 +848,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
@ -809,6 +888,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;

View File

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

View File

@ -22,6 +22,7 @@ class AppDelegate: NSObject,
@IBOutlet private var menuCheckForUpdates: NSMenuItem?
@IBOutlet private var menuOpenConfig: NSMenuItem?
@IBOutlet private var menuReloadConfig: NSMenuItem?
@IBOutlet private var menuSecureInput: NSMenuItem?
@IBOutlet private var menuQuit: NSMenuItem?
@IBOutlet private var menuNewWindow: NSMenuItem?
@ -48,6 +49,7 @@ class AppDelegate: NSObject,
@IBOutlet private var menuIncreaseFontSize: NSMenuItem?
@IBOutlet private var menuDecreaseFontSize: NSMenuItem?
@IBOutlet private var menuResetFontSize: NSMenuItem?
@IBOutlet private var menuQuickTerminal: NSMenuItem?
@IBOutlet private var menuTerminalInspector: NSMenuItem?
@IBOutlet private var menuEqualizeSplits: NSMenuItem?
@ -62,16 +64,28 @@ class AppDelegate: NSObject,
/// This is only true before application has become active.
private var applicationHasBecomeActive: Bool = false
/// This is set in applicationDidFinishLaunching with the system uptime so we can determine the
/// seconds since the process was launched.
private var applicationLaunchTime: TimeInterval = 0
/// The ghostty global state. Only one per process.
let ghostty: Ghostty.App = Ghostty.App()
/// Manages our terminal windows.
let terminalManager: TerminalManager
/// Our quick terminal. This starts out uninitialized and only initializes if used.
private var quickController: QuickTerminalController? = nil
/// Manages updates
let updaterController: SPUStandardUpdaterController
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
/// The elapsed time since the process was started
var timeSinceLaunch: TimeInterval {
return ProcessInfo.processInfo.systemUptime - applicationLaunchTime
}
override init() {
terminalManager = TerminalManager(ghostty)
updaterController = SPUStandardUpdaterController(
@ -105,6 +119,14 @@ class AppDelegate: NSObject,
"ApplePressAndHoldEnabled": false,
])
// Store our start time
applicationLaunchTime = ProcessInfo.processInfo.systemUptime
// Check if secure input was enabled when we last quit.
if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) {
toggleSecureInput(self)
}
// Hook up updater menu
menuCheckForUpdates?.target = updaterController
menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:))
@ -292,8 +314,11 @@ class AppDelegate: NSObject,
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: "inspector:toggle", menuItem: self.menuTerminalInspector)
syncMenuShortcut(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.
@ -410,6 +435,25 @@ class AppDelegate: NSObject,
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.
@ -445,6 +489,24 @@ class AppDelegate: NSObject,
dockMenu.addItem(newTab)
}
//MARK: - Global State
func setSecureInput(_ mode: Ghostty.SetSecureInput) {
let input = SecureInput.shared
switch (mode) {
case .on:
input.global = true
case .off:
input.global = false
case .toggle:
input.global.toggle()
}
self.menuSecureInput?.state = if (input.global) { .on } else { .off }
UserDefaults.standard.set(input.global, forKey: "SecureInput")
}
//MARK: - IB Actions
@IBAction func openConfig(_ sender: Any?) {
@ -484,4 +546,22 @@ class AppDelegate: NSObject,
guard let url = URL(string: "https://github.com/ghostty-org/ghostty") else { return }
NSWorkspace.shared.open(url)
}
@IBAction func toggleSecureInput(_ sender: Any) {
setSecureInput(.toggle)
}
@IBAction func toggleQuickTerminal(_ sender: Any) {
if quickController == nil {
quickController = QuickTerminalController(
ghostty,
position: ghostty.config.quickTerminalPosition
)
}
guard let quickController = self.quickController else { return }
quickController.toggle()
self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off }
}
}

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22505" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22505"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@ -32,17 +32,19 @@
<outlet property="menuOpenConfig" destination="BOF-NM-1cW" id="Nze-Go-glw"/>
<outlet property="menuPaste" destination="i27-pK-umN" id="ICc-X2-gV3"/>
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
<outlet property="menuQuickTerminal" destination="kvF-d2-JsP" id="a0u-tf-IEc"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
<outlet property="menuReloadConfig" destination="KKH-XX-5py" id="Wvp-7J-wqX"/>
<outlet property="menuResetFontSize" destination="Jah-MY-aLX" id="ger-qM-wrm"/>
<outlet property="menuSecureInput" destination="oC6-w4-qI7" id="PCc-pe-Mda"/>
<outlet property="menuSelectAll" destination="q2h-lq-e4r" id="s98-r1-Jcv"/>
<outlet property="menuSelectSplitAbove" destination="0yU-hC-8xF" id="aPc-lS-own"/>
<outlet property="menuSelectSplitBelow" destination="QDz-d9-CBr" id="FsH-Dq-jij"/>
<outlet property="menuSelectSplitLeft" destination="cTK-oy-KuV" id="Jpr-5q-dqz"/>
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
<outlet property="menuServices" destination="aQe-vS-j8Q" id="uWQ-Wo-T1L"/>
<outlet property="menuSplitRight" destination="VUR-Ld-nLx" id="RxO-Zw-ovb"/>
<outlet property="menuSplitDown" destination="UDZ-4y-6xL" id="fgZ-Wb-8OR"/>
<outlet property="menuSplitRight" destination="VUR-Ld-nLx" id="RxO-Zw-ovb"/>
<outlet property="menuTerminalInspector" destination="QwP-M5-fvh" id="wJi-Dh-S9f"/>
<outlet property="menuToggleFullScreen" destination="8kY-Pi-KaY" id="yQg-6V-OO6"/>
<outlet property="menuZoomSplit" destination="oPd-mn-IEH" id="wTu-jK-egI"/>
@ -76,6 +78,12 @@
<action selector="reloadConfig:" target="bbz-4X-AYv" id="h5x-tu-Izk"/>
</connections>
</menuItem>
<menuItem title="Secure Keyboard Entry" id="oC6-w4-qI7">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleSecureInput:" target="bbz-4X-AYv" id="vWx-z8-5Sy"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Services" id="rJe-5J-bwL">
<modifierMask key="keyEquivalentModifierMask"/>
@ -209,6 +217,13 @@
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="L3L-I8-sqk"/>
<menuItem title="Quick Terminal" id="kvF-d2-JsP">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleQuickTerminal:" target="bbz-4X-AYv" id="gm3-mk-l8N"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="bC9-n9-RbJ"/>
<menuItem title="Terminal Inspector" id="QwP-M5-fvh">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>

View File

@ -34,6 +34,9 @@ struct ClipboardConfirmationView: View {
/// Optional delegate to get results. If this is nil, then this view will never close on its own.
weak var delegate: ClipboardConfirmationViewDelegate? = nil
/// Used to track if we should rehide on disappear
@State private var cursorHiddenCount: UInt = 0
var body: some View {
VStack {
HStack {
@ -65,6 +68,25 @@ struct ClipboardConfirmationView: View {
}
.padding(.bottom)
}
.onAppear {
// I can't find a better way to handle this. There is no API to detect
// if the cursor is hidden and OTHER THINGS do unhide the cursor. So we
// try to unhide it completely here and hope for the best. Issue #1516.
cursorHiddenCount = Cursor.unhideCompletely()
// If we didn't unhide anything, we just send an unhide to be safe.
// I don't think the count can go negative on NSCursor so this handles
// scenarios cursor is hidden outside of our own NSCursor usage.
if (cursorHiddenCount == 0) {
_ = Cursor.unhide()
}
}
.onDisappear {
// Rehide if we unhid
for _ in 0..<cursorHiddenCount {
Cursor.hide()
}
}
}
private func onCancel() {

View File

@ -0,0 +1,151 @@
import Cocoa
import CoreGraphics
import Carbon
import OSLog
import GhosttyKit
// Manages the event tap to monitor global events, currently only used for
// global keybindings.
class GlobalEventTap {
static let shared = GlobalEventTap()
fileprivate static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: GlobalEventTap.self)
)
// The event tap used for global event listening. This is non-nil if it is
// created.
private var eventTap: CFMachPort? = nil
// This is the timer used to retry enabling the global event tap if we
// don't have permissions.
private var enableTimer: Timer? = nil
// Private init so it can't be constructed outside of our singleton
private init() {}
deinit {
disable()
}
// Enable the global event tap. This is safe to call if it is already enabled.
// If enabling fails due to permissions, this will start a timer to retry since
// accessibility permissions take affect immediately.
func enable() {
if (eventTap != nil) {
// Already enabled
return
}
// If we are already trying to enable, then stop the timer and restart it.
if let enableTimer {
enableTimer.invalidate()
}
// Try to enable the event tap immediately. If this succeeds then we're done!
if (tryEnable()) {
return
}
// Failed, probably due to permissions. The permissions dialog should've
// popped up. We retry on a timer since once the permissions are granted
// then they take affect immediately.
enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
_ = self.tryEnable()
}
}
// Disable the global event tap. This is safe to call if it is already disabled.
func disable() {
// Stop our enable timer if it is on
if let enableTimer {
enableTimer.invalidate()
self.enableTimer = nil
}
// Stop our event tap
if let eventTap {
Self.logger.debug("invalidating event tap mach port")
CFMachPortInvalidate(eventTap)
self.eventTap = nil
}
}
// Try to enable the global event type, returns false if it fails.
private func tryEnable() -> Bool {
// The events we care about
let eventMask = [
CGEventType.keyDown
].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue)})
// Try to create it
guard let eventTap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: eventMask,
callback: cgEventFlagsChangedHandler(proxy:type:cgEvent:userInfo:),
userInfo: nil
) else {
// Return false if creation failed. This is usually because we don't have
// Accessibility permissions but can probably be other reasons I don't
// know about.
Self.logger.debug("creating global event tap failed, missing permissions?")
return false
}
// Store our event tap
self.eventTap = eventTap
// If we have an enable timer we always want to disable it
if let enableTimer {
enableTimer.invalidate()
self.enableTimer = nil
}
// Attach our event tap to the main run loop. Note if you don't do this then
// the event tap will block every
CFRunLoopAddSource(
CFRunLoopGetMain(),
CFMachPortCreateRunLoopSource(nil, eventTap, 0),
.commonModes
)
Self.logger.info("global event tap enabled for global keybinds")
return true
}
}
fileprivate func cgEventFlagsChangedHandler(
proxy: CGEventTapProxy,
type: CGEventType,
cgEvent: CGEvent,
userInfo: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {
let result = Unmanaged.passUnretained(cgEvent)
// We only care about keydown events
guard type == .keyDown else { return result }
// We need an app delegate to get the Ghostty app instance
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return result }
guard let ghostty = appDelegate.ghostty.app else { return result }
// We need an NSEvent for our logic below
guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result }
// Build our event input and call ghostty
var key_ev = ghostty_input_key_s()
key_ev.action = GHOSTTY_ACTION_PRESS
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = nil
key_ev.composing = false
if (ghostty_app_key(ghostty, key_ev)) {
GlobalEventTap.logger.info("global key event handled event=\(event)")
return nil
}
return result
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="QuickTerminalController" customModule="Ghostty" customModuleProvider="target">
<connections>
<outlet property="window" destination="QvC-M9-y7g" id="JMU-zX-9Ie"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="QuickTerminalWindow" customModule="Ghostty" customModuleProvider="target">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="196" y="240" width="480" height="270"/>
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1667"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<connections>
<outlet property="delegate" destination="-2" id="u5f-FR-jJw"/>
</connections>
<point key="canvasLocation" x="132" y="-82"/>
</window>
</objects>
</document>

View File

@ -0,0 +1,211 @@
import Foundation
import Cocoa
import SwiftUI
import GhosttyKit
/// Controller for the "quick" terminal.
class QuickTerminalController: BaseTerminalController {
override var windowNibName: NSNib.Name? { "QuickTerminal" }
/// The position for the quick terminal.
let position: QuickTerminalPosition
/// The current state of the quick terminal
private(set) var visible: Bool = false
init(_ ghostty: Ghostty.App,
position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: Ghostty.SplitNode? = nil
) {
self.position = position
super.init(ghostty, baseConfig: base, surfaceTree: tree)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
// MARK: NSWindowController
override func windowDidLoad() {
guard let window = self.window else { return }
// The controller is the window delegate so we can detect events such as
// window close so we can animate out.
window.delegate = self
// The quick window is not restorable (yet!). "Yet" because in theory we can
// make this restorable, but it isn't currently implemented.
window.isRestorable = false
// Setup our initial size based on our configured position
position.setLoaded(window)
// Setup our content
window.contentView = NSHostingView(rootView: TerminalView(
ghostty: self.ghostty,
viewModel: self,
delegate: self
))
// Animate the window in
animateIn()
}
// MARK: NSWindowDelegate
override func windowDidResignKey(_ notification: Notification) {
super.windowDidResignKey(notification)
// We don't animate out if there is a modal sheet being shown currently.
// This lets us show alerts without causing the window to disappear.
guard window?.attachedSheet == nil else { return }
animateOut()
}
func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize {
// We use the actual screen the window is on for this, since it should
// be on the proper screen.
guard let screen = window?.screen ?? NSScreen.main else { return frameSize }
return position.restrictFrameSize(frameSize, on: screen)
}
// MARK: Base Controller Overrides
override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
super.surfaceTreeDidChange(from: from, to: to)
// If our surface tree is nil then we animate the window out.
if (to == nil) {
animateOut()
}
}
// MARK: Methods
func toggle() {
if (visible) {
animateOut()
} else {
animateIn()
}
}
func animateIn() {
guard let window = self.window else { return }
// Set our visibility state
guard !visible else { return }
visible = true
// Animate the window in
animateWindowIn(window: window, from: position)
// If our surface tree is nil then we initialize a new terminal. The surface
// tree can be nil if for example we run "eixt" in the terminal and force
// animate out.
if (surfaceTree == nil) {
let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil)
surfaceTree = .leaf(leaf)
focusedSurface = leaf.surface
// We need to grab first responder but it takes a few loop cycles
// before the view is attached to the window so we do it async.
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
// We should probably retry here but I was never able to trigger this.
// If this happens though its a crash so let's avoid it.
guard let leafWindow = leaf.surface.window,
leafWindow == window else { return }
window.makeFirstResponder(leaf.surface)
}
}
}
func animateOut() {
guard let window = self.window else { return }
// Set our visibility state
guard visible else { return }
visible = false
animateWindowOut(window: window, to: position)
}
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = ghostty.config.quickTerminalScreen.screen else { return }
// Move our window off screen to the top
position.setInitial(in: window, on: screen)
// Move it to the visible position since animation requires this
window.makeKeyAndOrderFront(nil)
// Run the animation that moves our window into the proper place and makes
// it visible.
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.2
context.timingFunction = .init(name: .easeIn)
position.setFinal(in: window.animator(), on: screen)
}
}
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
// We always animate out to whatever screen the window is actually on.
guard let screen = window.screen ?? NSScreen.main else { return }
// Keep track of if we were the key window. If we were the key window then we
// want to move focus to the next window so that focus is preserved somewhere
// in the app.
let wasKey = window.isKeyWindow
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2
context.timingFunction = .init(name: .easeIn)
position.setInitial(in: window.animator(), on: screen)
}, completionHandler: {
guard wasKey else { return }
self.focusNextWindow()
})
}
private func focusNextWindow() {
// We only want to consider windows that are visible
let windows = NSApp.windows.filter { $0.isVisible }
// If we have no windows there is nothing to focus.
guard !windows.isEmpty else { return }
// Find the current key window (the window that is currently focused)
if let keyWindow = NSApp.keyWindow,
let currentIndex = windows.firstIndex(of: keyWindow) {
// Calculate the index of the next window (cycle through the list)
let nextIndex = (currentIndex + 1) % windows.count
let nextWindow = windows[nextIndex]
// Make the next window key and bring it to the front
nextWindow.makeKeyAndOrderFront(nil)
} else {
// If there's no key window, focus the first available window
windows.first?.makeKeyAndOrderFront(nil)
}
}
// MARK: First Responder
@IBAction override func closeWindow(_ sender: Any) {
// Instead of closing the window, we animate it out.
animateOut()
}
@IBAction func newTab(_ sender: Any?) {
guard let window else { return }
let alert = NSAlert()
alert.messageText = "Cannot Create New Tab"
alert.informativeText = "Tabs aren't supported in the Quick Terminal."
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
alert.beginSheetModal(for: window)
}
}

View File

@ -0,0 +1,102 @@
import Cocoa
enum QuickTerminalPosition : String {
case top
case bottom
case left
case right
/// Set the loaded state for a window.
func setLoaded(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { return }
switch (self) {
case .top, .bottom:
window.setFrame(.init(
origin: window.frame.origin,
size: .init(
width: screen.frame.width,
height: screen.frame.height / 4)
), display: false)
case .left, .right:
window.setFrame(.init(
origin: window.frame.origin,
size: .init(
width: screen.frame.width / 4,
height: screen.frame.height)
), display: false)
}
}
/// Set the initial state for a window for animating out of this position.
func setInitial(in window: NSWindow, on screen: NSScreen) {
// We always start invisible
window.alphaValue = 0
// Position depends
window.setFrame(.init(
origin: initialOrigin(for: window, on: screen),
size: restrictFrameSize(window.frame.size, on: screen)
), display: false)
}
/// Set the final state for a window in this position.
func setFinal(in window: NSWindow, on screen: NSScreen) {
// We always end visible
window.alphaValue = 1
// Position depends
window.setFrame(.init(
origin: finalOrigin(for: window, on: screen),
size: restrictFrameSize(window.frame.size, on: screen)
), display: true)
}
/// Restrict the frame size during resizing.
func restrictFrameSize(_ size: NSSize, on screen: NSScreen) -> NSSize {
var finalSize = size
switch (self) {
case .top, .bottom:
finalSize.width = screen.frame.width
case .left, .right:
finalSize.height = screen.frame.height
}
return finalSize
}
/// The initial point origin for this position.
func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch (self) {
case .top:
return .init(x: screen.frame.minX, y: screen.frame.maxY)
case .bottom:
return .init(x: screen.frame.minX, y: -window.frame.height)
case .left:
return .init(x: -window.frame.width, y: 0)
case .right:
return .init(x: screen.frame.maxX, y: 0)
}
}
/// The final point origin for this position.
func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch (self) {
case .top:
return .init(x: screen.frame.minX, y: screen.visibleFrame.maxY - window.frame.height)
case .bottom:
return .init(x: screen.frame.minX, y: screen.frame.minY)
case .left:
return .init(x: screen.frame.minX, y: window.frame.origin.y)
case .right:
return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y)
}
}
}

View File

@ -0,0 +1,37 @@
import Cocoa
enum QuickTerminalScreen {
case main
case mouse
case menuBar
init?(fromGhosttyConfig string: String) {
switch (string) {
case "main":
self = .main
case "mouse":
self = .mouse
case "macos-menu-bar":
self = .menuBar
default:
return nil
}
}
var screen: NSScreen? {
switch (self) {
case .main:
return NSScreen.main
case .mouse:
let mouseLoc = NSEvent.mouseLocation
return NSScreen.screens.first(where: { $0.frame.contains(mouseLoc) })
case .menuBar:
return NSScreen.screens.first
}
}
}

View File

@ -0,0 +1,39 @@
import Cocoa
class QuickTerminalWindow: NSWindow {
// Both of these must be true for windows without decorations to be able to
// still become key/main and receive events.
override var canBecomeKey: Bool { return true }
override var canBecomeMain: Bool { return true }
override func awakeFromNib() {
super.awakeFromNib()
// Note: almost all of this stuff can be done in the nib/xib directly
// but I prefer to do it programmatically because the properties we
// care about are less hidden.
// Remove the title completely. This will make the window square. One
// downside is it also hides the cursor indications of resize but the
// window remains resizable.
self.styleMask.remove(.titled)
// We need to set our window level to a high value. In testing, only
// popUpMenu and above do what we want. This gets it above the menu bar
// and lets us render off screen.
self.level = .popUpMenu
// This plus the level above was what was needed for the animation to work,
// because it gets the window off screen properly. Plus we add some fields
// we just want the behavior of.
self.collectionBehavior = [
// We want this to be part of every space because it is a singleton.
.canJoinAllSpaces,
// We don't want to be part of command-tilde
.ignoresCycle,
// We never support fullscreen
.fullScreenNone]
}
}

View File

@ -0,0 +1,135 @@
import Carbon
import Cocoa
import OSLog
// Manages the secure keyboard input state. Secure keyboard input is an old Carbon
// API still in use by applications such as Webkit. From the old Carbon docs:
// "When secure event input mode is enabled, keyboard input goes only to the
// application with keyboard focus and is not echoed to other applications that
// might be using the event monitor target to watch keyboard input."
//
// Secure input is global and stateful so you need a singleton class to manage
// it. You have to yield secure input on application deactivation (because
// it'll affect other apps) and reacquire on reactivation, and every enable
// needs to be balanced with a disable.
class SecureInput : ObservableObject {
static let shared = SecureInput()
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: SecureInput.self)
)
// True if you want to enable secure input globally.
var global: Bool = false {
didSet {
apply()
}
}
// The scoped objects and whether they're currently in focus.
private var scoped: [ObjectIdentifier: Bool] = [:]
// This is set to true when we've successfully called EnableSecureInput.
@Published private(set) var enabled: Bool = false
// This is true if we want to enable secure input. We want to enable
// secure input if its enabled globally or any of the scoped objects are
// in focus.
private var desired: Bool {
global || scoped.contains(where: { $0.value })
}
private init() {
// Add notifications for application active/resign so we can disable
// secure input. This is only useful for global enabling of secure
// input.
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(onDidResignActive(notification:)),
name: NSApplication.didResignActiveNotification,
object: nil)
center.addObserver(
self,
selector: #selector(onDidBecomeActive(notification:)),
name: NSApplication.didBecomeActiveNotification,
object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
// Reset our state so that we can ensure we set the proper secure input
// system state
scoped.removeAll()
global = false
apply()
}
// Add a scoped object that has secure input enabled. The focused value will
// determine if the object currently has focus. This is used so that secure
// input is only enabled while the object is focused.
func setScoped(_ object: ObjectIdentifier, focused: Bool) {
scoped[object] = focused
apply()
}
// Remove a scoped object completely.
func removeScoped(_ object: ObjectIdentifier) {
scoped[object] = nil
apply()
}
private func apply() {
// If we aren't active then we don't do anything. The become/resign
// active notifications will handle applying for us.
guard NSApp.isActive else { return }
// We only need to apply if we're not in our desired state
guard enabled != desired else { return }
let err: OSStatus
if (enabled) {
err = DisableSecureEventInput()
} else {
err = EnableSecureEventInput()
}
if (err == noErr) {
enabled = desired
Self.logger.debug("secure input state=\(self.enabled)")
return
}
Self.logger.warning("secure input apply failed err=\(err)")
}
// MARK: Notifications
@objc private func onDidBecomeActive(notification: NSNotification) {
// We only want to re-enable if we're not already enabled and we
// desire to be enabled.
guard !enabled && desired else { return }
let err = EnableSecureEventInput()
if (err == noErr) {
enabled = true
Self.logger.debug("secure input enabled on activation")
return
}
Self.logger.warning("secure input apply failed err=\(err)")
}
@objc private func onDidResignActive(notification: NSNotification) {
// We only want to disable if we're enabled.
guard enabled else { return }
let err = DisableSecureEventInput()
if (err == noErr) {
enabled = false
Self.logger.debug("secure input disabled on deactivation")
return
}
Self.logger.warning("secure input apply failed err=\(err)")
}
}

View File

@ -0,0 +1,68 @@
import SwiftUI
struct SecureInputOverlay: View {
// Animations
@State private var shadowAngle: Angle = .degrees(0)
@State private var shadowWidth: CGFloat = 6
// Popover explainer text
@State private var isPopover = false
var body: some View {
VStack {
HStack {
Spacer()
Image(systemName: "lock.shield.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 25, height: 25)
.foregroundColor(.primary)
.padding(5)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.background)
.innerShadow(
using: RoundedRectangle(cornerRadius: 12),
stroke: AngularGradient(
gradient: Gradient(colors: [.cyan, .blue, .yellow, .blue, .cyan]),
center: .center,
angle: shadowAngle
),
width: shadowWidth
)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.gray, lineWidth: 1)
)
.onTapGesture {
isPopover = true
}
.backport.pointerStyle(.link)
.padding(.top, 10)
.padding(.trailing, 10)
.popover(isPresented: $isPopover, arrowEdge: .bottom) {
Text("""
Secure Input is active. Secure Input is a macOS security feature that
prevents applications from reading keyboard events. This is enabled
automatically whenever Ghostty detects a password prompt in the terminal,
or at all times if `Ghostty > Secure Keyboard Entry` is active.
""")
.padding(.all)
}
}
Spacer()
}
.onAppear {
withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) {
shadowAngle = .degrees(360)
}
withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: true)) {
shadowWidth = 12
}
}
}
}

View File

@ -0,0 +1,363 @@
import Cocoa
import SwiftUI
import GhosttyKit
/// A base class for windows that can contain Ghostty windows. This base class implements
/// the bare minimum functionality that every terminal window in Ghostty should implement.
///
/// Usage: Specify this as the base class of your window controller for the window that contains
/// a terminal. The window controller must also be the window delegate OR the window delegate
/// functions on this base class must be called by your own custom delegate. For the terminal
/// view the TerminalView SwiftUI view must be used and this class is the view model and
/// delegate.
///
/// Notably, things this class does NOT implement (not exhaustive):
///
/// - Tabbing, because there are many ways to get tabbed behavior in macOS and we
/// don't want to be opinionated about it.
/// - Fullscreen
/// - Window restoration or save state
/// - Window visual styles (such as titlebar colors)
///
/// The primary idea of all the behaviors we don't implement here are that subclasses may not
/// want these behaviors.
class BaseTerminalController: NSWindowController,
NSWindowDelegate,
TerminalViewDelegate,
TerminalViewModel,
ClipboardConfirmationViewDelegate
{
/// The app instance that this terminal view will represent.
let ghostty: Ghostty.App
/// The currently focused surface.
var focusedSurface: Ghostty.SurfaceView? = nil {
didSet { syncFocusToSurfaceTree() }
}
/// The surface tree for this window.
@Published var surfaceTree: Ghostty.SplitNode? = nil {
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
}
/// Non-nil when an alert is active so we don't overlap multiple.
private var alert: NSAlert? = nil
/// The clipboard confirmation window, if shown.
private var clipboardConfirmation: ClipboardConfirmationController? = nil
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
init(_ ghostty: Ghostty.App,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: Ghostty.SplitNode? = nil
) {
self.ghostty = ghostty
super.init(window: nil)
// Initialize our initial surface.
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base))
// Setup our notifications for behaviors
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(onConfirmClipboardRequest),
name: Ghostty.Notification.confirmClipboard,
object: nil)
}
/// Called when the surfaceTree variable changed.
///
/// Subclasses should call super first.
func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
// If our surface tree becomes nil then ensure all surfaces
// in the old tree have closed.
if (to == nil) {
from?.close()
focusedSurface = nil
}
}
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
/// what surface is focused. This must be called whenever a surface OR window changes focus.
func syncFocusToSurfaceTree() {
guard let tree = self.surfaceTree else { return }
for leaf in tree {
// Our focus state requires that this window is key and our currently
// focused surface is the surface in this leaf.
let focused: Bool = (window?.isKeyWindow ?? false) &&
focusedSurface != nil &&
leaf.surface == focusedSurface!
leaf.surface.focusDidChange(focused)
}
}
// MARK: TerminalViewDelegate
// Note: this is different from surfaceDidTreeChange(from:,to:) because this is called
// when the currently set value changed in place and the from:to: variant is called
// when the variable was set.
func surfaceTreeDidChange() {}
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
focusedSurface = to
}
func titleDidChange(to: String) {
guard let window else { return }
// Set the main window title
window.title = to
}
func cellSizeDidChange(to: NSSize) {
guard ghostty.config.windowStepResize else { return }
self.window?.contentResizeIncrements = to
}
func zoomStateDidChange(to: Bool) {}
// MARK: Clipboard Confirmation
@objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
guard let surface = target.surface else { return }
// We need a window
guard let window = self.window else { return }
// Check whether we use non-native fullscreen
guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return }
guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return }
guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return }
// If we already have a clipboard confirmation view up, we ignore this request.
// This shouldn't be possible...
guard self.clipboardConfirmation == nil else {
Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true)
return
}
// Show our paste confirmation
self.clipboardConfirmation = ClipboardConfirmationController(
surface: surface,
contents: str,
request: request,
state: state,
delegate: self
)
window.beginSheet(self.clipboardConfirmation!.window!)
}
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) {
// End our clipboard confirmation no matter what
guard let cc = self.clipboardConfirmation else { return }
self.clipboardConfirmation = nil
// Close the sheet
if let ccWindow = cc.window {
window?.endSheet(ccWindow)
}
switch (request) {
case .osc_52_write:
guard case .confirm = action else { break }
let pb = NSPasteboard.general
pb.declareTypes([.string], owner: nil)
pb.setString(cc.contents, forType: .string)
case .osc_52_read, .paste:
let str: String
switch (action) {
case .cancel:
str = ""
case .confirm:
str = cc.contents
}
Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true)
}
}
//MARK: - NSWindowDelegate
// This is called when performClose is called on a window (NOT when close()
// is called directly). performClose is called primarily when UI elements such
// as the "red X" are pressed.
func windowShouldClose(_ sender: NSWindow) -> Bool {
// We must have a window. Is it even possible not to?
guard let window = self.window else { return true }
// If we have no surfaces, close.
guard let node = self.surfaceTree else { return true }
// If we already have an alert, continue with it
guard alert == nil else { return false }
// If our surfaces don't require confirmation, close.
if (!node.needsConfirmQuit()) { return true }
// We require confirmation, so show an alert as long as we aren't already.
let alert = NSAlert()
alert.messageText = "Close Terminal?"
alert.informativeText = "The terminal still has a running process. If you close the " +
"terminal the process will be killed."
alert.addButton(withTitle: "Close the Terminal")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window, completionHandler: { response in
self.alert = nil
switch (response) {
case .alertFirstButtonReturn:
window.close()
default:
break
}
})
self.alert = alert
return false
}
func windowWillClose(_ notification: Notification) {
guard let window else { return }
// I don't know if this is required anymore. We previously had a ref cycle between
// the view and the window so we had to nil this out to break it but I think this
// may now be resolved. We should verify that no memory leaks and we can remove this.
window.contentView = nil
}
func windowDidBecomeKey(_ notification: Notification) {
// Becoming/losing key means we have to notify our surface(s) that we have focus
// so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree()
}
func windowDidResignKey(_ notification: Notification) {
// Becoming/losing key means we have to notify our surface(s) that we have focus
// so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree()
}
func windowDidChangeOcclusionState(_ notification: Notification) {
guard let surfaceTree = self.surfaceTree else { return }
let visible = self.window?.occlusionState.contains(.visible) ?? false
for leaf in surfaceTree {
if let surface = leaf.surface.surface {
ghostty_surface_set_occlusion(surface, visible)
}
}
}
// MARK: First Responder
@IBAction func close(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.requestClose(surface: surface)
}
@IBAction func closeWindow(_ sender: Any) {
guard let window = window else { return }
window.performClose(sender)
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT)
}
@IBAction func splitDown(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_DOWN)
}
@IBAction func splitZoom(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitToggleZoom(surface: surface)
}
@IBAction func splitMoveFocusPrevious(_ sender: Any) {
splitMoveFocus(direction: .previous)
}
@IBAction func splitMoveFocusNext(_ sender: Any) {
splitMoveFocus(direction: .next)
}
@IBAction func splitMoveFocusAbove(_ sender: Any) {
splitMoveFocus(direction: .top)
}
@IBAction func splitMoveFocusBelow(_ sender: Any) {
splitMoveFocus(direction: .bottom)
}
@IBAction func splitMoveFocusLeft(_ sender: Any) {
splitMoveFocus(direction: .left)
}
@IBAction func splitMoveFocusRight(_ sender: Any) {
splitMoveFocus(direction: .right)
}
@IBAction func equalizeSplits(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitEqualize(surface: surface)
}
@IBAction func moveSplitDividerUp(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitResize(surface: surface, direction: .up, amount: 10)
}
@IBAction func moveSplitDividerDown(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitResize(surface: surface, direction: .down, amount: 10)
}
@IBAction func moveSplitDividerLeft(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitResize(surface: surface, direction: .left, amount: 10)
}
@IBAction func moveSplitDividerRight(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitResize(surface: surface, direction: .right, amount: 10)
}
private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitMoveFocus(surface: surface, direction: direction)
}
@IBAction func increaseFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .increase(1))
}
@IBAction func decreaseFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .decrease(1))
}
@IBAction func resetFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .reset)
}
@objc func resetTerminal(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.resetTerminal(surface: surface)
}
}

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22505" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22505"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>

View File

@ -3,45 +3,14 @@ import Cocoa
import SwiftUI
import GhosttyKit
/// The terminal controller is an NSWindowController that maps 1:1 to a terminal window.
class TerminalController: NSWindowController, NSWindowDelegate,
TerminalViewDelegate, TerminalViewModel,
ClipboardConfirmationViewDelegate
/// A classic, tabbed terminal experience.
class TerminalController: BaseTerminalController
{
override var windowNibName: NSNib.Name? { "Terminal" }
/// The app instance that this terminal view will represent.
let ghostty: Ghostty.App
/// The currently focused surface.
var focusedSurface: Ghostty.SurfaceView? = nil {
didSet {
syncFocusToSurfaceTree()
}
}
/// The surface tree for this window.
@Published var surfaceTree: Ghostty.SplitNode? = nil {
didSet {
// If our surface tree becomes nil then ensure all surfaces
// in the old tree have closed and then close the window.
if (surfaceTree == nil) {
oldValue?.close()
focusedSurface = nil
lastSurfaceDidClose()
}
}
}
/// Fullscreen state management.
let fullscreenHandler = FullScreenHandler()
/// True when an alert is active so we don't overlap multiple.
private var alert: NSAlert? = nil
/// The clipboard confirmation window, if shown.
private var clipboardConfirmation: ClipboardConfirmationController? = nil
/// This is set to true when we care about frame changes. This is a small optimization since
/// this controller registers a listener for ALL frame change notifications and this lets us bail
/// early if we don't care.
@ -59,8 +28,6 @@ class TerminalController: NSWindowController, NSWindowDelegate,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: Ghostty.SplitNode? = nil
) {
self.ghostty = ghostty
// The window we manage is not restorable if we've specified a command
// to execute. We do this because the restored window is meaningless at the
// time of writing this: it'd just restore to a shell in the same directory
@ -68,11 +35,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// restoration.
self.restorable = (base?.command ?? "") == ""
super.init(window: nil)
// Initialize our initial surface.
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base))
super.init(ghostty, baseConfig: base, surfaceTree: tree)
// Setup our notifications for behaviors
let center = NotificationCenter.default
@ -86,11 +49,6 @@ class TerminalController: NSWindowController, NSWindowDelegate,
selector: #selector(onGotoTab),
name: Ghostty.Notification.ghosttyGotoTab,
object: nil)
center.addObserver(
self,
selector: #selector(onConfirmClipboardRequest),
name: Ghostty.Notification.confirmClipboard,
object: nil)
center.addObserver(
self,
selector: #selector(onFrameDidChange),
@ -108,6 +66,17 @@ class TerminalController: NSWindowController, NSWindowDelegate,
center.removeObserver(self)
}
// MARK: Base Controller Overrides
override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
super.surfaceTreeDidChange(from: from, to: to)
// If our surface tree is now nil then we close our window.
if (to == nil) {
self.window?.close()
}
}
//MARK: - Methods
func configDidReload() {
@ -230,21 +199,6 @@ class TerminalController: NSWindowController, NSWindowDelegate,
}
}
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
/// what surface is focused. This must be called whenever a surface OR window changes focus.
private func syncFocusToSurfaceTree() {
guard let tree = self.surfaceTree else { return }
for leaf in tree {
// Our focus state requires that this window is key and our currently
// focused surface is the surface in this leaf.
let focused: Bool = (window?.isKeyWindow ?? false) &&
focusedSurface != nil &&
leaf.surface == focusedSurface!
leaf.surface.focusDidChange(focused)
}
}
//MARK: - NSWindowController
override func windowWillLoad() {
@ -334,6 +288,34 @@ class TerminalController: NSWindowController, NSWindowDelegate,
delegate: self
))
// If our titlebar style is "hidden" we adjust the style appropriately
if (ghostty.config.macosTitlebarStyle == "hidden") {
window.styleMask = [
// We need `titled` in the mask to get the normal window frame
.titled,
// Full size content view so we can extend
// content in to the hidden titlebar's area
.fullSizeContentView,
.resizable,
.closable,
.miniaturizable,
]
// Hide the title
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
// Hide the traffic lights (window control buttons)
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
window.tabbingMode = .disallowed
}
// In various situations, macOS automatically tabs new windows. Ghostty handles
// its own tabbing so we DONT want this behavior. This detects this scenario and undoes
// it.
@ -369,84 +351,21 @@ class TerminalController: NSWindowController, NSWindowDelegate,
//MARK: - NSWindowDelegate
// This is called when performClose is called on a window (NOT when close()
// is called directly). performClose is called primarily when UI elements such
// as the "red X" are pressed.
func windowShouldClose(_ sender: NSWindow) -> Bool {
// We must have a window. Is it even possible not to?
guard let window = self.window else { return true }
// If we have no surfaces, close.
guard let node = self.surfaceTree else { return true }
// If we already have an alert, continue with it
guard alert == nil else { return false }
// If our surfaces don't require confirmation, close.
if (!node.needsConfirmQuit()) { return true }
// We require confirmation, so show an alert as long as we aren't already.
let alert = NSAlert()
alert.messageText = "Close Terminal?"
alert.informativeText = "The terminal still has a running process. If you close the " +
"terminal the process will be killed."
alert.addButton(withTitle: "Close the Terminal")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window, completionHandler: { response in
self.alert = nil
switch (response) {
case .alertFirstButtonReturn:
window.close()
default:
break
}
})
self.alert = alert
return false
}
func windowWillClose(_ notification: Notification) {
// I don't know if this is required anymore. We previously had a ref cycle between
// the view and the window so we had to nil this out to break it but I think this
// may now be resolved. We should verify that no memory leaks and we can remove this.
self.window?.contentView = nil
override func windowWillClose(_ notification: Notification) {
super.windowWillClose(notification)
self.relabelTabs()
}
func windowDidBecomeKey(_ notification: Notification) {
override func windowDidBecomeKey(_ notification: Notification) {
super.windowDidBecomeKey(notification)
self.relabelTabs()
self.fixTabBar()
// Becoming/losing key means we have to notify our surface(s) that we have focus
// so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree()
}
func windowDidResignKey(_ notification: Notification) {
// Becoming/losing key means we have to notify our surface(s) that we have focus
// so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree()
}
func windowDidMove(_ notification: Notification) {
self.fixTabBar()
}
func windowDidChangeOcclusionState(_ notification: Notification) {
guard let surfaceTree = self.surfaceTree else { return }
let visible = self.window?.occlusionState.contains(.visible) ?? false
for leaf in surfaceTree {
if let surface = leaf.surface.surface {
ghostty_surface_set_occlusion(surface, visible)
}
}
}
// Called when the window will be encoded. We handle the data encoding here in the
// window controller.
func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) {
@ -454,7 +373,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
data.encode(with: state)
}
//MARK: - First Responder
// MARK: First Responder
@IBAction func newWindow(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
@ -466,12 +385,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
ghostty.newTab(surface: surface)
}
@IBAction func close(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.requestClose(surface: surface)
}
@IBAction func closeWindow(_ sender: Any) {
@IBAction override func closeWindow(_ sender: Any) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else {
// No tabs, no tab group, just perform a normal close.
@ -521,120 +435,26 @@ class TerminalController: NSWindowController, NSWindowDelegate,
})
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT)
}
@IBAction func splitDown(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
}
@IBAction func splitZoom(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitToggleZoom(surface: surface)
}
@IBAction func splitMoveFocusPrevious(_ sender: Any) {
splitMoveFocus(direction: .previous)
}
@IBAction func splitMoveFocusNext(_ sender: Any) {
splitMoveFocus(direction: .next)
}
@IBAction func splitMoveFocusAbove(_ sender: Any) {
splitMoveFocus(direction: .top)
}
@IBAction func splitMoveFocusBelow(_ sender: Any) {
splitMoveFocus(direction: .bottom)
}
@IBAction func splitMoveFocusLeft(_ sender: Any) {
splitMoveFocus(direction: .left)
}
@IBAction func splitMoveFocusRight(_ sender: Any) {
splitMoveFocus(direction: .right)
}
@IBAction func equalizeSplits(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitEqualize(surface: surface)
}
@IBAction func moveSplitDividerUp(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitResize(surface: surface, direction: .up, amount: 10)
}
@IBAction func moveSplitDividerDown(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitResize(surface: surface, direction: .down, amount: 10)
}
@IBAction func moveSplitDividerLeft(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitResize(surface: surface, direction: .left, amount: 10)
}
@IBAction func moveSplitDividerRight(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitResize(surface: surface, direction: .right, amount: 10)
}
private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitMoveFocus(surface: surface, direction: direction)
}
@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleFullscreen(surface: surface)
}
@IBAction func increaseFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .increase(1))
}
@IBAction func decreaseFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .decrease(1))
}
@IBAction func resetFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .reset)
}
@IBAction func toggleTerminalInspector(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleTerminalInspector(surface: surface)
}
@objc func resetTerminal(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.resetTerminal(surface: surface)
}
//MARK: - TerminalViewDelegate
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
self.focusedSurface = to
}
override func titleDidChange(to: String) {
super.titleDidChange(to: to)
func titleDidChange(to: String) {
guard let window = window as? TerminalWindow else { return }
// Set the main window title
window.title = to
// Custom toolbar-based title used when titlebar tabs are enabled.
if let toolbar = window.toolbar as? TerminalToolbar {
if (window.titlebarTabs) {
if (window.titlebarTabs || ghostty.config.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.
@ -644,58 +464,17 @@ class TerminalController: NSWindowController, NSWindowDelegate,
}
}
func cellSizeDidChange(to: NSSize) {
guard ghostty.config.windowStepResize else { return }
self.window?.contentResizeIncrements = to
}
func lastSurfaceDidClose() {
self.window?.close()
}
func surfaceTreeDidChange() {
override func surfaceTreeDidChange() {
// Whenever our surface tree changes in any way (new split, close split, etc.)
// we want to invalidate our state.
invalidateRestorableState()
}
func zoomStateDidChange(to: Bool) {
override func zoomStateDidChange(to: Bool) {
guard let window = window as? TerminalWindow else { return }
window.surfaceIsZoomed = to
}
//MARK: - Clipboard Confirmation
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) {
// End our clipboard confirmation no matter what
guard let cc = self.clipboardConfirmation else { return }
self.clipboardConfirmation = nil
// Close the sheet
if let ccWindow = cc.window {
window?.endSheet(ccWindow)
}
switch (request) {
case .osc_52_write:
guard case .confirm = action else { break }
let pb = NSPasteboard.general
pb.declareTypes([.string], owner: nil)
pb.setString(cc.contents, forType: .string)
case .osc_52_read, .paste:
let str: String
switch (action) {
case .cancel:
str = ""
case .confirm:
str = cc.contents
}
Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true)
}
}
//MARK: - Notifications
@objc private func onGotoTab(notification: SwiftUI.Notification) {
@ -704,8 +483,9 @@ class TerminalController: NSWindowController, NSWindowDelegate,
guard let window = self.window else { return }
// Get the tab index from the notification
guard let tabIndexAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
guard let tabIndex = tabIndexAny as? Int32 else { return }
guard let tabEnumAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return }
let tabIndex: Int32 = tabEnum.rawValue
guard let windowController = window.windowController else { return }
guard let tabGroup = windowController.window?.tabGroup else { return }
@ -719,19 +499,19 @@ class TerminalController: NSWindowController, NSWindowDelegate,
guard let selectedWindow = tabGroup.selectedWindow else { return }
guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return }
if (tabIndex == GHOSTTY_TAB_PREVIOUS.rawValue) {
if (tabIndex == GHOSTTY_GOTO_TAB_PREVIOUS.rawValue) {
if (selectedIndex == 0) {
finalIndex = tabbedWindows.count - 1
} else {
finalIndex = selectedIndex - 1
}
} else if (tabIndex == GHOSTTY_TAB_NEXT.rawValue) {
} else if (tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue) {
if (selectedIndex == tabbedWindows.count - 1) {
finalIndex = 0
} else {
finalIndex = selectedIndex + 1
}
} else if (tabIndex == GHOSTTY_TAB_LAST.rawValue) {
} else if (tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue) {
finalIndex = tabbedWindows.count - 1
} else {
return
@ -755,44 +535,13 @@ class TerminalController: NSWindowController, NSWindowDelegate,
guard let window = self.window else { return }
// Check whether we use non-native fullscreen
guard let useNonNativeFullscreenAny = notification.userInfo?[Ghostty.Notification.NonNativeFullscreenKey] else { return }
guard let useNonNativeFullscreen = useNonNativeFullscreenAny as? ghostty_non_native_fullscreen_e else { return }
self.fullscreenHandler.toggleFullscreen(window: window, nonNativeFullscreen: useNonNativeFullscreen)
guard let fullscreenModeAny = notification.userInfo?[Ghostty.Notification.FullscreenModeKey] else { return }
guard let fullscreenMode = fullscreenModeAny as? ghostty_action_fullscreen_e else { return }
self.fullscreenHandler.toggleFullscreen(window: window, mode: fullscreenMode)
// For some reason focus always gets lost when we toggle fullscreen, so we set it back.
if let focusedSurface {
Ghostty.moveFocus(to: focusedSurface)
}
}
@objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
guard let surface = target.surface else { return }
// We need a window
guard let window = self.window else { return }
// Check whether we use non-native fullscreen
guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return }
guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return }
guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return }
// If we already have a clipboard confirmation view up, we ignore this request.
// This shouldn't be possible...
guard self.clipboardConfirmation == nil else {
Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true)
return
}
// Show our paste confirmation
self.clipboardConfirmation = ClipboardConfirmationController(
surface: surface,
contents: str,
request: request,
state: state,
delegate: self
)
window.beginSheet(self.clipboardConfirmation!.window!)
}
}

View File

@ -78,6 +78,12 @@ class TerminalManager {
window.toggleFullScreen(nil)
}
// If our app isn't active, we make it active. All new_window actions
// force our app to be active.
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
}
// We're dispatching this async because otherwise the lastCascadePoint doesn't
// take effect. Our best theory is there is some next-event-loop-tick logic
// that Cocoa is doing that we need to be after.
@ -142,19 +148,24 @@ class TerminalManager {
// the macOS APIs only work on a visible window.
controller.showWindow(self)
// Add the window to the tab group and show it.
switch ghostty.config.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.
if let last = parent.tabGroup?.windows.last {
last.addTabbedWindow(window, ordered: .above)
} else {
fallthrough
// 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") {
// Add the window to the tab group and show it.
switch ghostty.config.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.
if let last = parent.tabGroup?.windows.last {
last.addTabbedWindow(window, ordered: .above)
} else {
fallthrough
}
case "current": fallthrough
default:
parent.addTabbedWindow(window, ordered: .above)
}
case "current": fallthrough
default:
parent.addTabbedWindow(window, ordered: .above)
}
window.makeKeyAndOrderFront(self)

View File

@ -18,17 +18,10 @@ protocol TerminalViewDelegate: AnyObject {
/// not called initially.
func surfaceTreeDidChange()
/// This is called when a split is zoomed.
func zoomStateDidChange(to: Bool)
}
// Default all the functions so they're optional
extension TerminalViewDelegate {
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {}
func titleDidChange(to: String) {}
func cellSizeDidChange(to: NSSize) {}
func zoomStateDidChange(to: Bool) {}
}
/// The view model is a required implementation for TerminalView callers. This contains
/// the main state between the TerminalView caller and SwiftUI. This abstraction is what
/// allows AppKit to own most of the data in SwiftUI.
@ -83,7 +76,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)
@ -108,6 +101,8 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
self.delegate?.zoomStateDidChange(to: newValue ?? false)
}
}
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])
}
}
}

View File

@ -67,34 +67,12 @@ extension Ghostty {
userdata: Unmanaged.passUnretained(self).toOpaque(),
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) },
open_config_cb: { userdata in App.openConfig(userdata) },
set_title_cb: { userdata, title in App.setTitle(userdata, title: title) },
set_mouse_shape_cb: { userdata, shape in App.setMouseShape(userdata, shape: shape) },
set_mouse_visibility_cb: { userdata, visible in App.setMouseVisibility(userdata, visible: visible) },
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) },
new_split_cb: { userdata, direction, surfaceConfig in App.newSplit(userdata, direction: direction, config: surfaceConfig) },
new_tab_cb: { userdata, surfaceConfig in App.newTab(userdata, config: surfaceConfig) },
new_window_cb: { userdata, surfaceConfig in App.newWindow(userdata, config: surfaceConfig) },
control_inspector_cb: { userdata, mode in App.controlInspector(userdata, mode: mode) },
close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) },
focus_split_cb: { userdata, direction in App.focusSplit(userdata, direction: direction) },
resize_split_cb: { userdata, direction, amount in
App.resizeSplit(userdata, direction: direction, amount: amount) },
equalize_splits_cb: { userdata in
App.equalizeSplits(userdata) },
toggle_split_zoom_cb: { userdata in App.toggleSplitZoom(userdata) },
goto_tab_cb: { userdata, n in App.gotoTab(userdata, n: n) },
toggle_fullscreen_cb: { userdata, nonNativeFullscreen in App.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) },
set_initial_window_size_cb: { userdata, width, height in App.setInitialWindowSize(userdata, width: width, height: height) },
render_inspector_cb: { userdata in App.renderInspector(userdata) },
set_cell_size_cb: { userdata, width, height in App.setCellSize(userdata, width: width, height: height) },
show_desktop_notification_cb: { userdata, title, body in
App.showUserNotification(userdata, title: title, body: body) },
update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) },
mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) }
close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) }
)
// Create the ghostty app.
@ -183,7 +161,7 @@ extension Ghostty {
}
}
func split(surface: ghostty_surface_t, direction: ghostty_split_direction_e) {
func split(surface: ghostty_surface_t, direction: ghostty_action_split_direction_e) {
ghostty_surface_split(surface, direction)
}
@ -252,11 +230,8 @@ extension Ghostty {
// MARK: Ghostty Callbacks (iOS)
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 openConfig(_ userdata: UnsafeMutableRawPointer?) {}
static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?) {}
static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) {}
static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) {}
static func readClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
@ -277,28 +252,7 @@ extension Ghostty {
confirm: Bool
) {}
static func newSplit(
_ userdata: UnsafeMutableRawPointer?,
direction: ghostty_split_direction_e,
config: ghostty_surface_config_s
) {}
static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {}
static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {}
static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) {}
static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {}
static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) {}
static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) {}
static func equalizeSplits(_ userdata: UnsafeMutableRawPointer?) {}
static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) {}
static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) {}
static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) {}
static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {}
static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {}
static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {}
static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?, body: UnsafePointer<CChar>?) {}
static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {}
static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer<CChar>?, len: Int) {}
#endif
#if os(macOS)
@ -314,14 +268,6 @@ extension Ghostty {
// MARK: Ghostty Callbacks (macOS)
static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e, config: ghostty_surface_config_s) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [
"direction": direction,
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
])
}
static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface, userInfo: [
@ -329,56 +275,6 @@ extension Ghostty {
])
}
static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) {
let surface = self.surfaceUserdata(from: userdata)
guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return }
NotificationCenter.default.post(
name: Notification.ghosttyFocusSplit,
object: surface,
userInfo: [
Notification.SplitDirectionKey: splitDirection,
]
)
}
static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) {
let surface = self.surfaceUserdata(from: userdata)
guard let resizeDirection = SplitResizeDirection.from(direction: direction) else { return }
NotificationCenter.default.post(
name: Notification.didResizeSplit,
object: surface,
userInfo: [
Notification.ResizeSplitDirectionKey: resizeDirection,
Notification.ResizeSplitAmountKey: amount,
]
)
}
static func equalizeSplits(_ userdata: UnsafeMutableRawPointer?) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(name: Notification.didEqualizeSplits, object: surface)
}
static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(
name: Notification.didToggleSplitZoom,
object: surface
)
}
static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(
name: Notification.ghosttyGotoTab,
object: surface,
userInfo: [
Notification.GotoTabKey: n,
]
)
}
static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) {
// If we don't even have a surface, something went terrible wrong so we have
// to leak "state".
@ -450,10 +346,6 @@ extension Ghostty {
)
}
static func openConfig(_ userdata: UnsafeMutableRawPointer?) {
ghostty_config_open();
}
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? {
let newConfig = Config()
guard newConfig.loaded else {
@ -484,84 +376,661 @@ extension Ghostty {
DispatchQueue.main.async { state.appTick() }
}
static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(
name: Notification.inspectorNeedsDisplay,
object: surface
)
/// Determine if a given notification should be presented to the user when Ghostty is running in the foreground.
func shouldPresentNotification(notification: UNNotification) -> Bool {
let userInfo = notification.request.content.userInfo
guard let uuidString = userInfo["surface"] as? String,
let uuid = UUID(uuidString: uuidString),
let surface = delegate?.findSurface(forUUID: uuid),
let window = surface.window else { return false }
return !window.isKeyWindow || !surface.focused
}
static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?) {
let surfaceView = self.surfaceUserdata(from: userdata)
guard let titleStr = String(cString: title!, encoding: .utf8) else { return }
DispatchQueue.main.async {
surfaceView.title = titleStr
}
/// Returns the GhosttyState from the given userdata value.
static private func appState(fromView view: SurfaceView) -> App? {
guard let surface = view.surface else { return nil }
guard let app = ghostty_surface_app(surface) else { return nil }
guard let app_ud = ghostty_app_userdata(app) else { return nil }
return Unmanaged<App>.fromOpaque(app_ud).takeUnretainedValue()
}
static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) {
let surfaceView = self.surfaceUserdata(from: userdata)
surfaceView.setCursorShape(shape)
/// Returns the surface view from the userdata.
static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView {
return Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
}
static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) {
let surfaceView = self.surfaceUserdata(from: userdata)
surfaceView.setCursorVisibility(visible)
static private func surfaceView(from surface: ghostty_surface_t) -> SurfaceView? {
guard let surface_ud = ghostty_surface_userdata(surface) else { return nil }
return Unmanaged<SurfaceView>.fromOpaque(surface_ud).takeUnretainedValue()
}
static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(
name: Notification.ghosttyToggleFullscreen,
object: surface,
userInfo: [
Notification.NonNativeFullscreenKey: nonNativeFullscreen,
]
)
}
// MARK: Actions (macOS)
static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {
// We need a window to set the frame
let surfaceView = self.surfaceUserdata(from: userdata)
surfaceView.initialSize = NSMakeSize(Double(width), Double(height))
}
static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) {
// Make sure it a target we understand so all our action handlers can assert
switch (target.tag) {
case GHOSTTY_TARGET_APP, GHOSTTY_TARGET_SURFACE:
break
static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {
let surfaceView = self.surfaceUserdata(from: userdata)
let backingSize = NSSize(width: Double(width), height: Double(height))
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
}
static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer<CChar>?, len: Int) {
let surfaceView = self.surfaceUserdata(from: userdata)
guard len > 0 else {
surfaceView.hoverUrl = nil
default:
Ghostty.logger.warning("unknown action target=\(target.tag.rawValue)")
return
}
let buffer = Data(bytes: uri!, count: len)
surfaceView.hoverUrl = String(data: buffer, encoding: .utf8)
// Action dispatch
switch (action.tag) {
case GHOSTTY_ACTION_NEW_WINDOW:
newWindow(app, target: target)
case GHOSTTY_ACTION_NEW_TAB:
newTab(app, target: target)
case GHOSTTY_ACTION_NEW_SPLIT:
newSplit(app, target: target, direction: action.action.new_split)
case GHOSTTY_ACTION_TOGGLE_FULLSCREEN:
toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen)
case GHOSTTY_ACTION_GOTO_TAB:
gotoTab(app, target: target, tab: action.action.goto_tab)
case GHOSTTY_ACTION_GOTO_SPLIT:
gotoSplit(app, target: target, direction: action.action.goto_split)
case GHOSTTY_ACTION_RESIZE_SPLIT:
resizeSplit(app, target: target, resize: action.action.resize_split)
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
equalizeSplits(app, target: target)
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
toggleSplitZoom(app, target: target)
case GHOSTTY_ACTION_INSPECTOR:
controlInspector(app, target: target, mode: action.action.inspector)
case GHOSTTY_ACTION_RENDER_INSPECTOR:
renderInspector(app, target: target)
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
showDesktopNotification(app, target: target, n: action.action.desktop_notification)
case GHOSTTY_ACTION_SET_TITLE:
setTitle(app, target: target, v: action.action.set_title)
case GHOSTTY_ACTION_OPEN_CONFIG:
ghostty_config_open()
case GHOSTTY_ACTION_SECURE_INPUT:
toggleSecureInput(app, target: target, mode: action.action.secure_input)
case GHOSTTY_ACTION_MOUSE_SHAPE:
setMouseShape(app, target: target, shape: action.action.mouse_shape)
case GHOSTTY_ACTION_MOUSE_VISIBILITY:
setMouseVisibility(app, target: target, v: action.action.mouse_visibility)
case GHOSTTY_ACTION_MOUSE_OVER_LINK:
setMouseOverLink(app, target: target, v: action.action.mouse_over_link)
case GHOSTTY_ACTION_INITIAL_SIZE:
setInitialSize(app, target: target, v: action.action.initial_size)
case GHOSTTY_ACTION_CELL_SIZE:
setCellSize(app, target: target, v: action.action.cell_size)
case GHOSTTY_ACTION_RENDERER_HEALTH:
rendererHealth(app, target: target, v: action.action.renderer_health)
case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL:
toggleQuickTerminal(app, target: target)
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
fallthrough
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
fallthrough
case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS:
fallthrough
case GHOSTTY_ACTION_PRESENT_TERMINAL:
fallthrough
case GHOSTTY_ACTION_SIZE_LIMIT:
fallthrough
case GHOSTTY_ACTION_QUIT_TIMER:
Ghostty.logger.info("known but unimplemented action action=\(action.tag.rawValue)")
default:
Ghostty.logger.warning("unknown action action=\(action.tag.rawValue)")
}
}
static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?, body: UnsafePointer<CChar>?) {
let surfaceView = self.surfaceUserdata(from: userdata)
guard let title = String(cString: title!, encoding: .utf8) else { return }
guard let body = String(cString: body!, encoding: .utf8) else { return }
private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
NotificationCenter.default.post(
name: Notification.ghosttyNewWindow,
object: nil,
userInfo: [:]
)
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { _, error in
if let error = error {
AppDelegate.logger.error("Error while requesting notification authorization: \(error)")
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: Notification.ghosttyNewWindow,
object: surfaceView,
userInfo: [
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
]
)
default:
assertionFailure()
}
}
private static func newTab(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
NotificationCenter.default.post(
name: Notification.ghosttyNewTab,
object: nil,
userInfo: [:]
)
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let appState = self.appState(fromView: surfaceView) else { return }
guard appState.config.windowDecorations else {
let alert = NSAlert()
alert.messageText = "Tabs are disabled"
alert.informativeText = "Enable window decorations to use tabs"
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
_ = alert.runModal()
return
}
}
center.getNotificationSettings() { settings in
guard settings.authorizationStatus == .authorized else { return }
surfaceView.showUserNotification(title: title, body: body)
NotificationCenter.default.post(
name: Notification.ghosttyNewTab,
object: surfaceView,
userInfo: [
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
]
)
default:
assertionFailure()
}
}
private static func newSplit(
_ app: ghostty_app_t,
target: ghostty_target_s,
direction: ghostty_action_split_direction_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
// New split does nothing with an app target
Ghostty.logger.warning("new split 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: Notification.ghosttyNewSplit,
object: surfaceView,
userInfo: [
"direction": direction,
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
]
)
default:
assertionFailure()
}
}
private static func toggleFullscreen(
_ app: ghostty_app_t,
target: ghostty_target_s,
mode: ghostty_action_fullscreen_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle fullscreen 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: Notification.ghosttyToggleFullscreen,
object: surfaceView,
userInfo: [
Notification.FullscreenModeKey: mode,
]
)
default:
assertionFailure()
}
}
private static func gotoTab(
_ app: ghostty_app_t,
target: ghostty_target_s,
tab: ghostty_action_goto_tab_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("goto tab 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: Notification.ghosttyGotoTab,
object: surfaceView,
userInfo: [
Notification.GotoTabKey: tab,
]
)
default:
assertionFailure()
}
}
private static func gotoSplit(
_ app: ghostty_app_t,
target: ghostty_target_s,
direction: ghostty_action_goto_split_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("goto split 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: Notification.ghosttyFocusSplit,
object: surfaceView,
userInfo: [
Notification.SplitDirectionKey: SplitFocusDirection.from(direction: direction) as Any,
]
)
default:
assertionFailure()
}
}
private static func resizeSplit(
_ app: ghostty_app_t,
target: ghostty_target_s,
resize: ghostty_action_resize_split_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("resize split 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 resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return }
NotificationCenter.default.post(
name: Notification.didResizeSplit,
object: surfaceView,
userInfo: [
Notification.ResizeSplitDirectionKey: resizeDirection,
Notification.ResizeSplitAmountKey: resize.amount,
]
)
default:
assertionFailure()
}
}
private static func equalizeSplits(
_ app: ghostty_app_t,
target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("equalize splits 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: Notification.didEqualizeSplits,
object: surfaceView
)
default:
assertionFailure()
}
}
private static func toggleSplitZoom(
_ app: ghostty_app_t,
target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle split zoom 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: Notification.didToggleSplitZoom,
object: surfaceView
)
default:
assertionFailure()
}
}
private static func controlInspector(
_ app: ghostty_app_t,
target: ghostty_target_s,
mode: ghostty_action_inspector_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle split zoom 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: Notification.didControlInspector,
object: surfaceView,
userInfo: ["mode": mode]
)
default:
assertionFailure()
}
}
private static func showDesktopNotification(
_ app: ghostty_app_t,
target: ghostty_target_s,
n: ghostty_action_desktop_notification_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle split zoom 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 title = String(cString: n.title!, encoding: .utf8) else { return }
guard let body = String(cString: n.body!, encoding: .utf8) else { return }
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { _, error in
if let error = error {
Ghostty.logger.error("Error while requesting notification authorization: \(error)")
}
}
center.getNotificationSettings() { settings in
guard settings.authorizationStatus == .authorized else { return }
surfaceView.showUserNotification(title: title, body: body)
}
default:
assertionFailure()
}
}
private static func toggleSecureInput(
_ app: ghostty_app_t,
target: ghostty_target_s,
mode mode_raw: ghostty_action_secure_input_e
) {
guard let mode = SetSecureInput.from(mode_raw) else { return }
switch (target.tag) {
case GHOSTTY_TARGET_APP:
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return }
appDelegate.setSecureInput(mode)
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let appState = self.appState(fromView: surfaceView) else { return }
guard appState.config.autoSecureInput else { return }
switch (mode) {
case .on:
surfaceView.passwordInput = true
case .off:
surfaceView.passwordInput = false
case .toggle:
surfaceView.passwordInput = !surfaceView.passwordInput
}
default:
assertionFailure()
}
}
private static func toggleQuickTerminal(
_ app: ghostty_app_t,
target: ghostty_target_s
) {
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return }
appDelegate.toggleQuickTerminal(self)
}
private static func setTitle(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_set_title_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set title 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 title = String(cString: v.title!, encoding: .utf8) else { return }
// We must set this in a dispatchqueue to avoid a deadlock on startup on some
// versions of macOS. I unfortunately didn't document the exact versions so
// I don't know when its safe to remove this.
DispatchQueue.main.async {
surfaceView.title = title
}
default:
assertionFailure()
}
}
private static func setMouseShape(
_ app: ghostty_app_t,
target: ghostty_target_s,
shape: ghostty_action_mouse_shape_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set mouse shapes 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 }
surfaceView.setCursorShape(shape)
default:
assertionFailure()
}
}
private static func setMouseVisibility(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_mouse_visibility_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set mouse shapes 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 }
switch (v) {
case GHOSTTY_MOUSE_VISIBLE:
surfaceView.setCursorVisibility(true)
case GHOSTTY_MOUSE_HIDDEN:
surfaceView.setCursorVisibility(false)
default:
return
}
default:
assertionFailure()
}
}
private static func setMouseOverLink(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_mouse_over_link_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("mouse over link 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 v.len > 0 else {
surfaceView.hoverUrl = nil
return
}
let buffer = Data(bytes: v.url!, count: v.len)
surfaceView.hoverUrl = String(data: buffer, encoding: .utf8)
default:
assertionFailure()
}
}
private static func setInitialSize(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_initial_size_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("mouse over link 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 }
surfaceView.initialSize = NSMakeSize(Double(v.width), Double(v.height))
default:
assertionFailure()
}
}
private static func setCellSize(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_cell_size_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("mouse over link 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 }
let backingSize = NSSize(width: Double(v.width), height: Double(v.height))
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
default:
assertionFailure()
}
}
private static func renderInspector(
_ app: ghostty_app_t,
target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("mouse over link 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: Notification.inspectorNeedsDisplay,
object: surfaceView
)
default:
assertionFailure()
}
}
private static func rendererHealth(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_renderer_health_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("mouse over link 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: Notification.didUpdateRendererHealth,
object: surfaceView,
userInfo: [
"health": v,
]
)
default:
assertionFailure()
}
}
// MARK: User Notifications
/// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user
func handleUserNotification(response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
@ -581,82 +1050,6 @@ extension Ghostty {
}
}
/// Determine if a given notification should be presented to the user when Ghostty is running in the foreground.
func shouldPresentNotification(notification: UNNotification) -> Bool {
let userInfo = notification.request.content.userInfo
guard let uuidString = userInfo["surface"] as? String,
let uuid = UUID(uuidString: uuidString),
let surface = delegate?.findSurface(forUUID: uuid),
let window = surface.window else { return false }
return !window.isKeyWindow || !surface.focused
}
static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
let surface = self.surfaceUserdata(from: userdata)
guard let appState = self.appState(fromView: surface) else { return }
guard appState.config.windowDecorations else {
let alert = NSAlert()
alert.messageText = "Tabs are disabled"
alert.informativeText = "Enable window decorations to use tabs"
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
_ = alert.runModal()
return
}
NotificationCenter.default.post(
name: Notification.ghosttyNewTab,
object: surface,
userInfo: [
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
]
)
}
static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(
name: Notification.ghosttyNewWindow,
object: surface,
userInfo: [
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
]
)
}
static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [
"mode": mode,
])
}
static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(
name: Notification.didUpdateRendererHealth,
object: surface,
userInfo: [
"health": health,
]
)
}
/// Returns the GhosttyState from the given userdata value.
static private func appState(fromView view: SurfaceView) -> App? {
guard let surface = view.surface else { return nil }
guard let app = ghostty_surface_app(surface) else { return nil }
guard let app_ud = ghostty_app_userdata(app) else { return nil }
return Unmanaged<App>.fromOpaque(app_ud).takeUnretainedValue()
}
/// Returns the surface view from the userdata.
static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView {
return Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
}
#endif
}
}

View File

@ -332,6 +332,28 @@ extension Ghostty {
return Color(newColor)
}
#if canImport(AppKit)
var quickTerminalPosition: QuickTerminalPosition {
guard let config = self.config else { return .top }
var v: UnsafePointer<Int8>? = nil
let key = "quick-terminal-position"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .top }
guard let ptr = v else { return .top }
let str = String(cString: ptr)
return QuickTerminalPosition(rawValue: str) ?? .top
}
var quickTerminalScreen: QuickTerminalScreen {
guard let config = self.config else { return .main }
var v: UnsafePointer<Int8>? = nil
let key = "quick-terminal-screen"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .main }
guard let ptr = v else { return .main }
let str = String(cString: ptr)
return QuickTerminalScreen(fromGhosttyConfig: str) ?? .main
}
#endif
var resizeOverlay: ResizeOverlay {
guard let config = self.config else { return .after_first }
var v: UnsafePointer<Int8>? = nil
@ -371,6 +393,22 @@ extension Ghostty {
let str = String(cString: ptr)
return AutoUpdate(rawValue: str) ?? defaultValue
}
var autoSecureInput: Bool {
guard let config = self.config else { return true }
var v = false;
let key = "macos-auto-secure-input"
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v
}
var secureInputIndication: Bool {
guard let config = self.config else { return true }
var v = false;
let key = "macos-secure-input-indication"
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v
}
}
}

View File

@ -219,13 +219,13 @@ extension Ghostty {
// Determine our desired direction
guard let directionAny = notification.userInfo?["direction"] else { return }
guard let direction = directionAny as? ghostty_split_direction_e else { return }
guard let direction = directionAny as? ghostty_action_split_direction_e else { return }
var splitDirection: SplitViewDirection
switch (direction) {
case GHOSTTY_SPLIT_RIGHT:
case GHOSTTY_SPLIT_DIRECTION_RIGHT:
splitDirection = .horizontal
case GHOSTTY_SPLIT_DOWN:
case GHOSTTY_SPLIT_DIRECTION_DOWN:
splitDirection = .vertical
default:

View File

@ -55,7 +55,7 @@ extension Ghostty {
private func onControlInspector(_ notification: SwiftUI.Notification) {
// Determine our mode
guard let modeAny = notification.userInfo?["mode"] else { return }
guard let mode = modeAny as? ghostty_inspector_mode_e else { return }
guard let mode = modeAny as? ghostty_action_inspector_e else { return }
switch (mode) {
case GHOSTTY_INSPECTOR_TOGGLE:

View File

@ -39,32 +39,54 @@ extension Ghostty {
}
}
// MARK: Surface Notifications
// MARK: Swift Types for C Types
extension Ghostty {
enum SetSecureInput {
case on
case off
case toggle
static func from(_ c: ghostty_action_secure_input_e) -> Self? {
switch (c) {
case GHOSTTY_SECURE_INPUT_ON:
return .on
case GHOSTTY_SECURE_INPUT_OFF:
return .off
case GHOSTTY_SECURE_INPUT_TOGGLE:
return .toggle
default:
return nil
}
}
}
/// An enum that is used for the directions that a split focus event can change.
enum SplitFocusDirection {
case previous, next, top, bottom, left, right
/// Initialize from a Ghostty API enum.
static func from(direction: ghostty_split_focus_direction_e) -> Self? {
static func from(direction: ghostty_action_goto_split_e) -> Self? {
switch (direction) {
case GHOSTTY_SPLIT_FOCUS_PREVIOUS:
case GHOSTTY_GOTO_SPLIT_PREVIOUS:
return .previous
case GHOSTTY_SPLIT_FOCUS_NEXT:
case GHOSTTY_GOTO_SPLIT_NEXT:
return .next
case GHOSTTY_SPLIT_FOCUS_TOP:
case GHOSTTY_GOTO_SPLIT_TOP:
return .top
case GHOSTTY_SPLIT_FOCUS_BOTTOM:
case GHOSTTY_GOTO_SPLIT_BOTTOM:
return .bottom
case GHOSTTY_SPLIT_FOCUS_LEFT:
case GHOSTTY_GOTO_SPLIT_LEFT:
return .left
case GHOSTTY_SPLIT_FOCUS_RIGHT:
case GHOSTTY_GOTO_SPLIT_RIGHT:
return .right
default:
@ -72,25 +94,25 @@ extension Ghostty {
}
}
func toNative() -> ghostty_split_focus_direction_e {
func toNative() -> ghostty_action_goto_split_e {
switch (self) {
case .previous:
return GHOSTTY_SPLIT_FOCUS_PREVIOUS
return GHOSTTY_GOTO_SPLIT_PREVIOUS
case .next:
return GHOSTTY_SPLIT_FOCUS_NEXT
return GHOSTTY_GOTO_SPLIT_NEXT
case .top:
return GHOSTTY_SPLIT_FOCUS_TOP
return GHOSTTY_GOTO_SPLIT_TOP
case .bottom:
return GHOSTTY_SPLIT_FOCUS_BOTTOM
return GHOSTTY_GOTO_SPLIT_BOTTOM
case .left:
return GHOSTTY_SPLIT_FOCUS_LEFT
return GHOSTTY_GOTO_SPLIT_LEFT
case .right:
return GHOSTTY_SPLIT_FOCUS_RIGHT
return GHOSTTY_GOTO_SPLIT_RIGHT
}
}
}
@ -99,31 +121,31 @@ extension Ghostty {
enum SplitResizeDirection {
case up, down, left, right
static func from(direction: ghostty_split_resize_direction_e) -> Self? {
static func from(direction: ghostty_action_resize_split_direction_e) -> Self? {
switch (direction) {
case GHOSTTY_SPLIT_RESIZE_UP:
case GHOSTTY_RESIZE_SPLIT_UP:
return .up;
case GHOSTTY_SPLIT_RESIZE_DOWN:
case GHOSTTY_RESIZE_SPLIT_DOWN:
return .down;
case GHOSTTY_SPLIT_RESIZE_LEFT:
case GHOSTTY_RESIZE_SPLIT_LEFT:
return .left;
case GHOSTTY_SPLIT_RESIZE_RIGHT:
case GHOSTTY_RESIZE_SPLIT_RIGHT:
return .right;
default:
return nil
}
}
func toNative() -> ghostty_split_resize_direction_e {
func toNative() -> ghostty_action_resize_split_direction_e {
switch (self) {
case .up:
return GHOSTTY_SPLIT_RESIZE_UP;
return GHOSTTY_RESIZE_SPLIT_UP;
case .down:
return GHOSTTY_SPLIT_RESIZE_DOWN;
return GHOSTTY_RESIZE_SPLIT_DOWN;
case .left:
return GHOSTTY_SPLIT_RESIZE_LEFT;
return GHOSTTY_RESIZE_SPLIT_LEFT;
case .right:
return GHOSTTY_SPLIT_RESIZE_RIGHT;
return GHOSTTY_RESIZE_SPLIT_RIGHT;
}
}
}
@ -174,6 +196,8 @@ extension Ghostty {
}
}
// MARK: Surface Notifications
extension Ghostty.Notification {
/// Used to pass a configuration along when creating a new tab/window/split.
static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig"
@ -201,7 +225,7 @@ extension Ghostty.Notification {
/// Toggle fullscreen of current window
static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen")
static let NonNativeFullscreenKey = ghosttyToggleFullscreen.rawValue
static let FullscreenModeKey = ghosttyToggleFullscreen.rawValue
/// Notification that a surface is becoming focused. This is only sent on macOS 12 to
/// work around bugs. macOS 13+ should use the ".focused()" attribute.

View File

@ -52,8 +52,30 @@ extension Ghostty {
// True if we're hovering over the left URL view, so we can show it on the right.
@State private var isHoveringURLLeft: Bool = false
#if canImport(AppKit)
// Observe SecureInput to detect when its enabled
@ObservedObject private var secureInput = SecureInput.shared
#endif
@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
@ -77,6 +99,8 @@ extension Ghostty {
.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 }
guard let surfaceWindow = surfaceView.window else { return }
@ -197,6 +221,17 @@ extension Ghostty {
}
}
#if canImport(AppKit)
// If we have secure input enabled and we're the focused surface and window
// then we want to show the secure input overlay.
if (ghostty.config.secureInputIndication &&
secureInput.enabled &&
surfaceFocus &&
windowFocus) {
SecureInputOverlay()
}
#endif
// If our surface is not healthy, then we render an error view over it.
if (!surfaceView.healthy) {
Rectangle().fill(ghostty.config.backgroundColor)

View File

@ -38,10 +38,29 @@ extension Ghostty {
// structure because I'm lazy.
@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
// 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
// Set whether the surface is currently on a password input or not. This is
// detected with the set_password_input_cb on the Ghostty state.
var passwordInput: Bool = false {
didSet {
// We need to update our state within the SecureInput manager.
let input = SecureInput.shared
let id = ObjectIdentifier(self)
if (passwordInput) {
input.setScoped(id, focused: focused)
} else {
input.removeScoped(id)
}
}
}
// Returns true if quit confirmation is required for this surface to
// exit safely.
var needsConfirmQuit: Bool {
@ -59,6 +78,7 @@ extension Ghostty {
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? {
@ -81,11 +101,8 @@ extension Ghostty {
private(set) var surface: ghostty_surface_t?
private var markedText: NSMutableAttributedString
private var mouseEntered: Bool = false
private(set) var focused: Bool = true
private var prevPressureStage: Int = 0
private var cursor: NSCursor = .iBeam
private var cursorVisible: CursorVisibility = .visible
private var appearanceObserver: NSKeyValueObservation? = nil
// This is set to non-null during keyDown to accumulate insertText contents
@ -98,15 +115,6 @@ extension Ghostty {
// so we'll use that to tell ghostty to refresh.
override var wantsUpdateLayer: Bool { return true }
// State machine for mouse cursor visibility because every call to
// NSCursor.hide/unhide must be balanced.
enum CursorVisibility {
case visible
case hidden
case pendingVisible
case pendingHidden
}
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
self.markedText = NSMutableAttributedString()
self.uuid = uuid ?? .init()
@ -178,12 +186,8 @@ extension Ghostty {
trackingAreas.forEach { removeTrackingArea($0) }
// mouseExited is not called by AppKit one last time when the view
// closes so we do it manually to ensure our NSCursor state remains
// accurate.
if (mouseEntered) {
mouseExited(with: NSEvent())
}
// Remove ourselves from secure input if we have to
SecureInput.shared.removeScoped(ObjectIdentifier(self))
guard let surface = self.surface else { return }
ghostty_surface_free(surface)
@ -209,6 +213,11 @@ extension Ghostty {
self.focused = focused
ghostty_surface_set_focus(surface, focused)
// Update our secure input state if we are a password input
if (passwordInput) {
SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused)
}
// On macOS 13+ we can store our continuous clock...
if #available(macOS 13, iOS 16, *) {
if (focused) {
@ -218,33 +227,12 @@ extension Ghostty {
}
func sizeDidChange(_ size: CGSize) {
guard let surface = self.surface else { return }
// Ghostty wants to know the actual framebuffer size... It is very important
// here that we use "size" and NOT the view frame. If we're in the middle of
// an animation (i.e. a fullscreen animation), the frame will not yet be updated.
// The size represents our final size we're going for.
let scaledSize = self.convertToBacking(size)
setSurfaceSize(width: UInt32(scaledSize.width), height: UInt32(scaledSize.height))
// Frame changes do not always call mouseEntered/mouseExited, so we do some
// calculations ourself to call those events.
if let window = self.window {
let mouseScreen = NSEvent.mouseLocation
let mouseWindow = window.convertPoint(fromScreen: mouseScreen)
let mouseView = self.convert(mouseWindow, from: nil)
let isEntered = self.isMousePoint(mouseView, in: bounds)
if (isEntered) {
mouseEntered(with: NSEvent())
} else {
mouseExited(with: NSEvent())
}
} else {
// If we don't have a window, then our mouse can NOT be in our view.
// When the window comes back, I believe this event fires again so
// we'll get a mouseEntered.
mouseExited(with: NSEvent())
}
}
private func setSurfaceSize(width: UInt32, height: UInt32) {
@ -254,105 +242,77 @@ extension Ghostty {
ghostty_surface_set_size(surface, width, height)
// Update our cached size metrics
self.surfaceSize = ghostty_surface_size(surface)
let size = ghostty_surface_size(surface)
DispatchQueue.main.async {
// DispatchQueue required since this may be called by SwiftUI off
// the main thread and Published changes need to be on the main
// thread. This caused a crash on macOS <= 14.
self.surfaceSize = size
}
}
func setCursorShape(_ shape: ghostty_mouse_shape_e) {
func setCursorShape(_ shape: ghostty_action_mouse_shape_e) {
switch (shape) {
case GHOSTTY_MOUSE_SHAPE_DEFAULT:
cursor = .arrow
case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU:
cursor = .contextualMenu
pointerStyle = .default
case GHOSTTY_MOUSE_SHAPE_TEXT:
cursor = .iBeam
case GHOSTTY_MOUSE_SHAPE_CROSSHAIR:
cursor = .crosshair
pointerStyle = .horizontalText
case GHOSTTY_MOUSE_SHAPE_GRAB:
cursor = .openHand
pointerStyle = .grabIdle
case GHOSTTY_MOUSE_SHAPE_GRABBING:
cursor = .closedHand
pointerStyle = .grabActive
case GHOSTTY_MOUSE_SHAPE_POINTER:
cursor = .pointingHand
pointerStyle = .link
case GHOSTTY_MOUSE_SHAPE_W_RESIZE:
cursor = .resizeLeft
pointerStyle = .resizeLeft
case GHOSTTY_MOUSE_SHAPE_E_RESIZE:
cursor = .resizeRight
pointerStyle = .resizeRight
case GHOSTTY_MOUSE_SHAPE_N_RESIZE:
cursor = .resizeUp
pointerStyle = .resizeUp
case GHOSTTY_MOUSE_SHAPE_S_RESIZE:
cursor = .resizeDown
pointerStyle = .resizeDown
case GHOSTTY_MOUSE_SHAPE_NS_RESIZE:
cursor = .resizeUpDown
pointerStyle = .resizeUpDown
case GHOSTTY_MOUSE_SHAPE_EW_RESIZE:
cursor = .resizeLeftRight
pointerStyle = .resizeLeftRight
case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT:
cursor = .iBeamCursorForVerticalLayout
pointerStyle = .default
// These are not yet supported. We should support them by constructing a
// PointerStyle from an NSCursor.
case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU:
fallthrough
case GHOSTTY_MOUSE_SHAPE_CROSSHAIR:
fallthrough
case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED:
cursor = .operationNotAllowed
pointerStyle = .default
default:
// We ignore unknown shapes.
return
}
// Set our cursor immediately if our mouse is over our window
if (mouseEntered) { cursorUpdate(with: NSEvent()) }
if let window = self.window {
window.invalidateCursorRects(for: self)
}
}
func setCursorVisibility(_ visible: Bool) {
switch (cursorVisible) {
case .visible:
// If we want to be visible, do nothing. If we want to be hidden
// enter the pending state.
if (visible) { return }
cursorVisible = .pendingHidden
case .hidden:
// If we want to be hidden, do nothing. If we want to be visible
// enter the pending state.
if (!visible) { return }
cursorVisible = .pendingVisible
case .pendingVisible:
// If we want to be visible, do nothing because we're already pending.
// If we want to be hidden, we're already hidden so reset state.
if (visible) { return }
cursorVisible = .hidden
case .pendingHidden:
// If we want to be hidden, do nothing because we're pending that switch.
// If we want to be visible, we're already visible so reset state.
if (!visible) { return }
cursorVisible = .visible
}
if (mouseEntered) {
cursorUpdate(with: NSEvent())
}
pointerVisible = visible
}
// MARK: - Notifications
@objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) {
guard let healthAny = notification.userInfo?["health"] else { return }
guard let health = healthAny as? ghostty_renderer_health_e else { return }
guard let health = healthAny as? ghostty_action_renderer_health_e else { return }
healthy = health == GHOSTTY_RENDERER_HEALTH_OK
}
@ -395,7 +355,6 @@ extension Ghostty {
addTrackingArea(NSTrackingArea(
rect: frame,
options: [
.mouseEnteredAndExited,
.mouseMoved,
// Only send mouse events that happen in our visible (not obscured) rect
@ -409,11 +368,6 @@ extension Ghostty {
userInfo: nil))
}
override func resetCursorRects() {
discardCursorRects()
addCursorRect(frame, cursor: self.cursor)
}
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
@ -538,7 +492,8 @@ extension Ghostty {
// Convert window position to view position. Note (0, 0) is bottom left.
let pos = self.convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y)
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods)
// If focus follows mouse is enabled then move focus to this surface.
if let window = self.window as? TerminalWindow,
@ -554,40 +509,6 @@ extension Ghostty {
self.mouseMoved(with: event)
}
override func mouseEntered(with event: NSEvent) {
// For reasons unknown (Cocoaaaaaaaaa), mouseEntered is called
// multiple times in an unbalanced way with mouseExited when a new
// tab is created. In this scenario, we only want to process our
// callback once since this is stateful and we expect balancing.
if (mouseEntered) { return }
mouseEntered = true
// Update our cursor when we enter so we fully process our
// cursorVisible state.
cursorUpdate(with: NSEvent())
}
override func mouseExited(with event: NSEvent) {
// See mouseEntered
if (!mouseEntered) { return }
mouseEntered = false
// If the mouse is currently hidden, we want to show it when we exit
// this view. We go through the cursorVisible dance so that only
// cursorUpdate manages cursor state.
if (cursorVisible == .hidden) {
cursorVisible = .pendingVisible
cursorUpdate(with: NSEvent())
assert(cursorVisible == .visible)
// We set the state to pending hidden again for the next time
// we enter.
cursorVisible = .pendingHidden
}
}
override func scrollWheel(with event: NSEvent) {
guard let surface = self.surface else { return }
@ -651,24 +572,6 @@ extension Ghostty {
quickLook(with: event)
}
override func cursorUpdate(with event: NSEvent) {
switch (cursorVisible) {
case .visible, .hidden:
// Do nothing, stable state
break
case .pendingHidden:
NSCursor.hide()
cursorVisible = .hidden
case .pendingVisible:
NSCursor.unhide()
cursorVisible = .visible
}
cursor.set()
}
override func keyDown(with event: NSEvent) {
guard let surface = self.surface else {
self.interpretKeyEvents([event])
@ -789,6 +692,11 @@ extension Ghostty {
// sound and we don't like the beep sound.
equivalent = "_"
case "\r":
// Pass C-<return> through verbatim
// (prevent the default context menu equivalent)
equivalent = "\r"
default:
// Ignore other events
return false
@ -1018,12 +926,12 @@ extension Ghostty {
@IBAction func splitRight(_ sender: Any) {
guard let surface = self.surface else { return }
ghostty_surface_split(surface, GHOSTTY_SPLIT_RIGHT)
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT)
}
@IBAction func splitDown(_ sender: Any) {
guard let surface = self.surface else { return }
ghostty_surface_split(surface, GHOSTTY_SPLIT_DOWN)
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_DOWN)
}
@objc func resetTerminal(_ sender: Any) {

View File

@ -23,3 +23,79 @@ extension Backport where Content: Scene {
}
}
}
extension Backport where Content: View {
func pointerVisibility(_ v: BackportVisibility) -> some View {
#if canImport(AppKit)
if #available(macOS 15, *) {
return content.pointerVisibility(v.official)
} else {
return content
}
#else
return content
#endif
}
func pointerStyle(_ style: BackportPointerStyle?) -> some View {
#if canImport(AppKit)
if #available(macOS 15, *) {
return content.pointerStyle(style?.official)
} else {
return content
}
#else
return content
#endif
}
}
enum BackportVisibility {
case automatic
case visible
case hidden
@available(macOS 15, *)
var official: Visibility {
switch self {
case .automatic: return .automatic
case .visible: return .visible
case .hidden: return .hidden
}
}
}
enum BackportPointerStyle {
case `default`
case grabIdle
case grabActive
case horizontalText
case verticalText
case link
case resizeLeft
case resizeRight
case resizeUp
case resizeDown
case resizeUpDown
case resizeLeftRight
#if canImport(AppKit)
@available(macOS 15, *)
var official: PointerStyle {
switch self {
case .default: return .default
case .grabIdle: return .grabIdle
case .grabActive: return .grabActive
case .horizontalText: return .horizontalText
case .verticalText: return .verticalText
case .link: return .link
case .resizeLeft: return .frameResize(position: .trailing, directions: [.inward])
case .resizeRight: return .frameResize(position: .leading, directions: [.inward])
case .resizeUp: return .frameResize(position: .bottom, directions: [.inward])
case .resizeDown: return .frameResize(position: .top, directions: [.inward])
case .resizeUpDown: return .frameResize(position: .top)
case .resizeLeftRight: return .frameResize(position: .trailing)
}
}
#endif
}

View File

@ -0,0 +1,38 @@
import Cocoa
/// This helps manage the stateful nature of NSCursor hiding and unhiding.
class Cursor {
private static var counter: UInt = 0
static var isVisible: Bool {
counter == 0
}
static func hide() {
counter += 1
NSCursor.hide()
}
/// Unhide the cursor. Returns true if the cursor was previously hidden.
static func unhide() -> Bool {
// Its always safe to call unhide when the counter is zero because it
// won't go negative.
NSCursor.unhide()
if (counter > 0) {
counter -= 1
return true
}
return false
}
static func unhideCompletely() -> UInt {
let counter = self.counter
for _ in 0..<counter {
assert(unhide())
}
assert(self.counter == 0)
return counter
}
}

View File

@ -0,0 +1,19 @@
import Cocoa
import SwiftUI
struct DraggableWindowView: NSViewRepresentable {
func makeNSView(context: Context) -> DraggableWindowNSView {
return DraggableWindowNSView()
}
func updateNSView(_ nsView: DraggableWindowNSView, context: Context) {
// No need to update anything here
}
}
class DraggableWindowNSView: NSView {
override func mouseDown(with event: NSEvent) {
guard let window = self.window else { return }
window.performDrag(with: event)
}
}

View File

@ -13,8 +13,18 @@ class FullScreenHandler {
var isInNonNativeFullscreen: Bool = false
var isInFullscreen: Bool = false
func toggleFullscreen(window: NSWindow, nonNativeFullscreen: ghostty_non_native_fullscreen_e) {
let useNonNativeFullscreen = nonNativeFullscreen != GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE
func toggleFullscreen(window: NSWindow, mode: ghostty_action_fullscreen_e) {
let useNonNativeFullscreen = switch (mode) {
case GHOSTTY_FULLSCREEN_NATIVE:
false
case GHOSTTY_FULLSCREEN_NON_NATIVE, GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU:
true
default:
false
}
if isInFullscreen {
if useNonNativeFullscreen || isInNonNativeFullscreen {
leaveFullscreen(window: window)
@ -27,7 +37,7 @@ class FullScreenHandler {
isInFullscreen = false
} else {
if useNonNativeFullscreen {
let hideMenu = nonNativeFullscreen != GHOSTTY_NON_NATIVE_FULLSCREEN_VISIBLE_MENU
let hideMenu = mode != GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU
enterFullscreen(window: window, hideMenu: hideMenu)
isInNonNativeFullscreen = true
} else {

View File

@ -44,15 +44,30 @@ extension SplitView {
}
}
private var pointerStyle: BackportPointerStyle {
return switch (direction) {
case .horizontal: .resizeLeftRight
case .vertical: .resizeUpDown
}
}
var body: some View {
ZStack {
Color.clear
.frame(width: invisibleWidth, height: invisibleHeight)
.contentShape(Rectangle()) // Makes it hit testable for pointerStyle
Rectangle()
.fill(color)
.frame(width: visibleWidth, height: visibleHeight)
}
.backport.pointerStyle(pointerStyle)
.onHover { isHovered in
// macOS 15+ we use the pointerStyle helper which is much less
// error-prone versus manual NSCursor push/pop
if #available(macOS 15, *) {
return
}
if (isHovered) {
switch (direction) {
case .horizontal:

View File

@ -0,0 +1,31 @@
import SwiftUI
extension View {
func innerShadow<S: Shape, ST: ShapeStyle>(
using shape: S = Rectangle(),
stroke: ST = Color.black,
width: CGFloat = 6,
blur: CGFloat = 6
) -> some View {
return self
.overlay(
shape
.stroke(stroke, lineWidth: width)
.blur(radius: blur)
.mask(shape)
)
}
}
extension View {
func pointerStyleFromCursor(_ cursor: NSCursor) -> some View {
if #available(macOS 15.0, *) {
return self.pointerStyle(.image(
Image(nsImage: cursor.image),
hotSpot: .init(x: cursor.hotSpot.x, y: cursor.hotSpot.y)
))
} else {
return self
}
}
}

View File

@ -25,16 +25,19 @@ elif [ "$1" != "--update" ]; then
exit 1
fi
TMP_CACHE_DIR="$(mktemp --directory --suffix nix-zig-cache)"
ZIG_GLOBAL_CACHE_DIR="$(mktemp --directory --suffix nix-zig-cache)"
export ZIG_GLOBAL_CACHE_DIR
# This is not 100% necessary in CI but is helpful when running locally to keep
# a local workstation clean.
trap 'rm -rf "${TMP_CACHE_DIR}"' EXIT
trap 'rm -rf "${ZIG_GLOBAL_CACHE_DIR}"' EXIT
# Run Zig and download the cache to the temporary directory.
zig build --fetch --global-cache-dir "${TMP_CACHE_DIR}"
sh ./nix/build-support/fetch-zig-cache.sh
# Now, calculate the hash.
ZIG_CACHE_HASH="sha256-$(nix-hash --type sha256 --to-base64 "$(nix-hash --type sha256 "${TMP_CACHE_DIR}")")"
ZIG_CACHE_HASH="sha256-$(nix-hash --type sha256 --to-base64 "$(nix-hash --type sha256 "${ZIG_GLOBAL_CACHE_DIR}")")"
if [ "${OLD_CACHE_HASH}" == "${ZIG_CACHE_HASH}" ]; then
echo -e "\nOK: Zig cache store hash unchanged."

View File

@ -0,0 +1,39 @@
#!/bin/sh
set -e
# Because Zig does not fetch recursive dependencies when you run `zig build
# --fetch` (see https://github.com/ziglang/zig/issues/20976) we need to do some
# extra work to fetch everything that we actually need to build without Internet
# access (such as when building a Nix package).
#
# An example of this happening:
#
# error: builder for '/nix/store/cx8qcwrhjmjxik2547fw99v5j6np5san-ghostty-0.1.0.drv' failed with exit code 1;
# la/build/tmp.xgHOheUF7V/p/12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133/build.zig.zon:7:20: error: unable to discover remote git server capabilities: TemporaryNameServerFailure
# > .url = "git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e",
# > ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# > /build/tmp.xgHOheUF7V/p/12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133/build.zig.zon:16:20: error: unable to discover remote git server capabilities: TemporaryNameServerFailure
# > .url = "git+https://github.com/mitchellh/libxev#f6a672a78436d8efee1aa847a43a900ad773618b",
# > ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# >
# For full logs, run 'nix log /nix/store/cx8qcwrhjmjxik2547fw99v5j6np5san-ghostty-0.1.0.drv'.
#
# To update this script, add any failing URLs with a line like this:
#
# zig fetch <url>
#
# Periodically old URLs may need to be cleaned out.
#
# Hopefully when the Zig issue is fixed this script can be eliminated in favor
# of a plain `zig build --fetch`.
if [ -z ${ZIG_GLOBAL_CACHE_DIR+x} ]
then
echo "must set ZIG_GLOBAL_CACHE_DIR!"
exit 1
fi
zig build --fetch
zig fetch git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e
zig fetch git+https://github.com/mitchellh/libxev#f6a672a78436d8efee1aa847a43a900ad773618b

View File

@ -14,7 +14,6 @@
python3,
qemu,
scdoc,
tracy,
valgrind,
#, vulkan-loader # unused
vttest,
@ -100,7 +99,6 @@ in
# Testing
parallel
python3
tracy
vttest
hyperfine

View File

@ -34,7 +34,7 @@
# https://github.com/ziglang/zig/issues/14281#issuecomment-1624220653 is
# ultimately acted on and has made its way to a nixpkgs implementation, this
# can probably be removed in favor of that.
zig012Hook = zig_0_13.hook.overrideAttrs {
zig_hook = zig_0_13.hook.overrideAttrs {
zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize}";
};
@ -56,6 +56,7 @@
../vendor
../build.zig
../build.zig.zon
./build-support/fetch-zig-cache.sh
]
);
};
@ -79,7 +80,7 @@
name = "ghostty-cache";
nativeBuildInputs = [
git
zig_0_13.hook
zig_hook
];
dontConfigure = true;
@ -90,7 +91,7 @@
buildPhase = ''
runHook preBuild
zig build --fetch
sh ./nix/build-support/fetch-zig-cache.sh
runHook postBuild
'';
@ -117,7 +118,7 @@ in
ncurses
pandoc
pkg-config
zig012Hook
zig_hook
wrapGAppsHook4
];

View File

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

5
pkg/macos/carbon.zig Normal file
View File

@ -0,0 +1,5 @@
pub const c = @import("carbon/c.zig").c;
test {
@import("std").testing.refAllDecls(@This());
}

3
pkg/macos/carbon/c.zig Normal file
View File

@ -0,0 +1,3 @@
pub const c = @cImport({
@cInclude("Carbon/Carbon.h");
});

View File

@ -1,3 +1,4 @@
pub const carbon = @import("carbon.zig");
pub const foundation = @import("foundation.zig");
pub const animation = @import("animation.zig");
pub const dispatch = @import("dispatch.zig");

View File

@ -188,6 +188,14 @@ pub const Font = opaque {
return c.CTFontGetUnderlineThickness(@ptrCast(self));
}
pub fn getCapHeight(self: *Font) f64 {
return c.CTFontGetCapHeight(@ptrCast(self));
}
pub fn getXHeight(self: *Font) f64 {
return c.CTFontGetXHeight(@ptrCast(self));
}
pub fn getUnitsPerEm(self: *Font) u32 {
return c.CTFontGetUnitsPerEm(@ptrCast(self));
}

View File

@ -138,7 +138,13 @@ pub fn addSurface(self: *App, rt_surface: *apprt.Surface) !void {
// Since we have non-zero surfaces, we can cancel the quit timer.
// It is up to the apprt if there is a quit timer at all and if it
// should be canceled.
if (@hasDecl(apprt.App, "cancelQuitTimer")) rt_surface.app.cancelQuitTimer();
rt_surface.app.performAction(
.app,
.quit_timer,
.stop,
) catch |err| {
log.warn("error stopping quit timer err={}", .{err});
};
}
/// Delete the surface from the known surface list. This will NOT call the
@ -166,8 +172,13 @@ pub fn deleteSurface(self: *App, rt_surface: *apprt.Surface) void {
// If we have no surfaces, we can start the quit timer. It is up to the
// apprt to determine if this is necessary.
if (@hasDecl(apprt.App, "startQuitTimer") and
self.surfaces.items.len == 0) rt_surface.app.startQuitTimer();
if (self.surfaces.items.len == 0) rt_surface.app.performAction(
.app,
.quit_timer,
.start,
) catch |err| {
log.warn("error starting quit timer err={}", .{err});
};
}
/// The last focused surface. This is only valid while on the main thread
@ -194,7 +205,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
log.debug("mailbox message={s}", .{@tagName(message)});
switch (message) {
.reload_config => try self.reloadConfig(rt_app),
.open_config => try self.openConfig(rt_app),
.open_config => try self.performAction(rt_app, .open_config),
.new_window => |msg| try self.newWindow(rt_app, msg),
.close => |surface| try self.closeSurface(surface),
.quit => try self.setQuit(),
@ -205,12 +216,6 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
}
}
pub fn openConfig(self: *App, rt_app: *apprt.App) !void {
_ = self;
log.debug("opening configuration", .{});
try rt_app.openConfig();
}
pub fn reloadConfig(self: *App, rt_app: *apprt.App) !void {
log.debug("reloading configuration", .{});
if (try rt_app.reloadConfig()) |new| {
@ -241,19 +246,17 @@ fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) !voi
/// Create a new window
pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void {
if (!@hasDecl(apprt.App, "newWindow")) {
log.warn("newWindow is not supported by this runtime", .{});
return;
}
const target: apprt.Target = target: {
const parent = msg.parent orelse break :target .app;
if (self.hasSurface(parent)) break :target .{ .surface = parent };
break :target .app;
};
const parent = if (msg.parent) |parent| parent: {
break :parent if (self.hasSurface(parent))
parent
else
null;
} else null;
try rt_app.newWindow(parent);
try rt_app.performAction(
target,
.new_window,
{},
);
}
/// Start quitting
@ -262,6 +265,99 @@ pub fn setQuit(self: *App) !void {
self.quit = true;
}
/// Handle a key event at the app-scope. If this key event is used,
/// this will return true and the caller shouldn't continue processing
/// the event. If the event is not used, this will return false.
pub fn keyEvent(
self: *App,
rt_app: *apprt.App,
event: input.KeyEvent,
) bool {
switch (event.action) {
// We don't care about key release events.
.release => return false,
// Continue processing key press events.
.press, .repeat => {},
}
// Get the keybind entry for this event. We don't support key sequences
// so we can look directly in the top-level set.
const entry = rt_app.config.keybind.set.getEvent(event) orelse return false;
const leaf: input.Binding.Set.Leaf = switch (entry) {
// Sequences aren't supported. Our configuration parser verifies
// this for global keybinds but we may still get an entry for
// a non-global keybind.
.leader => return false,
// Leaf entries are good
.leaf => |leaf| leaf,
};
// We only care about global keybinds
if (!leaf.flags.global) return false;
// Perform the action
self.performAllAction(rt_app, leaf.action) catch |err| {
log.warn("error performing global keybind action action={s} err={}", .{
@tagName(leaf.action),
err,
});
};
return 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.
pub fn performAction(
self: *App,
rt_app: *apprt.App,
action: input.Binding.Action.Scoped(.app),
) !void {
switch (action) {
.unbind => unreachable,
.ignore => {},
.quit => try 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),
.close_all_windows => try rt_app.performAction(.app, .close_all_windows, {}),
.toggle_quick_terminal => try rt_app.performAction(.app, .toggle_quick_terminal, {}),
}
}
/// Perform an app-wide binding action. If the action is surface-specific
/// then it will be performed on all surfaces. To perform only app-scoped
/// actions, use performAction.
pub fn performAllAction(
self: *App,
rt_app: *apprt.App,
action: input.Binding.Action,
) !void {
switch (action.scope()) {
// App-scoped actions are handled by the app so that they aren't
// repeated for each surface (since each surface forwards
// app-scoped actions back up).
.app => try self.performAction(
rt_app,
action.scoped(.app).?, // asserted through the scope match
),
// Surface-scoped actions are performed on all surfaces. Errors
// are logged but processing continues.
.surface => for (self.surfaces.items) |surface| {
_ = surface.core_surface.performBindingAction(action) catch |err| {
log.warn("error performing binding action on surface ptr={X} err={}", .{
@intFromPtr(surface),
err,
});
};
},
}
}
/// Handle a window message
fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !void {
// We want to ensure our window is still active. Window messages

View File

@ -515,14 +515,25 @@ pub fn init(
errdefer self.io.deinit();
// Report initial cell size on surface creation
try rt_surface.setCellSize(cell_size.width, cell_size.height);
try rt_app.performAction(
.{ .surface = self },
.cell_size,
.{ .width = cell_size.width, .height = cell_size.height },
);
// Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app
// but is otherwise somewhat arbitrary.
try rt_surface.setSizeLimits(.{
.width = cell_size.width * 10,
.height = cell_size.height * 4,
}, null);
try rt_app.performAction(
.{ .surface = self },
.size_limit,
.{
.min_width = cell_size.width * 10,
.min_height = cell_size.height * 4,
// No max:
.max_width = 0,
.max_height = 0,
},
);
// Call our size callback which handles all our retina setup
// Note: this shouldn't be necessary and when we clean up the surface
@ -576,13 +587,23 @@ pub fn init(
padding.top +
padding.bottom;
rt_surface.setInitialWindowSize(final_width, final_height) catch |err| {
rt_app.performAction(
.{ .surface = self },
.initial_size,
.{ .width = final_width, .height = final_height },
) catch |err| {
// We don't treat this as a fatal error because not setting
// an initial size shouldn't stop our terminal from working.
log.warn("unable to set initial window size: {s}", .{err});
};
}
if (config.title) |title| {
try rt_surface.setTitle(title);
try rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = title },
);
} else if ((comptime builtin.os.tag == .linux) and
config.@"_xdg-terminal-exec")
xdg: {
@ -599,7 +620,11 @@ pub fn init(
break :xdg;
};
defer alloc.free(title);
try rt_surface.setTitle(title);
try rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = title },
);
}
}
}
@ -743,15 +768,15 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
// We know that our title should end in 0.
const slice = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(v)), 0);
log.debug("changing title \"{s}\"", .{slice});
try self.rt_surface.setTitle(slice);
try self.rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = slice },
);
},
.report_title => |style| {
const title: ?[:0]const u8 = title: {
if (!@hasDecl(apprt.runtime.Surface, "getTitle")) break :title null;
break :title self.rt_surface.getTitle();
};
const title: ?[:0]const u8 = self.rt_surface.getTitle();
const data = switch (style) {
.csi_21_t => try std.fmt.allocPrint(
self.alloc,
@ -773,7 +798,11 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.set_mouse_shape => |shape| {
log.debug("changing mouse shape: {}", .{shape});
try self.rt_surface.setMouseShape(shape);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
shape,
);
},
.clipboard_read => |clipboard| {
@ -837,6 +866,18 @@ fn passwordInput(self: *Surface, v: bool) !void {
self.io.terminal.flags.password_input = v;
}
// Notify our apprt so it can do whatever it wants.
self.rt_app.performAction(
.{ .surface = self },
.secure_input,
if (v) .on else .off,
) catch |err| {
// We ignore this error because we don't want to fail this
// entire operation just because the apprt failed to set
// the secure input state.
log.warn("apprt failed to set secure input state err={}", .{err});
};
try self.queueRender();
}
@ -889,8 +930,13 @@ fn modsChanged(self: *Surface, mods: input.Mods) void {
/// Called when our renderer health state changes.
fn updateRendererHealth(self: *Surface, health: renderer.Health) void {
log.warn("renderer health status change status={}", .{health});
if (!@hasDecl(apprt.runtime.Surface, "updateRendererHealth")) return;
self.rt_surface.updateRendererHealth(health);
self.rt_app.performAction(
.{ .surface = self },
.renderer_health,
health,
) catch |err| {
log.warn("failed to notify app of renderer health change err={}", .{err});
};
}
/// Update our configuration at runtime.
@ -1146,10 +1192,8 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void {
// Check if our runtime supports the selection clipboard at all.
// We can save a lot of work if it doesn't.
if (@hasDecl(apprt.runtime.Surface, "supportsClipboard")) {
if (!self.rt_surface.supportsClipboard(clipboard)) {
return;
}
if (!self.rt_surface.supportsClipboard(clipboard)) {
return;
}
const buf = self.io.terminal.screen.selectionString(self.alloc, .{
@ -1189,7 +1233,11 @@ fn setCellSize(self: *Surface, size: renderer.CellSize) !void {
}, .unlocked);
// Notify the window
try self.rt_surface.setCellSize(size.width, size.height);
try self.rt_app.performAction(
.{ .surface = self },
.cell_size,
.{ .width = size.width, .height = size.height },
);
}
/// Change the font size.
@ -1454,7 +1502,7 @@ pub fn keyCallback(
// mod changes can affect link highlighting.
self.mouse.link_point = null;
const pos = self.rt_surface.getCursorPos() catch break :mouse_mods;
self.cursorPosCallback(pos) catch {};
self.cursorPosCallback(pos, null) catch {};
if (rehide) self.mouse.hidden = true;
}
@ -1467,8 +1515,11 @@ pub fn keyCallback(
.mods = self.mouse.mods,
.over_link = self.mouse.over_link,
.hidden = self.mouse.hidden,
}).keyToMouseShape()) |shape|
try self.rt_surface.setMouseShape(shape);
}).keyToMouseShape()) |shape| try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
shape,
);
// We've processed a key event that produced some data so we want to
// track the last pressed key.
@ -1556,19 +1607,8 @@ fn maybeHandleBinding(
const entry: input.Binding.Set.Entry = entry: {
const set = self.keyboard.bindings orelse &self.config.keybind.set;
var trigger: input.Binding.Trigger = .{
.mods = event.mods.binding(),
.key = .{ .translated = event.key },
};
if (set.get(trigger)) |v| break :entry v;
trigger.key = .{ .physical = event.physical_key };
if (set.get(trigger)) |v| break :entry v;
if (event.unshifted_codepoint > 0) {
trigger.key = .{ .unicode = event.unshifted_codepoint };
if (set.get(trigger)) |v| break :entry v;
}
// Get our entry from the set for the given event.
if (set.getEvent(event)) |v| break :entry v;
// No entry found. If we're not looking at the root set of the
// bindings we need to encode everything up to this point and
@ -1585,7 +1625,7 @@ fn maybeHandleBinding(
};
// Determine if this entry has an action or if its a leader key.
const action: input.Binding.Action, const consumed: bool = switch (entry) {
const leaf: input.Binding.Set.Leaf = switch (entry) {
.leader => |set| {
// Setup the next set we'll look at.
self.keyboard.bindings = set;
@ -1600,8 +1640,20 @@ fn maybeHandleBinding(
return .consumed;
},
.action => |v| .{ v, true },
.action_unconsumed => |v| .{ v, false },
.leaf => |leaf| leaf,
};
const action = leaf.action;
// consumed determines if the input is consumed or if we continue
// encoding the key (if we have a key to encode).
const consumed = consumed: {
// If the consumed flag is explicitly set, then we are consumed.
if (leaf.flags.consumed) break :consumed true;
// If the global or all flag is set, we always consume.
if (leaf.flags.global or leaf.flags.all) break :consumed true;
break :consumed false;
};
// We have an action, so at this point we're handling SOMETHING so
@ -1613,8 +1665,22 @@ fn maybeHandleBinding(
self.keyboard.bindings = null;
// Attempt to perform the action
log.debug("key event binding consumed={} action={}", .{ consumed, action });
const performed = try self.performBindingAction(action);
log.debug("key event binding flags={} action={}", .{
leaf.flags,
action,
});
const performed = performed: {
// If this is a global or all action, then we perform it on
// the app and it applies to every surface.
if (leaf.flags.global or leaf.flags.all) {
try self.app.performAllAction(self.rt_app, action);
// "All" actions are always performed since they are global.
break :performed true;
}
break :performed try self.performBindingAction(action);
};
// If we performed an action and it was a closing action,
// our "self" pointer is not safe to use anymore so we need to
@ -2410,15 +2476,33 @@ pub fn mouseButtonCallback(
if (mods.shift and
self.mouse.left_click_count > 0 and
!shift_capture)
{
extend_selection: {
// We split this conditional out on its own because this is the
// only one that requires a renderer mutex grab which is VERY
// expensive because it could block all our threads.
if (self.hasSelection()) {
const pos = try self.rt_surface.getCursorPos();
try self.cursorPosCallback(pos);
return true;
if (!self.hasSelection()) break :extend_selection;
// If we are within the interval that the click would register
// an increment then we do not extend the selection.
if (std.time.Instant.now()) |now| {
const since = now.since(self.mouse.left_click_time);
if (since <= self.config.mouse_interval) {
// Click interval very short, we may be increasing
// click counts so we don't extend the selection.
break :extend_selection;
}
} else |err| {
// This is a weird behavior, I think either behavior is actually
// fine. This failure should be exceptionally rare anyways.
// My thinking here is that we can't be sure if we should extend
// the selection or not so we just don't.
log.warn("failed to get time, not extending selection err={}", .{err});
break :extend_selection;
}
const pos = try self.rt_surface.getCursorPos();
try self.cursorPosCallback(pos, null);
return true;
}
}
@ -2882,9 +2966,18 @@ pub fn mousePressureCallback(
}
}
/// Cursor position callback.
///
/// The mods parameter is optional because some apprts do not provide
/// modifier information on cursor position events. If mods is null then
/// we'll use the last known mods. This is usually accurate since mod events
/// will trigger key press events but on some platforms we don't get them.
/// For example, on macOS, unfocused surfaces don't receive key events but
/// do receive mouse events so we have to rely on updated mods.
pub fn cursorPosCallback(
self: *Surface,
pos: apprt.CursorPos,
mods: ?input.Mods,
) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
@ -2893,6 +2986,9 @@ pub fn cursorPosCallback(
// Always show the mouse again if it is hidden
if (self.mouse.hidden) self.showMouse();
// Update our modifiers if they changed
if (mods) |v| self.modsChanged(v);
// The mouse position in the viewport
const pos_vp = self.posToViewport(pos.x, pos.y);
@ -2943,7 +3039,11 @@ pub fn cursorPosCallback(
// We also queue a render so the renderer can undo the rendered link
// state.
if (over_link) {
self.rt_surface.mouseOverLink(null);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = "" },
);
try self.queueRender();
}
@ -3029,7 +3129,11 @@ pub fn cursorPosCallback(
self.renderer_state.mouse.point = pos_vp;
self.mouse.over_link = true;
self.renderer_state.terminal.screen.dirty.hyperlink_hover = true;
try self.rt_surface.setMouseShape(.pointer);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
.pointer,
);
switch (link[0]) {
.open => {
@ -3038,7 +3142,11 @@ pub fn cursorPosCallback(
.trim = false,
});
defer self.alloc.free(str);
self.rt_surface.mouseOverLink(str);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = str },
);
},
._open_osc8 => link: {
@ -3048,14 +3156,26 @@ pub fn cursorPosCallback(
log.warn("failed to get URI for OSC8 hyperlink", .{});
break :link;
};
self.rt_surface.mouseOverLink(uri);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = uri },
);
},
}
try self.queueRender();
} else if (over_link) {
try self.rt_surface.setMouseShape(self.io.terminal.mouse_shape);
self.rt_surface.mouseOverLink(null);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
self.io.terminal.mouse_shape,
);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = "" },
);
try self.queueRender();
}
}
@ -3364,13 +3484,25 @@ fn scrollToBottom(self: *Surface) !void {
fn hideMouse(self: *Surface) void {
if (self.mouse.hidden) return;
self.mouse.hidden = true;
self.rt_surface.setMouseVisibility(false);
self.rt_app.performAction(
.{ .surface = self },
.mouse_visibility,
.hidden,
) catch |err| {
log.warn("apprt failed to set mouse visibility err={}", .{err});
};
}
fn showMouse(self: *Surface) void {
if (!self.mouse.hidden) return;
self.mouse.hidden = false;
self.rt_surface.setMouseVisibility(true);
self.rt_app.performAction(
.{ .surface = self },
.mouse_visibility,
.visible,
) catch |err| {
log.warn("apprt failed to set mouse visibility err={}", .{err});
};
}
/// Perform a binding action. A binding is a keybinding. This function
@ -3384,14 +3516,25 @@ fn showMouse(self: *Surface) void {
/// will ever return false. We can expand this in the future if it becomes
/// useful. We did previous/next tab so we could implement #498.
pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {
switch (action) {
.unbind => unreachable,
.ignore => {},
// Forward app-scoped actions to the app. Some app-scoped actions are
// special-cased here because they do some special things when performed
// from the surface.
if (action.scoped(.app)) |app_action| {
switch (app_action) {
.new_window => try self.app.newWindow(
self.rt_app,
.{ .parent = self },
),
.open_config => try self.app.openConfig(self.rt_app),
.reload_config => try self.app.reloadConfig(self.rt_app),
else => try self.app.performAction(
self.rt_app,
action.scoped(.app).?,
),
}
return true;
}
switch (action.scoped(.surface).?) {
.csi, .esc => |data| {
// We need to send the CSI/ESC sequence as a single write request.
// If you split it across two then the shell can interpret it
@ -3613,109 +3756,105 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
v,
),
.new_window => try self.app.newWindow(self.rt_app, .{ .parent = self }),
.new_tab => try self.rt_app.performAction(
.{ .surface = self },
.new_tab,
{},
),
.new_tab => {
if (@hasDecl(apprt.Surface, "newTab")) {
try self.rt_surface.newTab();
} else log.warn("runtime doesn't implement newTab", .{});
},
inline .previous_tab,
.next_tab,
.last_tab,
.goto_tab,
=> |v, tag| try self.rt_app.performAction(
.{ .surface = self },
.goto_tab,
switch (tag) {
.previous_tab => .previous,
.next_tab => .next,
.last_tab => .last,
.goto_tab => @enumFromInt(v),
else => comptime unreachable,
},
),
.previous_tab => {
if (@hasDecl(apprt.Surface, "hasTabs")) {
if (!self.rt_surface.hasTabs()) {
log.debug("surface has no tabs, ignoring previous_tab binding", .{});
return false;
}
}
.new_split => |direction| try self.rt_app.performAction(
.{ .surface = self },
.new_split,
switch (direction) {
.right => .right,
.down => .down,
.auto => if (self.screen_size.width > self.screen_size.height)
.right
else
.down,
},
),
if (@hasDecl(apprt.Surface, "gotoTab")) {
self.rt_surface.gotoTab(.previous);
} else log.warn("runtime doesn't implement gotoTab", .{});
},
.goto_split => |direction| try self.rt_app.performAction(
.{ .surface = self },
.goto_split,
switch (direction) {
inline else => |tag| @field(
apprt.action.GotoSplit,
@tagName(tag),
),
},
),
.next_tab => {
if (@hasDecl(apprt.Surface, "hasTabs")) {
if (!self.rt_surface.hasTabs()) {
log.debug("surface has no tabs, ignoring next_tab binding", .{});
return false;
}
}
.resize_split => |value| try self.rt_app.performAction(
.{ .surface = self },
.resize_split,
.{
.amount = value[1],
.direction = switch (value[0]) {
inline else => |tag| @field(
apprt.action.ResizeSplit.Direction,
@tagName(tag),
),
},
},
),
if (@hasDecl(apprt.Surface, "gotoTab")) {
self.rt_surface.gotoTab(.next);
} else log.warn("runtime doesn't implement gotoTab", .{});
},
.equalize_splits => try self.rt_app.performAction(
.{ .surface = self },
.equalize_splits,
{},
),
.last_tab => {
if (@hasDecl(apprt.Surface, "hasTabs")) {
if (!self.rt_surface.hasTabs()) {
log.debug("surface has no tabs, ignoring last_tab binding", .{});
return false;
}
}
.toggle_split_zoom => try self.rt_app.performAction(
.{ .surface = self },
.toggle_split_zoom,
{},
),
if (@hasDecl(apprt.Surface, "gotoTab")) {
self.rt_surface.gotoTab(.last);
} else log.warn("runtime doesn't implement gotoTab", .{});
},
.toggle_fullscreen => try self.rt_app.performAction(
.{ .surface = self },
.toggle_fullscreen,
switch (self.config.macos_non_native_fullscreen) {
.false => .native,
.true => .macos_non_native,
.@"visible-menu" => .macos_non_native_visible_menu,
},
),
.goto_tab => |n| {
if (@hasDecl(apprt.Surface, "gotoTab")) {
self.rt_surface.gotoTab(@enumFromInt(n));
} else log.warn("runtime doesn't implement gotoTab", .{});
},
.toggle_window_decorations => try self.rt_app.performAction(
.{ .surface = self },
.toggle_window_decorations,
{},
),
.new_split => |direction| {
if (@hasDecl(apprt.Surface, "newSplit")) {
try self.rt_surface.newSplit(switch (direction) {
.right => .right,
.down => .down,
.auto => if (self.screen_size.width > self.screen_size.height)
.right
else
.down,
});
} else log.warn("runtime doesn't implement newSplit", .{});
},
.toggle_tab_overview => try self.rt_app.performAction(
.{ .surface = self },
.toggle_tab_overview,
{},
),
.goto_split => |direction| {
if (@hasDecl(apprt.Surface, "gotoSplit")) {
self.rt_surface.gotoSplit(direction);
} else log.warn("runtime doesn't implement gotoSplit", .{});
},
.resize_split => |param| {
if (@hasDecl(apprt.Surface, "resizeSplit")) {
const direction = param[0];
const amount = param[1];
self.rt_surface.resizeSplit(direction, amount);
} else log.warn("runtime doesn't implement resizeSplit", .{});
},
.equalize_splits => {
if (@hasDecl(apprt.Surface, "equalizeSplits")) {
self.rt_surface.equalizeSplits();
} else log.warn("runtime doesn't implement equalizeSplits", .{});
},
.toggle_split_zoom => {
if (@hasDecl(apprt.Surface, "toggleSplitZoom")) {
self.rt_surface.toggleSplitZoom();
} else log.warn("runtime doesn't implement toggleSplitZoom", .{});
},
.toggle_fullscreen => {
if (@hasDecl(apprt.Surface, "toggleFullscreen")) {
self.rt_surface.toggleFullscreen(self.config.macos_non_native_fullscreen);
} else log.warn("runtime doesn't implement toggleFullscreen", .{});
},
.toggle_window_decorations => {
if (@hasDecl(apprt.Surface, "toggleWindowDecorations")) {
self.rt_surface.toggleWindowDecorations();
} else log.warn("runtime doesn't implement toggleWindowDecorations", .{});
},
.toggle_secure_input => try self.rt_app.performAction(
.{ .surface = self },
.secure_input,
.toggle,
),
.select_all => {
const sel = self.io.terminal.screen.selectAll();
@ -3725,24 +3864,21 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
}
},
.inspector => |mode| {
if (@hasDecl(apprt.Surface, "controlInspector")) {
self.rt_surface.controlInspector(mode);
} else log.warn("runtime doesn't implement controlInspector", .{});
},
.inspector => |mode| try self.rt_app.performAction(
.{ .surface = self },
.inspector,
switch (mode) {
inline else => |tag| @field(
apprt.action.Inspector,
@tagName(tag),
),
},
),
.close_surface => self.close(),
.close_window => try self.app.closeSurface(self),
.close_all_windows => {
if (@hasDecl(apprt.Surface, "closeAllWindows")) {
self.rt_surface.closeAllWindows();
} else log.warn("runtime doesn't implement closeAllWindows", .{});
},
.quit => try self.app.setQuit(),
.crash => |location| switch (location) {
.main => @panic("crash binding action, crashing intentionally"),
@ -4124,11 +4260,6 @@ fn completeClipboardReadOSC52(
}
fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void {
if (comptime !@hasDecl(apprt.Surface, "showDesktopNotification")) {
log.warn("runtime doesn't support desktop notifications", .{});
return;
}
// Wyhash is used to hash the contents of the desktop notification to limit
// how fast identical notifications can be sent sequentially.
const hash_algorithm = std.hash.Wyhash;
@ -4164,7 +4295,14 @@ fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const
self.app.last_notification_time = now;
self.app.last_notification_digest = new_digest;
try self.rt_surface.showDesktopNotification(title, body);
try self.rt_app.performAction(
.{ .surface = self },
.desktop_notification,
.{
.title = title,
.body = body,
},
);
}
fn crashThreadState(self: *Surface) crash.sentry.ThreadState {
@ -4177,9 +4315,11 @@ fn crashThreadState(self: *Surface) crash.sentry.ThreadState {
/// Tell the surface to present itself to the user. This may involve raising the
/// window and switching tabs.
fn presentSurface(self: *Surface) !void {
if (@hasDecl(apprt.Surface, "presentSurface")) {
self.rt_surface.presentSurface();
} else log.warn("runtime doesn't support presentSurface", .{});
try self.rt_app.performAction(
.{ .surface = self },
.present_terminal,
{},
);
}
pub const face_ttf = @embedFile("font/res/JetBrainsMono-Regular.ttf");

View File

@ -14,6 +14,7 @@ const build_config = @import("build_config.zig");
const structs = @import("apprt/structs.zig");
pub const action = @import("apprt/action.zig");
pub const glfw = @import("apprt/glfw.zig");
pub const gtk = @import("apprt/gtk.zig");
pub const none = @import("apprt/none.zig");
@ -21,17 +22,17 @@ pub const browser = @import("apprt/browser.zig");
pub const embedded = @import("apprt/embedded.zig");
pub const surface = @import("apprt/surface.zig");
pub const Action = action.Action;
pub const Target = action.Target;
pub const ContentScale = structs.ContentScale;
pub const Clipboard = structs.Clipboard;
pub const ClipboardRequest = structs.ClipboardRequest;
pub const ClipboardRequestType = structs.ClipboardRequestType;
pub const ColorScheme = structs.ColorScheme;
pub const CursorPos = structs.CursorPos;
pub const DesktopNotification = structs.DesktopNotification;
pub const GotoTab = structs.GotoTab;
pub const IMEPos = structs.IMEPos;
pub const Selection = structs.Selection;
pub const SplitDirection = structs.SplitDirection;
pub const SurfaceSize = structs.SurfaceSize;
/// The implementation to use for the app runtime. This is comptime chosen
@ -84,4 +85,6 @@ pub const Runtime = enum {
test {
_ = Runtime;
_ = runtime;
_ = action;
_ = structs;
}

407
src/apprt/action.zig Normal file
View File

@ -0,0 +1,407 @@
const std = @import("std");
const assert = std.debug.assert;
const apprt = @import("../apprt.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
const CoreSurface = @import("../Surface.zig");
/// The target for an action. This is generally the thing that had focus
/// while the action was made but the concept of "focus" is not guaranteed
/// since actions can also be triggered by timers, scripts, etc.
pub const Target = union(Key) {
app,
surface: *CoreSurface,
// Sync with: ghostty_target_tag_e
pub const Key = enum(c_int) {
app,
surface,
};
// Sync with: ghostty_target_u
pub const CValue = extern union {
app: void,
surface: *apprt.Surface,
};
// Sync with: ghostty_target_s
pub const C = extern struct {
key: Key,
value: CValue,
};
/// Convert to ghostty_target_s.
pub fn cval(self: Target) C {
return .{
.key = @as(Key, self),
.value = switch (self) {
.app => .{ .app = {} },
.surface => |v| .{ .surface = v.rt_surface },
},
};
}
};
/// The possible actions an apprt has to react to. Actions are one-way
/// messages that are sent to the app runtime to trigger some behavior.
///
/// Actions are very often key binding actions but can also be triggered
/// by lifecycle events. For example, the `quit_timer` action is not bindable.
///
/// Importantly, actions are generally OPTIONAL to implement by an apprt.
/// Required functionality is called directly on the runtime structure so
/// there is a compiler error if an action is not implemented.
pub const Action = union(Key) {
// A GUIDE TO ADDING NEW ACTIONS:
//
// 1. Add the action to the `Key` enum. The order of the enum matters
// because it maps directly to the libghostty C enum. For ABI
// compatibility, new actions should be added to the end of the enum.
//
// 2. Add the action and optional value to the Action union.
//
// 3. If the value type is not void, ensure the value is C ABI
// compatible (extern). If it is not, add a `C` decl to the value
// and a `cval` function to convert to the C ABI compatible value.
//
// 4. Update `include/ghostty.h`: add the new key, value, and union
// entry. If the value type is void then only the key needs to be
// added. Ensure the order matches exactly with the Zig code.
/// Open a new window. The target determines whether properties such
/// as font size should be inherited.
new_window,
/// Open a new tab. If the target is a surface it should be opened in
/// the same window as the surface. If the target is the app then
/// the tab should be opened in a new window.
new_tab,
/// Create a new split. The value determines the location of the split
/// relative to the target.
new_split: SplitDirection,
/// Close all open windows.
close_all_windows,
/// Toggle fullscreen mode.
toggle_fullscreen: Fullscreen,
/// Toggle tab overview.
toggle_tab_overview,
/// Toggle whether window directions are shown.
toggle_window_decorations,
/// Toggle the quick terminal in or out.
toggle_quick_terminal,
/// Jump to a specific tab. Must handle the scenario that the tab
/// value is invalid.
goto_tab: GotoTab,
/// Jump to a specific split.
goto_split: GotoSplit,
/// Resize the split in the given direction.
resize_split: ResizeSplit,
/// Equalize all the splits in the target window.
equalize_splits,
/// Toggle whether a split is zoomed or not. A zoomed split is resized
/// to take up the entire window.
toggle_split_zoom,
/// Present the target terminal whether its a tab, split, or window.
present_terminal,
/// Sets a size limit (in pixels) for the target terminal.
size_limit: SizeLimit,
/// Specifies the initial size of the target terminal. This will be
/// sent only during the initialization of a surface. If it is received
/// after the surface is initialized it should be ignored.
initial_size: InitialSize,
/// The cell size has changed to the given dimensions in pixels.
cell_size: CellSize,
/// Control whether the inspector is shown or hidden.
inspector: Inspector,
/// The inspector for the given target has changes and should be
/// rendered at the next opportunity.
render_inspector,
/// Show a desktop notification.
desktop_notification: DesktopNotification,
/// Set the title of the target.
set_title: SetTitle,
/// Set the mouse cursor shape.
mouse_shape: terminal.MouseShape,
/// Set whether the mouse cursor is visible or not.
mouse_visibility: MouseVisibility,
/// Called when the mouse is over or recently left a link.
mouse_over_link: MouseOverLink,
/// The health of the renderer has changed.
renderer_health: renderer.Health,
/// Open the Ghostty configuration. This is platform-specific about
/// what it means; it can mean opening a dedicated UI or just opening
/// a file in a text editor.
open_config,
/// Called when there are no more surfaces and the app should quit
/// after the configured delay. This can be cancelled by sending
/// another quit_timer action with "stop". Multiple "starts" shouldn't
/// happen and can be ignored or cause a restart it isn't that important.
quit_timer: QuitTimer,
/// Set the secure input functionality on or off. "Secure input" means
/// that the user is currently at some sort of prompt where they may be
/// entering a password or other sensitive information. This can be used
/// by the app runtime to change the appearance of the cursor, setup
/// system APIs to not log the input, etc.
secure_input: SecureInput,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
new_window,
new_tab,
new_split,
close_all_windows,
toggle_fullscreen,
toggle_tab_overview,
toggle_window_decorations,
toggle_quick_terminal,
goto_tab,
goto_split,
resize_split,
equalize_splits,
toggle_split_zoom,
present_terminal,
size_limit,
initial_size,
cell_size,
inspector,
render_inspector,
desktop_notification,
set_title,
mouse_shape,
mouse_visibility,
mouse_over_link,
renderer_health,
open_config,
quit_timer,
secure_input,
};
/// Sync with: ghostty_action_u
pub const CValue = cvalue: {
const key_fields = @typeInfo(Key).Enum.fields;
var union_fields: [key_fields.len]std.builtin.Type.UnionField = undefined;
for (key_fields, 0..) |field, i| {
const action = @unionInit(Action, field.name, undefined);
const Type = t: {
const Type = @TypeOf(@field(action, field.name));
// Types can provide custom types for their CValue.
if (Type != void and @hasDecl(Type, "C")) break :t Type.C;
break :t Type;
};
union_fields[i] = .{
.name = field.name,
.type = Type,
.alignment = @alignOf(Type),
};
}
break :cvalue @Type(.{ .Union = .{
.layout = .@"extern",
.tag_type = Key,
.fields = &union_fields,
.decls = &.{},
} });
};
/// Sync with: ghostty_action_s
pub const C = extern struct {
key: Key,
value: CValue,
};
/// Returns the value type for the given key.
pub fn Value(comptime key: Key) type {
inline for (@typeInfo(Action).Union.fields) |field| {
const field_key = @field(Key, field.name);
if (field_key == key) return field.type;
}
unreachable;
}
/// Convert to ghostty_action_s.
pub fn cval(self: Action) C {
const value: CValue = switch (self) {
inline else => |v, tag| @unionInit(
CValue,
@tagName(tag),
if (@TypeOf(v) != void and @hasDecl(@TypeOf(v), "cval")) v.cval() else v,
),
};
return .{
.key = @as(Key, self),
.value = value,
};
}
};
// This is made extern (c_int) to make interop easier with our embedded
// runtime. The small size cost doesn't make a difference in our union.
pub const SplitDirection = enum(c_int) {
right,
down,
};
// This is made extern (c_int) to make interop easier with our embedded
// runtime. The small size cost doesn't make a difference in our union.
pub const GotoSplit = enum(c_int) {
previous,
next,
top,
left,
bottom,
right,
};
/// The amount to resize the split by and the direction to resize it in.
pub const ResizeSplit = extern struct {
amount: u16,
direction: Direction,
pub const Direction = enum(c_int) {
up,
down,
left,
right,
};
};
/// The tab to jump to. This is non-exhaustive so that integer values represent
/// the index (zero-based) of the tab to jump to. Negative values are special
/// values.
pub const GotoTab = enum(c_int) {
previous = -1,
next = -2,
last = -3,
_,
};
/// The fullscreen mode to toggle to if we're moving to fullscreen.
pub const Fullscreen = enum(c_int) {
native,
/// macOS has a non-native fullscreen mode that is more like a maximized
/// window. This is much faster to enter and exit than the native mode.
macos_non_native,
macos_non_native_visible_menu,
};
pub const SecureInput = enum(c_int) {
on,
off,
toggle,
};
/// The inspector mode to toggle to if we're toggling the inspector.
pub const Inspector = enum(c_int) {
toggle,
show,
hide,
};
pub const QuitTimer = enum(c_int) {
start,
stop,
};
pub const MouseVisibility = enum(c_int) {
visible,
hidden,
};
pub const MouseOverLink = struct {
url: []const u8,
// Sync with: ghostty_action_mouse_over_link_s
pub const C = extern struct {
url: [*]const u8,
len: usize,
};
pub fn cval(self: MouseOverLink) C {
return .{
.url = self.url.ptr,
.len = self.url.len,
};
}
};
pub const SizeLimit = extern struct {
min_width: u32,
min_height: u32,
max_width: u32,
max_height: u32,
};
pub const InitialSize = extern struct {
width: u32,
height: u32,
};
pub const CellSize = extern struct {
width: u32,
height: u32,
};
pub const SetTitle = struct {
title: [:0]const u8,
// Sync with: ghostty_action_set_title_s
pub const C = extern struct {
title: [*:0]const u8,
};
pub fn cval(self: SetTitle) C {
return .{
.title = self.title.ptr,
};
}
};
/// The desktop notification to show.
pub const DesktopNotification = struct {
title: [:0]const u8,
body: [:0]const u8,
// Sync with: ghostty_action_desktop_notification_s
pub const C = extern struct {
title: [*:0]const u8,
body: [*:0]const u8,
};
pub fn cval(self: DesktopNotification) C {
return .{
.title = self.title.ptr,
.body = self.body.ptr,
};
}
};

File diff suppressed because it is too large Load Diff

View File

@ -127,9 +127,87 @@ pub const App = struct {
glfw.postEmptyEvent();
}
/// Open the configuration in the system editor.
pub fn openConfig(self: *App) !void {
try configpkg.edit.open(self.app.alloc);
/// Perform a given action.
pub fn performAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
) !void {
switch (action) {
.new_window => _ = try self.newSurface(switch (target) {
.app => null,
.surface => |v| v,
}),
.new_tab => try self.newTab(switch (target) {
.app => null,
.surface => |v| v,
}),
.size_limit => switch (target) {
.app => {},
.surface => |surface| try surface.rt_surface.setSizeLimits(.{
.width = value.min_width,
.height = value.min_height,
}, if (value.max_width > 0) .{
.width = value.max_width,
.height = value.max_height,
} else null),
},
.initial_size => switch (target) {
.app => {},
.surface => |surface| try surface.rt_surface.setInitialWindowSize(
value.width,
value.height,
),
},
.toggle_fullscreen => self.toggleFullscreen(target),
.open_config => try configpkg.edit.open(self.app.alloc),
.set_title => switch (target) {
.app => {},
.surface => |surface| try surface.rt_surface.setTitle(value.title),
},
.mouse_shape => switch (target) {
.app => {},
.surface => |surface| try surface.rt_surface.setMouseShape(value),
},
.mouse_visibility => switch (target) {
.app => {},
.surface => |surface| surface.rt_surface.setMouseVisibility(switch (value) {
.visible => true,
.hidden => false,
}),
},
// Unimplemented
.new_split,
.goto_split,
.resize_split,
.equalize_splits,
.toggle_split_zoom,
.present_terminal,
.close_all_windows,
.toggle_tab_overview,
.toggle_window_decorations,
.toggle_quick_terminal,
.goto_tab,
.inspector,
.render_inspector,
.quit_timer,
.secure_input,
.desktop_notification,
.mouse_over_link,
.cell_size,
.renderer_health,
=> log.info("unimplemented action={}", .{action}),
}
}
/// Reload the configuration. This should return the new configuration.
@ -150,8 +228,12 @@ pub const App = struct {
}
/// Toggle the window to fullscreen mode.
pub fn toggleFullscreen(self: *App, surface: *Surface) void {
fn toggleFullscreen(self: *App, target: apprt.Target) void {
_ = self;
const surface: *Surface = switch (target) {
.app => return,
.surface => |v| v.rt_surface,
};
const win = surface.window;
if (surface.isFullscreen()) {
@ -195,18 +277,18 @@ pub const App = struct {
win.setMonitor(monitor, 0, 0, video_mode.getWidth(), video_mode.getHeight(), 0);
}
/// Create a new window for the app.
pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
_ = try self.newSurface(parent_);
}
/// Create a new tab in the parent surface.
fn newTab(self: *App, parent: *CoreSurface) !void {
fn newTab(self: *App, parent_: ?*CoreSurface) !void {
if (!Darwin.enabled) {
log.warn("tabbing is not supported on this platform", .{});
return;
}
const parent = parent_ orelse {
_ = try self.newSurface(null);
return;
};
// Create the new window
const window = try self.newSurface(parent);
@ -370,7 +452,6 @@ pub const Surface = struct {
/// Initialize the surface into the given self pointer. This gives a
/// stable pointer to the destination that can be used for callbacks.
pub fn init(self: *Surface, app: *App) !void {
// Create our window
const win = glfw.Window.create(
640,
@ -525,20 +606,11 @@ pub const Surface = struct {
}
}
/// Create a new tab in the window containing this surface.
pub fn newTab(self: *Surface) !void {
try self.app.newTab(&self.core_surface);
}
/// Checks if the glfw window is in fullscreen.
pub fn isFullscreen(self: *Surface) bool {
return self.window.getMonitor() != null;
}
pub fn toggleFullscreen(self: *Surface, _: Config.NonNativeFullscreen) void {
self.app.toggleFullscreen(self);
}
/// Close this surface.
pub fn close(self: *Surface, processActive: bool) void {
_ = processActive;
@ -550,7 +622,7 @@ pub const Surface = struct {
/// Set the initial window size. This is called exactly once at
/// surface initialization time. This may be called before "self"
/// is fully initialized.
pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void {
fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void {
const monitor = self.window.getMonitor() orelse glfw.Monitor.getPrimary() orelse {
log.warn("window is not on a monitor, not setting initial size", .{});
return;
@ -563,18 +635,11 @@ pub const Surface = struct {
});
}
/// Set the cell size. Unused by GLFW.
pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void {
_ = self;
_ = width;
_ = height;
}
/// Set the size limits of the window.
/// Note: this interface is not good, we should redo it if we plan
/// to use this more. i.e. you can't set max width but no max height,
/// or no mins.
pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void {
fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void {
self.window.setSizeLimits(.{
.width = min.width,
.height = min.height,
@ -624,7 +689,7 @@ pub const Surface = struct {
}
/// Set the title of the window.
pub fn setTitle(self: *Surface, slice: [:0]const u8) !void {
fn setTitle(self: *Surface, slice: [:0]const u8) !void {
if (self.title_text) |t| self.core_surface.alloc.free(t);
self.title_text = try self.core_surface.alloc.dupeZ(u8, slice);
self.window.setTitle(self.title_text.?.ptr);
@ -636,7 +701,7 @@ pub const Surface = struct {
}
/// Set the shape of the cursor.
pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void {
fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void {
if ((comptime builtin.target.isDarwin()) and
!internal_os.macosVersionAtLeast(13, 0, 0))
{
@ -672,15 +737,20 @@ pub const Surface = struct {
self.cursor = new;
}
pub fn mouseOverLink(self: *Surface, uri: ?[]const u8) void {
// We don't do anything in GLFW.
_ = self;
_ = uri;
/// Set the visibility of the mouse cursor.
fn setMouseVisibility(self: *Surface, visible: bool) void {
self.window.setInputModeCursor(if (visible) .normal else .hidden);
}
/// Set the visibility of the mouse cursor.
pub fn setMouseVisibility(self: *Surface, visible: bool) void {
self.window.setInputModeCursor(if (visible) .normal else .hidden);
pub fn supportsClipboard(
self: *const Surface,
clipboard_type: apprt.Clipboard,
) bool {
_ = self;
return switch (clipboard_type) {
.standard => true,
.selection, .primary => comptime builtin.os.tag == .linux,
};
}
/// Start an async clipboard request.
@ -1040,7 +1110,7 @@ pub const Surface = struct {
core_win.cursorPosCallback(.{
.x = @floatCast(pos.xpos),
.y = @floatCast(pos.ypos),
}) catch |err| {
}, null) catch |err| {
log.err("error in cursor pos callback err={}", .{err});
return;
};

View File

@ -17,6 +17,7 @@ const apprt = @import("../../apprt.zig");
const configpkg = @import("../../config.zig");
const input = @import("../../input.zig");
const internal_os = @import("../../os/main.zig");
const terminal = @import("../../terminal/main.zig");
const Config = configpkg.Config;
const CoreApp = @import("../../App.zig");
const CoreSurface = @import("../../Surface.zig");
@ -27,6 +28,7 @@ const Surface = @import("Surface.zig");
const Window = @import("Window.zig");
const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig");
const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig");
const Split = @import("Split.zig");
const c = @import("c.zig").c;
const version = @import("version.zig");
const inspector = @import("inspector.zig");
@ -86,6 +88,16 @@ quit_timer: union(enum) {
pub fn init(core_app: *CoreApp, opts: Options) !App {
_ = opts;
// Log our GTK version
log.info("GTK version build={d}.{d}.{d} runtime={d}.{d}.{d}", .{
c.GTK_MAJOR_VERSION,
c.GTK_MINOR_VERSION,
c.GTK_MICRO_VERSION,
c.gtk_get_major_version(),
c.gtk_get_minor_version(),
c.gtk_get_micro_version(),
});
if (version.atLeast(4, 16, 0)) {
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE
_ = internal_os.setenv("GDK_DISABLE", "gles-api");
@ -340,9 +352,340 @@ pub fn terminate(self: *App) void {
self.config.deinit();
}
/// Open the configuration in the system editor.
pub fn openConfig(self: *App) !void {
try configpkg.edit.open(self.core_app.alloc);
/// Perform a given action.
pub fn performAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
) !void {
switch (action) {
.new_window => _ = try self.newWindow(switch (target) {
.app => null,
.surface => |v| v,
}),
.toggle_fullscreen => self.toggleFullscreen(target, value),
.new_tab => try self.newTab(target),
.goto_tab => self.gotoTab(target, value),
.new_split => try self.newSplit(target, value),
.resize_split => self.resizeSplit(target, value),
.equalize_splits => self.equalizeSplits(target),
.goto_split => self.gotoSplit(target, value),
.open_config => try configpkg.edit.open(self.core_app.alloc),
.inspector => self.controlInspector(target, value),
.desktop_notification => self.showDesktopNotification(target, value),
.set_title => try self.setTitle(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_window_decorations => self.toggleWindowDecorations(target),
.quit_timer => self.quitTimer(value),
// Unimplemented
.close_all_windows,
.toggle_split_zoom,
.toggle_quick_terminal,
.size_limit,
.cell_size,
.secure_input,
.render_inspector,
.renderer_health,
=> log.warn("unimplemented action={}", .{action}),
}
}
fn newTab(_: *App, target: apprt.Target) !void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"new_tab invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
try window.newTab(v);
},
}
}
fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"gotoTab invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
switch (tab) {
.previous => window.gotoPreviousTab(v.rt_surface),
.next => window.gotoNextTab(v.rt_surface),
.last => window.gotoLastTab(),
else => window.gotoTab(@intCast(@intFromEnum(tab))),
}
},
}
}
fn newSplit(
self: *App,
target: apprt.Target,
direction: apprt.action.SplitDirection,
) !void {
switch (target) {
.app => {},
.surface => |v| {
const alloc = self.core_app.alloc;
_ = try Split.create(alloc, v.rt_surface, direction);
},
}
}
fn equalizeSplits(_: *App, target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| {
const tab = v.rt_surface.container.tab() orelse return;
const top_split = switch (tab.elem) {
.split => |s| s,
else => return,
};
_ = top_split.equalize();
},
}
}
fn gotoSplit(
_: *const App,
target: apprt.Target,
direction: apprt.action.GotoSplit,
) void {
switch (target) {
.app => {},
.surface => |v| {
const s = v.rt_surface.container.split() orelse return;
const map = s.directionMap(switch (v.rt_surface.container) {
.split_tl => .top_left,
.split_br => .bottom_right,
.none, .tab_ => unreachable,
});
const surface_ = map.get(direction) orelse return;
if (surface_) |surface| surface.grabFocus();
},
}
}
fn resizeSplit(
_: *const App,
target: apprt.Target,
resize: apprt.action.ResizeSplit,
) void {
switch (target) {
.app => {},
.surface => |v| {
const s = v.rt_surface.container.firstSplitWithOrientation(
Split.Orientation.fromResizeDirection(resize.direction),
) orelse return;
s.moveDivider(resize.direction, resize.amount);
},
}
}
fn presentTerminal(
_: *const App,
target: apprt.Target,
) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.present(),
}
}
fn controlInspector(
_: *const App,
target: apprt.Target,
mode: apprt.action.Inspector,
) void {
const surface: *Surface = switch (target) {
.app => return,
.surface => |v| v.rt_surface,
};
surface.controlInspector(mode);
}
fn toggleFullscreen(
_: *App,
target: apprt.Target,
_: apprt.action.Fullscreen,
) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleFullscreen invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
window.toggleFullscreen();
},
}
}
fn toggleTabOverview(_: *App, target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleTabOverview invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
window.toggleTabOverview();
},
}
}
fn toggleWindowDecorations(
_: *App,
target: apprt.Target,
) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleFullscreen invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
window.toggleWindowDecorations();
},
}
}
fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void {
switch (mode) {
.start => self.startQuitTimer(),
.stop => self.stopQuitTimer(),
}
}
fn setTitle(
_: *App,
target: apprt.Target,
title: apprt.action.SetTitle,
) !void {
switch (target) {
.app => {},
.surface => |v| try v.rt_surface.setTitle(title.title),
}
}
fn setMouseVisibility(
_: *App,
target: apprt.Target,
visibility: apprt.action.MouseVisibility,
) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.setMouseVisibility(switch (visibility) {
.visible => true,
.hidden => false,
}),
}
}
fn setMouseShape(
_: *App,
target: apprt.Target,
shape: terminal.MouseShape,
) !void {
switch (target) {
.app => {},
.surface => |v| try v.rt_surface.setMouseShape(shape),
}
}
fn setMouseOverLink(
_: *App,
target: apprt.Target,
value: apprt.action.MouseOverLink,
) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.mouseOverLink(if (value.url.len > 0)
value.url
else
null),
}
}
fn setInitialSize(
_: *App,
target: apprt.Target,
value: apprt.action.InitialSize,
) !void {
switch (target) {
.app => {},
.surface => |v| try v.rt_surface.setInitialWindowSize(
value.width,
value.height,
),
}
}
fn showDesktopNotification(
self: *App,
target: apprt.Target,
n: apprt.action.DesktopNotification,
) void {
// Set a default title if we don't already have one
const t = switch (n.title.len) {
0 => "Ghostty",
else => n.title,
};
const notification = c.g_notification_new(t.ptr);
defer c.g_object_unref(notification);
c.g_notification_set_body(notification, n.body.ptr);
const icon = c.g_themed_icon_new("com.mitchellh.ghostty");
defer c.g_object_unref(icon);
c.g_notification_set_icon(notification, icon);
const pointer = c.g_variant_new_uint64(switch (target) {
.app => 0,
.surface => |v| @intFromPtr(v),
});
c.g_notification_set_default_action_and_target_value(
notification,
"app.present-surface",
pointer,
);
const g_app: *c.GApplication = @ptrCast(self.app);
// We set the notification ID to the body content. If the content is the
// same, this notification may replace a previous notification
c.g_application_send_notification(g_app, n.body.ptr, notification);
}
/// Reload the configuration. This should return the new configuration.
@ -442,9 +785,9 @@ fn loadRuntimeCss(config: *const Config, provider: *c.GtkCssProvider) !void {
\\ opacity: {d:.2};
\\ background-color: rgb({d},{d},{d});
\\}}
\\window.ghostty-theme-inherit headerbar,
\\window.ghostty-theme-inherit toolbarview > revealer > windowhandle,
\\window.ghostty-theme-inherit box > tabbar {{
\\window.window-theme-ghostty .top-bar,
\\window.window-theme-ghostty .bottom-bar,
\\window.window-theme-ghostty box > tabbar {{
\\ background-color: rgb({d},{d},{d});
\\ color: rgb({d},{d},{d});
\\}}
@ -564,9 +907,9 @@ pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean {
}
/// This will get called when there are no more open surfaces.
pub fn startQuitTimer(self: *App) void {
fn startQuitTimer(self: *App) void {
// Cancel any previous timer.
self.cancelQuitTimer();
self.stopQuitTimer();
// This is a no-op unless we are configured to quit after last window is closed.
if (!self.config.@"quit-after-last-window-closed") return;
@ -581,7 +924,7 @@ pub fn startQuitTimer(self: *App) void {
}
/// This will get called when a new surface gets opened.
pub fn cancelQuitTimer(self: *App) void {
fn stopQuitTimer(self: *App) void {
switch (self.quit_timer) {
.off => {},
.expired => self.quit_timer = .{ .off = {} },
@ -607,7 +950,7 @@ pub fn redrawInspector(self: *App, surface: *Surface) void {
}
/// Called by CoreApp to create a new window with a new surface.
pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
const alloc = self.core_app.alloc;
// Allocate a fixed pointer for our window. We try to minimize
@ -856,8 +1199,12 @@ fn gtkActionPresentSurface(
return;
}
// Convert that u64 to pointer to a core surface.
const surface: *CoreSurface = @ptrFromInt(c.g_variant_get_uint64(parameter));
// Convert that u64 to pointer to a core surface. A value of zero
// means that there was no target surface for the notification so
// we dont' focus any surface.
const ptr_int: u64 = c.g_variant_get_uint64(parameter);
if (ptr_int == 0) return;
const surface: *CoreSurface = @ptrFromInt(ptr_int);
// Send a message through the core app mailbox rather than presenting the
// surface directly so that it can validate that the surface pointer is

View File

@ -7,7 +7,6 @@ const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
const Surface = @import("Surface.zig");
@ -21,14 +20,14 @@ pub const Orientation = enum {
horizontal,
vertical,
pub fn fromDirection(direction: apprt.SplitDirection) Orientation {
pub fn fromDirection(direction: apprt.action.SplitDirection) Orientation {
return switch (direction) {
.right => .horizontal,
.down => .vertical,
};
}
pub fn fromResizeDirection(direction: input.SplitResizeDirection) Orientation {
pub fn fromResizeDirection(direction: apprt.action.ResizeSplit.Direction) Orientation {
return switch (direction) {
.up, .down => .vertical,
.left, .right => .horizontal,
@ -58,7 +57,7 @@ bottom_right: Surface.Container.Elem,
pub fn create(
alloc: Allocator,
sibling: *Surface,
direction: apprt.SplitDirection,
direction: apprt.action.SplitDirection,
) !*Split {
var split = try alloc.create(Split);
errdefer alloc.destroy(split);
@ -69,7 +68,7 @@ pub fn create(
pub fn init(
self: *Split,
sibling: *Surface,
direction: apprt.SplitDirection,
direction: apprt.action.SplitDirection,
) !void {
// Create the new child surface for the other direction.
const alloc = sibling.app.core_app.alloc;
@ -164,7 +163,11 @@ fn removeChild(
}
/// Move the divider in the given direction by the given amount.
pub fn moveDivider(self: *Split, direction: input.SplitResizeDirection, amount: u16) void {
pub fn moveDivider(
self: *Split,
direction: apprt.action.ResizeSplit.Direction,
amount: u16,
) void {
const min_pos = 10;
const pos = c.gtk_paned_get_position(self.paned);
@ -263,7 +266,7 @@ fn updateChildren(self: *const Split) void {
/// A mapping of direction to the element (if any) in that direction.
pub const DirectionMap = std.EnumMap(
input.SplitFocusDirection,
apprt.action.GotoSplit,
?*Surface,
);

View File

@ -9,6 +9,7 @@ const configpkg = @import("../../config.zig");
const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const renderer = @import("../../renderer.zig");
const terminal = @import("../../terminal/main.zig");
const CoreSurface = @import("../../Surface.zig");
const internal_os = @import("../../os/main.zig");
@ -688,7 +689,10 @@ pub fn close(self: *Surface, processActive: bool) void {
c.gtk_widget_show(alert);
}
pub fn controlInspector(self: *Surface, mode: input.InspectorMode) void {
pub fn controlInspector(
self: *Surface,
mode: apprt.action.Inspector,
) void {
const show = switch (mode) {
.toggle => self.inspector == null,
.show => true,
@ -715,30 +719,6 @@ pub fn controlInspector(self: *Surface, mode: input.InspectorMode) void {
};
}
pub fn toggleFullscreen(self: *Surface, mac_non_native: configpkg.NonNativeFullscreen) void {
const window = self.container.window() orelse {
log.info(
"toggleFullscreen invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
window.toggleFullscreen(mac_non_native);
}
pub fn toggleWindowDecorations(self: *Surface) void {
const window = self.container.window() orelse {
log.info(
"toggleWindowDecorations invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
window.toggleWindowDecorations();
}
pub fn getTitleLabel(self: *Surface) ?*c.GtkWidget {
switch (self.title) {
.none => return null,
@ -749,69 +729,6 @@ pub fn getTitleLabel(self: *Surface) ?*c.GtkWidget {
}
}
pub fn newSplit(self: *Surface, direction: apprt.SplitDirection) !void {
const alloc = self.app.core_app.alloc;
_ = try Split.create(alloc, self, direction);
}
pub fn gotoSplit(self: *const Surface, direction: input.SplitFocusDirection) void {
const s = self.container.split() orelse return;
const map = s.directionMap(switch (self.container) {
.split_tl => .top_left,
.split_br => .bottom_right,
.none, .tab_ => unreachable,
});
const surface_ = map.get(direction) orelse return;
if (surface_) |surface| surface.grabFocus();
}
pub fn resizeSplit(self: *const Surface, direction: input.SplitResizeDirection, amount: u16) void {
const s = self.container.firstSplitWithOrientation(
Split.Orientation.fromResizeDirection(direction),
) orelse return;
s.moveDivider(direction, amount);
}
pub fn equalizeSplits(self: *const Surface) void {
const tab = self.container.tab() orelse return;
const top_split = switch (tab.elem) {
.split => |s| s,
else => return,
};
_ = top_split.equalize();
}
pub fn newTab(self: *Surface) !void {
const window = self.container.window() orelse {
log.info("surface cannot create new tab when not attached to a window", .{});
return;
};
try window.newTab(&self.core_surface);
}
pub fn hasTabs(self: *const Surface) bool {
const window = self.container.window() orelse return false;
return window.hasTabs();
}
pub fn gotoTab(self: *Surface, tab: apprt.GotoTab) void {
const window = self.container.window() orelse {
log.info(
"gotoTab invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
switch (tab) {
.previous => window.gotoPreviousTab(self),
.next => window.gotoNextTab(self),
.last => window.gotoLastTab(),
else => window.gotoTab(@intCast(@intFromEnum(tab))),
}
}
pub fn setShouldClose(self: *Surface) void {
_ = self;
}
@ -867,18 +784,6 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void
);
}
pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void {
_ = self;
_ = width;
_ = height;
}
pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void {
_ = self;
_ = min;
_ = max_;
}
pub fn grabFocus(self: *Surface) void {
if (self.container.tab()) |tab| tab.focus_child = self;
@ -1026,6 +931,19 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void {
self.url_widget = URLWidget.init(self, uriZ);
}
pub fn supportsClipboard(
self: *const Surface,
clipboard_type: apprt.Clipboard,
) bool {
_ = self;
return switch (clipboard_type) {
.standard,
.selection,
.primary,
=> true,
};
}
pub fn clipboardRequest(
self: *Surface,
clipboard_type: apprt.Clipboard,
@ -1386,7 +1304,7 @@ fn gtkMouseUp(
}
fn gtkMouseMotion(
_: *c.GtkEventControllerMotion,
ec: *c.GtkEventControllerMotion,
x: c.gdouble,
y: c.gdouble,
ud: ?*anyopaque,
@ -1415,7 +1333,12 @@ fn gtkMouseMotion(
self.grabFocus();
}
self.core_surface.cursorPosCallback(self.cursor_pos) catch |err| {
// Get our modifiers
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec));
const gtk_mods = c.gdk_event_get_modifier_state(event);
const mods = translateMods(gtk_mods);
self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| {
log.err("error in cursor pos callback err={}", .{err});
return;
};
@ -1975,7 +1898,7 @@ fn translateMods(state: c.GdkModifierType) input.Mods {
return mods;
}
pub fn presentSurface(self: *Surface) void {
pub fn present(self: *Surface) void {
if (self.container.window()) |window| {
if (self.container.tab()) |tab| {
if (window.notebook.getTabPosition(tab)) |position|
@ -1983,5 +1906,6 @@ pub fn presentSurface(self: *Surface) void {
}
c.gtk_window_present(window.window);
}
self.grabFocus();
}

View File

@ -35,6 +35,10 @@ window: *c.GtkWindow,
/// GtkHeaderBar depending on if adw is enabled and linked.
header: ?*c.GtkWidget,
/// The tab overview for the window. This is possibly null since there is no
/// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0).
tab_overview: ?*c.GtkWidget,
/// The notebook (tab grouping) for this window.
/// can be either c.GtkNotebook or c.AdwTabView.
notebook: Notebook,
@ -68,6 +72,7 @@ pub fn init(self: *Window, app: *App) !void {
.app = app,
.window = undefined,
.header = null,
.tab_overview = null,
.notebook = undefined,
.context_menu = undefined,
.toast_overlay = undefined,
@ -97,7 +102,7 @@ pub fn init(self: *Window, app: *App) !void {
// Apply class to color headerbar if window-theme is set to `ghostty`.
if (app.config.@"window-theme" == .ghostty) {
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "ghostty-theme-inherit");
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window-theme-ghostty");
}
// Remove the window's background if any of the widgets need to be transparent
@ -114,7 +119,7 @@ pub fn init(self: *Window, app: *App) !void {
const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0);
// If we are using an AdwWindow then we can support the tab overview.
const tab_overview_: ?*c.GtkWidget = if (self.isAdwWindow()) overview: {
self.tab_overview = if (self.isAdwWindow()) overview: {
const tab_overview = c.adw_tab_overview_new();
c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1);
_ = c.g_signal_connect_data(
@ -152,14 +157,15 @@ pub fn init(self: *Window, app: *App) !void {
c.gtk_widget_set_tooltip_text(btn, "Main Menu");
c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic");
c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu)));
if (self.isAdwWindow())
c.adw_header_bar_pack_end(@ptrCast(header), btn)
else
c.gtk_header_bar_pack_end(@ptrCast(header), btn);
if (self.isAdwWindow()) {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
c.adw_header_bar_pack_end(@ptrCast(header), btn);
} else c.gtk_header_bar_pack_end(@ptrCast(header), btn);
}
// If we're using an AdwWindow then we can support the tab overview.
if (tab_overview_) |tab_overview| {
if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
assert(self.isAdwWindow());
const btn = c.gtk_toggle_button_new();
@ -235,7 +241,8 @@ pub fn init(self: *Window, app: *App) !void {
};
// If we have a tab overview then we can set it on our notebook.
if (tab_overview_) |tab_overview| {
if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
assert(self.notebook == .adw_tab_view);
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view);
}
@ -256,7 +263,8 @@ pub fn init(self: *Window, app: *App) !void {
// Our actions for the menu
initActions(self);
if (self.hasAdwToolbar()) {
if (self.isAdwWindow()) {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new());
const header_widget: *c.GtkWidget = @ptrCast(@alignCast(self.header.?));
@ -289,7 +297,7 @@ pub fn init(self: *Window, app: *App) !void {
// Set our application window content. The content depends on if
// we're using an AdwTabOverview or not.
if (tab_overview_) |tab_overview| {
if (self.tab_overview) |tab_overview| {
c.adw_tab_overview_set_child(
@ptrCast(tab_overview),
@ptrCast(@alignCast(toolbar_view)),
@ -391,18 +399,9 @@ pub fn deinit(self: *Window) void {
/// paths that are not enabled.
inline fn isAdwWindow(self: *Window) bool {
return (comptime adwaita.versionAtLeast(1, 4, 0)) and
adwaita.enabled(&self.app.config) and
self.app.config.@"gtk-titlebar" and
adwaita.versionAtLeast(1, 4, 0);
}
/// This must be `inline` so that the comptime check noops conditional
/// paths that are not enabled.
inline fn hasAdwToolbar(self: *Window) bool {
return ((comptime adwaita.versionAtLeast(1, 4, 0)) and
adwaita.enabled(&self.app.config) and
adwaita.versionAtLeast(1, 4, 0) and
self.app.config.@"gtk-titlebar");
self.app.config.@"gtk-titlebar";
}
/// Add a new tab to this window.
@ -421,11 +420,6 @@ pub fn closeTab(self: *Window, tab: *Tab) void {
self.notebook.closeTab(tab);
}
/// Returns true if this window has any tabs.
pub fn hasTabs(self: *const Window) bool {
return self.notebook.nPages() > 0;
}
/// Go to the previous tab for a surface.
pub fn gotoPreviousTab(self: *Window, surface: *Surface) void {
const tab = surface.container.tab() orelse {
@ -463,8 +457,17 @@ pub fn gotoTab(self: *Window, n: usize) void {
}
}
/// Toggle tab overview (if present)
pub fn toggleTabOverview(self: *Window) void {
if (self.tab_overview) |tab_overview_widget| {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
const tab_overview: *c.AdwTabOverview = @ptrCast(@alignCast(tab_overview_widget));
c.adw_tab_overview_set_open(tab_overview, 1 - c.adw_tab_overview_get_open(tab_overview));
}
}
/// Toggle fullscreen for this window.
pub fn toggleFullscreen(self: *Window, _: configpkg.NonNativeFullscreen) void {
pub fn toggleFullscreen(self: *Window) void {
const is_fullscreen = c.gtk_window_is_fullscreen(self.window);
if (is_fullscreen == 0) {
c.gtk_window_fullscreen(self.window);

View File

@ -66,9 +66,11 @@ pub const Notebook = union(enum) {
const tab_view: *c.AdwTabView = c.adw_tab_view_new().?;
// Adwaita enables all of the shortcuts by default.
// We want to manage keybindings ourselves.
c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS);
if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) {
// Adwaita enables all of the shortcuts by default.
// We want to manage keybindings ourselves.
c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS);
}
_ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT);
@ -261,6 +263,10 @@ pub const Notebook = union(enum) {
_ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT);
// Tab settings
c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1);
c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1);
if (self.nPages() > 1) {
c.gtk_notebook_set_show_tabs(notebook, 1);
}

40
src/apprt/gtk/version.zig Normal file
View File

@ -0,0 +1,40 @@
const c = @import("c.zig").c;
/// Verifies that the GTK version is at least the given version.
///
/// This can be run in both a comptime and runtime context. If it
/// is run in a comptime context, it will only check the version
/// in the headers. If it is run in a runtime context, it will
/// check the actual version of the library we are linked against.
///
/// This is inlined so that the comptime checks will disable the
/// runtime checks if the comptime checks fail.
pub inline fn atLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
// If our header has lower versions than the given version,
// we can return false immediately. This prevents us from
// compiling against unknown symbols and makes runtime checks
// very slightly faster.
if (comptime c.GTK_MAJOR_VERSION < major or
c.GTK_MINOR_VERSION < minor or
c.GTK_MICRO_VERSION < micro) return false;
// If we're in comptime then we can't check the runtime version.
if (@inComptime()) return true;
// We use the functions instead of the constants such as
// c.GTK_MINOR_VERSION because the function gets the actual
// runtime version.
if (c.gtk_get_major_version() >= major) {
if (c.gtk_get_major_version() > major) return true;
if (c.gtk_get_minor_version() >= minor) {
if (c.gtk_get_minor_version() > minor) return true;
return c.gtk_get_micro_version() >= micro;
}
}
return false;
}

View File

@ -52,33 +52,6 @@ pub const ClipboardRequest = union(ClipboardRequestType) {
osc_52_write: Clipboard,
};
/// A desktop notification.
pub const DesktopNotification = struct {
/// The title of the notification. May be an empty string to not show a
/// title.
title: []const u8,
/// The body of a notification. This will always be shown.
body: []const u8,
};
/// The tab to jump to. This is non-exhaustive so that integer values represent
/// the index (zero-based) of the tab to jump to. Negative values are special
/// values.
pub const GotoTab = enum(c_int) {
previous = -1,
next = -2,
last = -3,
_,
};
// This is made extern (c_int) to make interop easier with our embedded
// runtime. The small size cost doesn't make a difference in our union.
pub const SplitDirection = enum(c_int) {
right,
down,
};
/// The color scheme in use (light vs dark).
pub const ColorScheme = enum(u2) {
light = 0,

View File

@ -12,7 +12,7 @@ pub const fish_completions = comptimeGenerateFishCompletions();
fn comptimeGenerateFishCompletions() []const u8 {
comptime {
@setEvalBranchQuota(16000);
@setEvalBranchQuota(17000);
var counter = std.io.countingWriter(std.io.null_writer);
try writeFishCompletions(&counter.writer());

View File

@ -116,7 +116,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
while (iter.next()) |bind| {
const action = switch (bind.value_ptr.*) {
.leader => continue, // TODO: support this
.action, .action_unconsumed => |action| action,
.leaf => |leaf| leaf.action,
};
const key = switch (bind.key_ptr.key) {
.translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}),

File diff suppressed because it is too large Load Diff

45
src/cli/lorem_ipsum.txt Normal file
View File

@ -0,0 +1,45 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras hendrerit aliquet
turpis non dictum. Mauris pulvinar nisl sit amet dui cursus tempus. Pellentesque
ut dui justo. Etiam quis magna sagittis nisi pretium consequat vitae ut nisl.
Sed at metus id odio pulvinar sodales. Vestibulum sollicitudin, sem id tristique
vestibulum, neque ante dictum tortor, in convallis mi enim ac lorem. Suspendisse
orci ex, ullamcorper sed leo vitae, mattis egestas nisl. Morbi id est vel
ipsum mollis convallis vel at mauris. Duis vehicula facilisis placerat. Aliquam
venenatis auctor ipsum vel elementum. Proin ac tincidunt lacus. Sed facilisis
tellus ullamcorper bibendum lobortis. Pellentesque porta, lacus quis efficitur
pulvinar, sem mi varius ante, sed finibus diam ante et risus.
Morbi ut sollicitudin justo. Nulla mattis mi ac mauris tincidunt tempor. Morbi
vel gravida erat. Ut eu risus quis nisi facilisis aliquet varius id orci.
Pellentesque tortor diam, porttitor nec urna nec, convallis consectetur dui.
Vestibulum et hendrerit ipsum. Morbi pharetra dictum turpis in elementum. Ut
nec volutpat nunc, at venenatis leo. Morbi eget nulla luctus, tincidunt dui vel,
cursus urna. Maecenas ac pellentesque nisi. Quisque ut lorem porta, eleifend
metus id, pellentesque tellus.
Vivamus gravida convallis felis, at hendrerit dolor. Vestibulum tincidunt id
augue quis hendrerit. Praesent venenatis elit quis posuere gravida. Praesent
at massa a purus maximus tempus. Proin dui leo, feugiat et erat ac, tincidunt
aliquam risus. Aenean rutrum hendrerit turpis, sit amet consectetur justo porta
non. Sed auctor justo elit, sed mollis odio ullamcorper nec. Pellentesque ac
hendrerit tortor. Praesent quis viverra dui, sit amet imperdiet magna.
Mauris iaculis maximus felis, aliquet vehicula neque sagittis nec. Duis
convallis purus enim, vel scelerisque purus dignissim eu. Donec congue sapien
a neque rhoncus, sit amet accumsan libero tincidunt. Proin vitae placerat urna.
Donec dolor sapien, fringilla sed semper sit amet, sollicitudin sit amet orci.
Mauris maximus convallis vehicula. Aliquam urna ipsum, fermentum ac iaculis vel,
blandit eget lorem. Sed enim ante, sodales a diam in, convallis interdum quam.
Duis non urna risus. Proin ac neque at risus ullamcorper mattis eu vel nunc.
Proin et ipsum euismod, ullamcorper justo et, imperdiet est. Curabitur quis
arcu faucibus, bibendum nisl nec, hendrerit sapien. Curabitur vitae ante risus.
Praesent eget sagittis tortor.
Mauris aliquam nec nibh eu congue. Nullam congue auctor vestibulum. Donec
posuere sapien nec massa efficitur tincidunt. Vestibulum ante ipsum primis in
faucibus orci luctus et ultrices posuere cubilia curae; Proin molestie, nisl
in tincidunt condimentum, ante metus fermentum felis, ac molestie lacus dui vel
dolor. Donec ornare laoreet posuere. Etiam id tincidunt ante. Maecenas semper
diam ac tortor facilisis egestas. Nam eu bibendum nisl. Integer tempor nisl nec
ex consectetur, quis lobortis enim finibus. Sed ac erat posuere, fermentum metus
sed, suscipit nisl.

View File

@ -223,7 +223,7 @@ const c = @cImport({
@"font-codepoint-map": RepeatableCodepointMap = .{},
/// Draw fonts with a thicker stroke, if supported. This is only supported
/// currently on MacOS.
/// currently on macOS.
@"font-thicken": bool = false,
/// All of the configurations behavior adjust various metrics determined by the
@ -429,6 +429,8 @@ palette: Palette = .{},
/// Hide the mouse immediately when typing. The mouse becomes visible again when
/// the mouse is used. The mouse is only hidden if the mouse cursor is over the
/// active terminal surface.
///
/// macOS: This feature requires macOS 15.0 (Sequoia) or later.
@"mouse-hide-while-typing": bool = false,
/// Determines whether running programs can detect the shift key pressed with a
@ -649,7 +651,8 @@ class: ?[:0]const u8 = null,
@"working-directory": ?[]const u8 = null,
/// Key bindings. The format is `trigger=action`. Duplicate triggers will
/// overwrite previously set values.
/// overwrite previously set values. The list of actions is available in
/// the documentation or using the `ghostty +list-actions` command.
///
/// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`,
/// `ctrl+shift+b`, `up`. Some notes:
@ -701,6 +704,9 @@ class: ?[:0]const u8 = null,
/// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and
/// `ctrl+a>t` will become unbound.
///
/// * Trigger sequences are not allowed for `global:` or `all:`-prefixed
/// triggers. This is a limitation we could remove in the future.
///
/// Action is the action to take when the trigger is satisfied. It takes the
/// format `action` or `action:param`. The latter form is only valid if the
/// action requires a parameter.
@ -720,6 +726,9 @@ class: ?[:0]const u8 = null,
/// * `text:text` - Send a string. Uses Zig string literal syntax.
/// i.e. `text:\x15` sends Ctrl-U.
///
/// * All other actions can be found in the documentation or by using the
/// `ghostty +list-actions` command.
///
/// Some notes for the action:
///
/// * The parameter is taken as-is after the `:`. Double quotes or
@ -734,11 +743,48 @@ class: ?[:0]const u8 = null,
/// removes ALL keybindings up to this point, including the default
/// keybindings.
///
/// A keybind by default causes the input to be consumed. This means that the
/// associated encoding (if any) will not be sent to the running program
/// in the terminal. If you wish to send the encoded value to the program,
/// specify the "unconsumed:" prefix before the entire keybind. For example:
/// "unconsumed:ctrl+a=reload_config"
/// The keybind trigger can be prefixed with some special values to change
/// the behavior of the keybind. These are:
///
/// * `all:` - Make the keybind apply to all terminal surfaces. By default,
/// keybinds only apply to the focused terminal surface. If this is true,
/// then the keybind will be sent to all terminal surfaces. This only
/// applies to actions that are surface-specific. For actions that
/// are already global (i.e. `quit`), this prefix has no effect.
///
/// * `global:` - Make the keybind global. By default, keybinds only work
/// within Ghostty and under the right conditions (application focused,
/// sometimes terminal focused, etc.). If you want a keybind to work
/// globally across your system (i.e. even when Ghostty is not focused),
/// specify this prefix. This prefix implies `all:`. Note: this does not
/// work in all environments; see the additional notes below for more
/// information.
///
/// * `unconsumed:` - Do not consume the input. By default, a keybind
/// will consume the input, meaning that the associated encoding (if
/// any) will not be sent to the running program in the terminal. If
/// you wish to send the encoded value to the program, specify the
/// `unconsumed:` prefix before the entire keybind. For example:
/// `unconsumed:ctrl+a=reload_config`. `global:` and `all:`-prefixed
/// keybinds will always consume the input regardless of this setting.
/// Since they are not associated with a specific terminal surface,
/// they're never encoded.
///
/// Keybind trigger are not unique per prefix combination. For example,
/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind
/// set later will overwrite the keybind set earlier. In this case, the
/// `global:` keybind will be used.
///
/// Multiple prefixes can be specified. For example,
/// `global:unconsumed:ctrl+a=reload_config` will make the keybind global
/// and not consume the input to reload the config.
///
/// A note on `global:`: this feature is only supported on macOS. On macOS,
/// this feature requires accessibility permissions to be granted to Ghostty.
/// When a `global:` keybind is specified and Ghostty is launched or reloaded,
/// Ghostty will attempt to request these permissions. If the permissions are
/// not granted, the keybind will not work. On macOS, you can find these
/// permissions in System Preferences -> Privacy & Security -> Accessibility.
keybind: Keybinds = .{},
/// Horizontal window padding. This applies padding between the terminal cells
@ -845,13 +891,16 @@ keybind: Keybinds = .{},
///
/// * `true`
/// * `false` - windows won't have native decorations, i.e. titlebar and
/// borders. On MacOS this also disables tabs and tab overview.
/// borders. On macOS this also disables tabs and tab overview.
///
/// The "toggle_window_decoration" keybind action can be used to create
/// a keybinding to toggle this setting at runtime.
///
/// Changing this configuration in your configuration and reloading will
/// only affect new windows. Existing windows will not be affected.
///
/// macOS: To hide the titlebar without removing the native window borders
/// or rounded corners, use `macos-titlebar-style = hidden` instead.
@"window-decoration": bool = true,
/// The font that will be used for the application's window and tab titles.
@ -1171,6 +1220,40 @@ keybind: Keybinds = .{},
/// window is ever created. Only implemented on Linux.
@"initial-window": bool = true,
/// The position of the "quick" terminal window. To learn more about the
/// quick terminal, see the documentation for the `toggle_quick_terminal`
/// binding action.
///
/// Valid values are:
///
/// * `top` - Terminal appears at the top of the screen.
/// * `bottom` - Terminal appears at the bottom of the screen.
/// * `left` - Terminal appears at the left of the screen.
/// * `right` - Terminal appears at the right of the screen.
///
/// Changing this configuration requires restarting Ghostty completely.
@"quick-terminal-position": QuickTerminalPosition = .top,
/// The screen where the quick terminal should show up.
///
/// Valid values are:
///
/// * `main` - The screen that the operating system recommends as the main
/// screen. On macOS, this is the screen that is currently receiving
/// keyboard input. This screen is defined by the operating system and
/// not chosen by Ghostty.
///
/// * `mouse` - The screen that the mouse is currently hovered over.
///
/// * `macos-menu-bar` - The screen that contains the macOS menu bar as
/// set in the display settings on macOS. This is a bit confusing because
/// every screen on macOS has a menu bar, but this is the screen that
/// contains the primary menu bar.
///
/// The default value is `main` because this is the recommended screen
/// by the operating system.
@"quick-terminal-screen": QuickTerminalScreen = .main,
/// Whether to enable shell integration auto-injection or not. Shell integration
/// greatly enhances the terminal experience by enabling a number of features:
///
@ -1304,7 +1387,7 @@ keybind: Keybinds = .{},
@"macos-non-native-fullscreen": NonNativeFullscreen = .false,
/// The style of the macOS titlebar. Available values are: "native",
/// "transparent", and "tabs".
/// "transparent", "tabs", and "hidden".
///
/// The "native" style uses the native macOS titlebar with zero customization.
/// The titlebar will match your window theme (see `window-theme`).
@ -1321,6 +1404,13 @@ keybind: Keybinds = .{},
/// macOS 14 does not have this issue and any other macOS version has not
/// been tested.
///
/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`,
/// however, it does not remove the frame from the window or cause it to have
/// squared corners. Changing to or from this option at run-time may affect
/// existing windows in buggy ways. The top titlebar area of the window will
/// continue to drag the window around and you will not be able to use
/// the mouse for terminal events in this space.
///
/// The default value is "transparent". This is an opinionated choice
/// but its one I think is the most aesthetically pleasing and works in
/// most cases.
@ -1348,6 +1438,34 @@ keybind: Keybinds = .{},
/// find false more visually appealing.
@"macos-window-shadow": bool = true,
/// If true, Ghostty on macOS will automatically enable the "Secure Input"
/// feature when it detects that a password prompt is being displayed.
///
/// "Secure Input" is a macOS security feature that prevents applications from
/// reading keyboard events. This can always be enabled manually using the
/// `Ghostty > Secure Keyboard Entry` menu item.
///
/// Note that automatic password prompt detection is based on heuristics
/// and may not always work as expected. Specifically, it does not work
/// over SSH connections, but there may be other cases where it also
/// doesn't work.
///
/// A reason to disable this feature is if you find that it is interfering
/// with legitimate accessibility software (or software that uses the
/// accessibility APIs), since secure input prevents any application from
/// reading keyboard events.
@"macos-auto-secure-input": bool = true,
/// If true, Ghostty will show a graphical indication when secure input is
/// enabled. This indication is generally recommended to know when secure input
/// is enabled.
///
/// Normally, secure input is only active when a password prompt is displayed
/// or it is manually (and typically temporarily) enabled. However, if you
/// always have secure input enabled, the indication can be distracting and
/// you may want to disable it.
@"macos-secure-input-indication": bool = true,
/// Put every surface (tab, split, window) into a dedicated Linux cgroup.
///
/// This makes it so that resource management can be done on a per-surface
@ -3664,11 +3782,16 @@ pub const Keybinds = struct {
)) return false,
// Actions are compared by field directly
inline .action, .action_unconsumed => |_, tag| if (!equalField(
inputpkg.Binding.Action,
@field(self_entry.value_ptr.*, @tagName(tag)),
@field(other_entry.value_ptr.*, @tagName(tag)),
)) return false,
.leaf => {
const self_leaf = self_entry.value_ptr.*.leaf;
const other_leaf = other_entry.value_ptr.*.leaf;
if (!equalField(
inputpkg.Binding.Set.Leaf,
self_leaf,
other_leaf,
)) return false;
},
}
}
@ -4241,6 +4364,7 @@ pub const MacTitlebarStyle = enum {
native,
transparent,
tabs,
hidden,
};
/// See gtk-single-instance
@ -4311,6 +4435,21 @@ pub const ResizeOverlayPosition = enum {
@"bottom-right",
};
/// See quick-terminal-position
pub const QuickTerminalPosition = enum {
top,
bottom,
left,
right,
};
/// See quick-terminal-screen
pub const QuickTerminalScreen = enum {
main,
mouse,
@"macos-menu-bar",
};
/// See grapheme-width-method
pub const GraphemeWidthMethod = enum {
legacy,

View File

@ -5,6 +5,7 @@
const dir = @import("dir.zig");
const sentry_envelope = @import("sentry_envelope.zig");
pub const minidump = @import("minidump.zig");
pub const sentry = @import("sentry.zig");
pub const Envelope = sentry_envelope.Envelope;
pub const defaultDir = dir.defaultDir;

7
src/crash/minidump.zig Normal file
View File

@ -0,0 +1,7 @@
pub const reader = @import("minidump/reader.zig");
pub const stream = @import("minidump/stream.zig");
pub const Reader = reader.Reader;
test {
@import("std").testing.refAllDecls(@This());
}

View File

@ -0,0 +1,59 @@
//! This file contains the external structs and constants for the minidump
//! format. Most are from the Microsoft documentation on the minidump format:
//! https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/
//!
//! Wherever possible, we also compare our definitions to other projects
//! such as rust-minidump, libmdmp, breakpad, etc. to ensure we're doing
//! the right thing.
/// "MDMP" in little-endian.
pub const signature = 0x504D444D;
/// The version of the minidump format.
pub const version = 0xA793;
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_header
pub const Header = extern struct {
signature: u32,
version: packed struct(u32) { low: u16, high: u16 },
stream_count: u32,
stream_directory_rva: u32,
checksum: u32,
time_date_stamp: u32,
flags: u64,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_directory
pub const Directory = extern struct {
stream_type: u32,
location: LocationDescriptor,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_location_descriptor
pub const LocationDescriptor = extern struct {
data_size: u32,
rva: u32,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_memory_descriptor
pub const MemoryDescriptor = extern struct {
start_of_memory_range: u64,
memory: LocationDescriptor,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_thread_list
pub const ThreadList = extern struct {
number_of_threads: u32,
threads: [1]Thread,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_thread
pub const Thread = extern struct {
thread_id: u32,
suspend_count: u32,
priority_class: u32,
priority: u32,
teb: u64,
stack: MemoryDescriptor,
thread_context: LocationDescriptor,
};

View File

@ -0,0 +1,242 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const external = @import("external.zig");
const stream = @import("stream.zig");
const EncodedStream = stream.EncodedStream;
const log = std.log.scoped(.minidump_reader);
/// Possible minidump-specific errors that can occur when reading a minidump.
/// This isn't the full error set since IO errors can also occur depending
/// on the Source type.
pub const ReadError = error{
InvalidHeader,
InvalidVersion,
StreamSizeMismatch,
};
/// Reader creates a new minidump reader for the given source type. The
/// source must have both a "reader()" and "seekableStream()" function.
///
/// Given the format of a minidump file, we must keep the source open and
/// continually access it because the format of the minidump is full of
/// pointers and offsets that we must follow depending on the stream types.
/// Also, since we're not aware of all stream types (in fact its impossible
/// to be aware since custom stream types are allowed), its possible any stream
/// type can define their own pointers and offsets. So, the source must always
/// be available so callers can decode the streams as needed.
pub fn Reader(comptime S: type) type {
return struct {
const Self = @This();
/// The source data.
source: Source,
/// The endianness of the minidump file. This is detected by reading
/// the byte order of the header.
endian: std.builtin.Endian,
/// The number of streams within the minidump file. This is read from
/// the header and stored here so we can quickly access them. Note
/// the stream types require reading the source; this is an optimization
/// to avoid any allocations on the reader and the caller can choose
/// to store them if they want.
stream_count: u32,
stream_directory_rva: u32,
const SourceCallable = switch (@typeInfo(Source)) {
.Pointer => |v| v.child,
.Struct => Source,
else => @compileError("Source type must be a pointer or struct"),
};
const SourceReader = @typeInfo(@TypeOf(SourceCallable.reader)).Fn.return_type.?;
const SourceSeeker = @typeInfo(@TypeOf(SourceCallable.seekableStream)).Fn.return_type.?;
/// A limited reader for reading data from the source.
pub const LimitedReader = std.io.LimitedReader(SourceReader);
/// The source type for the reader.
pub const Source = S;
/// The stream types for reading
pub const ThreadList = stream.thread_list.ThreadListReader(Self);
/// The reader type for stream reading. This has some other methods so
/// you must still call reader() on the result to get the actual
/// reader to read the data.
pub const StreamReader = struct {
source: Source,
endian: std.builtin.Endian,
directory: external.Directory,
/// Should not be accessed directly. This is setup whenever
/// reader() is called.
limit_reader: LimitedReader = undefined,
pub const Reader = LimitedReader.Reader;
/// Returns a Reader implementation that reads the bytes of the
/// stream.
///
/// The reader is dependent on the state of Source so any
/// state-changing operations on Source will invalidate the
/// reader. For example, making another reader, reading another
/// stream directory, closing the source, etc.
pub fn reader(self: *StreamReader) LimitedReader.Reader {
try self.source.seekableStream().seekTo(self.directory.location.rva);
self.limit_reader = .{
.inner_reader = self.source.reader(),
.bytes_left = self.directory.location.data_size,
};
return self.limit_reader.reader();
}
/// Seeks the source to the location of the directory.
pub fn seekToPayload(self: *StreamReader) !void {
try self.source.seekableStream().seekTo(self.directory.location.rva);
}
};
/// Iterator type to read over the streams in the minidump file.
pub const StreamIterator = struct {
reader: *const Self,
i: u32 = 0,
pub fn next(self: *StreamIterator) !?StreamReader {
if (self.i >= self.reader.stream_count) return null;
const dir = try self.reader.directory(self.i);
self.i += 1;
return try self.reader.streamReader(dir);
}
};
/// Initialize a reader. The source must remain available for the entire
/// lifetime of the reader. The reader does not take ownership of the
/// source so if it has resources that need to be cleaned up, the caller
/// must do so once the reader is no longer needed.
pub fn init(source: Source) !Self {
const header, const endian = try readHeader(Source, source);
return .{
.source = source,
.endian = endian,
.stream_count = header.stream_count,
.stream_directory_rva = header.stream_directory_rva,
};
}
/// Return an iterator to read over the streams in the minidump file.
/// This is very similar to using a simple for loop to stream_count
/// and calling directory() on each index, but is more idiomatic
/// Zig.
pub fn streamIterator(self: *const Self) StreamIterator {
return .{ .reader = self };
}
/// Return a StreamReader for the given directory type. This streams
/// from the underlying source so the returned reader is only valid
/// as long as the source is unmodified (i.e. the source is not
/// closed, the source seek position is not moved, etc.).
pub fn streamReader(
self: *const Self,
dir: external.Directory,
) SourceSeeker.SeekError!StreamReader {
return .{
.source = self.source,
.endian = self.endian,
.directory = dir,
};
}
/// Get the directory entry with the given index.
///
/// Asserts the index is valid (idx < stream_count).
pub fn directory(self: *const Self, idx: usize) !external.Directory {
assert(idx < self.stream_count);
// Seek to the directory.
const offset: u32 = @intCast(@sizeOf(external.Directory) * idx);
const rva: u32 = self.stream_directory_rva + offset;
try self.source.seekableStream().seekTo(rva);
// Read the directory.
return try self.source.reader().readStructEndian(
external.Directory,
self.endian,
);
}
/// Return a reader for the given location descriptor. This is only
/// valid until the reader source is modified in some way.
pub fn locationReader(
self: *const Self,
loc: external.LocationDescriptor,
) !LimitedReader {
try self.source.seekableStream().seekTo(loc.rva);
return .{
.inner_reader = self.source.reader(),
.bytes_left = loc.data_size,
};
}
};
}
/// Reads the header for the minidump file and returns endianness of
/// the file.
fn readHeader(comptime T: type, source: T) !struct {
external.Header,
std.builtin.Endian,
} {
// Start by trying LE.
var endian: std.builtin.Endian = .little;
var header = try source.reader().readStructEndian(external.Header, endian);
// If the signature doesn't match, we assume its BE.
if (header.signature != external.signature) {
// Seek back to the start of the file so we can reread.
try source.seekableStream().seekTo(0);
// Try BE, if the signature doesn't match, return an error.
endian = .big;
header = try source.reader().readStructEndian(external.Header, endian);
if (header.signature != external.signature) return ReadError.InvalidHeader;
}
// "The low-order word is MINIDUMP_VERSION. The high-order word is an
// internal value that is implementation specific."
if (header.version.low != external.version) return ReadError.InvalidVersion;
return .{ header, endian };
}
// Uncomment to dump some debug information for a minidump file.
test "minidump debug" {
var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp"));
const r = try Reader(*@TypeOf(fbs)).init(&fbs);
var it = r.streamIterator();
while (try it.next()) |s| {
log.warn("directory i={} dir={}", .{ it.i - 1, s.directory });
}
}
test "minidump read" {
const testing = std.testing;
const alloc = testing.allocator;
var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp"));
const r = try Reader(*@TypeOf(fbs)).init(&fbs);
try testing.expectEqual(std.builtin.Endian.little, r.endian);
try testing.expectEqual(7, r.stream_count);
{
const dir = try r.directory(0);
try testing.expectEqual(3, dir.stream_type);
try testing.expectEqual(584, dir.location.data_size);
var bytes = std.ArrayList(u8).init(alloc);
defer bytes.deinit();
var sr = try r.streamReader(dir);
try sr.reader().readAllArrayList(&bytes, std.math.maxInt(usize));
try testing.expectEqual(584, bytes.items.len);
}
}

View File

@ -0,0 +1,30 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.minidump_stream);
/// The known stream types.
pub const thread_list = @import("stream_threadlist.zig");
/// A stream within the minidump file. A stream can be either in an encoded
/// form or decoded form. The encoded form are raw bytes and aren't validated
/// until they're decoded. The decoded form is a structured form of the stream.
///
/// The decoded form is more ergonomic to work with but the encoded form is
/// more efficient to read/write.
pub const Stream = union(enum) {
encoded: EncodedStream,
};
/// An encoded stream value. It is "encoded" in the sense that it is raw bytes
/// with a type associated. The raw bytes are not validated to be correct for
/// the type.
pub const EncodedStream = struct {
type: u32,
data: []const u8,
};
test {
@import("std").testing.refAllDecls(@This());
}

View File

@ -0,0 +1,117 @@
const std = @import("std");
const assert = std.debug.assert;
const external = @import("external.zig");
const readerpkg = @import("reader.zig");
const Reader = readerpkg.Reader;
const ReadError = readerpkg.ReadError;
const log = std.log.scoped(.minidump_stream);
/// This is the list of threads from the process.
///
/// This is the Reader implementation. You usually do not use this directly.
/// Instead, use Reader(T).ThreadList which will get you the same thing.
///
/// ThreadList is stream type 0x3.
/// StreamReader is the Reader(T).StreamReader type.
pub fn ThreadListReader(comptime R: type) type {
return struct {
const Self = @This();
/// The number of threads in the list.
count: u32,
/// The rva to the first thread in the list.
rva: u32,
/// Source data and endianness so we can read.
source: R.Source,
endian: std.builtin.Endian,
pub fn init(r: *R.StreamReader) !Self {
assert(r.directory.stream_type == 0x3);
try r.seekToPayload();
const reader = r.source.reader();
// Our count is always a u32 in the header.
const count = try reader.readInt(u32, r.endian);
// Determine if we have padding in our header. It is possible
// for there to be padding if the list header was written by
// a 32-bit process but is being read on a 64-bit process.
const padding = padding: {
const maybe_size = @sizeOf(u32) + (@sizeOf(external.Thread) * count);
switch (std.math.order(maybe_size, r.directory.location.data_size)) {
// It should never be larger than what the directory says.
.gt => return ReadError.StreamSizeMismatch,
// If the sizes match exactly we're good.
.eq => break :padding 0,
.lt => {
const padding = r.directory.location.data_size - maybe_size;
if (padding != 4) return ReadError.StreamSizeMismatch;
break :padding padding;
},
}
};
// Rva is the location of the first thread in the list.
const rva = r.directory.location.rva + @as(u32, @sizeOf(u32)) + padding;
return .{
.count = count,
.rva = rva,
.source = r.source,
.endian = r.endian,
};
}
/// Get the thread entry for the given index.
///
/// Index is asserted to be less than count.
pub fn thread(self: *const Self, i: usize) !external.Thread {
assert(i < self.count);
// Seek to the thread
const offset: u32 = @intCast(@sizeOf(external.Thread) * i);
const rva: u32 = self.rva + offset;
try self.source.seekableStream().seekTo(rva);
// Read the thread
return try self.source.reader().readStructEndian(
external.Thread,
self.endian,
);
}
};
}
test "minidump: threadlist" {
const testing = std.testing;
const alloc = testing.allocator;
var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp"));
const R = Reader(*@TypeOf(fbs));
const r = try R.init(&fbs);
// Get our thread list stream
const dir = try r.directory(0);
try testing.expectEqual(3, dir.stream_type);
var sr = try r.streamReader(dir);
// Get our rich structure
const v = try R.ThreadList.init(&sr);
log.warn("threadlist count={} rva={}", .{ v.count, v.rva });
try testing.expectEqual(12, v.count);
for (0..v.count) |i| {
const t = try v.thread(i);
log.warn("thread i={} thread={}", .{ i, t });
// Read our stack memory
var stack_reader = try r.locationReader(t.stack.memory);
const bytes = try stack_reader.reader().readAllAlloc(alloc, t.stack.memory.data_size);
defer alloc.free(bytes);
}
}

BIN
src/crash/testdata/macos.dmp vendored Normal file

Binary file not shown.

View File

@ -594,30 +594,37 @@ pub const Face = struct {
// All of these metrics are based on our layout above.
const cell_height = @ceil(layout_metrics.height);
const cell_baseline = @ceil(layout_metrics.height - layout_metrics.ascent);
const underline_thickness = @ceil(@as(f32, @floatCast(ct_font.getUnderlineThickness())));
const strikethrough_position = strikethrough_position: {
// This is the height above baseline consumed by text. We must take
// into account that our cell height splits the leading between two
// rows so we subtract leading space (blank space).
const above_baseline = layout_metrics.ascent - (layout_metrics.leading / 2);
// We want to position the strikethrough at 65% of the height.
// This generally gives a nice visual appearance. The number 65%
// is somewhat arbitrary but is a common value across terminals.
const pos = above_baseline * 0.65;
const underline_thickness = @ceil(@as(f32, @floatCast(ct_font.getUnderlineThickness())));
const strikethrough_thickness = underline_thickness;
const strikethrough_position = strikethrough_position: {
// This is the height of lower case letters in our font.
const ex_height = ct_font.getXHeight();
// We want to position the strikethrough so that it's
// vertically centered on any lower case text. This is
// a fairly standard choice for strikethrough positioning.
//
// Because our `strikethrough_position` is relative to the
// top of the cell we start with the ascent metric, which
// is the distance from the top down to the baseline, then
// we subtract half of the ex height to go back up to the
// correct height that should evenly split lowercase text.
const pos = layout_metrics.ascent -
ex_height * 0.5 -
strikethrough_thickness * 0.5;
break :strikethrough_position @ceil(pos);
};
const strikethrough_thickness = underline_thickness;
// Underline position reported is usually something like "-1" to
// represent the amount under the baseline. We add this to our real
// baseline to get the actual value from the bottom (+y is up).
// The final underline position is +y from the TOP (confusing)
// so we have to subtract from the cell height.
const underline_position = cell_height -
(cell_baseline + @ceil(@as(f32, @floatCast(ct_font.getUnderlinePosition())))) +
1;
const underline_position = @ceil(layout_metrics.ascent -
@as(f32, @floatCast(ct_font.getUnderlinePosition())));
// Note: is this useful?
// const units_per_em = ct_font.getUnitsPerEm();

View File

@ -607,6 +607,20 @@ pub const Face = struct {
break :cell_width f26dot6ToFloat(size_metrics.max_advance);
};
// Ex height is calculated by measuring the height of the `x` glyph.
// If that fails then we just pretend it's 65% of the ascent height.
const ex_height: f32 = ex_height: {
if (face.getCharIndex('x')) |glyph_index| {
if (face.loadGlyph(glyph_index, .{ .render = true })) {
break :ex_height f26dot6ToFloat(face.handle.*.glyph.*.metrics.height);
} else |_| {
// Ignore the error since we just fall back to 65% of the ascent below
}
}
break :ex_height f26dot6ToFloat(size_metrics.ascender) * 0.65;
};
// Cell height is calculated as the maximum of multiple things in order
// to handle edge cases in fonts: (1) the height as reported in metadata
// by the font designer (2) the maximum glyph height as measured in the
@ -646,50 +660,55 @@ pub const Face = struct {
// is reversed.
const cell_baseline = -1 * f26dot6ToFloat(size_metrics.descender);
const underline_thickness = @max(@as(f32, 1), fontUnitsToPxY(
face,
face.handle.*.underline_thickness,
));
// The underline position. This is a value from the top where the
// underline should go.
const underline_position: f32 = underline_pos: {
// The ascender is already scaled for scalable fonts, but the
// underline position is not.
const ascender_px = @as(i32, @intCast(size_metrics.ascender)) >> 6;
const declared_px = freetype.mulFix(
// From the FreeType docs:
// > `underline_position`
// > The position, in font units, of the underline line for
// > this face. It is the center of the underlining stem.
const declared_px = @as(f32, @floatFromInt(freetype.mulFix(
face.handle.*.underline_position,
@intCast(face.handle.*.size.*.metrics.y_scale),
) >> 6;
))) / 64;
// We use the declared underline position if its available
const declared = ascender_px - declared_px;
// We use the declared underline position if its available.
const declared = @ceil(cell_height - cell_baseline - declared_px - underline_thickness * 0.5);
if (declared > 0)
break :underline_pos @floatFromInt(declared);
break :underline_pos declared;
// If we have no declared underline position, we go slightly under the
// cell height (mainly: non-scalable fonts, i.e. emoji)
break :underline_pos cell_height - 1;
};
const underline_thickness = @max(@as(f32, 1), fontUnitsToPxY(
face,
face.handle.*.underline_thickness,
));
// The strikethrough position. We use the position provided by the
// font if it exists otherwise we calculate a best guess.
const strikethrough: struct {
pos: f32,
thickness: f32,
} = if (face.getSfntTable(.os2)) |os2| .{
.pos = pos: {
// Ascender is scaled, strikeout pos is not
const ascender_px = @as(i32, @intCast(size_metrics.ascender)) >> 6;
const declared_px = freetype.mulFix(
os2.yStrikeoutPosition,
@as(i32, @intCast(face.handle.*.size.*.metrics.y_scale)),
) >> 6;
} = if (face.getSfntTable(.os2)) |os2| st: {
const thickness = @max(@as(f32, 1), fontUnitsToPxY(face, os2.yStrikeoutSize));
break :pos @floatFromInt(ascender_px - declared_px);
},
.thickness = @max(@as(f32, 1), fontUnitsToPxY(face, os2.yStrikeoutSize)),
const pos = @as(f32, @floatFromInt(freetype.mulFix(
os2.yStrikeoutPosition,
@as(i32, @intCast(face.handle.*.size.*.metrics.y_scale)),
))) / 64;
break :st .{
.pos = @ceil(cell_height - cell_baseline - pos),
.thickness = thickness,
};
} else .{
.pos = cell_baseline * 0.6,
// Exactly 50% of the ex height so that our strikethrough is
// centered through lowercase text. This is a common choice.
.pos = @ceil(cell_height - cell_baseline - ex_height * 0.5 - underline_thickness * 0.5),
.thickness = underline_thickness,
};
@ -832,7 +851,7 @@ test "metrics" {
.cell_width = 16,
.cell_height = 35,
.cell_baseline = 7,
.underline_position = 36,
.underline_position = 35,
.underline_thickness = 2,
.strikethrough_position = 20,
.strikethrough_thickness = 2,

View File

@ -27,189 +27,174 @@ pub fn renderGlyph(
line_pos: u32,
line_thickness: u32,
) !font.Glyph {
// Create the canvas we'll use to draw. We draw the underline in
// a full cell size and position it according to "pos".
var canvas = try font.sprite.Canvas.init(alloc, width, height);
// Draw the appropriate sprite
var canvas: font.sprite.Canvas, const offset_y: i32 = switch (sprite) {
.underline => try drawSingle(alloc, width, line_thickness),
.underline_double => try drawDouble(alloc, width, line_thickness),
.underline_dotted => try drawDotted(alloc, width, line_thickness),
.underline_dashed => try drawDashed(alloc, width, line_thickness),
.underline_curly => try drawCurly(alloc, width, line_thickness),
.strikethrough => try drawSingle(alloc, width, line_thickness),
else => unreachable,
};
defer canvas.deinit(alloc);
// Perform the actual drawing
(Draw{
.width = width,
.height = height,
.pos = line_pos,
.thickness = line_thickness,
}).draw(&canvas, sprite);
// Write the drawing to the atlas
const region = try canvas.writeAtlas(alloc, atlas);
// Our coordinates start at the BOTTOM for our renderers so we have to
// specify an offset of the full height because we rendered a full size
// cell.
const offset_y = @as(i32, @intCast(height));
return font.Glyph{
.width = width,
.height = height,
.height = @intCast(region.height),
.offset_x = 0,
.offset_y = offset_y,
// Glyph.offset_y is the distance between the top of the glyph and the
// bottom of the cell. We want the top of the glyph to be at line_pos
// from the TOP of the cell, and then offset by the offset_y from the
// draw function.
.offset_y = @as(i32, @intCast(height - line_pos)) - offset_y,
.atlas_x = region.x,
.atlas_y = region.y,
.advance_x = @floatFromInt(width),
};
}
/// Stores drawing state.
const Draw = struct {
width: u32,
height: u32,
pos: u32,
thickness: u32,
/// A tuple with the canvas that the desired sprite was drawn on and
/// a recommended offset (+Y = down) to shift its Y position by, to
/// correct for underline styles with additional thickness.
const CanvasAndOffset = struct { font.sprite.Canvas, i32 };
/// Draw a specific underline sprite to the canvas.
fn draw(self: Draw, canvas: *font.sprite.Canvas, sprite: Sprite) void {
switch (sprite) {
.underline => self.drawSingle(canvas),
.underline_double => self.drawDouble(canvas),
.underline_dotted => self.drawDotted(canvas),
.underline_dashed => self.drawDashed(canvas),
.underline_curly => self.drawCurly(canvas),
.strikethrough => self.drawSingle(canvas),
else => unreachable,
}
}
/// Draw a single underline.
fn drawSingle(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
/// Draw a single underline.
fn drawSingle(self: Draw, canvas: *font.sprite.Canvas) void {
// Ensure we never overflow out of bounds on the canvas
const y_max = self.height -| 1;
const bottom = @min(self.pos + self.thickness, y_max);
const y = bottom -| self.thickness;
const max_height = self.height - y;
canvas.rect(.{
.x = 0,
.y = 0,
.width = width,
.height = thickness,
}, .on);
const offset_y: i32 = 0;
return .{ canvas, offset_y };
}
/// Draw a double underline.
fn drawDouble(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness * 3;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
canvas.rect(.{
.x = 0,
.y = 0,
.width = width,
.height = thickness,
}, .on);
canvas.rect(.{
.x = 0,
.y = @intCast(thickness * 2),
.width = width,
.height = thickness,
}, .on);
const offset_y: i32 = -@as(i32, @intCast(thickness));
return .{ canvas, offset_y };
}
/// Draw a dotted underline.
fn drawDotted(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
const dot_width = @max(thickness, 3);
const dot_count = @max((width / dot_width) / 2, 1);
const gap_width = try std.math.divCeil(u32, width -| (dot_count * dot_width), dot_count);
var i: u32 = 0;
while (i < dot_count) : (i += 1) {
// Ensure we never go out of bounds for the rect
const x = @min(i * (dot_width + gap_width), width - 1);
const rect_width = @min(width - x, dot_width);
canvas.rect(.{
.x = 0,
.y = @intCast(y),
.width = self.width,
.height = @min(self.thickness, max_height),
.x = @intCast(x),
.y = 0,
.width = rect_width,
.height = thickness,
}, .on);
}
/// Draw a double underline.
fn drawDouble(self: Draw, canvas: *font.sprite.Canvas) void {
// The maximum y value has to have space for the bottom underline.
// If we underflow (saturated) to 0, then we don't draw. This should
// never happen but we don't want to draw something undefined.
const y_max = self.height -| 1 -| self.thickness;
if (y_max == 0) return;
const offset_y: i32 = 0;
const space = self.thickness * 2;
const bottom = @min(self.pos + space, y_max);
const top = bottom - space;
return .{ canvas, offset_y };
}
/// Draw a dashed underline.
fn drawDashed(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
const dash_width = width / 3 + 1;
const dash_count = (width / dash_width) + 1;
var i: u32 = 0;
while (i < dash_count) : (i += 2) {
// Ensure we never go out of bounds for the rect
const x = @min(i * dash_width, width - 1);
const rect_width = @min(width - x, dash_width);
canvas.rect(.{
.x = 0,
.y = @intCast(top),
.width = self.width,
.height = self.thickness,
}, .on);
canvas.rect(.{
.x = 0,
.y = @intCast(bottom),
.width = self.width,
.height = self.thickness,
.x = @intCast(x),
.y = 0,
.width = rect_width,
.height = thickness,
}, .on);
}
/// Draw a dotted underline.
fn drawDotted(self: Draw, canvas: *font.sprite.Canvas) void {
const y_max = self.height -| 1 -| self.thickness;
if (y_max == 0) return;
const y = @min(self.pos, y_max);
const dot_width = @max(self.thickness, 3);
const dot_count = self.width / dot_width;
var i: u32 = 0;
while (i < dot_count) : (i += 2) {
// Ensure we never go out of bounds for the rect
const x = @min(i * dot_width, self.width - 1);
const width = @min(self.width - 1 - x, dot_width);
canvas.rect(.{
.x = @intCast(i * dot_width),
.y = @intCast(y),
.width = width,
.height = self.thickness,
}, .on);
const offset_y: i32 = 0;
return .{ canvas, offset_y };
}
/// Draw a curly underline. Thanks to Wez Furlong for providing
/// the basic math structure for this since I was lazy with the
/// geometry.
fn drawCurly(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness * 4;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
// Calculate the wave period for a single character
// `2 * pi...` = 1 peak per character
// `4 * pi...` = 2 peaks per character
const wave_period = 2 * std.math.pi / @as(f64, @floatFromInt(width - 1));
// The full amplitude of the wave can be from the bottom to the
// underline position. We also calculate our mid y point of the wave
const half_amplitude: f64 = @as(f64, @floatFromInt(thickness));
const y_mid: f64 = half_amplitude + 1;
// follow Xiaolin Wu's antialias algorithm to draw the curve
var x: u32 = 0;
while (x < width) : (x += 1) {
const cosx: f64 = @cos(@as(f64, @floatFromInt(x)) * wave_period);
const y: f64 = y_mid + half_amplitude * cosx;
const y_upper: u32 = @intFromFloat(@floor(y));
const y_lower: u32 = y_upper + thickness + (thickness >> 1);
const alpha: u8 = @intFromFloat(255 * @abs(y - @floor(y)));
// upper and lower bounds
canvas.pixel(x, @min(y_upper, height), @enumFromInt(255 - alpha));
canvas.pixel(x, @min(y_lower, height), @enumFromInt(alpha));
// fill between upper and lower bound
var y_fill: u32 = y_upper + 1;
while (y_fill < y_lower) : (y_fill += 1) {
canvas.pixel(x, @min(y_fill, height), .on);
}
}
/// Draw a dashed underline.
fn drawDashed(self: Draw, canvas: *font.sprite.Canvas) void {
const y_max = self.height -| 1 -| self.thickness;
if (y_max == 0) return;
const y = @min(self.pos, y_max);
const dash_width = self.width / 3 + 1;
const dash_count = (self.width / dash_width) + 1;
var i: u32 = 0;
while (i < dash_count) : (i += 2) {
// Ensure we never go out of bounds for the rect
const x = @min(i * dash_width, self.width - 1);
const width = @min(self.width - 1 - x, dash_width);
canvas.rect(.{
.x = @intCast(x),
.y = @intCast(y),
.width = width,
.height = self.thickness,
}, .on);
}
}
const offset_y: i32 = -@as(i32, @intCast(thickness * 2));
/// Draw a curly underline. Thanks to Wez Furlong for providing
/// the basic math structure for this since I was lazy with the
/// geometry.
fn drawCurly(self: Draw, canvas: *font.sprite.Canvas) void {
// This is the lowest that the curl can go.
const y_max = self.height - 1;
// Calculate the wave period for a single character
// `2 * pi...` = 1 peak per character
// `4 * pi...` = 2 peaks per character
const wave_period = 2 * std.math.pi / @as(f64, @floatFromInt(self.width - 1));
// Some fonts put the underline too close to the bottom of the
// cell height and this doesn't allow us to make a high enough
// wave. This constant is arbitrary, change it for aesthetics.
const pos: u32 = pos: {
const MIN_AMPLITUDE: u32 = @max(self.height / 9, 2);
break :pos y_max - (MIN_AMPLITUDE * 2);
};
// The full amplitude of the wave can be from the bottom to the
// underline position. We also calculate our mid y point of the wave
const double_amplitude: f64 = @floatFromInt(y_max - pos);
const half_amplitude: f64 = @max(1, double_amplitude / 4);
const y_mid: u32 = pos + @as(u32, @intFromFloat(2 * half_amplitude));
// follow Xiaolin Wu's antialias algorithm to draw the curve
var x: u32 = 0;
while (x < self.width) : (x += 1) {
const y: f64 = @as(f64, @floatFromInt(y_mid)) + (half_amplitude * @cos(@as(f64, @floatFromInt(x)) * wave_period));
const y_upper: u32 = @intFromFloat(@floor(y));
const y_lower: u32 = y_upper + self.thickness;
const alpha: u8 = @intFromFloat(255 * @abs(y - @floor(y)));
// upper and lower bounds
canvas.pixel(x, @min(y_upper, y_max), @enumFromInt(255 - alpha));
canvas.pixel(x, @min(y_lower, y_max), @enumFromInt(alpha));
// fill between upper and lower bound
var y_fill: u32 = y_upper + 1;
while (y_fill < y_lower) : (y_fill += 1) {
canvas.pixel(x, @min(y_fill, y_max), .on);
}
}
}
};
return .{ canvas, offset_y };
}
test "single" {
const testing = std.testing;

View File

@ -6,6 +6,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const key = @import("key.zig");
const KeyEvent = key.KeyEvent;
/// The trigger that needs to be performed to execute the action.
trigger: Trigger,
@ -13,21 +14,36 @@ trigger: Trigger,
/// The action to take if this binding matches
action: Action,
/// True if this binding should consume the input when the
/// action is triggered.
consumed: bool = true,
/// Boolean flags that can be set per binding.
flags: Flags = .{},
pub const Error = error{
InvalidFormat,
InvalidAction,
};
/// Flags the full binding-scoped flags that can be set per binding.
pub const Flags = packed struct {
/// True if this binding should consume the input when the
/// action is triggered.
consumed: bool = true,
/// True if this binding should be forwarded to all active surfaces
/// in the application.
all: bool = false,
/// True if this binding is global. Global bindings should work system-wide
/// and not just while Ghostty is focused. This may not work on all platforms.
/// See the keybind config documentation for more information.
global: bool = false,
};
/// Full binding parser. The binding parser is implemented as an iterator
/// which yields elements to support multi-key sequences without allocation.
pub const Parser = struct {
unconsumed: bool = false,
trigger_it: SequenceIterator,
action: Action,
flags: Flags = .{},
pub const Elem = union(enum) {
/// A leader trigger in a sequence.
@ -38,11 +54,7 @@ pub const Parser = struct {
};
pub fn init(raw_input: []const u8) Error!Parser {
// If our entire input is prefixed with "unconsumed:" then we are
// not consuming this keybind when the action is triggered.
const unconsumed_prefix = "unconsumed:";
const unconsumed = std.mem.startsWith(u8, raw_input, unconsumed_prefix);
const start_idx = if (unconsumed) unconsumed_prefix.len else 0;
const flags, const start_idx = try parseFlags(raw_input);
const input = raw_input[start_idx..];
// Find the first = which splits are mapping into the trigger
@ -52,24 +64,63 @@ pub const Parser = struct {
// Sequence iterator goes up to the equal, action is after. We can
// parse the action now.
return .{
.unconsumed = unconsumed,
.trigger_it = .{ .input = input[0..eql_idx] },
.action = try Action.parse(input[eql_idx + 1 ..]),
.flags = flags,
};
}
fn parseFlags(raw_input: []const u8) Error!struct { Flags, usize } {
var flags: Flags = .{};
var start_idx: usize = 0;
var input: []const u8 = raw_input;
while (true) {
// Find the next prefix
const idx = std.mem.indexOf(u8, input, ":") orelse break;
const prefix = input[0..idx];
// If the prefix is one of our flags then set it.
if (std.mem.eql(u8, prefix, "all")) {
if (flags.all) return Error.InvalidFormat;
flags.all = true;
} else if (std.mem.eql(u8, prefix, "global")) {
if (flags.global) return Error.InvalidFormat;
flags.global = true;
} else if (std.mem.eql(u8, prefix, "unconsumed")) {
if (!flags.consumed) return Error.InvalidFormat;
flags.consumed = false;
} else {
// If we don't recognize the prefix then we're done.
// There are trigger-specific prefixes like "physical:" so
// this lets us fall into that.
break;
}
// Move past the prefix
start_idx += idx + 1;
input = input[idx + 1 ..];
}
return .{ flags, start_idx };
}
pub fn next(self: *Parser) Error!?Elem {
// Get our trigger. If we're out of triggers then we're done.
const trigger = (try self.trigger_it.next()) orelse return null;
// If this is our last trigger then it is our final binding.
if (!self.trigger_it.done()) return .{ .leader = trigger };
if (!self.trigger_it.done()) {
// Global/all bindings can't be sequences
if (self.flags.global or self.flags.all) return error.InvalidFormat;
return .{ .leader = trigger };
}
// Out of triggers, yield the final action.
return .{ .binding = .{
.trigger = trigger,
.action = self.action,
.consumed = !self.unconsumed,
.flags = self.flags,
} };
}
@ -228,7 +279,8 @@ pub const Action = union(enum) {
/// available values.
write_selection_file: WriteScreenAction,
/// Open a new window.
/// Open a new window. If the application isn't currently focused,
/// this will bring it to the front.
new_window: void,
/// Open a new tab.
@ -246,6 +298,10 @@ pub const Action = union(enum) {
/// Go to the tab with the specific number, 1-indexed.
goto_tab: usize,
/// Toggle the tab overview.
/// This only works with libadwaita enabled currently.
toggle_tab_overview: void,
/// Create a new split in the given direction. The new split will appear in
/// the direction given.
new_split: SplitDirection,
@ -297,6 +353,37 @@ pub const Action = union(enum) {
/// Toggle window decorations on and off. This only works on Linux.
toggle_window_decorations: void,
/// Toggle secure input mode on or off. This is used to prevent apps
/// that monitor input from seeing what you type. This is useful for
/// entering passwords or other sensitive information.
///
/// This applies to the entire application, not just the focused
/// terminal. You must toggle it off to disable it, or quit Ghostty.
///
/// This only works on macOS, since this is a system API on macOS.
toggle_secure_input: void,
/// Toggle the "quick" terminal. The quick terminal is a terminal that
/// appears on demand from a keybinding, often sliding in from a screen
/// edge such as the top. This is useful for quick access to a terminal
/// without having to open a new window or tab.
///
/// When the quick terminal loses focus, it disappears. The terminal state
/// is preserved between appearances, so you can always press the keybinding
/// to bring it back up.
///
/// The quick terminal has some limitations:
///
/// - It is a singleton; only one instance can exist at a time.
/// - It does not support tabs.
/// - It does not support fullscreen.
/// - It will not be restored when the application is restarted
/// (for systems that support window restoration).
///
/// See the various configurations for the quick terminal in the
/// configuration file to customize its behavior.
toggle_quick_terminal: void,
/// Quit ghostty.
quit: void,
@ -348,8 +435,7 @@ pub const Action = union(enum) {
// Note: we don't support top or left yet
};
// Extern because it is used in the embedded runtime ABI.
pub const SplitFocusDirection = enum(c_int) {
pub const SplitFocusDirection = enum {
previous,
next,
@ -359,8 +445,7 @@ pub const Action = union(enum) {
right,
};
// Extern because it is used in the embedded runtime ABI.
pub const SplitResizeDirection = enum(c_int) {
pub const SplitResizeDirection = enum {
up,
down,
left,
@ -378,7 +463,7 @@ pub const Action = union(enum) {
};
// Extern because it is used in the embedded runtime ABI.
pub const InspectorMode = enum(c_int) {
pub const InspectorMode = enum {
toggle,
show,
hide,
@ -479,6 +564,144 @@ pub const Action = union(enum) {
return Error.InvalidAction;
}
/// The scope of an action. The scope is the context in which an action
/// must be executed.
pub const Scope = enum {
app,
surface,
};
/// Returns the scope of an action.
pub fn scope(self: Action) Scope {
return switch (self) {
// Doesn't really matter, so we'll see app.
.ignore,
.unbind,
=> .app,
// Obviously app actions.
.open_config,
.reload_config,
.close_all_windows,
.quit,
.toggle_quick_terminal,
=> .app,
// These are app but can be special-cased in a surface context.
.new_window,
=> .app,
// Obviously surface actions.
.csi,
.esc,
.text,
.cursor_key,
.reset,
.copy_to_clipboard,
.paste_from_clipboard,
.paste_from_selection,
.increase_font_size,
.decrease_font_size,
.reset_font_size,
.clear_screen,
.select_all,
.scroll_to_top,
.scroll_to_bottom,
.scroll_page_up,
.scroll_page_down,
.scroll_page_fractional,
.scroll_page_lines,
.adjust_selection,
.jump_to_prompt,
.write_scrollback_file,
.write_screen_file,
.write_selection_file,
.close_surface,
.close_window,
.toggle_fullscreen,
.toggle_window_decorations,
.toggle_secure_input,
.crash,
=> .surface,
// These are less obvious surface actions. They're surface
// actions because they are relevant to the surface they
// come from. For example `new_window` needs to be sourced to
// a surface so inheritance can be done correctly.
.new_tab,
.previous_tab,
.next_tab,
.last_tab,
.goto_tab,
.toggle_tab_overview,
.new_split,
.goto_split,
.toggle_split_zoom,
.resize_split,
.equalize_splits,
.inspector,
=> .surface,
};
}
/// Returns a union type that only contains actions that are scoped to
/// the given scope.
pub fn Scoped(comptime s: Scope) type {
const all_fields = @typeInfo(Action).Union.fields;
// Find all fields that are app-scoped
var i: usize = 0;
var union_fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
var enum_fields: [all_fields.len]std.builtin.Type.EnumField = undefined;
for (all_fields) |field| {
const action = @unionInit(Action, field.name, undefined);
if (action.scope() == s) {
union_fields[i] = field;
enum_fields[i] = .{ .name = field.name, .value = i };
i += 1;
}
}
// Build our union
return @Type(.{ .Union = .{
.layout = .auto,
.tag_type = @Type(.{ .Enum = .{
.tag_type = std.math.IntFittingRange(0, i),
.fields = enum_fields[0..i],
.decls = &.{},
.is_exhaustive = true,
} }),
.fields = union_fields[0..i],
.decls = &.{},
} });
}
/// Returns the scoped version of this action. If the action is not
/// scoped to the given scope then this returns null.
///
/// The benefit of this function is that it allows us to use Zig's
/// exhaustive switch safety to ensure we always properly handle certain
/// scoped actions.
pub fn scoped(self: Action, comptime s: Scope) ?Scoped(s) {
switch (self) {
inline else => |v, tag| {
// Use comptime to prune out non-app actions
if (comptime @unionInit(
Action,
@tagName(tag),
undefined,
).scope() != s) return null;
// Initialize our app action
return @unionInit(
Scoped(s),
@tagName(tag),
v,
);
},
}
}
/// Implements the formatter for the fmt package. This encodes the
/// action back into the format used by parse.
pub fn format(
@ -534,10 +757,15 @@ pub const Action = union(enum) {
/// action.
pub fn hash(self: Action) u64 {
var hasher = std.hash.Wyhash.init(0);
self.hashIncremental(&hasher);
return hasher.final();
}
/// Hash the action into the given hasher.
fn hashIncremental(self: Action, hasher: anytype) void {
// Always has the active tag.
const Tag = @typeInfo(Action).Union.tag_type.?;
std.hash.autoHash(&hasher, @as(Tag, self));
std.hash.autoHash(hasher, @as(Tag, self));
// Hash the value of the field.
switch (self) {
@ -552,25 +780,23 @@ pub const Action = union(enum) {
// signed zeros but these are not cases we expect for
// our bindings.
f32 => std.hash.autoHash(
&hasher,
hasher,
@as(u32, @bitCast(field)),
),
f64 => std.hash.autoHash(
&hasher,
hasher,
@as(u64, @bitCast(field)),
),
// Everything else automatically handle.
else => std.hash.autoHashStrat(
&hasher,
hasher,
field,
.DeepRecursive,
),
}
},
}
return hasher.final();
}
};
@ -727,11 +953,16 @@ pub const Trigger = struct {
/// Returns a hash code that can be used to uniquely identify this trigger.
pub fn hash(self: Trigger) u64 {
var hasher = std.hash.Wyhash.init(0);
std.hash.autoHash(&hasher, self.key);
std.hash.autoHash(&hasher, self.mods.binding());
self.hashIncremental(&hasher);
return hasher.final();
}
/// Hash the trigger into the given hasher.
fn hashIncremental(self: Trigger, hasher: anytype) void {
std.hash.autoHash(hasher, self.key);
std.hash.autoHash(hasher, self.mods.binding());
}
/// Convert the trigger to a C API compatible trigger.
pub fn cval(self: Trigger) C {
return .{
@ -808,10 +1039,8 @@ pub const Set = struct {
leader: *Set,
/// This trigger completes a sequence and the value is the action
/// to take. The "_unconsumed" variant is used for triggers that
/// should not consume the input.
action: Action,
action_unconsumed: Action,
/// to take along with the flags that may define binding behavior.
leaf: Leaf,
/// Implements the formatter for the fmt package. This encodes the
/// action back into the format used by parse.
@ -836,14 +1065,28 @@ pub const Set = struct {
}
},
.action, .action_unconsumed => |action| {
.leaf => |leaf| {
// action implements the format
try writer.print("={s}", .{action});
try writer.print("={s}", .{leaf.action});
},
}
}
};
/// Leaf node of a set is an action to trigger. This is a "leaf" compared
/// to the inner nodes which are "leaders" for sequences.
pub const Leaf = struct {
action: Action,
flags: Flags,
pub fn hash(self: Leaf) u64 {
var hasher = std.hash.Wyhash.init(0);
self.action.hash(&hasher);
std.hash.autoHash(&hasher, self.flags);
return hasher.final();
}
};
pub fn deinit(self: *Set, alloc: Allocator) void {
// Clear any leaders if we have them
var it = self.bindings.iterator();
@ -852,7 +1095,7 @@ pub const Set = struct {
s.deinit(alloc);
alloc.destroy(s);
},
.action, .action_unconsumed => {},
.leaf => {},
};
self.bindings.deinit(alloc);
@ -924,7 +1167,7 @@ pub const Set = struct {
error.OutOfMemory => return error.OutOfMemory,
},
.action, .action_unconsumed => {
.leaf => {
// Remove the existing action. Fallthrough as if
// we don't have a leader.
set.remove(alloc, t);
@ -948,11 +1191,11 @@ pub const Set = struct {
set.remove(alloc, t);
if (old) |entry| switch (entry) {
.leader => unreachable, // Handled above
inline .action, .action_unconsumed => |action, tag| set.put_(
.leaf => |leaf| set.putFlags(
alloc,
t,
action,
tag == .action,
leaf.action,
leaf.flags,
) catch {},
};
},
@ -967,11 +1210,12 @@ pub const Set = struct {
return error.SequenceUnbind;
},
else => if (b.consumed) {
try set.put(alloc, b.trigger, b.action);
} else {
try set.putUnconsumed(alloc, b.trigger, b.action);
},
else => try set.putFlags(
alloc,
b.trigger,
b.action,
b.flags,
),
},
}
}
@ -984,29 +1228,16 @@ pub const Set = struct {
t: Trigger,
action: Action,
) Allocator.Error!void {
try self.put_(alloc, t, action, true);
try self.putFlags(alloc, t, action, .{});
}
/// Same as put but marks the trigger as unconsumed. An unconsumed
/// trigger will evaluate the action and continue to encode for the
/// terminal.
///
/// This is a separate function because this case is rare.
pub fn putUnconsumed(
/// Add a binding to the set with explicit flags.
pub fn putFlags(
self: *Set,
alloc: Allocator,
t: Trigger,
action: Action,
) Allocator.Error!void {
try self.put_(alloc, t, action, false);
}
fn put_(
self: *Set,
alloc: Allocator,
t: Trigger,
action: Action,
consumed: bool,
flags: Flags,
) Allocator.Error!void {
// unbind should never go into the set, it should be handled prior
assert(action != .unbind);
@ -1022,7 +1253,7 @@ pub const Set = struct {
// If we have an existing binding for this trigger, we have to
// update the reverse mapping to remove the old action.
.action, .action_unconsumed => {
.leaf => {
const t_hash = t.hash();
var it = self.reverse.iterator();
while (it.next()) |reverse_entry| it: {
@ -1034,11 +1265,10 @@ pub const Set = struct {
},
};
gop.value_ptr.* = if (consumed) .{
gop.value_ptr.* = .{ .leaf = .{
.action = action,
} else .{
.action_unconsumed = action,
};
.flags = flags,
} };
errdefer _ = self.bindings.remove(t);
try self.reverse.put(alloc, action, t);
errdefer _ = self.reverse.remove(action);
@ -1055,6 +1285,31 @@ pub const Set = struct {
return self.reverse.get(a);
}
/// Get an entry for the given key event. This will attempt to find
/// a binding using multiple parts of the event in the following order:
///
/// 1. Translated key (event.key)
/// 2. Physical key (event.physical_key)
/// 3. Unshifted Unicode codepoint (event.unshifted_codepoint)
///
pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry {
var trigger: Trigger = .{
.mods = event.mods.binding(),
.key = .{ .translated = event.key },
};
if (self.get(trigger)) |v| return v;
trigger.key = .{ .physical = event.physical_key };
if (self.get(trigger)) |v| return v;
if (event.unshifted_codepoint > 0) {
trigger.key = .{ .unicode = event.unshifted_codepoint };
if (self.get(trigger)) |v| return v;
}
return null;
}
/// Remove a binding for a given trigger.
pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void {
const entry = self.bindings.get(t) orelse return;
@ -1073,15 +1328,16 @@ pub const Set = struct {
// Note: we'd LIKE to replace this with the most recent binding but
// our hash map obviously has no concept of ordering so we have to
// choose whatever. Maybe a switch to an array hash map here.
.action, .action_unconsumed => |action| {
const action_hash = action.hash();
.leaf => |leaf| {
const action_hash = leaf.action.hash();
var it = self.bindings.iterator();
while (it.next()) |it_entry| {
switch (it_entry.value_ptr.*) {
.leader => {},
.action, .action_unconsumed => |action_search| {
if (action_search.hash() == action_hash) {
self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*);
.leaf => |leaf_search| {
if (leaf_search.action.hash() == action_hash) {
self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*);
break;
}
},
@ -1089,7 +1345,7 @@ pub const Set = struct {
} else {
// No over trigger points to this action so we remove
// the reverse mapping completely.
_ = self.reverse.remove(action);
_ = self.reverse.remove(leaf.action);
}
},
}
@ -1106,7 +1362,7 @@ pub const Set = struct {
var it = result.bindings.iterator();
while (it.next()) |entry| switch (entry.value_ptr.*) {
// No data to clone
.action, .action_unconsumed => {},
.leaf => {},
// Must be deep cloned.
.leader => |*s| {
@ -1208,7 +1464,7 @@ test "parse: triggers" {
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.consumed = false,
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:shift+a=ignore"));
// unconsumed physical keys
@ -1218,7 +1474,7 @@ test "parse: triggers" {
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.consumed = false,
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:physical:a+shift=ignore"));
// invalid key
@ -1231,6 +1487,92 @@ test "parse: triggers" {
try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore"));
}
test "parse: global triggers" {
const testing = std.testing;
// global keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .global = true },
}, try parseSingle("global:shift+a=ignore"));
// global physical keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .global = true },
}, try parseSingle("global:physical:a+shift=ignore"));
// global unconsumed keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{
.global = true,
.consumed = false,
},
}, try parseSingle("unconsumed:global:a+shift=ignore"));
// global sequences not allowed
{
var p = try Parser.init("global:a>b=ignore");
try testing.expectError(Error.InvalidFormat, p.next());
}
}
test "parse: all triggers" {
const testing = std.testing;
// all keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .all = true },
}, try parseSingle("all:shift+a=ignore"));
// all physical keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .all = true },
}, try parseSingle("all:physical:a+shift=ignore"));
// all unconsumed keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{
.all = true,
.consumed = false,
},
}, try parseSingle("unconsumed:all:a+shift=ignore"));
// all sequences not allowed
{
var p = try Parser.init("all:a>b=ignore");
try testing.expectError(Error.InvalidFormat, p.next());
}
}
test "parse: modifier aliases" {
const testing = std.testing;
@ -1456,8 +1798,9 @@ test "set: parseAndPut typical binding" {
// Creates forward mapping
{
const action = s.get(.{ .key = .{ .translated = .a } }).?.action;
try testing.expect(action == .new_window);
const action = s.get(.{ .key = .{ .translated = .a } }).?.leaf;
try testing.expect(action.action == .new_window);
try testing.expectEqual(Flags{}, action.flags);
}
// Creates reverse mapping
@ -1479,8 +1822,9 @@ test "set: parseAndPut unconsumed binding" {
// Creates forward mapping
{
const trigger: Trigger = .{ .key = .{ .translated = .a } };
const action = s.get(trigger).?.action_unconsumed;
try testing.expect(action == .new_window);
const action = s.get(trigger).?.leaf;
try testing.expect(action.action == .new_window);
try testing.expectEqual(Flags{ .consumed = false }, action.flags);
}
// Creates reverse mapping
@ -1526,8 +1870,9 @@ test "set: parseAndPut sequence" {
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
@ -1550,14 +1895,16 @@ test "set: parseAndPut sequence with two actions" {
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
{
const t: Trigger = .{ .key = .{ .translated = .c } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_tab);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_tab);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
@ -1580,8 +1927,9 @@ test "set: parseAndPut overwrite sequence" {
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
@ -1604,8 +1952,9 @@ test "set: parseAndPut overwrite leader" {
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
@ -1734,11 +2083,19 @@ test "set: consumed state" {
defer s.deinit(alloc);
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed);
try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action_unconsumed);
try s.putFlags(
alloc,
.{ .key = .{ .translated = .a } },
.{ .new_window = {} },
.{ .consumed = false },
);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf);
try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed);
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed);
}

View File

@ -1,3 +1,4 @@
const std = @import("std");
const build_config = @import("build_config.zig");
/// See build_config.ExeEntrypoint for why we do this.
@ -16,6 +17,12 @@ const entrypoint = switch (build_config.exe_entrypoint) {
/// The main entrypoint for the program.
pub const main = entrypoint.main;
/// Standard options such as logger overrides.
pub const std_options: std.Options = if (@hasDecl(entrypoint, "std_options"))
entrypoint.std_options
else
.{};
test {
_ = entrypoint;
}

View File

@ -2523,6 +2523,45 @@ fn updateCell(
}
}
// If the cell has an underline, draw it before the character glyph,
// so that it layers underneath instead of overtop, since that can
// make text difficult to read.
if (underline != .none) {
const sprite: font.Sprite = switch (underline) {
.none => unreachable,
.single => .underline,
.double => .underline_double,
.dotted => .underline_dotted,
.dashed => .underline_dashed,
.curly => .underline_curly,
};
const render = try self.font_grid.renderGlyph(
self.alloc,
font.sprite_index,
@intFromEnum(sprite),
.{
.cell_width = if (cell.wide == .wide) 2 else 1,
.grid_metrics = self.grid_metrics,
},
);
const color = style.underlineColor(palette) orelse colors.fg;
try self.cells.add(self.alloc, .underline, .{
.mode = .fg,
.grid_pos = .{ @intCast(coord.x), @intCast(coord.y) },
.constraint_width = cell.gridWidth(),
.color = .{ color.r, color.g, color.b, alpha },
.glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
.glyph_size = .{ render.glyph.width, render.glyph.height },
.bearings = .{
@intCast(render.glyph.offset_x),
@intCast(render.glyph.offset_y),
},
});
}
// If the shaper cell has a glyph, draw it.
if (shaper_cell.glyph_index) |glyph_index| glyph: {
// Render
@ -2566,42 +2605,6 @@ fn updateCell(
});
}
if (underline != .none) {
const sprite: font.Sprite = switch (underline) {
.none => unreachable,
.single => .underline,
.double => .underline_double,
.dotted => .underline_dotted,
.dashed => .underline_dashed,
.curly => .underline_curly,
};
const render = try self.font_grid.renderGlyph(
self.alloc,
font.sprite_index,
@intFromEnum(sprite),
.{
.cell_width = if (cell.wide == .wide) 2 else 1,
.grid_metrics = self.grid_metrics,
},
);
const color = style.underlineColor(palette) orelse colors.fg;
try self.cells.add(self.alloc, .underline, .{
.mode = .fg,
.grid_pos = .{ @intCast(coord.x), @intCast(coord.y) },
.constraint_width = cell.gridWidth(),
.color = .{ color.r, color.g, color.b, alpha },
.glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
.glyph_size = .{ render.glyph.width, render.glyph.height },
.bearings = .{
@intCast(render.glyph.offset_x),
@intCast(render.glyph.offset_y),
},
});
}
if (style.flags.strikethrough) {
const render = try self.font_grid.renderGlyph(
self.alloc,

View File

@ -1761,52 +1761,9 @@ fn updateCell(
@intFromFloat(@max(0, @min(255, @round(self.config.background_opacity * 255)))),
};
// If the cell has a character, draw it
if (cell.hasText()) fg: {
// Render
const render = try self.font_grid.renderGlyph(
self.alloc,
shaper_run.font_index,
shaper_cell.glyph_index orelse break :fg,
.{
.grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken,
},
);
// If we're rendering a color font, we use the color atlas
const mode: CellProgram.CellMode = switch (try fgMode(
render.presentation,
cell_pin,
)) {
.normal => .fg,
.color => .fg_color,
.constrained => .fg_constrained,
.powerline => .fg_powerline,
};
try self.cells.append(self.alloc, .{
.mode = mode,
.grid_col = @intCast(x),
.grid_row = @intCast(y),
.grid_width = cell.gridWidth(),
.glyph_x = render.glyph.atlas_x,
.glyph_y = render.glyph.atlas_y,
.glyph_width = render.glyph.width,
.glyph_height = render.glyph.height,
.glyph_offset_x = render.glyph.offset_x + shaper_cell.x_offset,
.glyph_offset_y = render.glyph.offset_y + shaper_cell.y_offset,
.r = colors.fg.r,
.g = colors.fg.g,
.b = colors.fg.b,
.a = alpha,
.bg_r = bg[0],
.bg_g = bg[1],
.bg_b = bg[2],
.bg_a = bg[3],
});
}
// If the cell has an underline, draw it before the character glyph,
// so that it layers underneath instead of overtop, since that can
// make text difficult to read.
if (underline != .none) {
const sprite: font.Sprite = switch (underline) {
.none => unreachable,
@ -1851,6 +1808,58 @@ fn updateCell(
});
}
// If the shaper cell has a glyph, draw it.
if (shaper_cell.glyph_index) |glyph_index| glyph: {
// Render
const render = try self.font_grid.renderGlyph(
self.alloc,
shaper_run.font_index,
glyph_index,
.{
.grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken,
},
);
// If the glyph is 0 width or height, it will be invisible
// when drawn, so don't bother adding it to the buffer.
if (render.glyph.width == 0 or render.glyph.height == 0) {
break :glyph;
}
// If we're rendering a color font, we use the color atlas
const mode: CellProgram.CellMode = switch (try fgMode(
render.presentation,
cell_pin,
)) {
.normal => .fg,
.color => .fg_color,
.constrained => .fg_constrained,
.powerline => .fg_powerline,
};
try self.cells.append(self.alloc, .{
.mode = mode,
.grid_col = @intCast(x),
.grid_row = @intCast(y),
.grid_width = cell.gridWidth(),
.glyph_x = render.glyph.atlas_x,
.glyph_y = render.glyph.atlas_y,
.glyph_width = render.glyph.width,
.glyph_height = render.glyph.height,
.glyph_offset_x = render.glyph.offset_x + shaper_cell.x_offset,
.glyph_offset_y = render.glyph.offset_y + shaper_cell.y_offset,
.r = colors.fg.r,
.g = colors.fg.g,
.b = colors.fg.b,
.a = alpha,
.bg_r = bg[0],
.bg_g = bg[1],
.bg_b = bg[2],
.bg_a = bg[3],
});
}
if (style.flags.strikethrough) {
const render = try self.font_grid.renderGlyph(
self.alloc,

View File

@ -224,30 +224,30 @@ vertex CellTextVertexOut cell_text_vertex(
out.color = float4(in.color) / 255.0f;
// === Grid Cell ===
//
// offset.x = bearings.x
// .|.
// | |
// +-------+_.
// ._| | |
// | | .###. | |
// | | #...# | +- bearings.y
// glyph_size.y -+ | ##### | |
// | | #.... | |
// ^ |_| .#### |_| _.
// | | | +- offset.y = cell_size.y - bearings.y
// . cell_pos -> +-------+ -'
// +Y. |_._|
// . |
// | glyph_size.x
// 0,0--...->
// +X
// 0,0--...->
// |
// . offset.x = bearings.x
// +Y. .|.
// . | |
// | cell_pos -> +-------+ _.
// v ._| |_. _|- offset.y = cell_size.y - bearings.y
// | | .###. | |
// | | #...# | |
// glyph_size.y -+ | ##### | |
// | | #.... | +- bearings.y
// |_| .#### | |
// | |_|
// +-------+
// |_._|
// |
// glyph_size.x
//
// In order to get the bottom left of the glyph, we compute an offset based
// on the bearings. The Y bearing is the distance from the top of the cell
// to the bottom of the glyph, so we subtract it from the cell height to get
// the y offset. The X bearing is the distance from the left of the cell to
// the left of the glyph, so it works as the x offset directly.
// In order to get the top left of the glyph, we compute an offset based on
// the bearings. The Y bearing is the distance from the bottom of the cell
// to the top of the glyph, so we subtract it from the cell height to get
// the y offset. The X bearing is the distance from the left of the cell
// to the left of the glyph, so it works as the x offset directly.
float2 size = float2(in.glyph_size);
float2 offset = float2(in.bearings);

View File

@ -1694,7 +1694,14 @@ pub fn grow(self: *PageList) !?*List.Node {
// If allocation would exceed our max size, we prune the first page.
// We don't need to reallocate because we can simply reuse that first
// page.
if (self.page_size + PagePool.item_size > self.maxSize()) prune: {
//
// We only take this path if we have more than one page since pruning
// reuses the popped page. It is possible to have a single page and
// exceed the max size if that page was adjusted to be larger after
// initial allocation.
if (self.pages.len > 1 and
self.page_size + PagePool.item_size > self.maxSize())
prune: {
// If we need to add more memory to ensure our active area is
// satisfied then we do not prune.
if (self.growRequiredForActive()) break :prune;
@ -3772,6 +3779,51 @@ test "PageList grow allows exceeding max size for active area" {
try testing.expectEqual(start_pages + 1, s.totalPages());
}
test "PageList grow prune required with a single page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 80, 24, 0);
defer s.deinit();
// This block is all test setup. There is nothing required about this
// behavior during a refactor. This is setting up a scenario that is
// possible to trigger a bug (#2280).
{
// Adjust our capacity until our page is larger than the standard size.
// This is important because it triggers a scenario where our calculated
// minSize() which is supposed to accommodate 2 pages is no longer true.
var cap = std_capacity;
while (true) {
cap.grapheme_bytes *= 2;
const layout = Page.layout(cap);
if (layout.total_size > std_size) break;
}
// Adjust to that capacity. After we should still have one page.
_ = try s.adjustCapacity(
s.pages.first.?,
.{ .grapheme_bytes = cap.grapheme_bytes },
);
try testing.expect(s.pages.first != null);
try testing.expect(s.pages.first == s.pages.last);
}
// Figure out the remaining number of rows. This is the amount that
// can be added to the current page before we need to allocate a new
// page.
const rem = rem: {
const page = s.pages.first.?;
break :rem page.data.capacity.rows - page.data.size.rows;
};
for (0..rem) |_| try testing.expect(try s.grow() == null);
// The next one we add will trigger a new page.
const new = try s.grow();
try testing.expect(new != null);
try testing.expect(new != s.pages.first);
}
test "PageList scroll top" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -122,6 +122,26 @@ test "garbage Kitty command" {
try testing.expect(h.end() == null);
}
test "Kitty command with overflow u32" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.start();
for ("Ga=p,i=10000000000") |c| h.feed(alloc, c);
try testing.expect(h.end() == null);
}
test "Kitty command with overflow i32" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.start();
for ("Ga=p,i=1,z=-9999999999") |c| h.feed(alloc, c);
try testing.expect(h.end() == null);
}
test "valid Kitty command" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -23,10 +23,10 @@ pub const Parser = struct {
/// This is the list of KV pairs that we're building up.
kv: KV = .{},
/// This is used as a buffer to store the key/value of a KV pair.
/// The value of a KV pair is at most a 32-bit integer which at most
/// is 10 characters (4294967295).
kv_temp: [10]u8 = undefined,
/// This is used as a buffer to store the key/value of a KV pair. The value
/// of a KV pair is at most a 32-bit integer which at most is 10 characters
/// (4294967295), plus one character for the sign bit on signed ints.
kv_temp: [11]u8 = undefined,
kv_temp_len: u4 = 0,
kv_current: u8 = 0, // Current kv key
@ -237,16 +237,14 @@ pub const Parser = struct {
}
}
// Only "z" is currently signed. This is a bit of a kloodge; if more
// fields become signed we can rethink this but for now we parse
// "z" as i32 then bitcast it to u32 then bitcast it back later.
if (self.kv_current == 'z') {
const v = try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10);
try self.kv.put(alloc, self.kv_current, @bitCast(v));
} else {
const v = try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10);
try self.kv.put(alloc, self.kv_current, v);
}
// Handle integer fields, parsing signed fields accordingly. We still
// store the fields as u32 as they can be bitcast back later during
// building of the higher-level command tree.
const v: u32 = switch (self.kv_current) {
'z', 'H', 'V' => @bitCast(try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10)),
else => try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10),
};
try self.kv.put(alloc, self.kv_current, v);
// Clear our temp buffer
self.kv_temp_len = 0;
@ -505,8 +503,8 @@ pub const Display = struct {
virtual_placement: bool = false, // U
parent_id: u32 = 0, // P
parent_placement_id: u32 = 0, // Q
horizontal_offset: u32 = 0, // H
vertical_offset: u32 = 0, // V
horizontal_offset: i32 = 0, // H
vertical_offset: i32 = 0, // V
z: i32 = 0, // z
pub const CursorMovement = enum {
@ -591,11 +589,13 @@ pub const Display = struct {
}
if (kv.get('H')) |v| {
result.horizontal_offset = v;
// We can bitcast here because of how we parse it earlier.
result.horizontal_offset = @bitCast(v);
}
if (kv.get('V')) |v| {
result.vertical_offset = v;
// We can bitcast here because of how we parse it earlier.
result.vertical_offset = @bitCast(v);
}
return result;
@ -1069,6 +1069,95 @@ test "ignore very long values" {
try testing.expectEqual(@as(u32, 0), v.height);
}
test "ensure very large negative values don't get skipped" {
const testing = std.testing;
const alloc = testing.allocator;
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,z=-2000000000";
for (input) |c| try p.feed(c);
const command = try p.complete();
defer command.deinit(alloc);
try testing.expect(command.control == .display);
const v = command.control.display;
try testing.expectEqual(1, v.image_id);
try testing.expectEqual(-2000000000, v.z);
}
test "ensure proper overflow error for u32" {
const testing = std.testing;
const alloc = testing.allocator;
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=10000000000";
for (input) |c| try p.feed(c);
try testing.expectError(error.Overflow, p.complete());
}
test "ensure proper overflow error for i32" {
const testing = std.testing;
const alloc = testing.allocator;
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,z=-9999999999";
for (input) |c| try p.feed(c);
try testing.expectError(error.Overflow, p.complete());
}
test "all i32 values" {
const testing = std.testing;
const alloc = testing.allocator;
{
// 'z' (usually z-axis values)
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,z=-1";
for (input) |c| try p.feed(c);
const command = try p.complete();
defer command.deinit(alloc);
try testing.expect(command.control == .display);
const v = command.control.display;
try testing.expectEqual(1, v.image_id);
try testing.expectEqual(-1, v.z);
}
{
// 'H' (relative placement, horizontal offset)
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,H=-1";
for (input) |c| try p.feed(c);
const command = try p.complete();
defer command.deinit(alloc);
try testing.expect(command.control == .display);
const v = command.control.display;
try testing.expectEqual(1, v.image_id);
try testing.expectEqual(-1, v.horizontal_offset);
}
{
// 'V' (relative placement, vertical offset)
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,V=-1";
for (input) |c| try p.feed(c);
const command = try p.complete();
defer command.deinit(alloc);
try testing.expect(command.control == .display);
const v = command.control.display;
try testing.expectEqual(1, v.image_id);
try testing.expectEqual(-1, v.vertical_offset);
}
}
test "response: encode nothing without ID or image number" {
const testing = std.testing;
var buf: [1024]u8 = undefined;

View File

@ -495,3 +495,37 @@ test "kittygfx default format is rgba" {
const img = storage.imageById(1).?;
try testing.expectEqual(command.Transmission.Format.rgba, img.format);
}
test "kittygfx test valid u32 (expect invalid image ID)" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
alloc,
"a=p,i=4294967295",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqual(resp.message, "ENOENT: image not found");
}
test "kittygfx test valid i32 (expect invalid image ID)" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
alloc,
"a=p,i=1,z=-2147483648",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqual(resp.message, "ENOENT: image not found");
}

View File

@ -165,6 +165,8 @@ pub const Parser = struct {
9 => return Attribute{ .strikethrough = {} },
21 => return Attribute{ .underline = .double },
22 => return Attribute{ .reset_bold = {} },
23 => return Attribute{ .reset_italic = {} },

View File

@ -154,7 +154,7 @@ pub fn threadEnter(
processExit,
);
// Start our termios timer. We only support this on Windows.
// Start our termios timer. We don't support this on Windows.
// Fundamentally, we could support this on Windows so we're just
// waiting for someone to implement it.
if (comptime builtin.os.tag != .windows) {
@ -1257,9 +1257,19 @@ const Subprocess = struct {
// descendents are well and truly dead. We will not rest
// until the entire family tree is obliterated.
while (true) {
if (c.killpg(pgid, c.SIGHUP) < 0) {
log.warn("error killing process group pgid={}", .{pgid});
return error.KillFailed;
switch (posix.errno(c.killpg(pgid, c.SIGHUP))) {
.SUCCESS => log.debug("process group killed pgid={}", .{pgid}),
else => |err| killpg: {
if ((comptime builtin.target.isDarwin()) and
err == .PERM)
{
log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{});
break :killpg;
}
log.warn("error killing process group pgid={} err={}", .{ pgid, err });
return error.KillFailed;
},
}
// See Command.zig wait for why we specify WNOHANG.
@ -1267,6 +1277,7 @@ const Subprocess = struct {
// are still alive without blocking so that we can
// kill them again.
const res = posix.waitpid(pid, std.c.W.NOHANG);
log.debug("waitpid result={}", .{res.pid});
if (res.pid != 0) break;
std.time.sleep(10 * std.time.ns_per_ms);
}

View File

@ -1,9 +1,9 @@
[files]
extend-exclude = [
# vendored code
"vendor/*",
"pkg/*",
"src/stb/*",
"*.xib",
# "grey" color names are valid
"src/terminal/res/rgb.txt",
# Do not self-check
@ -17,7 +17,9 @@ extend-exclude = [
"*.icns",
# Other
"*.pdf",
"*.data"
"*.data",
"*.xib",
"src/cli/lorem_ipsum.txt"
]
[default]