diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 91bfd679c..5570312b6 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -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 diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 1ab280761..8d325f5c0 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -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. diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 74466c7b8..5ed8040d8 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -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. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 366cd05bd..42a5084db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/build.zig b/build.zig index ba3e4f553..63ac1a2cf 100644 --- a/build.zig +++ b/build.zig @@ -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()) { diff --git a/build.zig.zon b/build.zig.zon index bd86b3427..973e6e6b5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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", }, }, } diff --git a/flake.nix b/flake.nix index 43b808393..01acca063 100644 --- a/flake.nix +++ b/flake.nix @@ -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"; diff --git a/include/ghostty.h b/include/ghostty.h index b413dec41..e66ce08ea 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b758411cf..e3ad5adf3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateDelegate.swift; sourceTree = ""; }; A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Input.swift; sourceTree = ""; }; + A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalScreen.swift; sourceTree = ""; }; A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_UIKit.swift; sourceTree = ""; }; A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossKit.swift; sourceTree = ""; }; A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_AppKit.swift; sourceTree = ""; }; @@ -98,6 +112,7 @@ A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = ""; }; + A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; @@ -105,6 +120,7 @@ A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Shell.swift; sourceTree = ""; }; A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = ""; }; A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; + A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; }; A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = ""; }; @@ -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 = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; + A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; + A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = ""; }; + A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalController.swift; sourceTree = ""; }; + A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalWindow.swift; sourceTree = ""; }; + A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalPosition.swift; sourceTree = ""; }; + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; + A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; + A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsController.swift; sourceTree = ""; }; A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsView.swift; sourceTree = ""; }; @@ -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 = ""; }; + A57D79252C9C8782001D522E /* Secure Input */ = { + isa = PBXGroup; + children = ( + A57D79262C9C8798001D522E /* SecureInput.swift */, + A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */, + ); + path = "Secure Input"; + sourceTree = ""; + }; A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( @@ -306,6 +346,7 @@ A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */, AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, + A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, ); path = Terminal; sourceTree = ""; @@ -346,6 +387,26 @@ name = Products; sourceTree = ""; }; + A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = { + isa = PBXGroup; + children = ( + A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, + A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, + A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, + A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */, + A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, + ); + path = QuickTerminal; + sourceTree = ""; + }; + A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { + isa = PBXGroup; + children = ( + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */, + ); + path = "Global Keybinds"; + sourceTree = ""; + }; 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; diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme index eec27fad9..d9b7183aa 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme @@ -1,6 +1,6 @@ 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 } + } } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index bbfd59eae..63aae4c60 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -32,17 +32,19 @@ + + - + @@ -76,6 +78,12 @@ + + + + + + @@ -209,6 +217,13 @@ + + + + + + + diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 8a4f24678..1a7272e16 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -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.. 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? { + 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 +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminal.xib b/macos/Sources/Features/QuickTerminal/QuickTerminal.xib new file mode 100644 index 000000000..b2a99cbf5 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminal.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift new file mode 100644 index 000000000..f5d899e76 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -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) + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift new file mode 100644 index 000000000..51b450700 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -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) + } + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift new file mode 100644 index 000000000..cd07a6f12 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift @@ -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 + } + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift new file mode 100644 index 000000000..2d9d1df7c --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift @@ -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] + } +} diff --git a/macos/Sources/Features/Secure Input/SecureInput.swift b/macos/Sources/Features/Secure Input/SecureInput.swift new file mode 100644 index 000000000..f999ce5ca --- /dev/null +++ b/macos/Sources/Features/Secure Input/SecureInput.swift @@ -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)") + } +} diff --git a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift new file mode 100644 index 000000000..96f309de5 --- /dev/null +++ b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift @@ -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 + } + } + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift new file mode 100644 index 000000000..4417ce9cc --- /dev/null +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -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) + } +} diff --git a/macos/Sources/Features/Terminal/Terminal.xib b/macos/Sources/Features/Terminal/Terminal.xib index 4078fa2c6..65b03b6eb 100644 --- a/macos/Sources/Features/Terminal/Terminal.xib +++ b/macos/Sources/Features/Terminal/Terminal.xib @@ -1,8 +1,8 @@ - + - + diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index bc69255fc..bb8b5665d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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!) - } } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 2559e1ec8..3930012df 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -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) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 33193fb0e..ec7d7c229 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -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: 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: 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 : []) } } } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 69cbfbfc6..05c01a75e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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?) {} - 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?, body: UnsafePointer?) {} - static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {} - static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, 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?) { - 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.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.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.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?, 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?, body: UnsafePointer?) { - 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.fromOpaque(app_ud).takeUnretainedValue() - } - - /// Returns the surface view from the userdata. - static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView { - return Unmanaged.fromOpaque(userdata!).takeUnretainedValue() - } - #endif } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 8ea9371fe..76f85d2a3 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -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? = 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? = 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? = 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 + } } } diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index c9429ab79..fa8335416 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -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: diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift index 2d867e000..b6147647e 100644 --- a/macos/Sources/Ghostty/InspectorView.swift +++ b/macos/Sources/Ghostty/InspectorView.swift @@ -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: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 475d68733..c55af2357 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -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. diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index cd3967052..5eb277ba1 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8a617fdd6..a5e6b2f04 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -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- 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) { diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 1af5e6fe1..8c3c10502 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -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 +} diff --git a/macos/Sources/Helpers/Cursor.swift b/macos/Sources/Helpers/Cursor.swift new file mode 100644 index 000000000..fe4a148b5 --- /dev/null +++ b/macos/Sources/Helpers/Cursor.swift @@ -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.. 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) + } +} diff --git a/macos/Sources/Helpers/FullScreenHandler.swift b/macos/Sources/Helpers/FullScreenHandler.swift index c9d6e594e..d12809d71 100644 --- a/macos/Sources/Helpers/FullScreenHandler.swift +++ b/macos/Sources/Helpers/FullScreenHandler.swift @@ -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 { diff --git a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift b/macos/Sources/Helpers/SplitView/SplitView.Divider.swift index f1a7f666d..83847ff0c 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift +++ b/macos/Sources/Helpers/SplitView/SplitView.Divider.swift @@ -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: diff --git a/macos/Sources/Helpers/View+Extension.swift b/macos/Sources/Helpers/View+Extension.swift new file mode 100644 index 000000000..fb6e0c20f --- /dev/null +++ b/macos/Sources/Helpers/View+Extension.swift @@ -0,0 +1,31 @@ +import SwiftUI + +extension View { + func innerShadow( + 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 + } + } +} diff --git a/nix/build-support/check-zig-cache-hash.sh b/nix/build-support/check-zig-cache-hash.sh index 2fd1ead5b..49ea29ffb 100755 --- a/nix/build-support/check-zig-cache-hash.sh +++ b/nix/build-support/check-zig-cache-hash.sh @@ -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." diff --git a/nix/build-support/fetch-zig-cache.sh b/nix/build-support/fetch-zig-cache.sh new file mode 100644 index 000000000..56b94e35d --- /dev/null +++ b/nix/build-support/fetch-zig-cache.sh @@ -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 +# +# 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 diff --git a/nix/devShell.nix b/nix/devShell.nix index b4e109513..6a973d17a 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -14,7 +14,6 @@ python3, qemu, scdoc, - tracy, valgrind, #, vulkan-loader # unused vttest, @@ -100,7 +99,6 @@ in # Testing parallel python3 - tracy vttest hyperfine diff --git a/nix/package.nix b/nix/package.nix index 47bf5ac48..fccaf1c92 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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 ]; diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 842f39762..f11d67db1 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -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=" diff --git a/pkg/macos/carbon.zig b/pkg/macos/carbon.zig new file mode 100644 index 000000000..8eafaffe6 --- /dev/null +++ b/pkg/macos/carbon.zig @@ -0,0 +1,5 @@ +pub const c = @import("carbon/c.zig").c; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/pkg/macos/carbon/c.zig b/pkg/macos/carbon/c.zig new file mode 100644 index 000000000..248af3c90 --- /dev/null +++ b/pkg/macos/carbon/c.zig @@ -0,0 +1,3 @@ +pub const c = @cImport({ + @cInclude("Carbon/Carbon.h"); +}); diff --git a/pkg/macos/main.zig b/pkg/macos/main.zig index 20274e9c0..ef244fc78 100644 --- a/pkg/macos/main.zig +++ b/pkg/macos/main.zig @@ -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"); diff --git a/pkg/macos/text/font.zig b/pkg/macos/text/font.zig index c08e8ee14..85f7de47e 100644 --- a/pkg/macos/text/font.zig +++ b/pkg/macos/text/font.zig @@ -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)); } diff --git a/src/App.zig b/src/App.zig index f933b7126..5922528ab 100644 --- a/src/App.zig +++ b/src/App.zig @@ -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 diff --git a/src/Surface.zig b/src/Surface.zig index cb7f8a9ae..e8bbb885f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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"); diff --git a/src/apprt.zig b/src/apprt.zig index 491f1b8b5..dd726b3f2 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -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; } diff --git a/src/apprt/action.zig b/src/apprt/action.zig new file mode 100644 index 000000000..2f7616bc4 --- /dev/null +++ b/src/apprt/action.zig @@ -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, + }; + } +}; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 9127bb5bd..dc6006caf 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -44,23 +44,14 @@ pub const App = struct { /// a full tick of the app loop. wakeup: *const fn (AppUD) callconv(.C) void, + /// Callback called to handle an action. + action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.C) void, + /// Reload the configuration and return the new configuration. /// The old configuration can be freed immediately when this is /// called. reload_config: *const fn (AppUD) callconv(.C) ?*const Config, - /// Open the configuration file. - open_config: *const fn (AppUD) callconv(.C) void, - - /// Called to set the title of the window. - set_title: *const fn (SurfaceUD, [*]const u8) callconv(.C) void, - - /// Called to set the cursor shape. - set_mouse_shape: *const fn (SurfaceUD, terminal.MouseShape) callconv(.C) void, - - /// Called to set the mouse visibility. - set_mouse_visibility: *const fn (SurfaceUD, bool) callconv(.C) void, - /// Read the clipboard value. The return value must be preserved /// by the host until the next call. If there is no valid clipboard /// value then this should return null. @@ -79,60 +70,23 @@ pub const App = struct { /// Write the clipboard value. write_clipboard: *const fn (SurfaceUD, [*:0]const u8, c_int, bool) callconv(.C) void, - /// Create a new split view. If the embedder doesn't support split - /// views then this can be null. - new_split: ?*const fn (SurfaceUD, apprt.SplitDirection, apprt.Surface.Options) callconv(.C) void = null, - - /// New tab with options. - new_tab: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, - - /// New window with options. - new_window: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, - - /// Control the inspector visibility - control_inspector: ?*const fn (SurfaceUD, input.InspectorMode) callconv(.C) void = null, - /// Close the current surface given by this function. close_surface: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, + }; - /// Focus the previous/next split (if any). - focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null, + /// This is the key event sent for ghostty_surface_key and + /// ghostty_app_key. + pub const KeyEvent = struct { + /// The three below are absolutely required. + action: input.Action, + mods: input.Mods, + keycode: u32, - /// Resize the current split. - resize_split: ?*const fn (SurfaceUD, input.SplitResizeDirection, u16) callconv(.C) void = null, - - /// Equalize all splits in the current window - equalize_splits: ?*const fn (SurfaceUD) callconv(.C) void = null, - - /// Zoom the current split. - toggle_split_zoom: ?*const fn (SurfaceUD) callconv(.C) void = null, - - /// Goto tab - goto_tab: ?*const fn (SurfaceUD, apprt.GotoTab) callconv(.C) void = null, - - /// Toggle fullscreen for current window. - toggle_fullscreen: ?*const fn (SurfaceUD, configpkg.NonNativeFullscreen) callconv(.C) void = null, - - /// Set the initial window size. It is up to the user of libghostty to - /// determine if it is the initial window and set this appropriately. - set_initial_window_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null, - - /// Render the inspector for the given surface. - render_inspector: ?*const fn (SurfaceUD) callconv(.C) void = null, - - /// Called when the cell size changes. - set_cell_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null, - - /// Show a desktop notification to the user. - show_desktop_notification: ?*const fn (SurfaceUD, [*:0]const u8, [*:0]const u8) void = null, - - /// Called when the health of the renderer changes. - update_renderer_health: ?*const fn (SurfaceUD, renderer.Health) void = null, - - /// Called when the mouse goes over a link. The link target is the - /// parameter. The link target will be null if the mouse is no longer - /// over a link. - mouse_over_link: ?*const fn (SurfaceUD, ?[*]const u8, usize) void = null, + /// Optionally, the embedder can handle text translation and send + /// the text value here. If text is non-nil, it is assumed that the + /// embedder also handles dead key states and sets composing as necessary. + text: ?[:0]const u8, + composing: bool, }; core_app: *CoreApp, @@ -140,12 +94,17 @@ pub const App = struct { opts: Options, keymap: input.Keymap, + /// The keymap state is used for global keybinds only. Each surface + /// also has its own keymap state for focused keybinds. + keymap_state: input.Keymap.State, + pub fn init(core_app: *CoreApp, config: *const Config, opts: Options) !App { return .{ .core_app = core_app, .config = config, .opts = opts, .keymap = try input.Keymap.init(), + .keymap_state = .{}, }; } @@ -153,6 +112,251 @@ pub const App = struct { self.keymap.deinit(); } + /// Returns true if there are any global keybinds in the configuration. + pub fn hasGlobalKeybinds(self: *const App) bool { + var it = self.config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + switch (entry.value_ptr.*) { + .leader => {}, + .leaf => |leaf| if (leaf.flags.global) return true, + } + } + + return false; + } + + /// The target of a key event. This is used to determine some subtly + /// different behavior between app and surface key events. + pub const KeyTarget = union(enum) { + app, + surface: *Surface, + }; + + /// See CoreApp.keyEvent. + pub fn keyEvent( + self: *App, + target: KeyTarget, + event: KeyEvent, + ) !bool { + const action = event.action; + const keycode = event.keycode; + const mods = event.mods; + + // True if this is a key down event + const is_down = action == .press or action == .repeat; + + // If we're on macOS and we have macos-option-as-alt enabled, + // then we strip the alt modifier from the mods for translation. + const translate_mods = translate_mods: { + var translate_mods = mods; + if (comptime builtin.target.isDarwin()) { + const strip = switch (self.config.@"macos-option-as-alt") { + .false => false, + .true => mods.alt, + .left => mods.sides.alt == .left, + .right => mods.sides.alt == .right, + }; + if (strip) translate_mods.alt = false; + } + + // On macOS we strip ctrl because UCKeyTranslate + // converts to the masked values (i.e. ctrl+c becomes 3) + // and we don't want that behavior. + // + // We also strip super because its not used for translation + // on macos and it results in a bad translation. + if (comptime builtin.target.isDarwin()) { + translate_mods.ctrl = false; + translate_mods.super = false; + } + + break :translate_mods translate_mods; + }; + + const event_text: ?[]const u8 = event_text: { + // This logic only applies to macOS. + if (comptime builtin.os.tag != .macos) break :event_text event.text; + + // If the modifiers are ONLY "control" then we never process + // the event text because we want to do our own translation so + // we can handle ctrl+c, ctrl+z, etc. + // + // This is specifically because on macOS using the + // "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as + // "/" (the physical key that is z on a qwerty keyboard). But on + // other layouts, ctrl+ is not translated by AppKit. So, + // we just avoid this by never allowing AppKit to translate + // ctrl+ and instead do it ourselves. + const ctrl_only = comptime (input.Mods{ .ctrl = true }).int(); + break :event_text if (mods.binding().int() == ctrl_only) null else event.text; + }; + + // Translate our key using the keymap for our localized keyboard layout. + // We only translate for keydown events. Otherwise, we only care about + // the raw keycode. + var buf: [128]u8 = undefined; + const result: input.Keymap.Translation = if (is_down) translate: { + // If the event provided us with text, then we use this as a result + // and do not do manual translation. + const result: input.Keymap.Translation = if (event_text) |text| .{ + .text = text, + .composing = event.composing, + } else try self.keymap.translate( + &buf, + switch (target) { + .app => &self.keymap_state, + .surface => |surface| &surface.keymap_state, + }, + @intCast(keycode), + translate_mods, + ); + + // If this is a dead key, then we're composing a character and + // we need to set our proper preedit state if we're targeting a + // surface. + if (result.composing) { + switch (target) { + .app => {}, + .surface => |surface| surface.core_surface.preeditCallback( + result.text, + ) catch |err| { + log.err("error in preedit callback err={}", .{err}); + return false; + }, + } + } else { + switch (target) { + .app => {}, + .surface => |surface| surface.core_surface.preeditCallback(null) catch |err| { + log.err("error in preedit callback err={}", .{err}); + return false; + }, + } + + // If the text is just a single non-printable ASCII character + // then we clear the text. We handle non-printables in the + // key encoder manual (such as tab, ctrl+c, etc.) + if (result.text.len == 1 and result.text[0] < 0x20) { + break :translate .{ .composing = false, .text = "" }; + } + } + + break :translate result; + } else .{ .composing = false, .text = "" }; + + // UCKeyTranslate always consumes all mods, so if we have any output + // then we've consumed our translate mods. + const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{}; + + // We need to always do a translation with no modifiers at all in + // order to get the "unshifted_codepoint" for the key event. + const unshifted_codepoint: u21 = unshifted: { + var nomod_buf: [128]u8 = undefined; + var nomod_state: input.Keymap.State = .{}; + const nomod = try self.keymap.translate( + &nomod_buf, + &nomod_state, + @intCast(keycode), + .{}, + ); + + const view = std.unicode.Utf8View.init(nomod.text) catch |err| { + log.warn("cannot build utf8 view over text: {}", .{err}); + break :unshifted 0; + }; + var it = view.iterator(); + break :unshifted it.nextCodepoint() orelse 0; + }; + + // log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{ + // action, + // keycode, + // result.composing, + // result.text.len, + // result.text, + // result.text, + // mods, + // }); + + // We want to get the physical unmapped key to process keybinds. + const physical_key = keycode: for (input.keycodes.entries) |entry| { + if (entry.native == keycode) break :keycode entry.key; + } else .invalid; + + // If the resulting text has length 1 then we can take its key + // and attempt to translate it to a key enum and call the key callback. + // If the length is greater than 1 then we're going to call the + // charCallback. + // + // We also only do key translation if this is not a dead key. + const key = if (!result.composing) key: { + // If our physical key is a keypad key, we use that. + if (physical_key.keypad()) break :key physical_key; + + // A completed key. If the length of the key is one then we can + // attempt to translate it to a key enum and call the key + // callback. First try plain ASCII. + if (result.text.len > 0) { + if (input.Key.fromASCII(result.text[0])) |key| { + break :key key; + } + } + + // If the above doesn't work, we use the unmodified value. + if (std.math.cast(u8, unshifted_codepoint)) |ascii| { + if (input.Key.fromASCII(ascii)) |key| { + break :key key; + } + } + + break :key physical_key; + } else .invalid; + + // Build our final key event + const input_event: input.KeyEvent = .{ + .action = action, + .key = key, + .physical_key = physical_key, + .mods = mods, + .consumed_mods = consumed_mods, + .composing = result.composing, + .utf8 = result.text, + .unshifted_codepoint = unshifted_codepoint, + }; + + // Invoke the core Ghostty logic to handle this input. + const effect: CoreSurface.InputEffect = switch (target) { + .app => if (self.core_app.keyEvent( + self, + input_event, + )) + .consumed + else + .ignored, + + .surface => |surface| try surface.core_surface.keyCallback(input_event), + }; + + return switch (effect) { + .closed => true, + .ignored => false, + .consumed => consumed: { + if (is_down) { + // If we consume the key then we want to reset the dead + // key state. + self.keymap_state = .{}; + + switch (target) { + .app => {}, + .surface => |surface| surface.core_surface.preeditCallback(null) catch {}, + } + } + + break :consumed true; + }, + }; + } + /// This should be called whenever the keyboard layout was changed. pub fn reloadKeymap(self: *App) !void { // Reload the keymap @@ -166,10 +370,6 @@ pub const App = struct { } } - pub fn openConfig(self: *App) !void { - try configpkg.edit.open(self.core_app.alloc); - } - pub fn reloadConfig(self: *App) !?*const Config { // Reload if (self.opts.reload_config(self.opts.userdata)) |new| { @@ -218,15 +418,39 @@ pub const App = struct { surface.queueInspectorRender(); } - pub fn newWindow(self: *App, parent: ?*CoreSurface) !void { - _ = self; + /// Perform a given action. + pub fn performAction( + self: *App, + target: apprt.Target, + comptime action: apprt.Action.Key, + value: apprt.Action.Value(action), + ) !void { + // Special case certain actions before they are sent to the embedder + switch (action) { + .set_title => switch (target) { + .app => {}, + .surface => |surface| { + // Dupe the title so that we can store it. If we get an allocation + // error we just ignore it, since this only breaks a few minor things. + const alloc = self.core_app.alloc; + if (surface.rt_surface.title) |v| alloc.free(v); + surface.rt_surface.title = alloc.dupeZ(u8, value.title) catch null; + }, + }, - // Right now we only support creating a new window with a parent - // through this code. - // The other case is handled by the embedding runtime. - if (parent) |surface| { - try surface.rt_surface.newWindow(); + else => {}, } + + log.debug("dispatching action target={s} action={} value={}", .{ + @tagName(target), + action, + value, + }); + self.opts.action( + self, + target.cval(), + @unionInit(apprt.Action, @tagName(action), value).cval(), + ); } }; @@ -328,20 +552,6 @@ pub const Surface = struct { command: [*:0]const u8 = "", }; - /// This is the key event sent for ghostty_surface_key. - pub const KeyEvent = struct { - /// The three below are absolutely required. - action: input.Action, - mods: input.Mods, - keycode: u32, - - /// Optionally, the embedder can handle text translation and send - /// the text value here. If text is non-nil, it is assumed that the - /// embedder also handles dead key states and sets composing as necessary. - text: ?[:0]const u8, - composing: bool, - }; - pub fn init(self: *Surface, app: *App, opts: Options) !void { self.* = .{ .app = app, @@ -458,25 +668,6 @@ pub const Surface = struct { } } - pub fn controlInspector(self: *const Surface, mode: input.InspectorMode) void { - const func = self.app.opts.control_inspector orelse { - log.info("runtime embedder does not support the terminal inspector", .{}); - return; - }; - - func(self.userdata, mode); - } - - pub fn newSplit(self: *const Surface, direction: apprt.SplitDirection) !void { - const func = self.app.opts.new_split orelse { - log.info("runtime embedder does not support splits", .{}); - return; - }; - - const options = self.newSurfaceOptions(); - func(self.userdata, direction, options); - } - pub fn close(self: *const Surface, process_alive: bool) void { const func = self.app.opts.close_surface orelse { log.info("runtime embedder does not support closing a surface", .{}); @@ -486,42 +677,6 @@ pub const Surface = struct { func(self.userdata, process_alive); } - pub fn gotoSplit(self: *const Surface, direction: input.SplitFocusDirection) void { - const func = self.app.opts.focus_split orelse { - log.info("runtime embedder does not support focus split", .{}); - return; - }; - - func(self.userdata, direction); - } - - pub fn resizeSplit(self: *const Surface, direction: input.SplitResizeDirection, amount: u16) void { - const func = self.app.opts.resize_split orelse { - log.info("runtime embedder does not support resize split", .{}); - return; - }; - - func(self.userdata, direction, amount); - } - - pub fn equalizeSplits(self: *const Surface) void { - const func = self.app.opts.equalize_splits orelse { - log.info("runtime embedder does not support equalize splits", .{}); - return; - }; - - func(self.userdata); - } - - pub fn toggleSplitZoom(self: *const Surface) void { - const func = self.app.opts.toggle_split_zoom orelse { - log.info("runtime embedder does not support split zoom", .{}); - return; - }; - - func(self.userdata); - } - pub fn getContentScale(self: *const Surface) !apprt.ContentScale { return self.content_scale; } @@ -530,44 +685,10 @@ pub const Surface = struct { return self.size; } - pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { - _ = self; - _ = min; - _ = max_; - } - - pub fn setTitle(self: *Surface, slice: [:0]const u8) !void { - // Dupe the title so that we can store it. If we get an allocation - // error we just ignore it, since this only breaks a few minor things. - const alloc = self.app.core_app.alloc; - if (self.title) |v| alloc.free(v); - self.title = alloc.dupeZ(u8, slice) catch null; - - self.app.opts.set_title( - self.userdata, - slice.ptr, - ); - } - pub fn getTitle(self: *Surface) ?[:0]const u8 { return self.title; } - pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { - self.app.opts.set_mouse_shape( - self.userdata, - shape, - ); - } - - /// Set the visibility of the mouse cursor. - pub fn setMouseVisibility(self: *Surface, visible: bool) void { - self.app.opts.set_mouse_visibility( - self.userdata, - visible, - ); - } - pub fn supportsClipboard( self: *const Surface, clipboard_type: apprt.Clipboard, @@ -755,7 +876,12 @@ pub const Surface = struct { }; } - pub fn cursorPosCallback(self: *Surface, x: f64, y: f64) void { + pub fn cursorPosCallback( + self: *Surface, + x: f64, + y: f64, + mods: input.Mods, + ) void { // Convert our unscaled x/y to scaled. self.cursor_pos = self.cursorPosToPixels(.{ .x = @floatCast(x), @@ -768,204 +894,12 @@ pub const Surface = struct { return; }; - self.core_surface.cursorPosCallback(self.cursor_pos) catch |err| { + self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { log.err("error in cursor pos callback err={}", .{err}); return; }; } - pub fn keyCallback( - self: *Surface, - event: KeyEvent, - ) !void { - const action = event.action; - const keycode = event.keycode; - const mods = event.mods; - - // True if this is a key down event - const is_down = action == .press or action == .repeat; - - // If we're on macOS and we have macos-option-as-alt enabled, - // then we strip the alt modifier from the mods for translation. - const translate_mods = translate_mods: { - var translate_mods = mods; - if (comptime builtin.target.isDarwin()) { - const strip = switch (self.app.config.@"macos-option-as-alt") { - .false => false, - .true => mods.alt, - .left => mods.sides.alt == .left, - .right => mods.sides.alt == .right, - }; - if (strip) translate_mods.alt = false; - } - - // On macOS we strip ctrl because UCKeyTranslate - // converts to the masked values (i.e. ctrl+c becomes 3) - // and we don't want that behavior. - // - // We also strip super because its not used for translation - // on macos and it results in a bad translation. - if (comptime builtin.target.isDarwin()) { - translate_mods.ctrl = false; - translate_mods.super = false; - } - - break :translate_mods translate_mods; - }; - - const event_text: ?[]const u8 = event_text: { - // This logic only applies to macOS. - if (comptime builtin.os.tag != .macos) break :event_text event.text; - - // If the modifiers are ONLY "control" then we never process - // the event text because we want to do our own translation so - // we can handle ctrl+c, ctrl+z, etc. - // - // This is specifically because on macOS using the - // "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as - // "/" (the physical key that is z on a qwerty keyboard). But on - // other layouts, ctrl+ is not translated by AppKit. So, - // we just avoid this by never allowing AppKit to translate - // ctrl+ and instead do it ourselves. - const ctrl_only = comptime (input.Mods{ .ctrl = true }).int(); - break :event_text if (mods.binding().int() == ctrl_only) null else event.text; - }; - - // Translate our key using the keymap for our localized keyboard layout. - // We only translate for keydown events. Otherwise, we only care about - // the raw keycode. - var buf: [128]u8 = undefined; - const result: input.Keymap.Translation = if (is_down) translate: { - // If the event provided us with text, then we use this as a result - // and do not do manual translation. - const result: input.Keymap.Translation = if (event_text) |text| .{ - .text = text, - .composing = event.composing, - } else try self.app.keymap.translate( - &buf, - &self.keymap_state, - @intCast(keycode), - translate_mods, - ); - - // If this is a dead key, then we're composing a character and - // we need to set our proper preedit state. - if (result.composing) { - self.core_surface.preeditCallback(result.text) catch |err| { - log.err("error in preedit callback err={}", .{err}); - return; - }; - } else { - // If we aren't composing, then we set our preedit to - // empty no matter what. - self.core_surface.preeditCallback(null) catch {}; - - // If the text is just a single non-printable ASCII character - // then we clear the text. We handle non-printables in the - // key encoder manual (such as tab, ctrl+c, etc.) - if (result.text.len == 1 and result.text[0] < 0x20) { - break :translate .{ .composing = false, .text = "" }; - } - } - - break :translate result; - } else .{ .composing = false, .text = "" }; - - // UCKeyTranslate always consumes all mods, so if we have any output - // then we've consumed our translate mods. - const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{}; - - // We need to always do a translation with no modifiers at all in - // order to get the "unshifted_codepoint" for the key event. - const unshifted_codepoint: u21 = unshifted: { - var nomod_buf: [128]u8 = undefined; - var nomod_state: input.Keymap.State = .{}; - const nomod = try self.app.keymap.translate( - &nomod_buf, - &nomod_state, - @intCast(keycode), - .{}, - ); - - const view = std.unicode.Utf8View.init(nomod.text) catch |err| { - log.warn("cannot build utf8 view over text: {}", .{err}); - break :unshifted 0; - }; - var it = view.iterator(); - break :unshifted it.nextCodepoint() orelse 0; - }; - - // log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{ - // action, - // keycode, - // result.composing, - // result.text.len, - // result.text, - // result.text, - // mods, - // }); - - // We want to get the physical unmapped key to process keybinds. - const physical_key = keycode: for (input.keycodes.entries) |entry| { - if (entry.native == keycode) break :keycode entry.key; - } else .invalid; - - // If the resulting text has length 1 then we can take its key - // and attempt to translate it to a key enum and call the key callback. - // If the length is greater than 1 then we're going to call the - // charCallback. - // - // We also only do key translation if this is not a dead key. - const key = if (!result.composing) key: { - // If our physical key is a keypad key, we use that. - if (physical_key.keypad()) break :key physical_key; - - // A completed key. If the length of the key is one then we can - // attempt to translate it to a key enum and call the key - // callback. First try plain ASCII. - if (result.text.len > 0) { - if (input.Key.fromASCII(result.text[0])) |key| { - break :key key; - } - } - - // If the above doesn't work, we use the unmodified value. - if (std.math.cast(u8, unshifted_codepoint)) |ascii| { - if (input.Key.fromASCII(ascii)) |key| { - break :key key; - } - } - - break :key physical_key; - } else .invalid; - - // Invoke the core Ghostty logic to handle this input. - const effect = self.core_surface.keyCallback(.{ - .action = action, - .key = key, - .physical_key = physical_key, - .mods = mods, - .consumed_mods = consumed_mods, - .composing = result.composing, - .utf8 = result.text, - .unshifted_codepoint = unshifted_codepoint, - }) catch |err| { - log.err("error in key callback err={}", .{err}); - return; - }; - - switch (effect) { - .closed => return, - .ignored => {}, - .consumed => if (is_down) { - // If we consume the key then we want to reset the dead - // key state. - self.keymap_state = .{}; - self.core_surface.preeditCallback(null) catch {}; - }, - } - } - pub fn textCallback(self: *Surface, text: []const u8) void { _ = self.core_surface.textCallback(text) catch |err| { log.err("error in key callback err={}", .{err}); @@ -987,72 +921,18 @@ pub const Surface = struct { }; } - pub fn gotoTab(self: *Surface, tab: apprt.GotoTab) void { - const func = self.app.opts.goto_tab orelse { - log.info("runtime embedder does not goto_tab", .{}); + fn queueInspectorRender(self: *Surface) void { + self.app.performAction( + .{ .surface = &self.core_surface }, + .render_inspector, + {}, + ) catch |err| { + log.err("error rendering the inspector err={}", .{err}); return; }; - - func(self.userdata, tab); } - pub fn toggleFullscreen(self: *Surface, nonNativeFullscreen: configpkg.NonNativeFullscreen) void { - const func = self.app.opts.toggle_fullscreen orelse { - log.info("runtime embedder does not toggle_fullscreen", .{}); - return; - }; - - func(self.userdata, nonNativeFullscreen); - } - - pub fn newTab(self: *const Surface) !void { - const func = self.app.opts.new_tab orelse { - log.info("runtime embedder does not support new_tab", .{}); - return; - }; - - const options = self.newSurfaceOptions(); - func(self.userdata, options); - } - - pub fn newWindow(self: *const Surface) !void { - const func = self.app.opts.new_window orelse { - log.info("runtime embedder does not support new_window", .{}); - return; - }; - - const options = self.newSurfaceOptions(); - func(self.userdata, options); - } - - pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void { - const func = self.app.opts.set_initial_window_size orelse { - log.info("runtime embedder does not set_initial_window_size", .{}); - return; - }; - - func(self.userdata, width, height); - } - - fn queueInspectorRender(self: *const Surface) void { - const func = self.app.opts.render_inspector orelse { - log.info("runtime embedder does not render_inspector", .{}); - return; - }; - - func(self.userdata); - } - - pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void { - const func = self.app.opts.set_cell_size orelse { - log.info("runtime embedder does not support set_cell_size", .{}); - return; - }; - - func(self.userdata, width, height); - } - - fn newSurfaceOptions(self: *const Surface) apprt.Surface.Options { + pub fn newSurfaceOptions(self: *const Surface) apprt.Surface.Options { const font_size: f32 = font_size: { if (!self.app.config.@"window-inherit-font-size") break :font_size 0; break :font_size self.core_surface.font_size.points; @@ -1069,43 +949,6 @@ pub const Surface = struct { const scale = try self.getContentScale(); return .{ .x = pos.x * scale.x, .y = pos.y * scale.y }; } - - /// Show a desktop notification. - pub fn showDesktopNotification( - self: *const Surface, - title: [:0]const u8, - body: [:0]const u8, - ) !void { - const func = self.app.opts.show_desktop_notification orelse { - log.info("runtime embedder does not support show_desktop_notification", .{}); - return; - }; - - func(self.userdata, title, body); - } - - /// Update the health of the renderer. - pub fn updateRendererHealth(self: *const Surface, health: renderer.Health) void { - const func = self.app.opts.update_renderer_health orelse { - log.info("runtime embedder does not support update_renderer_health", .{}); - return; - }; - - func(self.userdata, health); - } - - pub fn mouseOverLink(self: *const Surface, uri: ?[]const u8) void { - const func = self.app.opts.mouse_over_link orelse { - log.info("runtime embedder does not support over_link", .{}); - return; - }; - - if (uri) |v| { - func(self.userdata, v.ptr, v.len); - } else { - func(self.userdata, null, 0); - } - } }; /// Inspector is the state required for the terminal inspector. A terminal @@ -1367,7 +1210,7 @@ pub const CAPI = struct { composing: bool, /// Convert to surface key event. - fn keyEvent(self: KeyEvent) Surface.KeyEvent { + fn keyEvent(self: KeyEvent) App.KeyEvent { return .{ .action = self.action, .mods = @bitCast(@as( @@ -1453,6 +1296,19 @@ pub const CAPI = struct { core_app.destroy(); } + /// Notify the app of a global keypress capture. This will return + /// true if the key was captured by the app, in which case the caller + /// should not process the key. + export fn ghostty_app_key( + app: *App, + event: KeyEvent, + ) bool { + return app.keyEvent(.app, event.keyEvent()) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + }; + } + /// Notify the app that the keyboard was changed. This causes the /// keyboard layout to be reloaded from the OS. export fn ghostty_app_keyboard_changed(v: *App) void { @@ -1464,7 +1320,7 @@ pub const CAPI = struct { /// Open the configuration. export fn ghostty_app_open_config(v: *App) void { - _ = v.core_app.openConfig(v) catch |err| { + v.performAction(.app, .open_config, {}) catch |err| { log.err("error reloading config err={}", .{err}); return; }; @@ -1483,6 +1339,11 @@ pub const CAPI = struct { return v.core_app.needsConfirmQuit(); } + /// Returns true if the app has global keybinds. + export fn ghostty_app_has_global_keybinds(v: *App) bool { + return v.hasGlobalKeybinds(); + } + /// Returns initial surface options. export fn ghostty_surface_config_new() apprt.Surface.Options { return .{}; @@ -1510,11 +1371,21 @@ pub const CAPI = struct { ptr.app.closeSurface(ptr); } + /// Returns the userdata associated with the surface. + export fn ghostty_surface_userdata(surface: *Surface) ?*anyopaque { + return surface.userdata; + } + /// Returns the app associated with a surface. export fn ghostty_surface_app(surface: *Surface) *App { return surface.app; } + /// Returns the config to use for surfaces that inherit from this one. + export fn ghostty_surface_inherited_config(surface: *Surface) Surface.Options { + return surface.newSurfaceOptions(); + } + /// Returns true if the surface needs to confirm quitting. export fn ghostty_surface_needs_confirm_quit(surface: *Surface) bool { return surface.core_surface.needsConfirmQuit(); @@ -1641,16 +1512,15 @@ pub const CAPI = struct { /// Send this for raw keypresses (i.e. the keyDown event on macOS). /// This will handle the keymap translation and send the appropriate /// key and char events. - /// - /// You do NOT need to also send "ghostty_surface_char" unless - /// you want to send a unicode character that is not associated - /// with a keypress, i.e. IME keyboard. export fn ghostty_surface_key( surface: *Surface, event: KeyEvent, ) void { - surface.keyCallback(event.keyEvent()) catch |err| { - log.err("error processing key event err={}", .{err}); + _ = surface.app.keyEvent( + .{ .surface = surface }, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); return; }; } @@ -1690,8 +1560,20 @@ pub const CAPI = struct { } /// Update the mouse position within the view. - export fn ghostty_surface_mouse_pos(surface: *Surface, x: f64, y: f64) void { - surface.cursorPosCallback(x, y); + export fn ghostty_surface_mouse_pos( + surface: *Surface, + x: f64, + y: f64, + mods: c_int, + ) void { + surface.cursorPosCallback( + x, + y, + @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(mods))), + )), + ); } export fn ghostty_surface_mouse_scroll( @@ -1739,26 +1621,61 @@ pub const CAPI = struct { } /// Request that the surface split in the given direction. - export fn ghostty_surface_split(ptr: *Surface, direction: apprt.SplitDirection) void { - ptr.newSplit(direction) catch {}; + export fn ghostty_surface_split(ptr: *Surface, direction: apprt.action.SplitDirection) void { + ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .new_split, + direction, + ) catch |err| { + log.err("error creating new split err={}", .{err}); + return; + }; } /// Focus on the next split (if any). - export fn ghostty_surface_split_focus(ptr: *Surface, direction: input.SplitFocusDirection) void { - ptr.gotoSplit(direction); + export fn ghostty_surface_split_focus( + ptr: *Surface, + direction: apprt.action.GotoSplit, + ) void { + ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .goto_split, + direction, + ) catch |err| { + log.err("error creating new split err={}", .{err}); + return; + }; } /// Resize the current split by moving the split divider in the given /// direction. `direction` specifies which direction the split divider will /// move relative to the focused split. `amount` is a fractional value /// between 0 and 1 that specifies by how much the divider will move. - export fn ghostty_surface_split_resize(ptr: *Surface, direction: input.SplitResizeDirection, amount: u16) void { - ptr.resizeSplit(direction, amount); + export fn ghostty_surface_split_resize( + ptr: *Surface, + direction: apprt.action.ResizeSplit.Direction, + amount: u16, + ) void { + ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .resize_split, + .{ .direction = direction, .amount = amount }, + ) catch |err| { + log.err("error resizing split err={}", .{err}); + return; + }; } /// Equalize the size of all splits in the current window. export fn ghostty_surface_split_equalize(ptr: *Surface) void { - ptr.equalizeSplits(); + ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .equalize_splits, + {}, + ) catch |err| { + log.err("error equalizing splits err={}", .{err}); + return; + }; } /// Invoke an action on the surface. diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index f38214e32..87314c0e1 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -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; }; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 64125575b..8e683829c 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -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 diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 105646c7c..7a3645d1b 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -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, ); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 7d337fbe0..73837d11d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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(); } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 7ece474ac..ff8735ff9 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -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); diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index dad42e730..53cadc2d2 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -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); } diff --git a/src/apprt/gtk/version.zig b/src/apprt/gtk/version.zig new file mode 100644 index 000000000..c61e940fb --- /dev/null +++ b/src/apprt/gtk/version.zig @@ -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; +} diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 1e14b1b7c..e2e9b913d 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -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, diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index b6fe9b0dc..0ff0a2163 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -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()); diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index b12694625..9e734d1ec 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -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)}), diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 4a90df1c5..48919116d 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -4,15 +4,19 @@ const args = @import("args.zig"); const Action = @import("action.zig").Action; const Config = @import("../config/Config.zig"); const themepkg = @import("../config/theme.zig"); +const tui = @import("tui.zig"); const internal_os = @import("../os/main.zig"); const global_state = &@import("../global.zig").state; +const vaxis = @import("vaxis"); +const zf = @import("zf"); + pub const Options = struct { /// If true, print the full path to the theme. path: bool = false, - /// If true, show a small preview of the theme. - preview: bool = false, + /// If true, force a plain list of themes. + plain: bool = false, pub fn deinit(self: Options) void { _ = self; @@ -25,8 +29,41 @@ pub const Options = struct { } }; -/// The `list-themes` command is used to list all the available themes for -/// Ghostty. +const ThemeListElement = struct { + location: themepkg.Location, + path: []const u8, + theme: []const u8, + rank: ?f64 = null, + + fn lessThan(_: void, lhs: @This(), rhs: @This()) bool { + // TODO: use Unicode-aware comparison + return std.ascii.orderIgnoreCase(lhs.theme, rhs.theme) == .lt; + } + + pub fn toUri(self: *const ThemeListElement, alloc: std.mem.Allocator) ![]const u8 { + const uri = std.Uri{ + .scheme = "file", + .host = .{ .raw = "" }, + .path = .{ .raw = self.path }, + }; + var buf = std.ArrayList(u8).init(alloc); + errdefer buf.deinit(); + try uri.writeToStream(.{ .scheme = true, .authority = true, .path = true }, buf.writer()); + return buf.toOwnedSlice(); + } +}; + +/// The `list-themes` command is used to preview or list all the available +/// themes for Ghostty. +/// +/// If this command is run from a TTY, a TUI preview of the themes will be +/// shown. While in the preview, `F1` will bring up a help screen and `ESC` will +/// exit the preview. Other keys that can be used to navigate the preview are +/// listed in the help screen. +/// +/// If this command is not run from a TTY, or the output is piped to another +/// command, a plain list of theme names will be printed to the screen. A plain +/// list can be forced using the `--plain` CLI flag. /// /// Two different directories will be searched for themes. /// @@ -48,7 +85,7 @@ pub const Options = struct { /// Flags: /// /// * `--path`: Show the full path to the theme. -/// * `--preview`: Show a short preview of the theme colors. +/// * `--plain`: Force a plain listing of themes. pub fn run(gpa_alloc: std.mem.Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); @@ -69,16 +106,6 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { try stderr.print("Could not find the Ghostty resources directory. Please ensure " ++ "that Ghostty is installed correctly.\n", .{}); - const ThemeListElement = struct { - location: themepkg.Location, - path: []const u8, - theme: []const u8, - fn lessThan(_: void, lhs: @This(), rhs: @This()) bool { - // TODO: use Unicode-aware comparison - return std.ascii.orderIgnoreCase(lhs.theme, rhs.theme) == .lt; - } - }; - var count: usize = 0; var themes = std.ArrayList(ThemeListElement).init(alloc); @@ -98,55 +125,16 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { var walker = dir.iterate(); while (try walker.next()) |entry| { - if (entry.kind != .file) continue; - count += 1; - try themes.append(.{ - .location = loc.location, - .path = try std.fs.path.join(alloc, &.{ loc.dir, entry.name }), - .theme = try alloc.dupe(u8, entry.name), - }); - } - } - - std.mem.sortUnstable(ThemeListElement, themes.items, {}, ThemeListElement.lessThan); - - for (themes.items) |theme| { - if (opts.path) - try stdout.print("{s} ({s}) {s}\n", .{ theme.theme, @tagName(theme.location), theme.path }) - else - try stdout.print("{s} ({s})\n", .{ theme.theme, @tagName(theme.location) }); - - if (opts.preview) { - var config = try Config.default(gpa_alloc); - defer config.deinit(); - if (config.loadFile(config._arena.?.allocator(), theme.path)) |_| { - if (!config._errors.empty()) { - try stderr.print(" Problems were encountered trying to load the theme:\n", .{}); - for (config._errors.list.items) |err| { - try stderr.print(" {s}\n", .{err.message}); - } - } - try stdout.print("\n ", .{}); - for (0..8) |i| { - try stdout.print(" {d:2} \x1b[38;2;{d};{d};{d}m██\x1b[0m", .{ - i, - config.palette.value[i].r, - config.palette.value[i].g, - config.palette.value[i].b, + switch (entry.kind) { + .file, .sym_link => { + count += 1; + try themes.append(.{ + .location = loc.location, + .path = try std.fs.path.join(alloc, &.{ loc.dir, entry.name }), + .theme = try alloc.dupe(u8, entry.name), }); - } - try stdout.print("\n ", .{}); - for (8..16) |i| { - try stdout.print(" {d:2} \x1b[38;2;{d};{d};{d}m██\x1b[0m", .{ - i, - config.palette.value[i].r, - config.palette.value[i].g, - config.palette.value[i].b, - }); - } - try stdout.print("\n\n", .{}); - } else |err| { - try stderr.print("unable to load {s}: {}", .{ theme.path, err }); + }, + else => {}, } } } @@ -156,5 +144,1364 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { return 1; } + std.mem.sortUnstable(ThemeListElement, themes.items, {}, ThemeListElement.lessThan); + + if (tui.can_pretty_print and !opts.plain and std.posix.isatty(std.io.getStdOut().handle)) { + try preview(gpa_alloc, themes.items); + return 0; + } + + for (themes.items) |theme| { + if (opts.path) + try stdout.print("{s} ({s}) {s}\n", .{ theme.theme, @tagName(theme.location), theme.path }) + else + try stdout.print("{s} ({s})\n", .{ theme.theme, @tagName(theme.location) }); + } + return 0; } + +const Event = union(enum) { + key_press: vaxis.Key, + mouse: vaxis.Mouse, + color_scheme: vaxis.Color.Scheme, + winsize: vaxis.Winsize, +}; + +const Preview = struct { + allocator: std.mem.Allocator, + should_quit: bool, + tty: vaxis.Tty, + vx: vaxis.Vaxis, + mouse: ?vaxis.Mouse, + themes: []ThemeListElement, + filtered: std.ArrayList(usize), + current: usize, + window: usize, + hex: bool, + mode: enum { + normal, + help, + search, + }, + color_scheme: vaxis.Color.Scheme, + text_input: vaxis.widgets.TextInput, + + pub fn init(allocator: std.mem.Allocator, themes: []ThemeListElement) !*Preview { + const self = try allocator.create(Preview); + + self.* = .{ + .allocator = allocator, + .should_quit = false, + .tty = try vaxis.Tty.init(), + .vx = try vaxis.init(allocator, .{}), + .mouse = null, + .themes = themes, + .filtered = try std.ArrayList(usize).initCapacity(allocator, themes.len), + .current = 0, + .window = 0, + .hex = false, + .mode = .normal, + .color_scheme = .light, + .text_input = vaxis.widgets.TextInput.init(allocator, &self.vx.unicode), + }; + + for (0..themes.len) |i| { + try self.filtered.append(i); + } + + return self; + } + + pub fn deinit(self: *Preview) void { + const allocator = self.allocator; + self.filtered.deinit(); + self.text_input.deinit(); + self.vx.deinit(allocator, self.tty.anyWriter()); + self.tty.deinit(); + allocator.destroy(self); + } + + pub fn run(self: *Preview) !void { + var loop: vaxis.Loop(Event) = .{ + .tty = &self.tty, + .vaxis = &self.vx, + }; + try loop.init(); + try loop.start(); + + try self.vx.enterAltScreen(self.tty.anyWriter()); + try self.vx.setTitle(self.tty.anyWriter(), "👻 Ghostty Theme Preview 👻"); + try self.vx.queryTerminal(self.tty.anyWriter(), 1 * std.time.ns_per_s); + try self.vx.setMouseMode(self.tty.anyWriter(), true); + if (self.vx.caps.color_scheme_updates) + try self.vx.subscribeToColorSchemeUpdates(self.tty.anyWriter()); + + while (!self.should_quit) { + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + loop.pollEvent(); + while (loop.tryEvent()) |event| { + try self.update(event, alloc); + } + try self.draw(alloc); + + var buffered = self.tty.bufferedWriter(); + try self.vx.render(buffered.writer().any()); + try buffered.flush(); + } + } + + fn updateFiltered(self: *Preview) !void { + const relative = self.current -| self.window; + const selected = self.themes[self.filtered.items[self.current]].theme; + + const hash_algorithm = std.hash.Wyhash; + + const old_digest = d: { + var hash = hash_algorithm.init(0); + for (self.filtered.items) |item| + hash.update(std.mem.asBytes(&item)); + break :d hash.final(); + }; + + self.filtered.clearRetainingCapacity(); + + if (self.text_input.buf.realLength() > 0) { + const first_half = self.text_input.buf.firstHalf(); + const second_half = self.text_input.buf.secondHalf(); + + const buffer = try self.allocator.alloc(u8, first_half.len + second_half.len); + defer self.allocator.free(buffer); + + @memcpy(buffer[0..first_half.len], first_half); + @memcpy(buffer[first_half.len..], second_half); + + const string = try std.ascii.allocLowerString(self.allocator, buffer); + defer self.allocator.free(string); + + var tokens = std.ArrayList([]const u8).init(self.allocator); + defer tokens.deinit(); + + var it = std.mem.tokenizeScalar(u8, string, ' '); + while (it.next()) |token| try tokens.append(token); + + for (self.themes, 0..) |*theme, i| { + theme.rank = zf.rank(theme.theme, tokens.items, false, true); + if (theme.rank) |_| + try self.filtered.append(i); + } + } else { + for (self.themes, 0..) |*theme, i| { + try self.filtered.append(i); + theme.rank = null; + } + } + + const new_digest = d: { + var hash = hash_algorithm.init(0); + for (self.filtered.items) |item| + hash.update(std.mem.asBytes(&item)); + break :d hash.final(); + }; + + if (old_digest == new_digest) return; + + if (self.filtered.items.len == 0) { + self.current = 0; + self.window = 0; + return; + } + + self.current, self.window = current: { + for (self.filtered.items, 0..) |index, i| { + if (std.mem.eql(u8, self.themes[index].theme, selected)) + break :current .{ i, i -| relative }; + } + break :current .{ 0, 0 }; + }; + } + + fn up(self: *Preview, count: usize) void { + if (self.filtered.items.len == 0) { + self.current = 0; + return; + } + self.current -|= count; + } + + fn down(self: *Preview, count: usize) void { + if (self.filtered.items.len == 0) { + self.current = 0; + return; + } + self.current += count; + if (self.current >= self.filtered.items.len) + self.current = self.filtered.items.len - 1; + } + + pub fn update(self: *Preview, event: Event, alloc: std.mem.Allocator) !void { + switch (event) { + .key_press => |key| { + if (key.matches('c', .{ .ctrl = true })) + self.should_quit = true; + switch (self.mode) { + .normal => { + if (key.matchesAny(&.{ 'q', vaxis.Key.escape }, .{})) + self.should_quit = true; + if (key.matchesAny(&.{ '?', vaxis.Key.f1 }, .{})) + self.mode = .help; + if (key.matches('h', .{ .ctrl = true })) + self.mode = .help; + if (key.matches('/', .{})) + self.mode = .search; + if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) { + self.text_input.buf.clearRetainingCapacity(); + try self.updateFiltered(); + } + if (key.matchesAny(&.{ vaxis.Key.home, vaxis.Key.kp_home }, .{})) + self.current = 0; + if (key.matchesAny(&.{ vaxis.Key.end, vaxis.Key.kp_end }, .{})) + self.current = self.filtered.items.len - 1; + if (key.matchesAny(&.{ 'j', '+', vaxis.Key.down, vaxis.Key.kp_down, vaxis.Key.kp_add }, .{})) + self.down(1); + if (key.matchesAny(&.{ vaxis.Key.page_down, vaxis.Key.kp_down }, .{})) + self.down(20); + if (key.matchesAny(&.{ 'k', '-', vaxis.Key.up, vaxis.Key.kp_up, vaxis.Key.kp_subtract }, .{})) + self.up(1); + if (key.matchesAny(&.{ vaxis.Key.page_up, vaxis.Key.kp_page_up }, .{})) + self.up(20); + if (key.matchesAny(&.{ 'h', 'x' }, .{})) + self.hex = true; + if (key.matches('d', .{})) + self.hex = false; + if (key.matches('c', .{})) + try self.vx.copyToSystemClipboard( + self.tty.anyWriter(), + self.themes[self.filtered.items[self.current]].theme, + alloc, + ); + if (key.matches('c', .{ .shift = true })) + try self.vx.copyToSystemClipboard( + self.tty.anyWriter(), + self.themes[self.filtered.items[self.current]].path, + alloc, + ); + }, + .help => { + if (key.matches('q', .{})) + self.should_quit = true; + if (key.matchesAny(&.{ '?', vaxis.Key.escape, vaxis.Key.f1 }, .{})) + self.mode = .normal; + if (key.matches('h', .{ .ctrl = true })) + self.mode = .normal; + }, + .search => search: { + if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter }, .{})) { + self.mode = .normal; + break :search; + } + if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) { + self.text_input.clearRetainingCapacity(); + try self.updateFiltered(); + break :search; + } + try self.text_input.update(.{ .key_press = key }); + try self.updateFiltered(); + }, + } + }, + .color_scheme => |color_scheme| self.color_scheme = color_scheme, + .mouse => |mouse| self.mouse = mouse, + .winsize => |ws| try self.vx.resize(self.allocator, self.tty.anyWriter(), ws), + } + } + + pub fn ui_fg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } }, + .dark => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } }, + }; + } + + pub fn ui_bg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } }, + .dark => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } }, + }; + } + + pub fn ui_standard(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_fg(), + .bg = self.ui_bg(), + }; + } + + pub fn ui_hover_bg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xbb, 0xbb, 0xbb } }, + .dark => .{ .rgb = [_]u8{ 0x22, 0x22, 0x22 } }, + }; + } + + pub fn ui_highlighted(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_fg(), + .bg = self.ui_hover_bg(), + }; + } + + pub fn ui_selected_fg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } }, + .dark => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } }, + }; + } + + pub fn ui_selected_bg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xaa, 0xaa, 0xaa } }, + .dark => .{ .rgb = [_]u8{ 0x33, 0x33, 0x33 } }, + }; + } + + pub fn ui_selected(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_selected_fg(), + .bg = self.ui_selected_bg(), + }; + } + + pub fn ui_err_fg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } }, + .dark => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } }, + }; + } + + pub fn ui_err(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_err_fg(), + .bg = self.ui_bg(), + }; + } + + pub fn draw(self: *Preview, alloc: std.mem.Allocator) !void { + const win = self.vx.window(); + win.clear(); + + self.vx.setMouseShape(.default); + + const theme_list = win.child(.{ + .x_off = 0, + .y_off = 0, + .width = .{ .limit = 32 }, + .height = .{ .limit = win.height }, + }); + + if (self.filtered.items.len == 0) { + self.current = 0; + self.window = 0; + } else { + const start = self.window; + const end = self.window + theme_list.height - 1; + if (self.current > end) + self.window = self.current - theme_list.height + 1; + if (self.current < start) + self.window = self.current; + if (self.window >= self.filtered.items.len) + self.window = self.filtered.items.len - 1; + } + + var highlight: ?usize = null; + + if (self.mouse) |mouse| { + self.mouse = null; + if (self.mode == .normal) { + if (mouse.button == .wheel_up) { + self.up(1); + } + if (mouse.button == .wheel_down) { + self.down(1); + } + if (theme_list.hasMouse(mouse)) |_| { + if (mouse.button == .left and mouse.type == .release) { + self.current = self.window + mouse.row; + } + highlight = mouse.row; + } + } + } + + theme_list.fill(.{ .style = self.ui_standard() }); + + for (0..theme_list.height) |row| { + const index = self.window + row; + if (index >= self.filtered.items.len) break; + + const theme = self.themes[self.filtered.items[index]]; + + const style: enum { normal, highlighted, selected } = style: { + if (index == self.current) break :style .selected; + if (highlight) |h| if (h == row) break :style .highlighted; + break :style .normal; + }; + + if (style == .selected) { + _ = try theme_list.printSegment( + .{ + .text = "❯ ", + .style = self.ui_selected(), + }, + .{ + .row_offset = row, + .col_offset = 0, + }, + ); + } + _ = try theme_list.printSegment( + .{ + .text = theme.theme, + .style = switch (style) { + .normal => self.ui_standard(), + .highlighted => self.ui_highlighted(), + .selected => self.ui_selected(), + }, + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, + .{ + .row_offset = row, + .col_offset = 2, + }, + ); + if (style == .selected) { + if (theme.theme.len < theme_list.width - 4) { + for (2 + theme.theme.len..theme_list.width - 2) |i| + _ = try theme_list.printSegment( + .{ + .text = " ", + .style = self.ui_selected(), + }, + .{ + .row_offset = row, + .col_offset = i, + }, + ); + } + _ = try theme_list.printSegment( + .{ + .text = " ❮", + .style = self.ui_selected(), + }, + .{ + .row_offset = row, + .col_offset = theme_list.width - 2, + }, + ); + } + } + + try self.drawPreview(alloc, win, theme_list.x_off + theme_list.width); + + switch (self.mode) { + .normal => { + win.hideCursor(); + }, + .help => { + win.hideCursor(); + const width = 60; + const height = 20; + const child = win.child( + .{ + .x_off = win.width / 2 -| width / 2, + .y_off = win.height / 2 -| height / 2, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = height, + }, + .border = .{ + .where = .all, + .style = self.ui_standard(), + }, + }, + ); + + child.fill(.{ .style = self.ui_standard() }); + + const key_help = [_]struct { keys: []const u8, help: []const u8 }{ + .{ .keys = "^C, q, ESC", .help = "Quit." }, + .{ .keys = "F1, ?, ^H", .help = "Toggle help window." }, + .{ .keys = "k, ↑", .help = "Move up 1 theme." }, + .{ .keys = "ScrollUp", .help = "Move up 1 theme." }, + .{ .keys = "PgUp", .help = "Move up 20 themes." }, + .{ .keys = "j, ↓", .help = "Move down 1 theme." }, + .{ .keys = "ScrollDown", .help = "Move down 1 theme." }, + .{ .keys = "PgDown", .help = "Move down 20 themes." }, + .{ .keys = "h, x", .help = "Show palette numbers in hexadecimal." }, + .{ .keys = "d", .help = "Show palette numbers in decimal." }, + .{ .keys = "c", .help = "Copy theme name to the clipboard." }, + .{ .keys = "C", .help = "Copy theme path to the clipboard." }, + .{ .keys = "Home", .help = "Go to the start of the list." }, + .{ .keys = "End", .help = "Go to the end of the list." }, + .{ .keys = "/", .help = "Start search." }, + .{ .keys = "^X, ^/", .help = "Clear search." }, + .{ .keys = "⏎", .help = "Close search window." }, + }; + + for (key_help, 0..) |help, i| { + _ = try child.printSegment( + .{ + .text = help.keys, + .style = self.ui_standard(), + }, + .{ + .row_offset = i + 1, + .col_offset = 2, + }, + ); + _ = try child.printSegment( + .{ + .text = "—", + .style = self.ui_standard(), + }, + .{ + .row_offset = i + 1, + .col_offset = 15, + }, + ); + _ = try child.printSegment( + .{ + .text = help.help, + .style = self.ui_standard(), + }, + .{ + .row_offset = i + 1, + .col_offset = 17, + }, + ); + } + }, + .search => { + const child = win.child(.{ + .x_off = 20, + .y_off = win.height - 5, + .width = .{ + .limit = win.width - 40, + }, + .height = .{ + .limit = 3, + }, + .border = .{ + .where = .all, + .style = self.ui_standard(), + }, + }); + child.fill(.{ .style = self.ui_standard() }); + self.text_input.drawWithStyle(child, self.ui_standard()); + }, + } + } + + pub fn drawPreview(self: *Preview, alloc: std.mem.Allocator, win: vaxis.Window, x_off: usize) !void { + const width = win.width - x_off; + + const theme = self.themes[self.filtered.items[self.current]]; + + var config = try Config.default(alloc); + defer config.deinit(); + + config.loadFile(config._arena.?.allocator(), theme.path) catch |err| { + const child = win.child( + .{ + .x_off = x_off, + .y_off = 0, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = win.height, + }, + }, + ); + child.fill(.{ .style = self.ui_standard() }); + const middle = child.height / 2; + { + const text = try std.fmt.allocPrint(alloc, "Unable to open {s} from:", .{theme.theme}); + _ = try child.printSegment( + .{ + .text = text, + .style = self.ui_err(), + }, + .{ + .row_offset = middle -| 1, + .col_offset = child.width / 2 -| text.len / 2, + }, + ); + } + { + _ = try child.printSegment( + .{ + .text = theme.path, + .style = self.ui_err(), + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, + .{ + .row_offset = middle, + .col_offset = child.width / 2 -| theme.path.len / 2, + }, + ); + } + { + const text = try std.fmt.allocPrint(alloc, "{}", .{err}); + _ = try child.printSegment( + .{ + .text = text, + .style = self.ui_err(), + }, + .{ + .row_offset = middle + 1, + .col_offset = child.width / 2 -| text.len / 2, + }, + ); + } + return; + }; + + var next_start: usize = 0; + + const fg: vaxis.Color = .{ + .rgb = [_]u8{ + config.foreground.r, + config.foreground.g, + config.foreground.b, + }, + }; + const bg: vaxis.Color = .{ + .rgb = [_]u8{ + config.background.r, + config.background.g, + config.background.b, + }, + }; + const standard: vaxis.Style = .{ + .fg = fg, + .bg = bg, + }; + const standard_bold: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .bold = true, + }; + const standard_italic: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .italic = true, + }; + const standard_bold_italic: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .bold = true, + .italic = true, + }; + const standard_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .single, + }; + const standard_double_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .double, + }; + const standard_dashed_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .dashed, + }; + const standard_curly_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .curly, + }; + const standard_dotted_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .dotted, + }; + + { + const child = win.child( + .{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = 4, + }, + }, + ); + child.fill(.{ .style = standard }); + _ = try child.printSegment( + .{ + .text = theme.theme, + .style = standard_bold_italic, + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, + .{ + .row_offset = 1, + .col_offset = child.width / 2 -| theme.theme.len / 2, + }, + ); + _ = try child.printSegment( + .{ + .text = theme.path, + .style = standard, + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, + .{ + .row_offset = 2, + .col_offset = child.width / 2 -| theme.path.len / 2, + .wrap = .none, + }, + ); + next_start += child.height; + } + + if (!config._errors.empty()) { + const child = win.child( + .{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = if (config._errors.empty()) 0 else 2 + config._errors.list.items.len, + }, + }, + ); + { + const text = "Problems were encountered trying to load the theme:"; + _ = try child.printSegment( + .{ + .text = text, + .style = self.ui_err(), + }, + .{ + .row_offset = 0, + .col_offset = child.width / 2 -| (text.len / 2), + }, + ); + } + for (config._errors.list.items, 0..) |err, i| { + _ = try child.printSegment( + .{ + .text = err.message, + .style = self.ui_err(), + }, + .{ + .row_offset = 2 + i, + .col_offset = 2, + }, + ); + } + next_start += child.height; + } + { + const child = win.child(.{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = 6, + }, + }); + + child.fill(.{ .style = standard }); + + for (0..16) |i| { + const r = i / 8; + const c = i % 8; + const text = if (self.hex) + try std.fmt.allocPrint(alloc, " {x:0>2}", .{i}) + else + try std.fmt.allocPrint(alloc, "{d:3}", .{i}); + _ = try child.printSegment( + .{ + .text = text, + .style = standard, + }, + .{ + .row_offset = 3 * r, + .col_offset = c * 8, + }, + ); + _ = try child.printSegment( + .{ + .text = "████", + .style = .{ + .fg = color(config, i), + .bg = bg, + }, + }, + .{ + .row_offset = 3 * r, + .col_offset = 4 + c * 8, + }, + ); + _ = try child.printSegment( + .{ + .text = "████", + .style = .{ + .fg = color(config, i), + .bg = bg, + }, + }, + .{ + .row_offset = 3 * r + 1, + .col_offset = 4 + c * 8, + }, + ); + } + next_start += child.height; + } + { + const child = win.child( + .{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = 24, + }, + }, + ); + const bold: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .bold = true, + }; + const color1: vaxis.Style = .{ + .fg = color(config, 1), + .bg = bg, + }; + const color2: vaxis.Style = .{ + .fg = color(config, 2), + .bg = bg, + }; + const color3: vaxis.Style = .{ + .fg = color(config, 3), + .bg = bg, + }; + const color4: vaxis.Style = .{ + .fg = color(config, 4), + .bg = bg, + }; + const color5: vaxis.Style = .{ + .fg = color(config, 5), + .bg = bg, + }; + const color6: vaxis.Style = .{ + .fg = color(config, 6), + .bg = bg, + }; + const color6ul: vaxis.Style = .{ + .fg = color(config, 6), + .bg = bg, + .ul_style = .single, + }; + const color10: vaxis.Style = .{ + .fg = color(config, 10), + .bg = bg, + }; + const color12: vaxis.Style = .{ + .fg = color(config, 12), + .bg = bg, + }; + const color238: vaxis.Style = .{ + .fg = color(config, 238), + .bg = bg, + }; + child.fill(.{ .style = standard }); + _ = try child.print( + &.{ + .{ .text = "→", .style = color2 }, + .{ .text = " ", .style = standard }, + .{ .text = "bat", .style = color4 }, + .{ .text = " ", .style = standard }, + .{ .text = "ziggzagg.zig", .style = color6ul }, + }, + .{ + .row_offset = 0, + .col_offset = 2, + }, + ); + { + _ = try child.print( + &.{ + .{ + .text = "───────┬", + .style = color238, + }, + }, + .{ + .row_offset = 1, + .col_offset = 2, + }, + ); + if (child.width > 10) { + for (10..child.width) |col| { + _ = try child.print( + &.{ + .{ + .text = "─", + .style = color238, + }, + }, + .{ + .row_offset = 1, + .col_offset = col, + }, + ); + } + } + } + _ = try child.print( + &.{ + .{ + .text = " │ ", + .style = color238, + }, + + .{ + .text = "File: ", + .style = standard, + }, + + .{ + .text = "ziggzag.zig", + .style = bold, + }, + }, + .{ + .row_offset = 2, + .col_offset = 2, + }, + ); + { + _ = try child.print( + &.{ + .{ + .text = "───────┼", + .style = color238, + }, + }, + .{ + .row_offset = 3, + .col_offset = 2, + }, + ); + if (child.width > 10) { + for (10..child.width) |col| { + _ = try child.print( + &.{ + .{ + .text = "─", + .style = color238, + }, + }, + .{ + .row_offset = 3, + .col_offset = col, + }, + ); + } + } + } + _ = try child.print( + &.{ + .{ .text = " 1 │ ", .style = color238 }, + .{ .text = "const", .style = color5 }, + .{ .text = " std ", .style = standard }, + .{ .text = "= @import", .style = color5 }, + .{ .text = "(", .style = standard }, + .{ .text = "\"std\"", .style = color10 }, + .{ .text = ");", .style = standard }, + }, + .{ + .row_offset = 4, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 2 │", .style = color238 }, + }, + .{ + .row_offset = 5, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 3 │ ", .style = color238 }, + .{ .text = "pub ", .style = color5 }, + .{ .text = "fn ", .style = color12 }, + .{ .text = "main", .style = color2 }, + .{ .text = "() ", .style = standard }, + .{ .text = "!", .style = color5 }, + .{ .text = "void", .style = color12 }, + .{ .text = " {", .style = standard }, + }, + .{ + .row_offset = 6, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 4 │ ", .style = color238 }, + .{ .text = "const ", .style = color5 }, + .{ .text = "stdout ", .style = standard }, + .{ .text = "=", .style = color5 }, + .{ .text = " std.io.getStdOut().writer();", .style = standard }, + }, + .{ + .row_offset = 7, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 5 │ ", .style = color238 }, + .{ .text = "var ", .style = color5 }, + .{ .text = "i:", .style = standard }, + .{ .text = " usize", .style = color12 }, + .{ .text = " =", .style = color5 }, + .{ .text = " 1", .style = color4 }, + .{ .text = ";", .style = standard }, + }, + .{ + .row_offset = 8, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 6 │ ", .style = color238 }, + .{ .text = "while ", .style = color5 }, + .{ .text = "(i ", .style = standard }, + .{ .text = "<= ", .style = color5 }, + .{ .text = "16", .style = color4 }, + .{ .text = ") : (i ", .style = standard }, + .{ .text = "+= ", .style = color5 }, + .{ .text = "1", .style = color4 }, + .{ .text = ") {", .style = standard }, + }, + .{ + .row_offset = 9, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 7 │ ", .style = color238 }, + .{ .text = "if ", .style = color5 }, + .{ .text = "(i ", .style = standard }, + .{ .text = "% ", .style = color5 }, + .{ .text = "15 ", .style = color4 }, + .{ .text = "== ", .style = color5 }, + .{ .text = "0", .style = color4 }, + .{ .text = ") {", .style = standard }, + }, + .{ + .row_offset = 10, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 8 │ ", .style = color238 }, + .{ .text = "try ", .style = color5 }, + .{ .text = "stdout.writeAll(", .style = standard }, + .{ .text = "\"ZiggZagg", .style = color10 }, + .{ .text = "\\n", .style = color12 }, + .{ .text = "\"", .style = color10 }, + .{ .text = ");", .style = standard }, + }, + .{ + .row_offset = 11, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 9 │ ", .style = color238 }, + .{ .text = "} ", .style = standard }, + .{ .text = "else if ", .style = color5 }, + .{ .text = "(i ", .style = standard }, + .{ .text = "% ", .style = color5 }, + .{ .text = "3 ", .style = color4 }, + .{ .text = "== ", .style = color5 }, + .{ .text = "0", .style = color4 }, + .{ .text = ") {", .style = standard }, + }, + .{ + .row_offset = 12, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 10 │ ", .style = color238 }, + .{ .text = "try ", .style = color5 }, + .{ .text = "stdout.writeAll(", .style = standard }, + .{ .text = "\"Zigg", .style = color10 }, + .{ .text = "\\n", .style = color12 }, + .{ .text = "\"", .style = color10 }, + .{ .text = ");", .style = standard }, + }, + .{ + .row_offset = 13, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 11 │ ", .style = color238 }, + .{ .text = "} ", .style = standard }, + .{ .text = "else if ", .style = color5 }, + .{ .text = "(i ", .style = standard }, + .{ .text = "% ", .style = color5 }, + .{ .text = "5 ", .style = color4 }, + .{ .text = "== ", .style = color5 }, + .{ .text = "0", .style = color4 }, + .{ .text = ") {", .style = standard }, + }, + .{ + .row_offset = 14, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 12 │ ", .style = color238 }, + .{ .text = "try ", .style = color5 }, + .{ .text = "stdout.writeAll(", .style = standard }, + .{ .text = "\"Zagg", .style = color10 }, + .{ .text = "\\n", .style = color12 }, + .{ .text = "\"", .style = color10 }, + .{ .text = ");", .style = standard }, + }, + .{ + .row_offset = 15, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 13 │ ", .style = color238 }, + .{ .text = "} ", .style = standard }, + .{ .text = "else ", .style = color5 }, + .{ .text = "{", .style = standard }, + }, + .{ + .row_offset = 16, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 14 │ ", .style = color238 }, + .{ .text = "try ", .style = color5 }, + .{ .text = "stdout.print(", .style = standard }, + .{ .text = "\"{d}", .style = color10 }, + .{ .text = "\\n", .style = color12 }, + .{ .text = "\"", .style = color10 }, + .{ .text = ", .{i});", .style = standard }, + }, + .{ + .row_offset = 17, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 15 │ ", .style = color238 }, + .{ .text = "}", .style = standard }, + }, + .{ + .row_offset = 18, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 16 │ ", .style = color238 }, + .{ .text = "}", .style = standard }, + }, + .{ + .row_offset = 19, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 17 │ ", .style = color238 }, + .{ .text = "}", .style = standard }, + }, + .{ + .row_offset = 20, + .col_offset = 2, + }, + ); + { + _ = try child.print( + &.{ + .{ + .text = "───────┴", + .style = color238, + }, + }, + .{ + .row_offset = 21, + .col_offset = 2, + }, + ); + if (child.width > 10) { + for (10..child.width) |col| { + _ = try child.print( + &.{ + .{ + .text = "─", + .style = color238, + }, + }, + .{ + .row_offset = 21, + .col_offset = col, + }, + ); + } + } + } + _ = try child.print( + &.{ + .{ .text = "ghostty ", .style = color6 }, + .{ .text = "on ", .style = standard }, + .{ .text = " main ", .style = color4 }, + .{ .text = "[+] ", .style = color1 }, + .{ .text = "via ", .style = standard }, + .{ .text = " v0.13.0 ", .style = color3 }, + .{ .text = "via ", .style = standard }, + .{ .text = " impure (ghostty-env)", .style = color4 }, + }, + .{ + .row_offset = 22, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = "✦ ", .style = color4 }, + .{ .text = "at ", .style = standard }, + .{ .text = "10:36:15 ", .style = color3 }, + .{ .text = "→", .style = color2 }, + }, + .{ + .row_offset = 23, + .col_offset = 2, + }, + ); + next_start += child.height; + } + if (next_start < win.height) { + const child = win.child( + .{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = win.height - next_start, + }, + }, + ); + child.fill(.{ .style = standard }); + var it = std.mem.splitAny(u8, lorem_ipsum, " \n"); + var row: usize = 1; + var col: usize = 2; + while (row < child.height) { + const word = it.next() orelse line: { + it.reset(); + break :line it.next() orelse unreachable; + }; + if (col + word.len > child.width) { + row += 1; + col = 2; + } + const style: vaxis.Style = style: { + if (std.mem.eql(u8, "ipsum", word)) break :style .{ .fg = color(config, 2), .bg = bg }; + if (std.mem.eql(u8, "consectetur", word)) break :style standard_bold; + if (std.mem.eql(u8, "reprehenderit", word)) break :style standard_italic; + if (std.mem.eql(u8, "Praesent", word)) break :style standard_bold_italic; + if (std.mem.eql(u8, "auctor", word)) break :style standard_underline; + if (std.mem.eql(u8, "dui", word)) break :style standard_double_underline; + if (std.mem.eql(u8, "erat", word)) break :style standard_dashed_underline; + if (std.mem.eql(u8, "enim", word)) break :style standard_dotted_underline; + if (std.mem.eql(u8, "odio", word)) break :style standard_curly_underline; + break :style standard; + }; + _ = try child.printSegment( + .{ + .text = word, + .style = style, + }, + .{ + .row_offset = row, + .col_offset = col, + }, + ); + col += word.len + 1; + } + } + } +}; + +fn color(config: Config, palette: usize) vaxis.Color { + return .{ + .rgb = [_]u8{ + config.palette.value[palette].r, + config.palette.value[palette].g, + config.palette.value[palette].b, + }, + }; +} + +const lorem_ipsum = @embedFile("lorem_ipsum.txt"); + +fn preview(allocator: std.mem.Allocator, themes: []ThemeListElement) !void { + var app = try Preview.init(allocator, themes); + defer app.deinit(); + try app.run(); +} diff --git a/src/cli/lorem_ipsum.txt b/src/cli/lorem_ipsum.txt new file mode 100644 index 000000000..13b9a52c1 --- /dev/null +++ b/src/cli/lorem_ipsum.txt @@ -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. diff --git a/src/config/Config.zig b/src/config/Config.zig index 9739b36b8..0f5e9b81b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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, diff --git a/src/crash/main.zig b/src/crash/main.zig index 1ac971851..5f9aa96c5 100644 --- a/src/crash/main.zig +++ b/src/crash/main.zig @@ -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; diff --git a/src/crash/minidump.zig b/src/crash/minidump.zig new file mode 100644 index 000000000..0abd67eae --- /dev/null +++ b/src/crash/minidump.zig @@ -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()); +} diff --git a/src/crash/minidump/external.zig b/src/crash/minidump/external.zig new file mode 100644 index 000000000..451810883 --- /dev/null +++ b/src/crash/minidump/external.zig @@ -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, +}; diff --git a/src/crash/minidump/reader.zig b/src/crash/minidump/reader.zig new file mode 100644 index 000000000..f792d6670 --- /dev/null +++ b/src/crash/minidump/reader.zig @@ -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); + } +} diff --git a/src/crash/minidump/stream.zig b/src/crash/minidump/stream.zig new file mode 100644 index 000000000..00ec6b042 --- /dev/null +++ b/src/crash/minidump/stream.zig @@ -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()); +} diff --git a/src/crash/minidump/stream_threadlist.zig b/src/crash/minidump/stream_threadlist.zig new file mode 100644 index 000000000..51f3f9d4c --- /dev/null +++ b/src/crash/minidump/stream_threadlist.zig @@ -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); + } +} diff --git a/src/crash/testdata/macos.dmp b/src/crash/testdata/macos.dmp new file mode 100644 index 000000000..212cc7e62 Binary files /dev/null and b/src/crash/testdata/macos.dmp differ diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 5e141e053..dacb79476 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -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(); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 5004a040a..3ff9e9ffa 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -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, diff --git a/src/font/sprite/underline.zig b/src/font/sprite/underline.zig index b22db4f52..08e74c781 100644 --- a/src/font/sprite/underline.zig +++ b/src/font/sprite/underline.zig @@ -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; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 37b18f581..5df3ae8e4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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); } diff --git a/src/main.zig b/src/main.zig index a1f8d4a44..895ccfe48 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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; } diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index bb2a27f44..1790711c8 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -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, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index a8f7c385c..760721af3 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -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, diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index 734608c76..b6af33824 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -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); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 705d6ba2d..8d9641bfa 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -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; diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 6a6b8cc36..26c59729a 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -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; diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index 0ef054293..d199711d3 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -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; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index c43bbbb9f..42f12ea07 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -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"); +} diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index b23bd1514..67a4c05ea 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -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 = {} }, diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 87e7e4b2f..5018ced33 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -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); } diff --git a/typos.toml b/typos.toml index 9f2c96f11..a72944e5f 100644 --- a/typos.toml +++ b/typos.toml @@ -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]