46 Commits

Author SHA1 Message Date
Mitchell Hashimoto
15777050f3 apprt/gtk: set the title on the window immediately if set (#8535)
Fixes #5934

This was never confirmed to be a real issue on GTK, but it is
theoretically possible and good hygiene in general. Typically, we'd get
the title through a binding which comes from a bindinggroup which comes
from the active surface in the active tab. All of this takes multiple
event loop ticks to settle, if you will.

This commit changes it so that if an explicit, static title is set, we
set that title on startup before the window is mapped. The syncing still
happens later, but at least the window will have a title from the
initialization.
2025-09-05 12:03:03 -07:00
Mitchell Hashimoto
12bd7baaeb apprt/gtk: set the title on the window immediately if set
Fixes #5934

This was never confirmed to be a real issue on GTK, but it is
theoretically possible and good hygience in general. Typically, we'd get
the title through a binding which comes from a bindinggroup which comes
from the active surface in the active tab. All of this takes multiple
event loop ticks to settle, if you will.

This commit changes it so that if an explicit, static title is set, we
set that title on startup before the window is mapped. The syncing still
happens later, but at least the window will have a title from the
initialization.
2025-09-05 11:52:43 -07:00
Mitchell Hashimoto
0333a6f1d2 apprt/gtk: don't use Stacked for surface error status page (#8534)
Fixes #8533

Replace the usage of `Stacked` for error pages with programmatically
swapping the child of the `adw.Bin`.

I regret to say I don't know the root cause of this. I only know that
the usage of `Stacked` plus `Gtk.Paned` and the way we programmatically
change the paned position and stack child during initialization causes
major issues.

This change isn't without its warts, too, and you can see them heavily
commented in the diff.

(1) We have to workaround a GTK template double-free bug that is well
known to us: if you bind a template child that is also the direct child
of the template class, GTK does a double free on dispose. We workaround
this by removing our child in dispose. Valgrind verifies the fix.

(2) We have to workaround an issue where setting an `Adw.Bin` child
during a glarea realize causes some kind of critical GTK error that
results in a hard crash. We delay changing our bin child to an idle
tick.
2025-09-05 11:26:45 -07:00
Mitchell Hashimoto
ef822612d3 apprt/gtk: don't use Stacked for surface error status page
Fixes #8533

Replace the usage of `Stacked` for error pages with programmatically
swapping the child of the `adw.Bin`.

I regret to say I don't know the root cause of this. I only know that
the usage of `Stacked` plus `Gtk.Paned` and the way we programmatically 
change the paned position and stack child during initialization causes
major issues.

This change isn't without its warts, too, and you can see them heavily
commented in the diff. 

(1) We have to workaround a GTK template double-free bug that is well known 
to us: if you bind a template child that is also the direct child of the 
template class, GTK does a double free on dispose. We workaround this by
removing our child in dispose. Valgrind verifies the fix.

(2) We have to workaround an issue where setting an `Adw.Bin` child
during a glarea realize causes some kind of critical GTK error that
results in a hard crash. We delay changing our bin child to an idle
tick.
2025-09-05 11:14:53 -07:00
Jeffrey C. Ollie
a88e6cd428 renderer: add LUT-based implementation of isSymbol (#8528)
The LUT-based lookup gives a ~20%-30% speedup over the "naive" isSymbol
implementation.

<img width="1206" height="730" alt="Screenshot From 2025-09-04 22-45-10"
src="https://github.com/user-attachments/assets/09a8ef3a-8b4b-43ba-963a-849338307251"
/>
<img width="1206" height="730" alt="Screenshot From 2025-09-04 22-41-54"
src="https://github.com/user-attachments/assets/27962a88-f99c-446d-b986-30f526239ba3"
/>

Fixes #8523
2025-09-05 12:04:28 -05:00
Jeffrey C. Ollie
1ef220a679 render: address review feedback
1. `inline` the table get.
2. Delete unused functions on the LUT table.
3. Disable the isSymbol test under valgrind
2025-09-05 11:40:03 -05:00
Mitchell Hashimoto
0492cd16fa gtk-ng: deprecate detection of launch source (#8511)
Detecting the launch source frequently failed because various launchers
fail to sanitize the environment variables that Ghostty used to detect
the launch source. For example, if your desktop environment was launched
by `systemd`, but your desktop environment did not sanitize the
`INVOCATION_ID` or the `JOURNAL_STREAM` environment variables, Ghostty
would assume that it had been launched by `systemd` and behave as such.

This led to complaints about Ghostty not creating new windows when users
expected that it would.

To remedy this, Ghostty no longer does any detection of the launch
source. If your launch source is something other than the CLI, it must
be explicitly speciflied on the CLI. All of Ghostty's default desktop
and service files do this. Users or packagers that create custom desktop
or service files will need to take this into account.

On GTK, the `desktop` setting for `gtk-single-instance` is replaced with
`detect`. `detect` behaves as `gtk-single-instance=true` if one of the
following conditions is true:

1. If no CLI arguments have been set.
2. If `--launched-from` has been set to `desktop`, `dbus`, or `systemd`.

Otherwise `detect` behaves as `gtk-single-instance=false`.
2025-09-05 08:59:32 -07:00
Mitchell Hashimoto
587f47a587 apprt/gtk-ng: clean up our single instance, new window interactions
This removes `launched-from` entirely and moves our `gtk-single-instance`
detection logic to assume true unless we detect CLI instead of assume
false unless we detect desktop/dbus/systemd.

The "assume true" scenario for single instance is desirable because
detecting a CLI instance is much more reliable.

Removing `launched-from` fixes an issue where we had a
difficult-to-understand relationship between `launched-from`,
`gtk-single-instance`, and `initial-window`. Now, only
`gtk-single-instance` has some hueristic logic. And `initial-window`
ALWAYS sends a GTK activation signal regardless of single instance or
not.

As a result, we need to be explicit in our systemd, dbus, desktop files
about what we want Ghostty to do, but everything works as you'd mostly
expect.

Now, if you put plain old `ghostty` in your terminal, you get a new
Ghostty instance. If you put it anywhere else, you get a GTK single
instance activation call (either creates a first instance or opens a new
window in the existing instance). Works for launchers and so on.
2025-09-05 10:17:17 -05:00
Jeffrey C. Ollie
d10e474860 gtk-ng: deprecate detection of launch source
Detecting the launch source frequently failed because various launchers
fail to sanitize the environment variables that Ghostty used to
detect the launch source. For example, if your desktop environment was
launched by `systemd`, but your desktop environment did not sanitize the
`INVOCATION_ID` or the `JOURNAL_STREAM` environment variables, Ghostty
would assume that it had been launched by `systemd` and behave as such.

This led to complaints about Ghostty not creating new windows when users
expected that it would.

To remedy this, Ghostty no longer does any detection of the launch
source. If your launch source is something other than the CLI, it must
be explicitly speciflied on the CLI. All of Ghostty's default desktop
and service files do this. Users or packagers that create custom desktop
or service files will need to take this into account.

On GTK, the `desktop` setting for `gtk-single-instance` is replaced with
`detect`. `detect` behaves as `gtk-single-instance=true` if one of the
following conditions is true:

1. If no CLI arguments have been set.
2. If `--launched-from` has been set to `desktop`, `dbus`, or `systemd`.

Otherwise `detect` behaves as `gtk-single-instance=false`.
2025-09-05 09:54:24 -05:00
Mitchell Hashimoto
4552ea9104 devshell: add poop (#8529) 2025-09-05 07:24:02 -07:00
Mitchell Hashimoto
19b76df80e gtk: the Future is Now (#8531) 2025-09-05 07:23:37 -07:00
Jeffrey C. Ollie
e024b77ad5 drop the new LUT type as no performance advantage detected 2025-09-05 07:58:05 -05:00
Jeffrey C. Ollie
a7da96faee add two LUT-based implementations of isSymbol 2025-09-05 07:58:01 -05:00
Leah Amelia Chen
93debc439c gtk: the Future is Now 2025-09-05 10:10:52 +02:00
Jeffrey C. Ollie
bb78adbd93 devshell: add poop 2025-09-04 23:44:23 -05:00
Mitchell Hashimoto
968b9d536d gtk: nuke the legacy apprt from orbit (#8520)
We don't really have any large outstanding regressions on -ng to warrant
keeping this alive anymore. ¡Adiós!
2025-09-04 20:15:06 -07:00
Leah Amelia Chen
ac52af27d3 gtk: nuke the legacy apprt from orbit
We don't really have any large outstanding regressions on -ng to warrant
keeping this alive anymore. ¡Adiós!
2025-09-05 00:21:41 +02:00
Mitchell Hashimoto
8a2ab8ff21 ai: add gh-issue command to help diagnose GitHub issues (#8526)
This enables agents (namely Amp) to use `/gh-issue <number/url>` to
begin diagnosing a GitHub issue, explaining the problem, and suggesting
a plan of action. This action explicitly prompts the AI to not write
code.

**You can run this manually too,** for testing or curiosity or for
pasting into another LLM. Execute it like any other script:
`.agents/command/gh-issue <issue>`

I've used this manually for months with good results, so now I'm
formalizing it in the repo for other contributors.

Example diagnosing #8523:

https://ampcode.com/threads/T-3e26e8cc-83d1-4e3c-9b5e-02d9111909a7

**I'm going to be highly selective about integrating repository-level
commands.** I think guiding AI contributors in the right direction is
going to result in less AI slop being sent to us. But I want to only add
commands that maintainers use and also can vouch for. The best way to
vouch is to share something like an Amp thread link that shows it
working on real data.

Ironically, no AI was used to write this PR. I did consult Claude Chat
for help on some Nu syntax, but verified it manually with the Nu manual.
2025-09-04 14:02:26 -07:00
Mitchell Hashimoto
ee573ebd36 ai: add gh-issue command to help diagnose GitHub issues
This enables agents (namely Amp) to use `/gh-issue <number/url>` to 
begin diagnosing a GitHub issue, explaining the problem, and suggesting
a plan of action. This action explicitly prompts the AI to not write
code.

I've used this manually for months with good results, so now I'm
formalizing it in the repo for other contributors.

Example diagnosing #8523:

https://ampcode.com/threads/T-3e26e8cc-83d1-4e3c-9b5e-02d9111909a7
2025-09-04 13:56:50 -07:00
Mitchell Hashimoto
e2504d9cbf Fix font handling for bitmap and non-sfnt fonts (#8512)
Fixes #8483, fixes #2991

With this change, `font.face.getMetrics` is now infallible, and real
bitmap fonts are properly handled and can be configured as the primary
font (or used as fallbacks), as long as the backend (FreeType or
CoreText) knows how to interpret them, since we now fall back on the
backend for any metrics we can't extract from sfnt tables (which means
we don't need any to be present in the first place).

Also, doing this uncovered a double-free issue in our FreeType
`renderGlyph` code, which thankfully wasn't too hard to track down and
fix.

> [!NOTE]
> We should vendor a true bitmap font in each of the native formats
supported by each backend and add tests for the metrics being computed
right and the glyphs being rendered correctly. Idk if we wanna block
this PR on that or not.
2025-09-04 12:20:49 -07:00
Mitchell Hashimoto
93744a4002 update zig-gobject to Zig 0.15 version (but still builds on Zig 0.14) (#8522) 2025-09-04 11:10:23 -07:00
Qwerasd
a590194cd7 reduce nesting 2025-09-04 12:04:12 -06:00
Qwerasd
3ac2da99f4 Constrain dingbats + add some more symbol-like blocks to isSymbol (#8510)
This is a very minimal change to fix a regression from when the
constraints were reworked (moved off the GPU and on to the CPU), where
we weren't constraining dingbats anymore. This fixes that and also adds
several other Unicode blocks to also treat as symbols.

In the future (post-1.2) I'll make a more comprehensive set of custom
constraints that differ depending on a hand-rolled list of character
attributes.

|Before|After|
|-|-|
|<img width="1708" height="2096" alt="image"
src="https://github.com/user-attachments/assets/8ce593ac-f032-4c75-80e1-030dd301115d"
/>|<img width="1708" height="2096" alt="image"
src="https://github.com/user-attachments/assets/06dc2307-342d-4ea2-959d-7416eee6ddc2"
/>|

You'll notice that with spaces in between (so each glyph can be up to 2
cells wide), there's very little change with a font with wider cells,
there would be more of a change for people with narrower fonts. The real
star of the show is when they're all shoved together and need to be
constrained to 1 cell; it's utter chaos without constraints, but with
constraints it's nice and orderly (some glyphs get too small to read
well, but there's nothing we can really do about that, users should
really have a font like Iosevka Fixed installed on their system if they
want a good source for terminal-compatible symbol glyphs)
2025-09-04 11:54:23 -06:00
Jeffrey C. Ollie
43ee3cc8c6 update zig-gobject to Zig 0.15 version (but still builds on Zig 0.14) 2025-09-04 11:58:06 -05:00
Qwerasd
aeae54072c fix(font/freetype): mark glyph bitmap as owned if modifying
This caused a malloc fault due to a double free when deiniting the face
if we didn't do this, which makes sense- making it possible to actually
load bitmap fonts revealed this bug which was sitting dormant the whole
time before that ever since I refactored the freetype rasterization.
2025-09-03 21:33:38 -06:00
Qwerasd
5c1d87fda6 fix(font): make face.getMetrics() infallible
Before we had a bad day if we tried to get the metrics of a bitmap font,
which would happen if we ever used one as fallback because we started
doing it for all fonts when we added fallback font scaling. This is a
pretty easy fix and finally allows users to configure true bitmap fonts
as their primary font as long as FreeType/CoreText can handle it.
2025-09-03 21:13:39 -06:00
Qwerasd
7c4b45ecee font: expand set of characters considered symbols
Low hanging fruit of some Unicode blocks that are full of very symbol-y
characters.
2025-09-03 18:06:05 -06:00
Qwerasd
2464728851 font: constrain dingbats
This was a regression, we were giving dingbats an extra cell of
constraint width but not actually applying constraints to them.
2025-09-03 18:01:40 -06:00
Mitchell Hashimoto
c3e7857a2c gtk-ng: pull in latest zig-gobject changes (#8468) 2025-09-03 15:53:35 -07:00
Jeffrey C. Ollie
e67db2a01c gtk-ng: pull in latest zig-gobject changes 2025-09-03 15:49:34 -07:00
Mitchell Hashimoto
befee07f16 macOS: prevent focus loss in hidden titlebar + non-native fullscreen (#8508)
Fixes #8415

When using hidden titlebar with non-native fullscreen, the window would
lose focus after entering the first command. This occurred because:

1. Shell commands update the window title
2. Title changes trigger reapplyHiddenStyle() 
3. reapplyHiddenStyle() re-adds .titled to the style mask
4. Style mask changes during fullscreen confuse AppKit, causing focus
loss

Fixed by adding a guard to skip titlebar restyling while fullscreen is
active, using terminalController.fullscreenStyle.isFullscreen for proper
detection of both native and non-native fullscreen modes.

AI disclaimer:
https://ampcode.com/threads/T-c4ef59cc-1232-4fa5-8f09-c65724ee84d3 But I
hand-modified the end.
2025-09-03 10:19:19 -07:00
Mitchell Hashimoto
c8243ffd99 macOS: prevent focus loss in hidden titlebar + non-native fullscreen
When using hidden titlebar with non-native fullscreen, the window would
lose focus after entering the first command. This occurred because:

1. Shell commands update the window title
2. Title changes trigger reapplyHiddenStyle() 
3. reapplyHiddenStyle() re-adds .titled to the style mask
4. Style mask changes during fullscreen confuse AppKit, causing focus loss

Fixed by adding a guard to skip titlebar restyling while fullscreen is
active, using terminalController.fullscreenStyle.isFullscreen for
proper detection of both native and non-native fullscreen modes.

https://ampcode.com/threads/T-c4ef59cc-1232-4fa5-8f09-c65724ee84d3
2025-09-03 10:01:28 -07:00
Mitchell Hashimoto
084ff2de67 Initial AGENTS.md (#8507)
I've been using agents a lot more with Ghostty and so are contributors.
Ghostty welcomes AI contributions (but they must be disclosed as AI
assisted), and this AGENTS.md will help everyone using agents work
better with the codebase.

This AGENTS.md has thus far been working for me very successfully,
despite being simple. I suspect we'll add to it as time goes on but I
also want to avoid making it too large and polluting the context.

**Changes should not be made to `AGENTS.md` unless it demonstrably
improves agent behavior.** To demonstrate agent behavior, I'd prefer
sharing an amp thread before and after the change with the same prompt
and model to show an improvement.
2025-09-03 09:28:40 -07:00
Mitchell Hashimoto
e1f3f52686 macOS: SurfaceView should implement Identifiable (#8506)
This has no meaningful functionality yet, it was one of the paths I was
looking at for #8505 but didn't pursue further. But I still think that
this makes more sense in general for the macOS app and will likely be
more useful later.
2025-09-03 09:28:01 -07:00
Mitchell Hashimoto
fe3dab9467 macOS: SurfaceView should implement Identifiable
This has no meaningful functionality yet, it was one of the paths I was
looking at for #8505 but didn't pursue further. But I still think that
this makes more sense in general for the macOS app and will likely be
more useful later.
2025-09-03 09:17:37 -07:00
Mitchell Hashimoto
b90c72aea6 Initial AGENTS.md
I've been using agents a lot more with Ghostty and so are contributors.
Ghostty welcomes AI contributions (but they must be disclosed as AI
assisted), and this AGENTS.md will help everyone using agents work
better with the codebase.

This AGENTS.md has thus far been working for me very successfully,
despite being simple. I suspect we'll add to it as time goes on but I
also want to avoid making it too large and polluting the context.
2025-09-03 09:16:35 -07:00
Mitchell Hashimoto
e6d60dee07 macOS: split tree zoom state should encode as path, not full node (#8505)
Fixes #8356

Zoom state should encode as a path so that it can be mapped to a
reference to the node in `root`. Previously, we were encoding a full
node which was instantiating an extra terminal on restore.

AI disclaimer: Claude code did the Codable implementation.
2025-09-03 09:08:45 -07:00
Mitchell Hashimoto
508e36bc03 macOS: split tree zoom state should encode as path, not full node
Fixes #8356

Zoom state should encode as a path so that it can be mapped to a
reference to the node in `root`. Previously, we were encoding a full
node which was instantiating an extra terminal on restore.
2025-09-03 08:51:37 -07:00
Mitchell Hashimoto
6a9b8b70cc config: make default copy_to_clipboard binds performable (#8504)
Make the default keybind for copy_to_clipboard performable. This allows
TUIs to receive events when keybinds aren't used, for example cmd+c on
macos for copy, or ctrl+shift+c elsewhere.

Disclosure: This commit was made with the assistance of AI (ampcode.com)
2025-09-03 07:58:47 -07:00
Tim Culverhouse
1dee9e7cb2 config: make default copy_to_clipboard binds performable
Make the default keybind for copy_to_clipboard performable. This allows
TUIs to receive events when keybinds aren't used, for example cmd+c on
macos for copy, or ctrl+shift+c elsewhere.

Disclosure: This commit was made with the assistance of AI (ampcode.com)
2025-09-03 09:35:08 -05:00
Mitchell Hashimoto
291d4ed423 gtk-ng/wayland: allow more quick terminal configs (#8484)
We should at least be on par with what Kitty's panels could do in terms
of customization
2025-09-03 07:01:23 -07:00
Leah Amelia Chen
5eb69b405d gtk-ng/wayland: allow more quick terminal configs 2025-09-03 15:44:23 +08:00
Kat
7d5be8e960 i18n: update Russian translation (#8473)
Part of #8344
2025-09-03 04:12:21 +00:00
Mitchell Hashimoto
d05ec81b86 apprt/gtk-ng: must quit scenarios should quit immediately (#8500)
Fixes #8495

We were incorrectly calling graceful quit under must quit scenarios.
This would do things like confirm quit by inspecting for running
processes. However, must quit scenarios (namely when all windows are
destroyed) should quit immediately without any checks because the
dispose process takes more event loop ticks to fully finish.
2025-09-02 20:46:35 -07:00
Mitchell Hashimoto
f016b79f22 apprt/gtk-ng: must quit scenarios should quit immediately
Fixes #8495

We were incorrectly calling graceful quit under must quit scenarios.
This would do things like confirm quit by inspecting for running
processes. However, must quit scenarios (namely when all windows are
destroyed) should quit immediately without any checks because the
dispose process takes more event loop ticks to fully finish.
2025-09-02 20:42:01 -07:00
Ivan Bastrakov
52f5ab1a36 i18n: update Russian translation 2025-09-03 01:57:25 +03:00
166 changed files with 1680 additions and 14080 deletions

64
.agents/commands/gh-issue Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env nu
# A command to generate an agent prompt to diagnose and formulate
# a plan for resolving a GitHub issue.
#
# IMPORTANT: This command is prompted to NOT write any code and to ONLY
# produce a plan. You should still be vigilant when running this but that
# is the expected behavior.
#
# The `<issue>` parameter can be either an issue number or a full GitHub
# issue URL.
def main [
issue: any, # Ghostty issue number or URL
--repo: string = "ghostty-org/ghostty" # GitHub repository in the format "owner/repo"
] {
# TODO: This whole script doesn't handle errors very well. I actually
# don't know Nu well enough to know the proper way to handle it all.
let issueData = gh issue view $issue --json author,title,number,body,comments | from json
let comments = $issueData.comments | each { |comment|
$"
### Comment by ($comment.author.login)
($comment.body)
" | str trim
} | str join "\n\n"
$"
Deep-dive on this GitHub issue. Find the problem and generate a plan.
Do not write code. Explain the problem clearly and propose a comprehensive plan
to solve it.
# ($issueData.title) \(($issueData.number)\)
## Description
($issueData.body)
## Comments
($comments)
## Your Tasks
You are an experienced software developer tasked with diagnosing issues.
1. Review the issue context and details.
2. Examine the relevant parts of the codebase. Analyze the code thoroughly
until you have a solid understanding of how it works.
3. Explain the issue in detail, including the problem and its root cause.
4. Create a comprehensive plan to solve the issue. The plan should include:
- Required code changes
- Potential impacts on other parts of the system
- Necessary tests to be written or updated
- Documentation updates
- Performance considerations
- Security implications
- Backwards compatibility \(if applicable\)
- Include the reference link to the source issue and any related discussions
4. Think deeply about all aspects of the task. Consider edge cases, potential
challenges, and best practices for addressing the issue. Review the plan
with the oracle and adjust it based on its feedback.
**ONLY CREATE A PLAN. DO NOT WRITE ANY CODE.** Your task is to create
a thorough, comprehensive strategy for understanding and resolving the issue.
" | str trim
}

View File

@@ -23,7 +23,6 @@ jobs:
- build-windows
- test
- test-gtk
- test-gtk-ng
- test-sentry-linux
- test-macos
- pinact
@@ -495,9 +494,6 @@ jobs:
- name: Test GTK Build
run: nix develop -c zig build -Dapp-runtime=gtk -Demit-docs -Demit-webdata
- name: Test GTK-NG Build
run: nix develop -c zig build -Dapp-runtime=gtk-ng -Demit-docs -Demit-webdata
# This relies on the cache being populated by the commands above.
- name: Test System Build
run: nix develop -c zig build --system ${ZIG_GLOBAL_CACHE_DIR}/p
@@ -551,55 +547,6 @@ jobs:
-Dgtk-x11=${{ matrix.x11 }} \
-Dgtk-wayland=${{ matrix.wayland }}
test-gtk-ng:
strategy:
fail-fast: false
matrix:
x11: ["true", "false"]
wayland: ["true", "false"]
name: GTK x11=${{ matrix.x11 }} wayland=${{ matrix.wayland }}
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Test
run: |
nix develop -c \
zig build \
-Dapp-runtime=gtk-ng \
-Dgtk-x11=${{ matrix.x11 }} \
-Dgtk-wayland=${{ matrix.wayland }} \
test
- name: Build
run: |
nix develop -c \
zig build \
-Dapp-runtime=gtk-ng \
-Dgtk-x11=${{ matrix.x11 }} \
-Dgtk-wayland=${{ matrix.wayland }}
test-sentry-linux:
strategy:
fail-fast: false
@@ -1064,7 +1011,7 @@ jobs:
cd $GITHUB_WORKSPACE
zig build test
- name: Build GTK-NG app runtime
- name: Build GTK app runtime
shell: freebsd {0}
run: |
cd $GITHUB_WORKSPACE

23
AGENTS.md Normal file
View File

@@ -0,0 +1,23 @@
# Agent Development Guide
A file for [guiding coding agents](https://agents.md/).
## Commands
- **Build:** `zig build`
- **Test (Zig):** `zig build test`
- **Test filter (Zig)**: `zig build test -Dtest-filter=<test name>`
- **Formatting (Zig)**: `zig fmt .`
- **Formatting (other)**: `prettier -w .`
## Directory Structure
- Shared Zig core: `src/`
- C API: `include/ghostty.h`
- macOS app: `macos/`
- GTK (Linux and FreeBSD) app: `src/apprt/gtk`
## macOS App
- Do not use `xcodebuild`
- Use `zig build` to build the macOS app and any shared Zig code

View File

@@ -119,7 +119,6 @@
# GTK
/src/apprt/gtk/ @ghostty-org/gtk
/src/apprt/gtk-ng/ @ghostty-org/gtk
/src/os/cgroup.zig @ghostty-org/gtk
/src/os/flatpak.zig @ghostty-org/gtk
/dist/linux/ @ghostty-org/gtk

View File

@@ -45,11 +45,16 @@ work than any human. That isn't the world we live in today, and in most cases
it's generating slop. I say this despite being a fan of and using them
successfully myself (with heavy supervision)!
When using AI assistance, we expect contributors to understand the code
that is produced and be able to answer critical questions about it. It
isn't a maintainers job to review a PR so broken that it requires
significant rework to be acceptable.
Please be respectful to maintainers and disclose AI assistance.
## Quick Guide
### I'd like to contribute!
### I'd like to contribute
[All issues are actionable](#issues-are-actionable). Pick one and start
working on it. Thank you. If you need help or guidance, comment on the issue.
@@ -58,7 +63,7 @@ Issues that are extra friendly to new contributors are tagged with
["contributor friendly"]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22contributor%20friendly%22
### I'd like to translate Ghostty to my language!
### I'd like to translate Ghostty to my language
We have written a [Translator's Guide](po/README_TRANSLATORS.md) for
everyone interested in contributing translations to Ghostty.
@@ -67,7 +72,7 @@ and you can submit pull requests directly, although please make sure that
our [Style Guide](po/README_TRANSLATORS.md#style-guide) is followed before
submission.
### I have a bug! / Something isn't working!
### I have a bug! / Something isn't working
1. Search the issue tracker and discussions for similar issues. Tip: also
search for [closed issues] and [discussions] — your issue might have already
@@ -82,18 +87,18 @@ submission.
[discussions]: https://github.com/ghostty-org/ghostty/discussions?discussions_q=is%3Aclosed
["Issue Triage" discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage
### I have an idea for a feature!
### I have an idea for a feature
Open a discussion in the ["Feature Requests, Ideas" category](https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas).
### I've implemented a feature!
### I've implemented a feature
1. If there is an issue for the feature, open a pull request straight away.
2. If there is no issue, open a discussion and link to your branch.
3. If you want to live dangerously, open a pull request and
[hope for the best](#pull-requests-implement-an-issue).
### I have a question!
### I have a question
Open an [Q&A discussion], or join our [Discord Server] and ask away in the
`#help` channel.

View File

@@ -36,7 +36,7 @@ here:
| `zig build test` | Runs unit tests (accepts `-Dtest-filter=<filter>` to only run tests whose name matches the filter) |
| `zig build update-translations` | Updates Ghostty's translation strings (see the [Contributor's Guide on Localizing Ghostty](po/README_CONTRIBUTORS.md)) |
| `zig build dist` | Builds a source tarball |
| `zig build distcheck` | Installs and validates a source tarball |
| `zig build distcheck` | Builds and validates a source tarball |
## Extra Dependencies
@@ -69,6 +69,32 @@ sudo xcode-select --switch /Applications/Xcode-beta.app
> You do not need to be running on macOS 26 to build Ghostty, you can
> still use Xcode 26 beta on macOS 15 stable.
## AI and Agents
If you're using AI assistance with Ghostty, Ghostty provides an
[AGENTS.md file](https://github.com/ghostty-org/ghostty/blob/main/AGENTS.md)
read by most of the popular AI agents to help produce higher quality
results.
We also provide commands in `.agents/commands` that have some vetted
prompts for common tasks that have been shown to produce good results.
We provide these to help reduce the amount of time a contributor has to
spend prompting the AI to get good results, and hopefully to lower the slop
produced.
- `/gh-issue <number/url>` - Produces a prompt for diagnosing a GitHub
issue, explaining the problem, and suggesting a plan for resolving it.
Requires `gh` to be installed with read-only access to Ghostty.
> [!WARNING]
>
> All AI assistance usage [must be disclosed](https://github.com/ghostty-org/ghostty/blob/main/CONTRIBUTING.md#ai-assistance-notice)
> and we expect contributors to understand the code that is produced and
> be able to answer questions about it. If you don't understand the
> code produced, feel free to disclose that, but if it has problems, we
> may ask you to fix it and close the issue. It isn't a maintainers job to
> review a PR so broken that it requires significant rework to be acceptable.
## Linting
### Prettier

View File

@@ -55,8 +55,8 @@
.gobject = .{
// https://github.com/jcollie/ghostty-gobject based on zig_gobject
// Temporary until we generate them at build time automatically.
.url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst",
.hash = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM",
.url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst",
.hash = "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z",
.lazy = true,
},

6
build.zig.zon.json generated
View File

@@ -24,10 +24,10 @@
"url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz",
"hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U="
},
"gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM": {
"gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z": {
"name": "gobject",
"url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst",
"hash": "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI="
"url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst",
"hash": "sha256-h6aKUerGlX2ATVEeoN03eWaqDqvUmKdedgpxrSoHvrY="
},
"N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": {
"name": "gtk4_layer_shell",

6
build.zig.zon.nix generated
View File

@@ -123,11 +123,11 @@ in
};
}
{
name = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM";
name = "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z";
path = fetchZigArtifact {
name = "gobject";
url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst";
hash = "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI=";
url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst";
hash = "sha256-h6aKUerGlX2ATVEeoN03eWaqDqvUmKdedgpxrSoHvrY=";
};
}
{

2
build.zig.zon.txt generated
View File

@@ -27,7 +27,7 @@ https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d6
https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc.tar.gz
https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst
https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst
https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6cdbc8501d48601302e32d6b53e9e7934bf354b4.tar.gz
https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz
https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz

View File

@@ -4,7 +4,7 @@ Name=@NAME@
Type=Application
Comment=A terminal emulator
TryExec=@GHOSTTY@
Exec=@GHOSTTY@ --launched-from=desktop
Exec=@GHOSTTY@ --gtk-single-instance=true
Icon=com.mitchellh.ghostty
Categories=System;TerminalEmulator;
Keywords=terminal;tty;pty;
@@ -23,4 +23,4 @@ X-KDE-Shortcuts=Ctrl+Alt+T
[Desktop Action new-window]
Name=New Window
Exec=@GHOSTTY@ --launched-from=desktop
Exec=@GHOSTTY@ --gtk-single-instance=true

View File

@@ -1,3 +1,3 @@
[D-BUS Service]
Name=@APPID@
Exec=@GHOSTTY@ --launched-from=dbus
Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false

View File

@@ -1,4 +1,4 @@
[D-BUS Service]
Name=@APPID@
SystemdService=app-@APPID@.service
Exec=@GHOSTTY@ --launched-from=dbus
Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false

View File

@@ -8,7 +8,7 @@ Requires=dbus.socket
Type=notify-reload
ReloadSignal=SIGUSR2
BusName=@APPID@
ExecStart=@GHOSTTY@ --launched-from=systemd
ExecStart=@GHOSTTY@ --gtk-single-instance=true --initial-window=false
[Install]
WantedBy=graphical-session.target

View File

@@ -31,9 +31,9 @@
},
{
"type": "archive",
"url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst",
"dest": "vendor/p/gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM",
"sha256": "074ce22f32ae77e91d2aee53d414c4f46321f043ccfca861868349972b3940f2"
"url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst",
"dest": "vendor/p/gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z",
"sha256": "87a68a51eac6957d804d511ea0dd377966aa0eabd498a75e760a71ad2a07beb6"
},
{
"type": "archive",

View File

@@ -937,7 +937,7 @@ class AppDelegate: NSObject,
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
for c in TerminalController.all {
for view in c.surfaceTree {
if view.uuid == uuid {
if view.id == uuid {
return view
}
}

View File

@@ -34,7 +34,7 @@ struct TerminalEntity: AppEntity {
/// Returns the view associated with this entity. This may no longer exist.
@MainActor
var surfaceView: Ghostty.SurfaceView? {
Self.defaultQuery.all.first { $0.uuid == self.id }
Self.defaultQuery.all.first { $0.id == self.id }
}
@MainActor
@@ -46,7 +46,7 @@ struct TerminalEntity: AppEntity {
@MainActor
init(_ view: Ghostty.SurfaceView) {
self.id = view.uuid
self.id = view.id
self.title = view.title
self.workingDirectory = view.pwd
if let nsImage = ImageRenderer(content: view.screenshot()).nsImage {
@@ -80,7 +80,7 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery {
@MainActor
func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] {
return all.filter {
identifiers.contains($0.uuid)
identifiers.contains($0.id)
}.map {
TerminalEntity($0)
}

View File

@@ -1,7 +1,7 @@
import AppKit
/// SplitTree represents a tree of views that can be divided.
struct SplitTree<ViewType: NSView & Codable>: Codable {
struct SplitTree<ViewType: NSView & Codable & Identifiable> {
/// The root of the tree. This can be nil to indicate the tree is empty.
let root: Node?
@@ -29,12 +29,12 @@ struct SplitTree<ViewType: NSView & Codable>: Codable {
}
/// The path to a specific node in the tree.
struct Path {
struct Path: Codable {
let path: [Component]
var isEmpty: Bool { path.isEmpty }
enum Component {
enum Component: Codable {
case left
case right
}
@@ -53,7 +53,7 @@ struct SplitTree<ViewType: NSView & Codable>: Codable {
let node: Node
let bounds: CGRect
}
/// Direction for spatial navigation within the split tree.
enum Direction {
case left
@@ -127,44 +127,51 @@ extension SplitTree {
root: try root.insert(view: view, at: at, direction: direction),
zoomed: nil)
}
/// Find a node containing a view with the specified ID.
/// - Parameter id: The ID of the view to find
/// - Returns: The node containing the view if found, nil otherwise
func find(id: ViewType.ID) -> Node? {
guard let root else { return nil }
return root.find(id: id)
}
/// Remove a node from the tree. If the node being removed is part of a split,
/// the sibling node takes the place of the parent split.
func remove(_ target: Node) -> Self {
guard let root else { return self }
// If we're removing the root itself, return an empty tree
if root == target {
return .init(root: nil, zoomed: nil)
}
// Otherwise, try to remove from the tree
let newRoot = root.remove(target)
// Update zoomed if it was the removed node
let newZoomed = (zoomed == target) ? nil : zoomed
return .init(root: newRoot, zoomed: newZoomed)
}
/// Replace a node in the tree with a new node.
func replace(node: Node, with newNode: Node) throws -> Self {
guard let root else { throw SplitError.viewNotFound }
// Get the path to the node we want to replace
guard let path = root.path(to: node) else {
throw SplitError.viewNotFound
}
// Replace the node
let newRoot = try root.replaceNode(at: path, with: newNode)
// Update zoomed if it was the replaced node
let newZoomed = (zoomed == node) ? newNode : zoomed
return .init(root: newRoot, zoomed: newZoomed)
}
/// Find the next view to focus based on the current focused node and direction
func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? {
guard let root else { return nil }
@@ -230,13 +237,13 @@ extension SplitTree {
let newRoot = root.equalize()
return .init(root: newRoot, zoomed: zoomed)
}
/// Resize a node in the tree by the given pixel amount in the specified direction.
///
///
/// This method adjusts the split ratios of the tree to accommodate the requested resize
/// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts
/// its ratio. For left/right resizing, it finds the nearest parent horizontal split.
/// The bounds parameter is used to construct the spatial tree representation which is
/// The bounds parameter is used to construct the spatial tree representation which is
/// needed to calculate the current pixel dimensions.
///
/// This will always reset the zoomed state.
@@ -250,22 +257,22 @@ extension SplitTree {
/// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists
func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
guard let root else { throw SplitError.viewNotFound }
// Find the path to the target node
guard let path = root.path(to: node) else {
throw SplitError.viewNotFound
}
// Determine which type of split we need to find based on resize direction
let targetSplitDirection: Direction = switch direction {
case .up, .down: .vertical
case .left, .right: .horizontal
}
// Find the nearest parent split of the correct type by walking up the path
var splitPath: Path?
var splitNode: Node?
for i in stride(from: path.path.count - 1, through: 0, by: -1) {
let parentPath = Path(path: Array(path.path.prefix(i)))
if let parent = root.node(at: parentPath), case .split(let split) = parent {
@@ -276,29 +283,29 @@ extension SplitTree {
}
}
}
guard let splitPath = splitPath,
guard let splitPath = splitPath,
let splitNode = splitNode,
case .split(let split) = splitNode else {
throw SplitError.viewNotFound
}
// Get current spatial representation to calculate pixel dimensions
let spatial = root.spatial(within: bounds.size)
guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else {
throw SplitError.viewNotFound
}
// Calculate the new ratio based on pixel change
let pixelOffset = Double(pixels)
let newRatio: Double
switch (split.direction, direction) {
case (.horizontal, .left):
// Moving left boundary: decrease left side
newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width)))
case (.horizontal, .right):
// Moving right boundary: increase left side
// Moving right boundary: increase left side
newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width)))
case (.vertical, .up):
// Moving top boundary: decrease top side
@@ -310,7 +317,7 @@ extension SplitTree {
// Direction doesn't match split type - shouldn't happen due to earlier logic
throw SplitError.viewNotFound
}
// Create new split with adjusted ratio
let newSplit = Node.Split(
direction: split.direction,
@@ -318,12 +325,12 @@ extension SplitTree {
left: split.left,
right: split.right
)
// Replace the split node with the new one
let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit))
return .init(root: newRoot, zoomed: nil)
}
/// Returns the total bounds of the split hierarchy using NSView bounds.
/// Ignores x/y coordinates and assumes views are laid out in a perfect grid.
/// Also ignores any possible padding between views.
@@ -334,6 +341,60 @@ extension SplitTree {
}
}
// MARK: SplitTree Codable
fileprivate enum CodingKeys: String, CodingKey {
case version
case root
case zoomed
static let currentVersion: Int = 1
}
extension SplitTree: Codable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Check version
let version = try container.decode(Int.self, forKey: .version)
guard version == CodingKeys.currentVersion else {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unsupported SplitTree version: \(version)"
)
)
}
// Decode root
self.root = try container.decodeIfPresent(Node.self, forKey: .root)
// Zoomed is encoded as its path. Get the path and then find it.
if let zoomedPath = try container.decodeIfPresent(Path.self, forKey: .zoomed),
let root = self.root {
self.zoomed = root.node(at: zoomedPath)
} else {
self.zoomed = nil
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
// Encode version
try container.encode(CodingKeys.currentVersion, forKey: .version)
// Encode root
try container.encodeIfPresent(root, forKey: .root)
// Zoomed is encoded as its path since its a reference type. This lets us
// map it on decode back to the correct node in root.
if let zoomed, let path = root?.path(to: zoomed) {
try container.encode(path, forKey: .zoomed)
}
}
}
// MARK: SplitTree.Node
extension SplitTree.Node {
@@ -342,6 +403,23 @@ extension SplitTree.Node {
typealias SplitError = SplitTree.SplitError
typealias Path = SplitTree.Path
/// Find a node containing a view with the specified ID.
/// - Parameter id: The ID of the view to find
/// - Returns: The node containing the view if found, nil otherwise
func find(id: ViewType.ID) -> Node? {
switch self {
case .leaf(let view):
return view.id == id ? self : nil
case .split(let split):
if let found = split.left.find(id: id) {
return found
}
return split.right.find(id: id)
}
}
/// Returns the node in the tree that contains the given view.
func node(view: ViewType) -> Node? {
switch (self) {
@@ -396,20 +474,20 @@ extension SplitTree.Node {
return search(self) ? Path(path: components) : nil
}
/// Returns the node at the given path from this node as root.
func node(at path: Path) -> Node? {
if path.isEmpty {
return self
}
guard case .split(let split) = self else {
return nil
}
let component = path.path[0]
let remainingPath = Path(path: Array(path.path.dropFirst()))
switch component {
case .left:
return split.left.node(at: remainingPath)
@@ -521,12 +599,12 @@ extension SplitTree.Node {
if self == target {
return nil
}
switch self {
case .leaf:
// A leaf that isn't the target stays as is
return self
case .split(let split):
// Neither child is directly the target, so we need to recursively
// try to remove from both children
@@ -543,7 +621,7 @@ extension SplitTree.Node {
} else if newRight == nil {
return newLeft
}
// Both children still exist after removal
return .split(.init(
direction: split.direction,
@@ -562,7 +640,7 @@ extension SplitTree.Node {
case .leaf:
// Leaf nodes don't have a ratio to resize
return self
case .split(let split):
// Create a new split with the updated ratio
return .split(.init(
@@ -573,7 +651,7 @@ extension SplitTree.Node {
))
}
}
/// Get the leftmost leaf in this subtree
func leftmostLeaf() -> ViewType {
switch self {
@@ -583,7 +661,7 @@ extension SplitTree.Node {
return split.left.leftmostLeaf()
}
}
/// Get the rightmost leaf in this subtree
func rightmostLeaf() -> ViewType {
switch self {
@@ -593,7 +671,7 @@ extension SplitTree.Node {
return split.right.rightmostLeaf()
}
}
/// Equalize this node and all its children, returning a new node with splits
/// adjusted so that each split's ratio is based on the relative weight
/// (number of leaves) of its children.
@@ -601,14 +679,14 @@ extension SplitTree.Node {
let (equalizedNode, _) = equalizeWithWeight()
return equalizedNode
}
/// Internal helper that equalizes and returns both the node and its weight.
private func equalizeWithWeight() -> (node: Node, weight: Int) {
switch self {
case .leaf:
// A leaf has weight 1 and doesn't change
return (self, 1)
case .split(let split):
// Calculate weights based on split direction
let leftWeight = split.left.weightForDirection(split.direction)
@@ -629,7 +707,7 @@ extension SplitTree.Node {
left: leftNode,
right: rightNode
)
return (.split(newSplit), totalWeight)
}
}
@@ -656,12 +734,12 @@ extension SplitTree.Node {
switch self {
case .leaf(let view):
return [(view, bounds)]
case .split(let split):
// Calculate bounds for left and right based on split direction and ratio
let leftBounds: CGRect
let rightBounds: CGRect
switch split.direction {
case .horizontal:
// Split horizontally: left | right
@@ -678,7 +756,7 @@ extension SplitTree.Node {
width: bounds.width * (1 - split.ratio),
height: bounds.height
)
case .vertical:
// Split vertically: top / bottom
// Note: In our normalized coordinate system, Y increases upward
@@ -696,13 +774,13 @@ extension SplitTree.Node {
height: bounds.height * split.ratio
)
}
// Recursively calculate bounds for children
return split.left.calculateViewBounds(in: leftBounds) +
split.right.calculateViewBounds(in: rightBounds)
}
}
/// Returns the total bounds of this subtree using NSView bounds.
/// Ignores x/y coordinates and assumes views are laid out in a perfect grid.
/// - Returns: The total width and height needed to contain all views in this subtree
@@ -710,11 +788,11 @@ extension SplitTree.Node {
switch self {
case .leaf(let view):
return view.bounds.size
case .split(let split):
let leftBounds = split.left.viewBounds()
let rightBounds = split.right.viewBounds()
switch split.direction {
case .horizontal:
// Horizontal split: width is sum, height is max
@@ -722,7 +800,7 @@ extension SplitTree.Node {
width: leftBounds.width + rightBounds.width,
height: Swift.max(leftBounds.height, rightBounds.height)
)
case .vertical:
// Vertical split: height is sum, width is max
return CGSize(
@@ -760,7 +838,7 @@ extension SplitTree.Node {
/// // +--------+----+
/// // | C | D |
/// // +--------+----+
/// //
/// //
/// // The spatial representation would have:
/// // - Total dimensions: (width: 2, height: 2)
/// // - Node bounds based on actual split ratios
@@ -805,7 +883,7 @@ extension SplitTree.Node {
/// Example:
/// ```
/// // Single leaf: (1, 1)
/// // Horizontal split with 2 leaves: (2, 1)
/// // Horizontal split with 2 leaves: (2, 1)
/// // Vertical split with 2 leaves: (1, 2)
/// // Complex layout with both: (2, 2) or larger
/// ```
@@ -846,7 +924,7 @@ extension SplitTree.Node {
///
/// The calculation process:
/// 1. **Leaf nodes**: Create a single slot with the provided bounds
/// 2. **Split nodes**:
/// 2. **Split nodes**:
/// - Divide the bounds according to the split ratio and direction
/// - Create a slot for the split node itself
/// - Recursively calculate slots for both children
@@ -926,7 +1004,7 @@ extension SplitTree.Spatial {
///
/// This method finds all slots positioned in the given direction from the reference node:
/// - **Left**: Slots with bounds to the left of the reference node
/// - **Right**: Slots with bounds to the right of the reference node
/// - **Right**: Slots with bounds to the right of the reference node
/// - **Up**: Slots with bounds above the reference node (Y=0 is top)
/// - **Down**: Slots with bounds below the reference node
///
@@ -955,41 +1033,41 @@ extension SplitTree.Spatial {
let dy = rect2.minY - rect1.minY
return sqrt(dx * dx + dy * dy)
}
let result = switch direction {
case .left:
// Slots to the left: their right edge is at or left of reference's left edge
slots.filter {
$0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX
$0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX
}.sorted {
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
}
case .right:
// Slots to the right: their left edge is at or right of reference's right edge
slots.filter {
$0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX
$0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX
}.sorted {
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
}
case .up:
// Slots above: their bottom edge is at or above reference's top edge
slots.filter {
$0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY
$0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY
}.sorted {
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
}
case .down:
// Slots below: their top edge is at or below reference's bottom edge
slots.filter {
$0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY
$0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY
}.sorted {
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
}
}
return result
}
@@ -1008,14 +1086,14 @@ extension SplitTree.Spatial {
func doesBorder(side: Direction, from node: SplitTree.Node) -> Bool {
// Find the slot for this node
guard let slot = slots.first(where: { $0.node == node }) else { return false }
// Calculate the overall bounds of all slots
let overallBounds = slots.reduce(CGRect.null) { result, slot in
result.union(slot.bounds)
}
return switch side {
case .up:
case .up:
slot.bounds.minY == overallBounds.minY
case .down:
slot.bounds.maxY == overallBounds.maxY
@@ -1052,10 +1130,10 @@ extension SplitTree.Node {
case view
case split
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if container.contains(.view) {
let view = try container.decode(ViewType.self, forKey: .view)
self = .leaf(view: view)
@@ -1071,14 +1149,14 @@ extension SplitTree.Node {
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .leaf(let view):
try container.encode(view, forKey: .view)
case .split(let split):
try container.encode(split, forKey: .split)
}
@@ -1093,7 +1171,7 @@ extension SplitTree.Node {
switch self {
case .leaf(let view):
return [view]
case .split(let split):
return split.left.leaves() + split.right.leaves()
}
@@ -1145,7 +1223,7 @@ extension SplitTree.Node {
var structuralIdentity: StructuralIdentity {
StructuralIdentity(self)
}
/// A hashable representation of a node that captures its structural identity.
///
/// This type provides a way to track changes to a node's structure in SwiftUI
@@ -1159,20 +1237,20 @@ extension SplitTree.Node {
/// for unchanged portions of the tree.
struct StructuralIdentity: Hashable {
private let node: SplitTree.Node
init(_ node: SplitTree.Node) {
self.node = node
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.node.isStructurallyEqual(to: rhs.node)
}
func hash(into hasher: inout Hasher) {
node.hashStructure(into: &hasher)
}
}
/// Checks if this node is structurally equal to another node.
/// Two nodes are structurally equal if they have the same tree structure
/// and the same views (by identity) in the same positions.
@@ -1181,26 +1259,26 @@ extension SplitTree.Node {
case let (.leaf(view1), .leaf(view2)):
// Views must be the same instance
return view1 === view2
case let (.split(split1), .split(split2)):
// Splits must have same direction and structurally equal children
// Note: We intentionally don't compare ratios as they may change slightly
return split1.direction == split2.direction &&
split1.left.isStructurallyEqual(to: split2.left) &&
split1.right.isStructurallyEqual(to: split2.right)
default:
// Different node types
return false
}
}
/// Hash keys for structural identity
private enum HashKey: UInt8 {
case leaf = 0
case split = 1
}
/// Hashes the structural identity of this node.
/// Includes the tree structure and view identities in the hash.
fileprivate func hashStructure(into hasher: inout Hasher) {
@@ -1208,7 +1286,7 @@ extension SplitTree.Node {
case .leaf(let view):
hasher.combine(HashKey.leaf)
hasher.combine(ObjectIdentifier(view))
case .split(let split):
hasher.combine(HashKey.split)
hasher.combine(split.direction)
@@ -1247,17 +1325,17 @@ extension SplitTree {
struct StructuralIdentity: Hashable {
private let root: Node?
private let zoomed: Node?
init(_ tree: SplitTree) {
self.root = tree.root
self.zoomed = tree.zoomed
}
static func == (lhs: Self, rhs: Self) -> Bool {
areNodesStructurallyEqual(lhs.root, rhs.root) &&
areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed)
}
func hash(into hasher: inout Hasher) {
hasher.combine(0) // Tree marker
if let root = root {
@@ -1268,7 +1346,7 @@ extension SplitTree {
zoomed.hashStructure(into: &hasher)
}
}
/// Helper to compare optional nodes for structural equality
private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool {
switch (lhs, rhs) {

View File

@@ -860,7 +860,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Restore focus to the previously focused surface
if let focusedUUID = undoState.focusedSurface,
let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) {
let focusTarget = surfaceTree.first(where: { $0.id == focusedUUID }) {
DispatchQueue.main.async {
Ghostty.moveFocus(to: focusTarget, from: nil)
}
@@ -875,7 +875,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
return .init(
frame: window.frame,
surfaceTree: surfaceTree,
focusedSurface: focusedSurface?.uuid,
focusedSurface: focusedSurface?.id,
tabIndex: window.tabGroup?.windows.firstIndex(of: window),
tabGroup: window.tabGroup)
}

View File

@@ -4,13 +4,13 @@ import Cocoa
class TerminalRestorableState: Codable {
static let selfKey = "state"
static let versionKey = "version"
static let version: Int = 4
static let version: Int = 5
let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.uuid.uuidString
self.focusedSurface = controller.focusedSurface?.id.uuidString
self.surfaceTree = controller.surfaceTree
}
@@ -96,7 +96,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
if let focusedStr = state.focusedSurface {
var foundView: Ghostty.SurfaceView?
for view in c.surfaceTree {
if view.uuid.uuidString == focusedStr {
if view.id.uuidString == focusedStr {
foundView = view
break
}

View File

@@ -31,9 +31,16 @@ class HiddenTitlebarTerminalWindow: TerminalWindow {
.closable,
.miniaturizable,
]
/// Apply the hidden titlebar style.
private func reapplyHiddenStyle() {
// If our window is fullscreen then we don't reapply the hidden style because
// it can result in messing up non-native fullscreen. See:
// https://github.com/ghostty-org/ghostty/issues/8415
if terminalController?.fullscreenStyle?.isFullscreen ?? false {
return
}
// Apply our style mask while preserving the .fullScreen option
if styleMask.contains(.fullScreen) {
styleMask = Self.hiddenStyleMask.union([.fullScreen])

View File

@@ -6,9 +6,11 @@ import GhosttyKit
extension Ghostty {
/// The NSView implementation for a terminal surface.
class SurfaceView: OSView, ObservableObject, Codable {
class SurfaceView: OSView, ObservableObject, Codable, Identifiable {
typealias ID = UUID
/// Unique ID per surface
let uuid: UUID
let id: UUID
// The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go
@@ -180,7 +182,7 @@ extension Ghostty {
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
self.markedText = NSMutableAttributedString()
self.uuid = uuid ?? .init()
self.id = uuid ?? .init()
// Our initial config always is our application wide config.
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
@@ -1468,7 +1470,7 @@ extension Ghostty {
content.body = body
content.sound = UNNotificationSound.default
content.categoryIdentifier = Ghostty.userNotificationCategory
content.userInfo = ["surface": self.uuid.uuidString]
content.userInfo = ["surface": self.id.uuidString]
let uuid = UUID().uuidString
let request = UNNotificationRequest(
@@ -1576,7 +1578,7 @@ extension Ghostty {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(pwd, forKey: .pwd)
try container.encode(uuid.uuidString, forKey: .uuid)
try container.encode(id.uuidString, forKey: .uuid)
try container.encode(title, forKey: .title)
try container.encode(titleFromTerminal != nil, forKey: .isUserSetTitle)
}

View File

@@ -3,6 +3,7 @@
lib,
stdenv,
bashInteractive,
nushell,
appstream,
flatpak-builder,
gdb,
@@ -60,6 +61,7 @@
pandoc,
pinact,
hyperfine,
poop,
typos,
shellcheck,
uv,
@@ -124,6 +126,9 @@ in
# CI
uv
# Scripting
nushell
# We need these GTK-related deps on all platform so we can build
# dist tarballs.
blueprint-compiler
@@ -183,6 +188,9 @@ in
# developer shell
glycin-loaders
librsvg
# for benchmarking
poop
];
# This should be set onto the rpath of the ghostty binary if you want

View File

@@ -3,15 +3,16 @@
# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# blackzeshi <sergey_zhuzhgov@mail.ru>, 2025.
#
# Ivan Bastrakov <bastaynav@proton.me>, 2025.
msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2025-07-22 17:18+0000\n"
"PO-Revision-Date: 2025-03-24 00:01+0500\n"
"Last-Translator: blackzeshi <sergey_zhuzhgov@mail.ru>\n"
"Language-Team: Russian <gnu@d07.ru>\n"
"PO-Revision-Date: 2025-09-03 01:50+0300\n"
"Last-Translator: Ivan Bastrakov <bastaynav@proton.me>\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -89,7 +90,7 @@ msgstr "Сплит вправо"
#: src/apprt/gtk/ui/1.5/command-palette.blp:16
msgid "Execute a command…"
msgstr ""
msgstr "Выполнить команду…"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6
@@ -162,7 +163,7 @@ msgstr "Открыть конфигурационный файл"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85
msgid "Command Palette"
msgstr ""
msgstr "Палитра команд"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90
msgid "Terminal Inspector"
@@ -210,12 +211,12 @@ msgstr "Разрешить"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77
msgid "Remember choice for this split"
msgstr ""
msgstr "Запомнить выбор для этого сплита"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78
msgid "Reload configuration to show this prompt again"
msgstr ""
msgstr "Перезагрузите конфигурацию, чтобы снова увидеть это сообщение"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7
@@ -223,7 +224,8 @@ msgid ""
"An application is attempting to write to the clipboard. The current "
"clipboard contents are shown below."
msgstr ""
"Приложение пытается записать данные в буфер обмена. Эти данные показаны ниже."
"Приложение пытается записать данные в буфер обмена. Текущее содержимое "
"буфера обмена показано ниже."
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6
msgid "Warning: Potentially Unsafe Paste"
@@ -279,15 +281,15 @@ msgstr "Скопировано в буфер обмена"
#: src/apprt/gtk/Surface.zig:1268
msgid "Cleared clipboard"
msgstr ""
msgstr "Буфер обмена очищен"
#: src/apprt/gtk/Surface.zig:2525
msgid "Command succeeded"
msgstr ""
msgstr "Команда выполнена успешно"
#: src/apprt/gtk/Surface.zig:2527
msgid "Command failed"
msgstr ""
msgstr "Команда завершилась с ошибкой"
#: src/apprt/gtk/Window.zig:216
msgid "Main Menu"
@@ -299,7 +301,7 @@ msgstr "Просмотреть открытые вкладки"
#: src/apprt/gtk/Window.zig:266
msgid "New Split"
msgstr ""
msgstr "Новый сплит"
#: src/apprt/gtk/Window.zig:329
msgid ""

View File

@@ -17,7 +17,6 @@ const structs = @import("apprt/structs.zig");
pub const action = @import("apprt/action.zig");
pub const ipc = @import("apprt/ipc.zig");
pub const gtk = @import("apprt/gtk.zig");
pub const gtk_ng = @import("apprt/gtk-ng.zig");
pub const none = @import("apprt/none.zig");
pub const browser = @import("apprt/browser.zig");
pub const embedded = @import("apprt/embedded.zig");
@@ -44,7 +43,6 @@ pub const runtime = switch (build_config.artifact) {
.exe => switch (build_config.app_runtime) {
.none => none,
.gtk => gtk,
.@"gtk-ng" => gtk_ng,
},
.lib => embedded,
.wasm_module => browser,
@@ -62,18 +60,13 @@ pub const Runtime = enum {
/// GTK4. Rich windowed application. This uses a full GObject-based
/// approach to building the application.
@"gtk-ng",
/// GTK-backed. Rich windowed application. GTK is dynamically linked.
/// WARNING: Deprecated. This will be removed very soon. All bug fixes
/// and features should go into the gtk-ng backend.
gtk,
pub fn default(target: std.Target) Runtime {
return switch (target.os.tag) {
// The Linux and FreeBSD default is GTK because it is a full
// featured application.
.linux, .freebsd => .@"gtk-ng",
.linux, .freebsd => .gtk,
// Otherwise, we do NONE so we don't create an exe and we create
// libghostty. On macOS, Xcode is used to build the app that links
// to libghostty.

View File

@@ -542,7 +542,7 @@ pub const InitialSize = extern struct {
/// Make this a valid gobject if we're in a GTK environment.
pub const getGObjectType = switch (build_config.app_runtime) {
.gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed(
.gtk => @import("gobject").ext.defineBoxed(
InitialSize,
.{ .name = "GhosttyApprtInitialSize" },
),

View File

@@ -909,10 +909,7 @@ pub const Surface = struct {
// our translation settings for Ghostty. If we aren't from
// the desktop then we didn't set our LANGUAGE var so we
// don't need to remove it.
switch (self.app.config.@"launched-from".?) {
.desktop => env.remove("LANGUAGE"),
.dbus, .systemd, .cli => {},
}
if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE");
}
return env;

View File

@@ -1,15 +0,0 @@
const internal_os = @import("../os/main.zig");
// The required comptime API for any apprt.
pub const App = @import("gtk-ng/App.zig");
pub const Surface = @import("gtk-ng/Surface.zig");
pub const resourcesDir = internal_os.resourcesDir;
// The exported API, custom for the apprt.
pub const class = @import("gtk-ng/class.zig");
pub const WeakRef = @import("gtk-ng/weak_ref.zig").WeakRef;
test {
@import("std").testing.refAllDecls(@This());
_ = @import("gtk-ng/ext.zig");
}

View File

@@ -1,104 +0,0 @@
/// This is the main entrypoint to the apprt for Ghostty. Ghostty will
/// initialize this in main to start the application..
const App = @This();
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gio = @import("gio");
const apprt = @import("../../apprt.zig");
const configpkg = @import("../../config.zig");
const internal_os = @import("../../os/main.zig");
const Config = configpkg.Config;
const CoreApp = @import("../../App.zig");
const Application = @import("class/application.zig").Application;
const Surface = @import("Surface.zig");
const gtk_version = @import("gtk_version.zig");
const adw_version = @import("adw_version.zig");
const ipcNewWindow = @import("ipc/new_window.zig").newWindow;
const log = std.log.scoped(.gtk);
/// This is detected by the Renderer, in which case it sends a `redraw_surface`
/// message so that we can call `drawFrame` ourselves from the app thread,
/// because GTK's `GLArea` does not support drawing from a different thread.
pub const must_draw_from_app_thread = true;
/// GTK application ID
pub const application_id = switch (builtin.mode) {
.Debug, .ReleaseSafe => "com.mitchellh.ghostty-debug",
.ReleaseFast, .ReleaseSmall => "com.mitchellh.ghostty",
};
/// GTK object path
pub const object_path = switch (builtin.mode) {
.Debug, .ReleaseSafe => "/com/mitchellh/ghostty_debug",
.ReleaseFast, .ReleaseSmall => "/com/mitchellh/ghostty",
};
/// The GObject Application instance
app: *Application,
pub fn init(
self: *App,
core_app: *CoreApp,
// Required by the apprt interface but we don't use it.
opts: struct {},
) !void {
_ = opts;
const app: *Application = try .new(self, core_app);
errdefer app.unref();
self.* = .{ .app = app };
return;
}
pub fn run(self: *App) !void {
try self.app.run();
}
pub fn terminate(self: *App) void {
// We force deinitialize the app. We don't unref because other things
// tend to have a reference at this point, so this just forces the
// disposal now.
self.app.deinit();
}
/// Called by CoreApp to wake up the event loop.
pub fn wakeup(self: *App) void {
self.app.wakeup();
}
pub fn performAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
) !bool {
return try self.app.performAction(target, action, value);
}
/// Send the given IPC to a running Ghostty. Returns `true` if the action was
/// able to be performed, `false` otherwise.
///
/// Note that this is a static function. Since this is called from a CLI app (or
/// some other process that is not Ghostty) there is no full-featured apprt App
/// to use.
pub fn performIpc(
alloc: Allocator,
target: apprt.ipc.Target,
comptime action: apprt.ipc.Action.Key,
value: apprt.ipc.Action.Value(action),
) !bool {
switch (action) {
.new_window => return try ipcNewWindow(alloc, target, value),
}
}
/// Redraw the inspector for the given surface.
pub fn redrawInspector(_: *App, surface: *Surface) void {
surface.redrawInspector();
}

View File

@@ -1,103 +0,0 @@
const Self = @This();
const std = @import("std");
const apprt = @import("../../apprt.zig");
const CoreSurface = @import("../../Surface.zig");
const ApprtApp = @import("App.zig");
const Application = @import("class/application.zig").Application;
const Surface = @import("class/surface.zig").Surface;
/// The GObject Surface
surface: *Surface,
pub fn deinit(self: *Self) void {
_ = self;
}
/// Returns the GObject surface for this apprt surface. This is a function
/// so we can add some extra logic if we ever have to here.
pub fn gobj(self: *Self) *Surface {
return self.surface;
}
pub fn core(self: *Self) *CoreSurface {
// This asserts the non-optional because libghostty should only
// be calling this for initialized surfaces.
return self.surface.core().?;
}
pub fn rtApp(self: *Self) *ApprtApp {
_ = self;
return Application.default().rt();
}
pub fn close(self: *Self, process_active: bool) void {
_ = process_active;
self.surface.close();
}
pub fn cgroup(self: *Self) ?[]const u8 {
return self.surface.cgroupPath();
}
pub fn getTitle(self: *Self) ?[:0]const u8 {
return self.surface.getTitle();
}
pub fn getContentScale(self: *const Self) !apprt.ContentScale {
return self.surface.getContentScale();
}
pub fn getSize(self: *const Self) !apprt.SurfaceSize {
return self.surface.getSize();
}
pub fn getCursorPos(self: *const Self) !apprt.CursorPos {
return self.surface.getCursorPos();
}
pub fn supportsClipboard(
self: *const Self,
clipboard_type: apprt.Clipboard,
) bool {
_ = self;
return switch (clipboard_type) {
.standard,
.selection,
.primary,
=> true,
};
}
pub fn clipboardRequest(
self: *Self,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !void {
try self.surface.clipboardRequest(
clipboard_type,
state,
);
}
pub fn setClipboardString(
self: *Self,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
confirm: bool,
) !void {
self.surface.setClipboardString(
val,
clipboard_type,
confirm,
);
}
pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap {
return try self.surface.defaultTermioEnv();
}
/// Redraw the inspector for our surface.
pub fn redrawInspector(self: *Self) void {
self.surface.redrawInspector();
}

View File

@@ -1,122 +0,0 @@
const std = @import("std");
// Until the gobject bindings are built at the same time we are building
// Ghostty, we need to import `adwaita.h` directly to ensure that the version
// macros match the version of `libadwaita` that we are building/linking
// against.
const c = @cImport({
@cInclude("adwaita.h");
});
const adw = @import("adw");
const log = std.log.scoped(.gtk);
pub const comptime_version: std.SemanticVersion = .{
.major = c.ADW_MAJOR_VERSION,
.minor = c.ADW_MINOR_VERSION,
.patch = c.ADW_MICRO_VERSION,
};
pub fn getRuntimeVersion() std.SemanticVersion {
return .{
.major = adw.getMajorVersion(),
.minor = adw.getMinorVersion(),
.patch = adw.getMicroVersion(),
};
}
pub fn logVersion() void {
log.info("libadwaita version build={} runtime={}", .{
comptime_version,
getRuntimeVersion(),
});
}
/// Verifies that the running libadwaita version is at least the given
/// version. This will return false if Ghostty is configured to not build with
/// libadwaita.
///
/// 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. So generally you probably want to do both checks!
///
/// 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 comptime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) == .lt) return false;
// If we're in comptime then we can't check the runtime version.
if (@inComptime()) return true;
return runtimeAtLeast(major, minor, micro);
}
/// Verifies that the libadwaita version at runtime is at least the given version.
///
/// This function should be used in cases where the only the runtime behavior
/// is affected by the version check. For checks which would affect code
/// generation, use `atLeast`.
pub inline fn runtimeAtLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
// We use the functions instead of the constants such as c.GTK_MINOR_VERSION
// because the function gets the actual runtime version.
const runtime_version = getRuntimeVersion();
return runtime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) != .lt;
}
test "versionAtLeast" {
const testing = std.testing;
const funs = &.{ atLeast, runtimeAtLeast };
inline for (funs) |fun| {
try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
try testing.expect(!fun(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1));
}
}
// Whether AdwDialog, AdwAlertDialog, etc. are supported (1.5+)
pub inline fn supportsDialogs() bool {
return atLeast(1, 5, 0);
}
pub inline fn supportsTabOverview() bool {
return atLeast(1, 4, 0);
}
pub inline fn supportsSwitchRow() bool {
return atLeast(1, 4, 0);
}
pub inline fn supportsToolbarView() bool {
return atLeast(1, 4, 0);
}
pub inline fn supportsBanner() bool {
return atLeast(1, 3, 0);
}

View File

@@ -1,213 +0,0 @@
/// Contains all the logic for putting the Ghostty process and
/// each individual surface into its own cgroup.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const App = @import("App.zig");
const internal_os = @import("../../os/main.zig");
const log = std.log.scoped(.gtk_systemd_cgroup);
pub const Options = struct {
memory_high: ?u64 = null,
pids_max: ?u64 = null,
};
/// Initialize the cgroup for the app. This will create our
/// transient scope, initialize the cgroups we use for the app,
/// configure them, and return the cgroup path for the app.
///
/// Returns the path of the current cgroup for the app, which is
/// allocated with the given allocator.
pub fn init(
alloc: Allocator,
dbus: *gio.DBusConnection,
opts: Options,
) ![]const u8 {
const pid = std.os.linux.getpid();
// Get our initial cgroup. We need this so we can compare
// and detect when we've switched to our transient group.
const original = try internal_os.cgroup.current(
alloc,
pid,
) orelse "";
defer alloc.free(original);
// Create our transient scope. If this succeeds then the unit
// was created, but we may not have moved into it yet, so we need
// to do a dumb busy loop to wait for the move to complete.
try createScope(dbus, pid);
const transient = transient: while (true) {
const current = try internal_os.cgroup.current(
alloc,
pid,
) orelse "";
if (!std.mem.eql(u8, original, current)) break :transient current;
alloc.free(current);
std.time.sleep(25 * std.time.ns_per_ms);
};
errdefer alloc.free(transient);
log.info("transient scope created cgroup={s}", .{transient});
// Create the app cgroup and put ourselves in it. This is
// required because controllers can't be configured while a
// process is in a cgroup.
try internal_os.cgroup.create(transient, "app", pid);
// Create a cgroup that will contain all our surfaces. We will
// enable the controllers and configure resource limits for surfaces
// only on this cgroup so that it doesn't affect our main app.
try internal_os.cgroup.create(transient, "surfaces", null);
const surfaces = try std.fmt.allocPrint(alloc, "{s}/surfaces", .{transient});
defer alloc.free(surfaces);
// Enable all of our cgroup controllers. If these fail then
// we just log. We can't reasonably undo what we've done above
// so we log the warning and still return the transient group.
// I don't know a scenario where this fails yet.
try enableControllers(alloc, transient);
try enableControllers(alloc, surfaces);
// Configure the "high" memory limit. This limit is used instead
// of "max" because it's a soft limit that can be exceeded and
// can be monitored by things like systemd-oomd to kill if needed,
// versus an instant hard kill.
if (opts.memory_high) |limit| {
try internal_os.cgroup.configureLimit(surfaces, .{
.memory_high = limit,
});
}
// Configure the "max" pids limit. This is a hard limit and cannot be
// exceeded.
if (opts.pids_max) |limit| {
try internal_os.cgroup.configureLimit(surfaces, .{
.pids_max = limit,
});
}
return transient;
}
/// Enable all the cgroup controllers for the given cgroup.
fn enableControllers(alloc: Allocator, cgroup: []const u8) !void {
const raw = try internal_os.cgroup.controllers(alloc, cgroup);
defer alloc.free(raw);
// Build our string builder for enabling all controllers
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
// Controllers are space-separated
var it = std.mem.splitScalar(u8, raw, ' ');
while (it.next()) |controller| {
try builder.append('+');
try builder.appendSlice(controller);
if (it.rest().len > 0) try builder.append(' ');
}
// Enable them all
try internal_os.cgroup.configureControllers(
cgroup,
builder.items,
);
}
/// Create a transient systemd scope unit for the current process and
/// move our process into it.
fn createScope(
dbus: *gio.DBusConnection,
pid_: std.os.linux.pid_t,
) !void {
const pid: u32 = @intCast(pid_);
// The unit name needs to be unique. We use the pid for this.
var name_buf: [256]u8 = undefined;
const name = std.fmt.bufPrintZ(
&name_buf,
"app-ghostty-transient-{}.scope",
.{pid},
) catch unreachable;
const builder_type = glib.VariantType.new("(ssa(sv)a(sa(sv)))");
defer glib.free(builder_type);
// Initialize our builder to build up our parameters
var builder: glib.VariantBuilder = undefined;
builder.init(builder_type);
builder.add("s", name.ptr);
builder.add("s", "fail");
{
// Properties
const properties_type = glib.VariantType.new("a(sv)");
defer glib.free(properties_type);
builder.open(properties_type);
defer builder.close();
// https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html
const pressure_value = glib.Variant.newString("kill");
builder.add("(sv)", "ManagedOOMMemoryPressure", pressure_value);
// Delegate
const delegate_value = glib.Variant.newBoolean(1);
builder.add("(sv)", "Delegate", delegate_value);
// Pid to move into the unit
const pids_value_type = glib.VariantType.new("u");
defer glib.free(pids_value_type);
const pids_value = glib.Variant.newFixedArray(pids_value_type, &pid, 1, @sizeOf(u32));
builder.add("(sv)", "PIDs", pids_value);
}
{
// Aux
const aux_type = glib.VariantType.new("a(sa(sv))");
defer glib.free(aux_type);
builder.open(aux_type);
defer builder.close();
}
var err: ?*glib.Error = null;
defer if (err) |e| e.free();
const reply_type = glib.VariantType.new("(o)");
defer glib.free(reply_type);
const value = builder.end();
const reply = dbus.callSync(
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
"org.freedesktop.systemd1.Manager",
"StartTransientUnit",
value,
reply_type,
.{},
-1,
null,
&err,
) orelse {
if (err) |e| log.err(
"creating transient cgroup scope failed code={} err={s}",
.{
e.f_code,
if (e.f_message) |msg| msg else "(no message)",
},
);
return error.DbusCallFailed;
};
defer reply.unref();
}

View File

@@ -1,140 +0,0 @@
const std = @import("std");
// Until the gobject bindings are built at the same time we are building
// Ghostty, we need to import `gtk/gtk.h` directly to ensure that the version
// macros match the version of `gtk4` that we are building/linking against.
const c = @cImport({
@cInclude("gtk/gtk.h");
});
const gtk = @import("gtk");
const log = std.log.scoped(.gtk);
pub const comptime_version: std.SemanticVersion = .{
.major = c.GTK_MAJOR_VERSION,
.minor = c.GTK_MINOR_VERSION,
.patch = c.GTK_MICRO_VERSION,
};
pub fn getRuntimeVersion() std.SemanticVersion {
return .{
.major = gtk.getMajorVersion(),
.minor = gtk.getMinorVersion(),
.patch = gtk.getMicroVersion(),
};
}
pub fn logVersion() void {
log.info("GTK version build={} runtime={}", .{
comptime_version,
getRuntimeVersion(),
});
}
/// 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 function should be used in cases where the version check would affect
/// code generation, such as using symbols that are only available beyond a
/// certain version. For checks which only depend on GTK's runtime behavior,
/// use `runtimeAtLeast`.
///
/// 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 comptime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) == .lt) return false;
// If we're in comptime then we can't check the runtime version.
if (@inComptime()) return true;
return runtimeAtLeast(major, minor, micro);
}
/// Verifies that the GTK version at runtime is at least the given version.
///
/// This function should be used in cases where the only the runtime behavior
/// is affected by the version check. For checks which would affect code
/// generation, use `atLeast`.
pub inline fn runtimeAtLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
// We use the functions instead of the constants such as c.GTK_MINOR_VERSION
// because the function gets the actual runtime version.
const runtime_version = getRuntimeVersion();
return runtime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) != .lt;
}
pub inline fn runtimeUntil(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
const runtime_version = getRuntimeVersion();
return runtime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) == .lt;
}
test "atLeast" {
const testing = std.testing;
const funs = &.{ atLeast, runtimeAtLeast };
inline for (funs) |fun| {
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1));
}
}
test "runtimeUntil" {
const testing = std.testing;
// This is an array in case we add a comptime variant.
const funs = &.{runtimeUntil};
inline for (funs) |fun| {
try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1));
}
}

View File

@@ -1,62 +0,0 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const glib = @import("glib");
const apprt = @import("../../../apprt.zig");
const DBus = @import("DBus.zig");
// Use a D-Bus method call to open a new window on GTK.
// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI
//
// `ghostty +new-window` is equivalent to the following command (on a release build):
//
// ```
// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window [] []
// ```
//
// `ghostty +new-window -e echo hello` would be equivalent to the following command (on a release build):
//
// ```
// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["echo" "hello"]>]' []
// ```
pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool {
var dbus = try DBus.init(
alloc,
target,
if (value.arguments == null)
"new-window"
else
"new-window-command",
);
defer dbus.deinit(alloc);
if (value.arguments) |arguments| {
// If `-e` was specified on the command line, the first
// parameter is an array of strings that contain the arguments
// that came after `-e`, which will be interpreted as a command
// to run.
const as_variant_type = glib.VariantType.new("as");
defer as_variant_type.free();
const s_variant_type = glib.VariantType.new("s");
defer s_variant_type.free();
var command: glib.VariantBuilder = undefined;
command.init(as_variant_type);
errdefer command.clear();
for (arguments) |argument| {
const bytes = glib.Bytes.new(argument.ptr, argument.len + 1);
defer bytes.unref();
const string = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true));
command.addValue(string);
}
dbus.addParameter(command.end());
}
try dbus.send();
return true;
}

View File

@@ -1,411 +0,0 @@
const std = @import("std");
const build_options = @import("build_options");
const gdk = @import("gdk");
const glib = @import("glib");
const gtk = @import("gtk");
const input = @import("../../input.zig");
const winproto = @import("winproto.zig");
/// Returns a GTK accelerator string from a trigger.
pub fn accelFromTrigger(
buf: []u8,
trigger: input.Binding.Trigger,
) error{NoSpaceLeft}!?[:0]const u8 {
var buf_stream = std.io.fixedBufferStream(buf);
const writer = buf_stream.writer();
// Modifiers
if (trigger.mods.shift) try writer.writeAll("<Shift>");
if (trigger.mods.ctrl) try writer.writeAll("<Ctrl>");
if (trigger.mods.alt) try writer.writeAll("<Alt>");
if (trigger.mods.super) try writer.writeAll("<Super>");
// Write our key
if (!try writeTriggerKey(writer, trigger)) return null;
// We need to make the string null terminated.
try writer.writeByte(0);
const slice = buf_stream.getWritten();
return slice[0 .. slice.len - 1 :0];
}
/// Returns a XDG-compliant shortcuts string from a trigger.
/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/
pub fn xdgShortcutFromTrigger(
buf: []u8,
trigger: input.Binding.Trigger,
) error{NoSpaceLeft}!?[:0]const u8 {
var buf_stream = std.io.fixedBufferStream(buf);
const writer = buf_stream.writer();
// Modifiers
if (trigger.mods.shift) try writer.writeAll("SHIFT+");
if (trigger.mods.ctrl) try writer.writeAll("CTRL+");
if (trigger.mods.alt) try writer.writeAll("ALT+");
if (trigger.mods.super) try writer.writeAll("LOGO+");
// Write our key
// NOTE: While the spec specifies that only libxkbcommon keysyms are
// expected, using GTK's keysyms should still work as they are identical
// to *X11's* keysyms (which I assume is a subset of libxkbcommon's).
// I haven't been able to any evidence to back up that assumption but
// this works for now
if (!try writeTriggerKey(writer, trigger)) return null;
// We need to make the string null terminated.
try writer.writeByte(0);
const slice = buf_stream.getWritten();
return slice[0 .. slice.len - 1 :0];
}
fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) error{NoSpaceLeft}!bool {
switch (trigger.key) {
.physical => |k| {
const keyval = keyvalFromKey(k) orelse return false;
try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return false));
},
.unicode => |cp| {
if (gdk.keyvalName(cp)) |name| {
try writer.writeAll(std.mem.span(name));
} else {
try writer.print("{u}", .{cp});
}
},
}
return true;
}
pub fn translateMods(state: gdk.ModifierType) input.Mods {
return .{
.shift = state.shift_mask,
.ctrl = state.control_mask,
.alt = state.alt_mask,
.super = state.super_mask,
// Lock is dependent on the X settings but we just assume caps lock.
.caps_lock = state.lock_mask,
};
}
// Get the unshifted unicode value of the keyval. This is used
// by the Kitty keyboard protocol.
pub fn keyvalUnicodeUnshifted(
widget: *gtk.Widget,
event: *gdk.KeyEvent,
keycode: u32,
) u21 {
const display = widget.getDisplay();
// We need to get the currently active keyboard layout so we know
// what group to look at.
const layout = event.getLayout();
// Get all the possible keyboard mappings for this keycode. A keycode is the
// physical key pressed.
var keys: [*]gdk.KeymapKey = undefined;
var keyvals: [*]c_uint = undefined;
var n_entries: c_int = 0;
if (display.mapKeycode(keycode, &keys, &keyvals, &n_entries) == 0) return 0;
defer glib.free(keys);
defer glib.free(keyvals);
// debugging:
// std.log.debug("layout={}", .{layout});
// for (0..@intCast(n_entries)) |i| {
// std.log.debug("keymap key={} codepoint={x}", .{
// keys[i],
// gdk.keyvalToUnicode(keyvals[i]),
// });
// }
for (0..@intCast(n_entries)) |i| {
if (keys[i].f_group == layout and
keys[i].f_level == 0)
{
return std.math.cast(
u21,
gdk.keyvalToUnicode(keyvals[i]),
) orelse 0;
}
}
return 0;
}
/// Returns the mods to use a key event from a GTK event.
/// This requires a lot of context because the GdkEvent
/// doesn't contain enough on its own.
pub fn eventMods(
event: *gdk.Event,
physical_key: input.Key,
gtk_mods: gdk.ModifierType,
action: input.Action,
app_winproto: *winproto.App,
) input.Mods {
const device = event.getDevice();
var mods = app_winproto.eventMods(device, gtk_mods);
mods.num_lock = if (device) |d| d.getNumLockState() != 0 else false;
// We use the physical key to determine sided modifiers. As
// far as I can tell there's no other way to reliably determine
// this.
//
// We also set the main modifier to true if either side is true,
// since on both X11/Wayland, GTK doesn't set the main modifier
// if only the modifier key is pressed, but our core logic
// relies on it.
switch (physical_key) {
.shift_left => {
mods.shift = action != .release;
mods.sides.shift = .left;
},
.shift_right => {
mods.shift = action != .release;
mods.sides.shift = .right;
},
.control_left => {
mods.ctrl = action != .release;
mods.sides.ctrl = .left;
},
.control_right => {
mods.ctrl = action != .release;
mods.sides.ctrl = .right;
},
.alt_left => {
mods.alt = action != .release;
mods.sides.alt = .left;
},
.alt_right => {
mods.alt = action != .release;
mods.sides.alt = .right;
},
.meta_left => {
mods.super = action != .release;
mods.sides.super = .left;
},
.meta_right => {
mods.super = action != .release;
mods.sides.super = .right;
},
else => {},
}
return mods;
}
/// Returns an input key from a keyval or null if we don't have a mapping.
pub fn keyFromKeyval(keyval: c_uint) ?input.Key {
for (keymap) |entry| {
if (entry[0] == keyval) return entry[1];
}
return null;
}
/// Returns a keyval from an input key or null if we don't have a mapping.
pub fn keyvalFromKey(key: input.Key) ?c_uint {
switch (key) {
inline else => |key_comptime| {
return comptime value: {
@setEvalBranchQuota(50_000);
for (keymap) |entry| {
if (entry[1] == key_comptime) break :value entry[0];
}
break :value null;
};
},
}
}
test "accelFromTrigger" {
const testing = std.testing;
var buf: [256]u8 = undefined;
try testing.expectEqualStrings("<Super>q", (try accelFromTrigger(&buf, .{
.mods = .{ .super = true },
.key = .{ .unicode = 'q' },
})).?);
try testing.expectEqualStrings("<Shift><Ctrl><Alt><Super>backslash", (try accelFromTrigger(&buf, .{
.mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true },
.key = .{ .unicode = 92 },
})).?);
}
test "xdgShortcutFromTrigger" {
const testing = std.testing;
var buf: [256]u8 = undefined;
try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{
.mods = .{ .super = true },
.key = .{ .unicode = 'q' },
})).?);
try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{
.mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true },
.key = .{ .unicode = 92 },
})).?);
}
/// A raw entry in the keymap. Our keymap contains mappings between
/// GDK keys and our own key enum.
const RawEntry = struct { c_uint, input.Key };
const keymap: []const RawEntry = &.{
.{ gdk.KEY_a, .key_a },
.{ gdk.KEY_b, .key_b },
.{ gdk.KEY_c, .key_c },
.{ gdk.KEY_d, .key_d },
.{ gdk.KEY_e, .key_e },
.{ gdk.KEY_f, .key_f },
.{ gdk.KEY_g, .key_g },
.{ gdk.KEY_h, .key_h },
.{ gdk.KEY_i, .key_i },
.{ gdk.KEY_j, .key_j },
.{ gdk.KEY_k, .key_k },
.{ gdk.KEY_l, .key_l },
.{ gdk.KEY_m, .key_m },
.{ gdk.KEY_n, .key_n },
.{ gdk.KEY_o, .key_o },
.{ gdk.KEY_p, .key_p },
.{ gdk.KEY_q, .key_q },
.{ gdk.KEY_r, .key_r },
.{ gdk.KEY_s, .key_s },
.{ gdk.KEY_t, .key_t },
.{ gdk.KEY_u, .key_u },
.{ gdk.KEY_v, .key_v },
.{ gdk.KEY_w, .key_w },
.{ gdk.KEY_x, .key_x },
.{ gdk.KEY_y, .key_y },
.{ gdk.KEY_z, .key_z },
.{ gdk.KEY_0, .digit_0 },
.{ gdk.KEY_1, .digit_1 },
.{ gdk.KEY_2, .digit_2 },
.{ gdk.KEY_3, .digit_3 },
.{ gdk.KEY_4, .digit_4 },
.{ gdk.KEY_5, .digit_5 },
.{ gdk.KEY_6, .digit_6 },
.{ gdk.KEY_7, .digit_7 },
.{ gdk.KEY_8, .digit_8 },
.{ gdk.KEY_9, .digit_9 },
.{ gdk.KEY_semicolon, .semicolon },
.{ gdk.KEY_space, .space },
.{ gdk.KEY_apostrophe, .quote },
.{ gdk.KEY_comma, .comma },
.{ gdk.KEY_grave, .backquote },
.{ gdk.KEY_period, .period },
.{ gdk.KEY_slash, .slash },
.{ gdk.KEY_minus, .minus },
.{ gdk.KEY_equal, .equal },
.{ gdk.KEY_bracketleft, .bracket_left },
.{ gdk.KEY_bracketright, .bracket_right },
.{ gdk.KEY_backslash, .backslash },
.{ gdk.KEY_Up, .arrow_up },
.{ gdk.KEY_Down, .arrow_down },
.{ gdk.KEY_Right, .arrow_right },
.{ gdk.KEY_Left, .arrow_left },
.{ gdk.KEY_Home, .home },
.{ gdk.KEY_End, .end },
.{ gdk.KEY_Insert, .insert },
.{ gdk.KEY_Delete, .delete },
.{ gdk.KEY_Caps_Lock, .caps_lock },
.{ gdk.KEY_Scroll_Lock, .scroll_lock },
.{ gdk.KEY_Num_Lock, .num_lock },
.{ gdk.KEY_Page_Up, .page_up },
.{ gdk.KEY_Page_Down, .page_down },
.{ gdk.KEY_Escape, .escape },
.{ gdk.KEY_Return, .enter },
.{ gdk.KEY_Tab, .tab },
.{ gdk.KEY_BackSpace, .backspace },
.{ gdk.KEY_Print, .print_screen },
.{ gdk.KEY_Pause, .pause },
.{ gdk.KEY_F1, .f1 },
.{ gdk.KEY_F2, .f2 },
.{ gdk.KEY_F3, .f3 },
.{ gdk.KEY_F4, .f4 },
.{ gdk.KEY_F5, .f5 },
.{ gdk.KEY_F6, .f6 },
.{ gdk.KEY_F7, .f7 },
.{ gdk.KEY_F8, .f8 },
.{ gdk.KEY_F9, .f9 },
.{ gdk.KEY_F10, .f10 },
.{ gdk.KEY_F11, .f11 },
.{ gdk.KEY_F12, .f12 },
.{ gdk.KEY_F13, .f13 },
.{ gdk.KEY_F14, .f14 },
.{ gdk.KEY_F15, .f15 },
.{ gdk.KEY_F16, .f16 },
.{ gdk.KEY_F17, .f17 },
.{ gdk.KEY_F18, .f18 },
.{ gdk.KEY_F19, .f19 },
.{ gdk.KEY_F20, .f20 },
.{ gdk.KEY_F21, .f21 },
.{ gdk.KEY_F22, .f22 },
.{ gdk.KEY_F23, .f23 },
.{ gdk.KEY_F24, .f24 },
.{ gdk.KEY_F25, .f25 },
.{ gdk.KEY_KP_0, .numpad_0 },
.{ gdk.KEY_KP_1, .numpad_1 },
.{ gdk.KEY_KP_2, .numpad_2 },
.{ gdk.KEY_KP_3, .numpad_3 },
.{ gdk.KEY_KP_4, .numpad_4 },
.{ gdk.KEY_KP_5, .numpad_5 },
.{ gdk.KEY_KP_6, .numpad_6 },
.{ gdk.KEY_KP_7, .numpad_7 },
.{ gdk.KEY_KP_8, .numpad_8 },
.{ gdk.KEY_KP_9, .numpad_9 },
.{ gdk.KEY_KP_Decimal, .numpad_decimal },
.{ gdk.KEY_KP_Divide, .numpad_divide },
.{ gdk.KEY_KP_Multiply, .numpad_multiply },
.{ gdk.KEY_KP_Subtract, .numpad_subtract },
.{ gdk.KEY_KP_Add, .numpad_add },
.{ gdk.KEY_KP_Enter, .numpad_enter },
.{ gdk.KEY_KP_Equal, .numpad_equal },
.{ gdk.KEY_KP_Separator, .numpad_separator },
.{ gdk.KEY_KP_Left, .numpad_left },
.{ gdk.KEY_KP_Right, .numpad_right },
.{ gdk.KEY_KP_Up, .numpad_up },
.{ gdk.KEY_KP_Down, .numpad_down },
.{ gdk.KEY_KP_Page_Up, .numpad_page_up },
.{ gdk.KEY_KP_Page_Down, .numpad_page_down },
.{ gdk.KEY_KP_Home, .numpad_home },
.{ gdk.KEY_KP_End, .numpad_end },
.{ gdk.KEY_KP_Insert, .numpad_insert },
.{ gdk.KEY_KP_Delete, .numpad_delete },
.{ gdk.KEY_KP_Begin, .numpad_begin },
.{ gdk.KEY_Copy, .copy },
.{ gdk.KEY_Cut, .cut },
.{ gdk.KEY_Paste, .paste },
.{ gdk.KEY_Shift_L, .shift_left },
.{ gdk.KEY_Control_L, .control_left },
.{ gdk.KEY_Alt_L, .alt_left },
.{ gdk.KEY_Super_L, .meta_left },
.{ gdk.KEY_Shift_R, .shift_right },
.{ gdk.KEY_Control_R, .control_right },
.{ gdk.KEY_Alt_R, .alt_right },
.{ gdk.KEY_Super_R, .meta_right },
// TODO: media keys
};

View File

@@ -1,28 +0,0 @@
using Gtk 4.0;
// This is unused but if we remove it we get a blueprint-compiler error.
using Adw 1;
template $GhosttyConfigErrorsDialog: $GhosttyDialog {
heading: _("Configuration Errors");
body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors.");
responses [
ignore: _("Ignore"),
reload: _("Reload Configuration") suggested,
]
extra-child: ScrolledWindow {
min-content-width: 500;
min-content-height: 100;
TextView {
editable: false;
cursor-visible: false;
top-margin: 8;
bottom-margin: 8;
left-margin: 8;
right-margin: 8;
buffer: bind (template.config as <$GhosttyConfig>).diagnostics-buffer;
}
};
}

View File

@@ -1,292 +0,0 @@
using Gtk 4.0;
using Adw 1;
template $GhosttySurface: Adw.Bin {
styles [
"surface",
]
notify::bell-ringing => $notify_bell_ringing();
notify::config => $notify_config();
notify::error => $notify_error();
notify::mouse-hover-url => $notify_mouse_hover_url();
notify::mouse-hidden => $notify_mouse_hidden();
notify::mouse-shape => $notify_mouse_shape();
Stack {
StackPage {
name: "terminal";
child: Overlay {
focusable: false;
focus-on-click: false;
child: Box {
hexpand: true;
vexpand: true;
GLArea gl_area {
realize => $gl_realize();
unrealize => $gl_unrealize();
render => $gl_render();
resize => $gl_resize();
hexpand: true;
vexpand: true;
focusable: true;
focus-on-click: true;
has-stencil-buffer: false;
has-depth-buffer: false;
allowed-apis: gl;
}
PopoverMenu context_menu {
closed => $context_menu_closed();
menu-model: context_menu_model;
flags: nested;
halign: start;
has-arrow: false;
}
};
[overlay]
ProgressBar progress_bar_overlay {
styles [
"osd",
]
visible: false;
halign: fill;
valign: start;
}
[overlay]
// The "border" bell feature is implemented here as an overlay rather than
// just adding a border to the GLArea or other widget for two reasons.
// First, adding a border to an existing widget causes a resize of the
// widget which undesirable side effects. Second, we can make it reactive
// here in the blueprint with relatively little code.
Revealer {
reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as <bool>;
transition-type: crossfade;
transition-duration: 500;
Box bell_overlay {
styles [
"bell-overlay",
]
halign: fill;
valign: fill;
}
}
[overlay]
$GhosttySurfaceChildExited child_exited_overlay {
visible: bind template.child-exited;
close-request => $child_exited_close();
}
[overlay]
$GhosttyResizeOverlay resize_overlay {}
[overlay]
Label url_left {
styles [
"background",
"url-overlay",
]
visible: false;
halign: start;
valign: end;
label: bind template.mouse-hover-url;
EventControllerMotion url_ec_motion {
enter => $url_mouse_enter();
leave => $url_mouse_leave();
}
}
[overlay]
Label url_right {
styles [
"background",
"url-overlay",
]
visible: false;
halign: end;
valign: end;
label: bind template.mouse-hover-url;
}
// Event controllers for interactivity
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
EventControllerMotion {
motion => $mouse_motion();
leave => $mouse_leave();
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
}
GestureClick {
pressed => $mouse_down();
released => $mouse_up();
button: 0;
}
DropTarget drop_target {
drop => $drop();
actions: copy;
}
};
}
StackPage {
name: "error";
child: Adw.StatusPage {
icon-name: "computer-fail-symbolic";
title: _("Oh, no.");
description: _("Unable to acquire an OpenGL context for rendering.");
child: LinkButton {
label: "https://ghostty.org/docs/help/gtk-opengl-context";
uri: "https://ghostty.org/docs/help/gtk-opengl-context";
};
};
}
// The order matters here: we can only set this after the stack
// pages above have been created.
visible-child-name: bind $stack_child_name(template.error) as <string>;
}
}
IMMulticontext im_context {
input-purpose: terminal;
preedit-start => $im_preedit_start();
preedit-changed => $im_preedit_changed();
preedit-end => $im_preedit_end();
commit => $im_commit();
}
menu context_menu_model {
section {
item {
label: _("Copy");
action: "win.copy";
}
item {
label: _("Paste");
action: "win.paste";
}
}
section {
item {
label: _("Clear");
action: "win.clear";
}
item {
label: _("Reset");
action: "win.reset";
}
}
section {
submenu {
label: _("Split");
item {
label: _("Change Title…");
action: "surface.prompt-title";
}
item {
label: _("Split Up");
action: "split-tree.new-split";
target: "up";
}
item {
label: _("Split Down");
action: "split-tree.new-split";
target: "down";
}
item {
label: _("Split Left");
action: "split-tree.new-split";
target: "left";
}
item {
label: _("Split Right");
action: "split-tree.new-split";
target: "right";
}
}
submenu {
label: _("Tab");
item {
label: _("New Tab");
action: "win.new-tab";
}
item {
label: _("Close Tab");
action: "tab.close";
target: "this";
}
}
submenu {
label: _("Window");
item {
label: _("New Window");
action: "win.new-window";
}
item {
label: _("Close Window");
action: "win.close";
}
}
}
section {
submenu {
label: _("Config");
item {
label: _("Open Configuration");
action: "app.open-config";
}
item {
label: _("Reload Configuration");
action: "app.reload-config";
}
}
}
}

View File

@@ -1,110 +0,0 @@
using Gtk 4.0;
using Gio 2.0;
using Adw 1;
Adw.Dialog dialog {
content-width: 700;
closed => $closed();
Adw.ToolbarView {
top-bar-style: flat;
[top]
Adw.HeaderBar {
[title]
Gtk.SearchEntry search {
hexpand: true;
placeholder-text: _("Execute a command…");
stop-search => $search_stopped();
activate => $search_activated();
styles [
"command-palette-search",
]
}
}
Gtk.ScrolledWindow {
min-content-height: 300;
Gtk.ListView view {
show-separators: true;
single-click-activate: true;
activate => $row_activated();
model: Gtk.SingleSelection model {
model: Gtk.FilterListModel {
incremental: true;
filter: Gtk.AnyFilter {
Gtk.StringFilter {
expression: expr item as <$GhosttyCommand>.title;
search: bind search.text;
}
Gtk.StringFilter {
expression: expr item as <$GhosttyCommand>.action-key;
search: bind search.text;
}
};
model: Gio.ListStore source {
item-type: typeof<$GhosttyCommand>;
};
};
};
styles [
"rich-list",
]
factory: Gtk.BuilderListItemFactory {
template Gtk.ListItem {
child: Gtk.Box {
orientation: horizontal;
spacing: 10;
tooltip-text: bind template.item as <$GhosttyCommand>.description;
Gtk.Box {
orientation: vertical;
hexpand: true;
Gtk.Label {
ellipsize: end;
halign: start;
wrap: false;
single-line-mode: true;
styles [
"title",
]
label: bind template.item as <$GhosttyCommand>.title;
}
Gtk.Label {
ellipsize: end;
halign: start;
wrap: false;
single-line-mode: true;
styles [
"subtitle",
"monospace",
]
label: bind template.item as <$GhosttyCommand>.action-key;
}
}
Gtk.ShortcutLabel {
accelerator: bind template.item as <$GhosttyCommand>.action;
valign: center;
}
};
}
};
}
}
}
}

View File

@@ -1,155 +0,0 @@
const std = @import("std");
const build_options = @import("build_options");
const Allocator = std.mem.Allocator;
const gdk = @import("gdk");
const Config = @import("../../config.zig").Config;
const input = @import("../../input.zig");
const key = @import("key.zig");
const ApprtWindow = @import("class/window.zig").Window;
pub const noop = @import("winproto/noop.zig");
pub const x11 = @import("winproto/x11.zig");
pub const wayland = @import("winproto/wayland.zig");
pub const Protocol = enum {
none,
wayland,
x11,
};
/// App-state for the underlying windowing protocol. There should be one
/// instance of this struct per application.
pub const App = union(Protocol) {
none: noop.App,
wayland: if (build_options.wayland) wayland.App else noop.App,
x11: if (build_options.x11) x11.App else noop.App,
pub fn init(
alloc: Allocator,
gdk_display: *gdk.Display,
app_id: [:0]const u8,
config: *const Config,
) !App {
inline for (@typeInfo(App).@"union".fields) |field| {
if (try field.type.init(
alloc,
gdk_display,
app_id,
config,
)) |v| {
return @unionInit(App, field.name, v);
}
}
return .{ .none = .{} };
}
pub fn deinit(self: *App, alloc: Allocator) void {
switch (self.*) {
inline else => |*v| v.deinit(alloc),
}
}
pub fn eventMods(
self: *App,
device: ?*gdk.Device,
gtk_mods: gdk.ModifierType,
) input.Mods {
return switch (self.*) {
inline else => |*v| v.eventMods(device, gtk_mods),
} orelse key.translateMods(gtk_mods);
}
pub fn supportsQuickTerminal(self: App) bool {
return switch (self) {
inline else => |v| v.supportsQuickTerminal(),
};
}
/// Set up necessary support for the quick terminal that must occur
/// *before* the window-level winproto object is created.
///
/// Only has an effect on the Wayland backend, where the gtk4-layer-shell
/// library is initialized.
pub fn initQuickTerminal(self: *App, apprt_window: *ApprtWindow) !void {
switch (self.*) {
inline else => |*v| try v.initQuickTerminal(apprt_window),
}
}
};
/// Per-Window state for the underlying windowing protocol.
///
/// In Wayland, the terminology used is "Surface" and for it, this is
/// really "Surface"-specific state. But Ghostty uses the term "Surface"
/// heavily to mean something completely different, so we use "Window" here
/// to better match what it generally maps to in the Ghostty codebase.
pub const Window = union(Protocol) {
none: noop.Window,
wayland: if (build_options.wayland) wayland.Window else noop.Window,
x11: if (build_options.x11) x11.Window else noop.Window,
pub fn init(
alloc: Allocator,
app: *App,
apprt_window: *ApprtWindow,
) !Window {
return switch (app.*) {
inline else => |*v, tag| {
inline for (@typeInfo(Window).@"union".fields) |field| {
if (comptime std.mem.eql(
u8,
field.name,
@tagName(tag),
)) return @unionInit(
Window,
field.name,
try field.type.init(
alloc,
v,
apprt_window,
),
);
}
},
};
}
pub fn deinit(self: *Window, alloc: Allocator) void {
switch (self.*) {
inline else => |*v| v.deinit(alloc),
}
}
pub fn resizeEvent(self: *Window) !void {
switch (self.*) {
inline else => |*v| try v.resizeEvent(),
}
}
pub fn syncAppearance(self: *Window) !void {
switch (self.*) {
inline else => |*v| try v.syncAppearance(),
}
}
pub fn clientSideDecorationEnabled(self: Window) bool {
return switch (self) {
inline else => |v| v.clientSideDecorationEnabled(),
};
}
pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
switch (self.*) {
inline else => |*v| try v.addSubprocessEnv(env),
}
}
pub fn setUrgent(self: *Window, urgent: bool) !void {
switch (self.*) {
inline else => |*v| try v.setUrgent(urgent),
}
}
};

View File

@@ -1,75 +0,0 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const gdk = @import("gdk");
const Config = @import("../../../config.zig").Config;
const input = @import("../../../input.zig");
const ApprtWindow = @import("../class/window.zig").Window;
const log = std.log.scoped(.winproto_noop);
pub const App = struct {
pub fn init(
_: Allocator,
_: *gdk.Display,
_: [:0]const u8,
_: *const Config,
) !?App {
return null;
}
pub fn deinit(self: *App, alloc: Allocator) void {
_ = self;
_ = alloc;
}
pub fn eventMods(
_: *App,
_: ?*gdk.Device,
_: gdk.ModifierType,
) ?input.Mods {
return null;
}
pub fn supportsQuickTerminal(_: App) bool {
return false;
}
pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {}
};
pub const Window = struct {
pub fn init(
_: Allocator,
_: *App,
_: *ApprtWindow,
) !Window {
return .{};
}
pub fn deinit(self: Window, alloc: Allocator) void {
_ = self;
_ = alloc;
}
pub fn updateConfigEvent(
_: *Window,
_: *const ApprtWindow.DerivedConfig,
) !void {}
pub fn resizeEvent(_: *Window) !void {}
pub fn syncAppearance(_: *Window) !void {}
/// This returns true if CSD is enabled for this window. This
/// should be the actual present state of the window, not the
/// desired state.
pub fn clientSideDecorationEnabled(self: Window) bool {
_ = self;
return true;
}
pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {}
pub fn setUrgent(_: *Window, _: bool) !void {}
};

View File

@@ -1,518 +0,0 @@
//! Wayland protocol implementation for the Ghostty GTK apprt.
const std = @import("std");
const Allocator = std.mem.Allocator;
const build_options = @import("build_options");
const gdk = @import("gdk");
const gdk_wayland = @import("gdk_wayland");
const gobject = @import("gobject");
const gtk = @import("gtk");
const layer_shell = @import("gtk4-layer-shell");
const wayland = @import("wayland");
const Config = @import("../../../config.zig").Config;
const input = @import("../../../input.zig");
const ApprtWindow = @import("../class/window.zig").Window;
const wl = wayland.client.wl;
const org = wayland.client.org;
const xdg = wayland.client.xdg;
const log = std.log.scoped(.winproto_wayland);
/// Wayland state that contains application-wide Wayland objects (e.g. wl_display).
pub const App = struct {
display: *wl.Display,
context: *Context,
const Context = struct {
kde_blur_manager: ?*org.KdeKwinBlurManager = null,
// FIXME: replace with `zxdg_decoration_v1` once GTK merges
// https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null,
kde_slide_manager: ?*org.KdeKwinSlideManager = null,
default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null,
xdg_activation: ?*xdg.ActivationV1 = null,
/// Whether the xdg_wm_dialog_v1 protocol is present.
///
/// If it is present, gtk4-layer-shell < 1.0.4 may crash when the user
/// creates a quick terminal, and we need to ensure this fails
/// gracefully if this situation occurs.
///
/// FIXME: This is a temporary workaround - we should remove this when
/// all of our supported distros drop support for affected old
/// gtk4-layer-shell versions.
///
/// See https://github.com/wmww/gtk4-layer-shell/issues/50
xdg_wm_dialog_present: bool = false,
};
pub fn init(
alloc: Allocator,
gdk_display: *gdk.Display,
app_id: [:0]const u8,
config: *const Config,
) !?App {
_ = config;
_ = app_id;
const gdk_wayland_display = gobject.ext.cast(
gdk_wayland.WaylandDisplay,
gdk_display,
) orelse return null;
const display: *wl.Display = @ptrCast(@alignCast(
gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay,
));
// Create our context for our callbacks so we have a stable pointer.
// Note: at the time of writing this comment, we don't really need
// a stable pointer, but it's too scary that we'd need one in the future
// and not have it and corrupt memory or something so let's just do it.
const context = try alloc.create(Context);
errdefer alloc.destroy(context);
context.* = .{};
// Get our display registry so we can get all the available interfaces
// and bind to what we need.
const registry = try display.getRegistry();
registry.setListener(*Context, registryListener, context);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
// Do another round-trip to get the default decoration mode
if (context.kde_decoration_manager) |deco_manager| {
deco_manager.setListener(*Context, decoManagerListener, context);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
}
return .{
.display = display,
.context = context,
};
}
pub fn deinit(self: *App, alloc: Allocator) void {
alloc.destroy(self.context);
}
pub fn eventMods(
_: *App,
_: ?*gdk.Device,
_: gdk.ModifierType,
) ?input.Mods {
return null;
}
pub fn supportsQuickTerminal(self: App) bool {
if (!layer_shell.isSupported()) {
log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{});
return false;
}
if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{
.major = 1,
.minor = 0,
.patch = 4,
}) == .lt) {
log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{});
return false;
}
return true;
}
pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void {
const window = apprt_window.as(gtk.Window);
layer_shell.initForWindow(window);
layer_shell.setLayer(window, .top);
layer_shell.setNamespace(window, "ghostty-quick-terminal");
}
fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type {
// Globals should be optional pointers
const T = switch (@typeInfo(field.type)) {
.optional => |o| switch (@typeInfo(o.child)) {
.pointer => |v| v.child,
else => return null,
},
else => return null,
};
// Only process Wayland interfaces
if (!@hasDecl(T, "interface")) return null;
return T;
}
fn registryListener(
registry: *wl.Registry,
event: wl.Registry.Event,
context: *Context,
) void {
const ctx_fields = @typeInfo(Context).@"struct".fields;
switch (event) {
.global => |v| {
log.debug("found global {s}", .{v.interface});
// We don't actually do anything with this other than checking
// for its existence, so we process this separately.
if (std.mem.orderZ(
u8,
v.interface,
"xdg_wm_dialog_v1",
) == .eq) {
context.xdg_wm_dialog_present = true;
return;
}
inline for (ctx_fields) |field| {
const T = getInterfaceType(field) orelse continue;
if (std.mem.orderZ(
u8,
v.interface,
T.interface.name,
) == .eq) {
log.debug("matched {}", .{T});
@field(context, field.name) = registry.bind(
v.name,
T,
T.generated_version,
) catch |err| {
log.warn(
"error binding interface {s} error={}",
.{ v.interface, err },
);
return;
};
}
}
},
// This should be a rare occurrence, but in case a global
// is suddenly no longer available, we destroy and unset it
// as the protocol mandates.
.global_remove => |v| remove: {
inline for (ctx_fields) |field| {
if (getInterfaceType(field) == null) continue;
const global = @field(context, field.name) orelse break :remove;
if (global.getId() == v.name) {
global.destroy();
@field(context, field.name) = null;
}
}
},
}
}
fn decoManagerListener(
_: *org.KdeKwinServerDecorationManager,
event: org.KdeKwinServerDecorationManager.Event,
context: *Context,
) void {
switch (event) {
.default_mode => |mode| {
context.default_deco_mode = @enumFromInt(mode.mode);
},
}
}
};
/// Per-window (wl_surface) state for the Wayland protocol.
pub const Window = struct {
apprt_window: *ApprtWindow,
/// The Wayland surface for this window.
surface: *wl.Surface,
/// The context from the app where we can load our Wayland interfaces.
app_context: *App.Context,
/// A token that, when present, indicates that the window is blurred.
blur_token: ?*org.KdeKwinBlur = null,
/// Object that controls the decoration mode (client/server/auto)
/// of the window.
decoration: ?*org.KdeKwinServerDecoration = null,
/// Object that controls the slide-in/slide-out animations of the
/// quick terminal. Always null for windows other than the quick terminal.
slide: ?*org.KdeKwinSlide = null,
/// Object that, when present, denotes that the window is currently
/// requesting attention from the user.
activation_token: ?*xdg.ActivationTokenV1 = null,
pub fn init(
alloc: Allocator,
app: *App,
apprt_window: *ApprtWindow,
) !Window {
_ = alloc;
const gtk_native = apprt_window.as(gtk.Native);
const gdk_surface = gtk_native.getSurface() orelse return error.NotWaylandSurface;
// This should never fail, because if we're being called at this point
// then we've already asserted that our app state is Wayland.
const gdk_wl_surface = gobject.ext.cast(
gdk_wayland.WaylandSurface,
gdk_surface,
) orelse return error.NoWaylandSurface;
const wl_surface: *wl.Surface = @ptrCast(@alignCast(
gdk_wl_surface.getWlSurface() orelse return error.NoWaylandSurface,
));
// Get our decoration object so we can control the
// CSD vs SSD status of this surface.
const deco: ?*org.KdeKwinServerDecoration = deco: {
const mgr = app.context.kde_decoration_manager orelse
break :deco null;
const deco: *org.KdeKwinServerDecoration = mgr.create(
wl_surface,
) catch |err| {
log.warn("could not create decoration object={}", .{err});
break :deco null;
};
break :deco deco;
};
if (apprt_window.isQuickTerminal()) {
_ = gdk.Surface.signals.enter_monitor.connect(
gdk_surface,
*ApprtWindow,
enteredMonitor,
apprt_window,
.{},
);
}
return .{
.apprt_window = apprt_window,
.surface = wl_surface,
.app_context = app.context,
.decoration = deco,
};
}
pub fn deinit(self: Window, alloc: Allocator) void {
_ = alloc;
if (self.blur_token) |blur| blur.release();
if (self.decoration) |deco| deco.release();
if (self.slide) |slide| slide.release();
}
pub fn resizeEvent(_: *Window) !void {}
pub fn syncAppearance(self: *Window) !void {
self.syncBlur() catch |err| {
log.err("failed to sync blur={}", .{err});
};
self.syncDecoration() catch |err| {
log.err("failed to sync blur={}", .{err});
};
if (self.apprt_window.isQuickTerminal()) {
self.syncQuickTerminal() catch |err| {
log.warn("failed to sync quick terminal appearance={}", .{err});
};
}
}
pub fn clientSideDecorationEnabled(self: Window) bool {
return switch (self.getDecorationMode()) {
.Client => true,
// If we support SSDs, then we should *not* enable CSDs if we prefer SSDs.
// However, if we do not support SSDs (e.g. GNOME) then we should enable
// CSDs even if the user prefers SSDs.
.Server => if (self.app_context.kde_decoration_manager) |_| false else true,
.None => false,
else => unreachable,
};
}
pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
_ = self;
_ = env;
}
pub fn setUrgent(self: *Window, urgent: bool) !void {
const activation = self.app_context.xdg_activation orelse return;
// If there already is a token, destroy and unset it
if (self.activation_token) |token| token.destroy();
self.activation_token = if (urgent) token: {
const token = try activation.getActivationToken();
token.setSurface(self.surface);
token.setListener(*Window, onActivationTokenEvent, self);
token.commit();
break :token token;
} else null;
}
/// Update the blur state of the window.
fn syncBlur(self: *Window) !void {
const manager = self.app_context.kde_blur_manager orelse return;
const config = if (self.apprt_window.getConfig()) |v|
v.get()
else
return;
const blur = config.@"background-blur";
if (self.blur_token) |tok| {
// Only release token when transitioning from blurred -> not blurred
if (!blur.enabled()) {
manager.unset(self.surface);
tok.release();
self.blur_token = null;
}
} else {
// Only acquire token when transitioning from not blurred -> blurred
if (blur.enabled()) {
const tok = try manager.create(self.surface);
tok.commit();
self.blur_token = tok;
}
}
}
fn syncDecoration(self: *Window) !void {
const deco = self.decoration orelse return;
// The protocol requests uint instead of enum so we have
// to convert it.
deco.requestMode(@intCast(@intFromEnum(self.getDecorationMode())));
}
fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode {
return switch (self.apprt_window.getWindowDecoration()) {
.auto => self.app_context.default_deco_mode orelse .Client,
.client => .Client,
.server => .Server,
.none => .None,
};
}
fn syncQuickTerminal(self: *Window) !void {
const window = self.apprt_window.as(gtk.Window);
const config = if (self.apprt_window.getConfig()) |v|
v.get()
else
return;
layer_shell.setKeyboardMode(
window,
switch (config.@"quick-terminal-keyboard-interactivity") {
.none => .none,
.@"on-demand" => on_demand: {
if (layer_shell.getProtocolVersion() < 4) {
log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{});
break :on_demand .exclusive;
}
break :on_demand .on_demand;
},
.exclusive => .exclusive,
},
);
const anchored_edge: ?layer_shell.ShellEdge = switch (config.@"quick-terminal-position") {
.left => .left,
.right => .right,
.top => .top,
.bottom => .bottom,
.center => null,
};
for (std.meta.tags(layer_shell.ShellEdge)) |edge| {
if (anchored_edge) |anchored| {
if (edge == anchored) {
layer_shell.setMargin(window, edge, 0);
layer_shell.setAnchor(window, edge, true);
continue;
}
}
// Arbitrary margin - could be made customizable?
layer_shell.setMargin(window, edge, 20);
layer_shell.setAnchor(window, edge, false);
}
if (self.slide) |slide| slide.release();
self.slide = if (anchored_edge) |anchored| slide: {
const mgr = self.app_context.kde_slide_manager orelse break :slide null;
const slide = mgr.create(self.surface) catch |err| {
log.warn("could not create slide object={}", .{err});
break :slide null;
};
const slide_location: org.KdeKwinSlide.Location = switch (anchored) {
.top => .top,
.bottom => .bottom,
.left => .left,
.right => .right,
};
slide.setLocation(@intCast(@intFromEnum(slide_location)));
slide.commit();
break :slide slide;
} else null;
}
/// Update the size of the quick terminal based on monitor dimensions.
fn enteredMonitor(
_: *gdk.Surface,
monitor: *gdk.Monitor,
apprt_window: *ApprtWindow,
) callconv(.c) void {
const window = apprt_window.as(gtk.Window);
const config = if (apprt_window.getConfig()) |v| v.get() else return;
var monitor_size: gdk.Rectangle = undefined;
monitor.getGeometry(&monitor_size);
const dims = config.@"quick-terminal-size".calculate(
config.@"quick-terminal-position",
.{
.width = @intCast(monitor_size.f_width),
.height = @intCast(monitor_size.f_height),
},
);
window.setDefaultSize(@intCast(dims.width), @intCast(dims.height));
}
fn onActivationTokenEvent(
token: *xdg.ActivationTokenV1,
event: xdg.ActivationTokenV1.Event,
self: *Window,
) void {
const activation = self.app_context.xdg_activation orelse return;
const current_token = self.activation_token orelse return;
if (token.getId() != current_token.getId()) {
log.warn("received event for unknown activation token; ignoring", .{});
return;
}
switch (event) {
.done => |done| {
activation.activate(done.token, self.surface);
token.destroy();
self.activation_token = null;
},
}
}
};

View File

@@ -1,505 +0,0 @@
//! X11 window protocol implementation for the Ghostty GTK apprt.
const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gdk = @import("gdk");
const gdk_x11 = @import("gdk_x11");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const xlib = @import("xlib");
pub const c = @cImport({
@cInclude("X11/Xlib.h");
@cInclude("X11/Xatom.h");
@cInclude("X11/XKBlib.h");
});
const input = @import("../../../input.zig");
const Config = @import("../../../config.zig").Config;
const ApprtWindow = @import("../class/window.zig").Window;
const log = std.log.scoped(.gtk_x11);
pub const App = struct {
display: *xlib.Display,
base_event_code: c_int,
atoms: Atoms,
pub fn init(
_: Allocator,
gdk_display: *gdk.Display,
app_id: [:0]const u8,
config: *const Config,
) !?App {
// If the display isn't X11, then we don't need to do anything.
const gdk_x11_display = gobject.ext.cast(
gdk_x11.X11Display,
gdk_display,
) orelse return null;
const xlib_display = gdk_x11_display.getXdisplay();
const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn|
pn
else if (builtin.mode == .Debug)
"ghostty-debug"
else
"ghostty";
// Set the X11 window class property (WM_CLASS) if are are on an X11
// display.
//
// Note that we also set the program name here using g_set_prgname.
// This is how the instance name field for WM_CLASS is derived when
// calling gdk_x11_display_set_program_class; there does not seem to be
// a way to set it directly. It does not look like this is being set by
// our other app initialization routines currently, but since we're
// currently deriving its value from x11-instance-name effectively, I
// feel like gating it behind an X11 check is better intent.
//
// This makes the property show up like so when using xprop:
//
// WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty"
//
// Append "-debug" on both when using the debug build.
glib.setPrgname(x11_program_name);
gdk_x11.X11Display.setProgramClass(gdk_display, app_id);
// XKB
log.debug("Xkb.init: initializing Xkb", .{});
log.debug("Xkb.init: running XkbQueryExtension", .{});
var opcode: c_int = 0;
var base_event_code: c_int = 0;
var base_error_code: c_int = 0;
var major = c.XkbMajorVersion;
var minor = c.XkbMinorVersion;
if (c.XkbQueryExtension(
@ptrCast(@alignCast(xlib_display)),
&opcode,
&base_event_code,
&base_error_code,
&major,
&minor,
) == 0) {
log.err("Fatal: error initializing Xkb extension: error executing XkbQueryExtension", .{});
return error.XkbInitializationError;
}
log.debug("Xkb.init: running XkbSelectEventDetails", .{});
if (c.XkbSelectEventDetails(
@ptrCast(@alignCast(xlib_display)),
c.XkbUseCoreKbd,
c.XkbStateNotify,
c.XkbModifierStateMask,
c.XkbModifierStateMask,
) == 0) {
log.err("Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails", .{});
return error.XkbInitializationError;
}
return .{
.display = xlib_display,
.base_event_code = base_event_code,
.atoms = .init(gdk_x11_display),
};
}
pub fn deinit(self: *App, alloc: Allocator) void {
_ = self;
_ = alloc;
}
/// Checks for an immediate pending XKB state update event, and returns the
/// keyboard state based on if it finds any. This is necessary as the
/// standard GTK X11 API (and X11 in general) does not include the current
/// key pressed in any modifier state snapshot for that event (e.g. if the
/// pressed key is a modifier, that is not necessarily reflected in the
/// modifiers).
///
/// Returns null if there is no event. In this case, the caller should fall
/// back to the standard GDK modifier state (this likely means the key
/// event did not result in a modifier change).
pub fn eventMods(
self: App,
device: ?*gdk.Device,
gtk_mods: gdk.ModifierType,
) ?input.Mods {
_ = device;
_ = gtk_mods;
// Shoutout to Mozilla for figuring out a clean way to do this, this is
// paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp.
if (c.XEventsQueued(
@ptrCast(@alignCast(self.display)),
c.QueuedAfterReading,
) == 0) return null;
var nextEvent: c.XEvent = undefined;
_ = c.XPeekEvent(@ptrCast(@alignCast(self.display)), &nextEvent);
if (nextEvent.type != self.base_event_code) return null;
const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent);
if (xkb_event.any.xkb_type != c.XkbStateNotify) return null;
const xkb_state_notify_event: *c.XkbStateNotifyEvent = @ptrCast(xkb_event);
// Check the state according to XKB masks.
const lookup_mods = xkb_state_notify_event.lookup_mods;
var mods: input.Mods = .{};
log.debug("X11: found extra XkbStateNotify event w/lookup_mods: {b}", .{lookup_mods});
if (lookup_mods & c.ShiftMask != 0) mods.shift = true;
if (lookup_mods & c.ControlMask != 0) mods.ctrl = true;
if (lookup_mods & c.Mod1Mask != 0) mods.alt = true;
if (lookup_mods & c.Mod4Mask != 0) mods.super = true;
if (lookup_mods & c.LockMask != 0) mods.caps_lock = true;
return mods;
}
pub fn supportsQuickTerminal(_: App) bool {
log.warn("quick terminal is not yet supported on X11", .{});
return false;
}
pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {}
};
pub const Window = struct {
app: *App,
apprt_window: *ApprtWindow,
x11_surface: *gdk_x11.X11Surface,
blur_region: Region = .{},
pub fn init(
alloc: Allocator,
app: *App,
apprt_window: *ApprtWindow,
) !Window {
_ = alloc;
const surface = apprt_window.as(gtk.Native).getSurface() orelse
return error.NotX11Surface;
const x11_surface = gobject.ext.cast(
gdk_x11.X11Surface,
surface,
) orelse return error.NotX11Surface;
return .{
.app = app,
.apprt_window = apprt_window,
.x11_surface = x11_surface,
};
}
pub fn deinit(self: Window, alloc: Allocator) void {
_ = self;
_ = alloc;
}
pub fn resizeEvent(self: *Window) !void {
// The blur region must update with window resizes
try self.syncBlur();
}
pub fn syncAppearance(self: *Window) !void {
// The user could have toggled between CSDs and SSDs,
// therefore we need to recalculate the blur region offset.
self.blur_region = blur: {
// NOTE(pluiedev): CSDs are a f--king mistake.
// Please, GNOME, stop this nonsense of making a window ~30% bigger
// internally than how they really are just for your shadows and
// rounded corners and all that fluff. Please. I beg of you.
var x: f64 = 0;
var y: f64 = 0;
self.apprt_window.as(gtk.Native).getSurfaceTransform(&x, &y);
// Transform surface coordinates to device coordinates.
const scale: f64 = @floatFromInt(self.apprt_window.as(gtk.Widget).getScaleFactor());
x *= scale;
y *= scale;
break :blur .{
.x = @intFromFloat(x),
.y = @intFromFloat(y),
};
};
self.syncBlur() catch |err| {
log.err("failed to synchronize blur={}", .{err});
};
self.syncDecorations() catch |err| {
log.err("failed to synchronize decorations={}", .{err});
};
}
pub fn clientSideDecorationEnabled(self: Window) bool {
return switch (self.apprt_window.getWindowDecoration()) {
.auto, .client => true,
.server, .none => false,
};
}
fn syncBlur(self: *Window) !void {
// FIXME: This doesn't currently factor in rounded corners on Adwaita,
// which means that the blur region will grow slightly outside of the
// window borders. Unfortunately, actually calculating the rounded
// region can be quite complex without having access to existing APIs
// (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134)
// and I think it's not really noticeable enough to justify the effort.
// (Wayland also has this visual artifact anyway...)
const gtk_widget = self.apprt_window.as(gtk.Widget);
const config = if (self.apprt_window.getConfig()) |v| v.get() else return;
// Transform surface coordinates to device coordinates.
const scale = gtk_widget.getScaleFactor();
self.blur_region.width = gtk_widget.getWidth() * scale;
self.blur_region.height = gtk_widget.getHeight() * scale;
const blur = config.@"background-blur";
log.debug("set blur={}, window xid={}, region={}", .{
blur,
self.x11_surface.getXid(),
self.blur_region,
});
if (blur.enabled()) {
try self.changeProperty(
Region,
self.app.atoms.kde_blur,
c.XA_CARDINAL,
._32,
.{ .mode = .replace },
&self.blur_region,
);
} else {
try self.deleteProperty(self.app.atoms.kde_blur);
}
}
fn syncDecorations(self: *Window) !void {
var hints: MotifWMHints = .{};
self.getWindowProperty(
MotifWMHints,
self.app.atoms.motif_wm_hints,
self.app.atoms.motif_wm_hints,
._32,
.{},
&hints,
) catch |err| switch (err) {
// motif_wm_hints is already initialized, so this is fine
error.PropertyNotFound => {},
error.RequestFailed,
error.PropertyTypeMismatch,
error.PropertyFormatMismatch,
=> return err,
};
hints.flags.decorations = true;
hints.decorations.all = switch (self.apprt_window.getWindowDecoration()) {
.server => true,
.auto, .client, .none => false,
};
try self.changeProperty(
MotifWMHints,
self.app.atoms.motif_wm_hints,
self.app.atoms.motif_wm_hints,
._32,
.{ .mode = .replace },
&hints,
);
}
pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
var buf: [64]u8 = undefined;
const window_id = try std.fmt.bufPrint(
&buf,
"{}",
.{self.x11_surface.getXid()},
);
try env.put("WINDOWID", window_id);
}
pub fn setUrgent(self: *Window, urgent: bool) !void {
self.x11_surface.setUrgencyHint(@intFromBool(urgent));
}
fn getWindowProperty(
self: *Window,
comptime T: type,
name: c.Atom,
typ: c.Atom,
comptime format: PropertyFormat,
options: struct {
offset: c_long = 0,
length: c_long = std.math.maxInt(c_long),
delete: bool = false,
},
result: *T,
) GetWindowPropertyError!void {
// FIXME: Maybe we should switch to libxcb one day.
// Sounds like a much better idea than whatever this is
var actual_type_return: c.Atom = undefined;
var actual_format_return: c_int = undefined;
var nitems_return: c_ulong = undefined;
var bytes_after_return: c_ulong = undefined;
var prop_return: ?format.bufferType() = null;
const code = c.XGetWindowProperty(
@ptrCast(@alignCast(self.app.display)),
self.x11_surface.getXid(),
name,
options.offset,
options.length,
@intFromBool(options.delete),
typ,
&actual_type_return,
&actual_format_return,
&nitems_return,
&bytes_after_return,
@ptrCast(&prop_return),
);
if (code != c.Success) return error.RequestFailed;
if (actual_type_return == c.None) return error.PropertyNotFound;
if (typ != actual_type_return) return error.PropertyTypeMismatch;
if (@intFromEnum(format) != actual_format_return) return error.PropertyFormatMismatch;
const data_ptr: *T = @ptrCast(prop_return);
result.* = data_ptr.*;
_ = c.XFree(prop_return);
}
fn changeProperty(
self: *Window,
comptime T: type,
name: c.Atom,
typ: c.Atom,
comptime format: PropertyFormat,
options: struct {
mode: PropertyChangeMode,
},
value: *T,
) X11Error!void {
const data: format.bufferType() = @ptrCast(value);
const status = c.XChangeProperty(
@ptrCast(@alignCast(self.app.display)),
self.x11_surface.getXid(),
name,
typ,
@intFromEnum(format),
@intFromEnum(options.mode),
data,
@divExact(@sizeOf(T), @sizeOf(format.elemType())),
);
// For some godforsaken reason Xlib alternates between
// error values (0 = success) and booleans (1 = success), and they look exactly
// the same in the signature (just `int`, since Xlib is written in C89)...
if (status == 0) return error.RequestFailed;
}
fn deleteProperty(self: *Window, name: c.Atom) X11Error!void {
const status = c.XDeleteProperty(
@ptrCast(@alignCast(self.app.display)),
self.x11_surface.getXid(),
name,
);
if (status == 0) return error.RequestFailed;
}
};
const X11Error = error{
RequestFailed,
};
const GetWindowPropertyError = X11Error || error{
PropertyNotFound,
PropertyTypeMismatch,
PropertyFormatMismatch,
};
const Atoms = struct {
kde_blur: c.Atom,
motif_wm_hints: c.Atom,
fn init(display: *gdk_x11.X11Display) Atoms {
return .{
.kde_blur = gdk_x11.x11GetXatomByNameForDisplay(
display,
"_KDE_NET_WM_BLUR_BEHIND_REGION",
),
.motif_wm_hints = gdk_x11.x11GetXatomByNameForDisplay(
display,
"_MOTIF_WM_HINTS",
),
};
}
};
const PropertyChangeMode = enum(c_int) {
replace = c.PropModeReplace,
prepend = c.PropModePrepend,
append = c.PropModeAppend,
};
const PropertyFormat = enum(c_int) {
_8 = 8,
_16 = 16,
_32 = 32,
fn elemType(comptime self: PropertyFormat) type {
return switch (self) {
._8 => c_char,
._16 => c_int,
._32 => c_long,
};
}
fn bufferType(comptime self: PropertyFormat) type {
// The buffer type has to be a multi-pointer to bytes
// *aligned to the element type* (very important,
// otherwise you'll read garbage!)
//
// I know this is really ugly. X11 is ugly. I consider it apropos.
return [*]align(@alignOf(self.elemType())) u8;
}
};
const Region = extern struct {
x: c_long = 0,
y: c_long = 0,
width: c_long = 0,
height: c_long = 0,
};
// See Xm/MwmUtil.h, packaged with the Motif Window Manager
const MotifWMHints = extern struct {
flags: packed struct(c_ulong) {
_pad: u1 = 0,
decorations: bool = false,
// We don't really care about the other flags
_rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 2) = 0,
} = .{},
functions: c_ulong = 0,
decorations: packed struct(c_ulong) {
all: bool = false,
// We don't really care about the other flags
_rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 1) = 0,
} = .{},
input_mode: c_long = 0,
status: c_ulong = 0,
};

View File

@@ -1,12 +1,15 @@
//! Application runtime that uses GTK4.
const internal_os = @import("../os/main.zig");
// The required comptime API for any apprt.
pub const App = @import("gtk/App.zig");
pub const Surface = @import("gtk/Surface.zig");
pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir;
pub const resourcesDir = internal_os.resourcesDir;
// The exported API, custom for the apprt.
pub const class = @import("gtk/class.zig");
pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef;
test {
@import("std").testing.refAllDecls(@This());
_ = @import("gtk/inspector.zig");
_ = @import("gtk/key.zig");
_ = @import("gtk/ext.zig");
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +0,0 @@
/// Wrapper around GTK's builder APIs that perform some comptime checks.
const Builder = @This();
const std = @import("std");
const gtk = @import("gtk");
const gobject = @import("gobject");
resource_name: [:0]const u8,
builder: ?*gtk.Builder,
pub fn init(
/// The "name" of the resource.
comptime name: []const u8,
/// The major version of the minimum Adwaita version that is required to use
/// this resource.
comptime major: u16,
/// The minor version of the minimum Adwaita version that is required to use
/// this resource.
comptime minor: u16,
) Builder {
const resource_path = comptime resource_path: {
const gresource = @import("gresource.zig");
// Check to make sure that our file is listed as a
// `blueprint_file` in `gresource.zig`. If it isn't Ghostty
// could crash at runtime when we try and load a nonexistent
// GResource.
for (gresource.blueprint_files) |file| {
if (major != file.major or minor != file.minor or !std.mem.eql(u8, file.name, name)) continue;
// Use @embedFile to make sure that the `.blp` file exists
// at compile time. Zig _should_ discard the data so that
// it doesn't end up in the final executable. At runtime we
// will load the data from a GResource.
const blp_filename = std.fmt.comptimePrint(
"ui/{d}.{d}/{s}.blp",
.{
file.major,
file.minor,
file.name,
},
);
_ = @embedFile(blp_filename);
break :resource_path std.fmt.comptimePrint(
"/com/mitchellh/ghostty/ui/{d}.{d}/{s}.ui",
.{
file.major,
file.minor,
file.name,
},
);
} else @compileError("missing blueprint file '" ++ name ++ "' in gresource.zig");
};
return .{
.resource_name = resource_path,
.builder = null,
};
}
pub fn setWidgetClassTemplate(self: *const Builder, class: *gtk.WidgetClass) void {
class.setTemplateFromResource(self.resource_name);
}
pub fn getObject(self: *Builder, comptime T: type, name: [:0]const u8) ?*T {
const builder = builder: {
if (self.builder) |builder| break :builder builder;
const builder = gtk.Builder.newFromResource(self.resource_name);
self.builder = builder;
break :builder builder;
};
return gobject.ext.cast(T, builder.getObject(name) orelse return null);
}
pub fn deinit(self: *const Builder) void {
if (self.builder) |builder| builder.unref();
}

View File

@@ -1,212 +0,0 @@
/// Clipboard Confirmation Window
const ClipboardConfirmation = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const gtk = @import("gtk");
const adw = @import("adw");
const gobject = @import("gobject");
const gio = @import("gio");
const apprt = @import("../../apprt.zig");
const CoreSurface = @import("../../Surface.zig");
const App = @import("App.zig");
const Builder = @import("Builder.zig");
const adw_version = @import("adw_version.zig");
const log = std.log.scoped(.gtk);
const DialogType = if (adw_version.supportsDialogs()) adw.AlertDialog else adw.MessageDialog;
app: *App,
dialog: *DialogType,
data: [:0]u8,
core_surface: *CoreSurface,
pending_req: apprt.ClipboardRequest,
text_view: *gtk.TextView,
text_view_scroll: *gtk.ScrolledWindow,
reveal_button: *gtk.Button,
hide_button: *gtk.Button,
remember_choice: if (adw_version.supportsSwitchRow()) ?*adw.SwitchRow else ?*anyopaque,
pub fn create(
app: *App,
data: []const u8,
core_surface: *CoreSurface,
request: apprt.ClipboardRequest,
is_secure_input: bool,
) !void {
if (app.clipboard_confirmation_window != null) return error.WindowAlreadyExists;
const alloc = app.core_app.alloc;
const self = try alloc.create(ClipboardConfirmation);
errdefer alloc.destroy(self);
try self.init(
app,
data,
core_surface,
request,
is_secure_input,
);
app.clipboard_confirmation_window = self;
}
/// Not public because this should be called by the GTK lifecycle.
fn destroy(self: *ClipboardConfirmation) void {
const alloc = self.app.core_app.alloc;
self.app.clipboard_confirmation_window = null;
alloc.free(self.data);
alloc.destroy(self);
}
fn init(
self: *ClipboardConfirmation,
app: *App,
data: []const u8,
core_surface: *CoreSurface,
request: apprt.ClipboardRequest,
is_secure_input: bool,
) !void {
var builder: Builder = switch (DialogType) {
adw.AlertDialog => switch (request) {
.osc_52_read => .init("ccw-osc-52-read", 1, 5),
.osc_52_write => .init("ccw-osc-52-write", 1, 5),
.paste => .init("ccw-paste", 1, 5),
},
adw.MessageDialog => switch (request) {
.osc_52_read => .init("ccw-osc-52-read", 1, 2),
.osc_52_write => .init("ccw-osc-52-write", 1, 2),
.paste => .init("ccw-paste", 1, 2),
},
else => unreachable,
};
defer builder.deinit();
const dialog = builder.getObject(DialogType, "clipboard_confirmation_window").?;
const text_view = builder.getObject(gtk.TextView, "text_view").?;
const reveal_button = builder.getObject(gtk.Button, "reveal_button").?;
const hide_button = builder.getObject(gtk.Button, "hide_button").?;
const text_view_scroll = builder.getObject(gtk.ScrolledWindow, "text_view_scroll").?;
const remember_choice = if (adw_version.supportsSwitchRow())
builder.getObject(adw.SwitchRow, "remember_choice")
else
null;
const copy = try app.core_app.alloc.dupeZ(u8, data);
errdefer app.core_app.alloc.free(copy);
self.* = .{
.app = app,
.dialog = dialog,
.data = copy,
.core_surface = core_surface,
.pending_req = request,
.text_view = text_view,
.text_view_scroll = text_view_scroll,
.reveal_button = reveal_button,
.hide_button = hide_button,
.remember_choice = remember_choice,
};
const buffer = gtk.TextBuffer.new(null);
errdefer buffer.unref();
buffer.insertAtCursor(copy.ptr, @intCast(copy.len));
text_view.setBuffer(buffer);
if (is_secure_input) {
text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false));
self.text_view.as(gtk.Widget).addCssClass("blurred");
self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true));
_ = gtk.Button.signals.clicked.connect(
reveal_button,
*ClipboardConfirmation,
gtkRevealButtonClicked,
self,
.{},
);
_ = gtk.Button.signals.clicked.connect(
hide_button,
*ClipboardConfirmation,
gtkHideButtonClicked,
self,
.{},
);
}
_ = DialogType.signals.response.connect(
dialog,
*ClipboardConfirmation,
gtkResponse,
self,
.{},
);
switch (DialogType) {
adw.AlertDialog => {
const parent: ?*gtk.Widget = widget: {
const window = core_surface.rt_surface.container.window() orelse break :widget null;
break :widget window.window.as(gtk.Widget);
};
dialog.as(adw.Dialog).present(parent);
},
adw.MessageDialog => dialog.as(gtk.Window).present(),
else => unreachable,
}
}
fn handleResponse(self: *ClipboardConfirmation, response: [*:0]const u8) void {
const is_ok = std.mem.orderZ(u8, response, "ok") == .eq;
if (is_ok) {
self.core_surface.completeClipboardRequest(
self.pending_req,
self.data,
true,
) catch |err| {
log.err("Failed to requeue clipboard request: {}", .{err});
};
}
if (self.remember_choice) |remember| remember: {
if (!adw_version.supportsSwitchRow()) break :remember;
if (remember.getActive() == 0) break :remember;
switch (self.pending_req) {
.osc_52_read => self.core_surface.config.clipboard_read = if (is_ok) .allow else .deny,
.osc_52_write => self.core_surface.config.clipboard_write = if (is_ok) .allow else .deny,
.paste => {},
}
}
self.destroy();
}
fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.c) void {
const dialog = gobject.ext.cast(DialogType, dialog_.?).?;
const self: *ClipboardConfirmation = @ptrCast(@alignCast(ud.?));
const response = dialog.chooseFinish(result);
self.handleResponse(response);
}
fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.c) void {
self.handleResponse(response);
}
fn gtkRevealButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void {
self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true));
self.text_view.as(gtk.Widget).removeCssClass("blurred");
self.hide_button.as(gtk.Widget).setVisible(@intFromBool(true));
self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false));
}
fn gtkHideButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void {
self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false));
self.text_view.as(gtk.Widget).addCssClass("blurred");
self.hide_button.as(gtk.Widget).setVisible(@intFromBool(false));
self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true));
}

View File

@@ -1,151 +0,0 @@
const CloseDialog = @This();
const std = @import("std");
const gobject = @import("gobject");
const gio = @import("gio");
const adw = @import("adw");
const gtk = @import("gtk");
const i18n = @import("../../os/main.zig").i18n;
const App = @import("App.zig");
const Window = @import("Window.zig");
const Tab = @import("Tab.zig");
const Surface = @import("Surface.zig");
const adwaita = @import("adw_version.zig");
const log = std.log.scoped(.close_dialog);
// We don't fall back to the GTK Message/AlertDialogs since
// we don't plan to support libadw < 1.2 as of time of writing
// TODO: Switch to just adw.AlertDialog when we drop Debian 12 support
const DialogType = if (adwaita.supportsDialogs()) adw.AlertDialog else adw.MessageDialog;
/// Open the dialog when the user requests to close a window/tab/split/etc.
/// but there's still one or more running processes inside the target that
/// cannot be closed automatically. We then ask the user whether they want
/// to terminate existing processes.
pub fn show(target: Target) !void {
// If we don't have a possible window to ask the user,
// in most situations (e.g. when a split isn't attached to a window)
// we should just close unconditionally.
const dialog_window = target.dialogWindow() orelse {
target.close();
return;
};
const dialog = switch (DialogType) {
adw.AlertDialog => adw.AlertDialog.new(target.title(), target.body()),
adw.MessageDialog => adw.MessageDialog.new(dialog_window, target.title(), target.body()),
else => unreachable,
};
// AlertDialog and MessageDialog have essentially the same API,
// so we can cheat a little here
dialog.addResponse("cancel", i18n._("Cancel"));
dialog.setCloseResponse("cancel");
dialog.addResponse("close", i18n._("Close"));
dialog.setResponseAppearance("close", .destructive);
// Need a stable pointer
const target_ptr = try target.allocator().create(Target);
target_ptr.* = target;
_ = DialogType.signals.response.connect(dialog, *Target, responseCallback, target_ptr, .{});
switch (DialogType) {
adw.AlertDialog => dialog.as(adw.Dialog).present(dialog_window.as(gtk.Widget)),
adw.MessageDialog => dialog.as(gtk.Window).present(),
else => unreachable,
}
}
fn responseCallback(
_: *DialogType,
response: [*:0]const u8,
target: *Target,
) callconv(.c) void {
const alloc = target.allocator();
defer alloc.destroy(target);
if (std.mem.orderZ(u8, response, "close") == .eq) target.close();
}
/// The target of a close dialog.
///
/// This is here so that we can consolidate all logic related to
/// prompting the user and closing windows/tabs/surfaces/etc.
/// together into one struct that is the sole source of truth.
pub const Target = union(enum) {
app: *App,
window: *Window,
tab: *Tab,
surface: *Surface,
pub fn title(self: Target) [*:0]const u8 {
return switch (self) {
.app => i18n._("Quit Ghostty?"),
.window => i18n._("Close Window?"),
.tab => i18n._("Close Tab?"),
.surface => i18n._("Close Split?"),
};
}
pub fn body(self: Target) [*:0]const u8 {
return switch (self) {
.app => i18n._("All terminal sessions will be terminated."),
.window => i18n._("All terminal sessions in this window will be terminated."),
.tab => i18n._("All terminal sessions in this tab will be terminated."),
.surface => i18n._("The currently running process in this split will be terminated."),
};
}
pub fn dialogWindow(self: Target) ?*gtk.Window {
return switch (self) {
.app => {
// Find the currently focused window. We don't store this
// anywhere inside the App structure for some reason, so
// we have to query every single open window and see which
// one is active (focused and receiving keyboard input)
const list = gtk.Window.listToplevels();
defer list.free();
const focused = list.findCustom(null, findActiveWindow);
return @ptrCast(@alignCast(focused.f_data));
},
.window => |v| v.window.as(gtk.Window),
.tab => |v| v.window.window.as(gtk.Window),
.surface => |v| {
const window_ = v.container.window() orelse return null;
return window_.window.as(gtk.Window);
},
};
}
fn allocator(self: Target) std.mem.Allocator {
return switch (self) {
.app => |v| v.core_app.alloc,
.window => |v| v.app.core_app.alloc,
.tab => |v| v.window.app.core_app.alloc,
.surface => |v| v.app.core_app.alloc,
};
}
fn close(self: Target) void {
switch (self) {
.app => |v| v.quitNow(),
.window => |v| v.window.as(gtk.Window).destroy(),
.tab => |v| v.remove(),
.surface => |v| v.container.remove(),
}
}
};
fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int {
const window: *gtk.Window = @ptrCast(@alignCast(@constCast(data orelse return -1)));
// Confusingly, `isActive` returns 1 when active,
// but we want to return 0 to indicate equality.
// Abusing integers to be enums and booleans is a terrible idea, C.
return if (window.isActive() != 0) 0 else -1;
}

View File

@@ -1,258 +0,0 @@
const CommandPalette = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gio = @import("gio");
const gobject = @import("gobject");
const gtk = @import("gtk");
const configpkg = @import("../../config.zig");
const inputpkg = @import("../../input.zig");
const key = @import("key.zig");
const Builder = @import("Builder.zig");
const Window = @import("Window.zig");
const log = std.log.scoped(.command_palette);
window: *Window,
arena: std.heap.ArenaAllocator,
/// The dialog object containing the palette UI.
dialog: *adw.Dialog,
/// The search input text field.
search: *gtk.SearchEntry,
/// The view containing each result row.
view: *gtk.ListView,
/// The model that provides filtered data for the view to display.
model: *gtk.SingleSelection,
/// The list that serves as the data source of the model.
/// This is where all command data is ultimately stored.
source: *gio.ListStore,
pub fn init(self: *CommandPalette, window: *Window) !void {
// Register the custom command type *before* initializing the builder
// If we don't do this now, the builder will complain that it doesn't know
// about this type and fail to initialize
_ = Command.getGObjectType();
var builder = Builder.init("command-palette", 1, 5);
defer builder.deinit();
self.* = .{
.window = window,
.arena = .init(window.app.core_app.alloc),
.dialog = builder.getObject(adw.Dialog, "command-palette").?,
.search = builder.getObject(gtk.SearchEntry, "search").?,
.view = builder.getObject(gtk.ListView, "view").?,
.model = builder.getObject(gtk.SingleSelection, "model").?,
.source = builder.getObject(gio.ListStore, "source").?,
};
// Manually take a reference here so that the dialog
// remains in memory after closing
self.dialog.ref();
errdefer self.dialog.unref();
_ = gtk.SearchEntry.signals.stop_search.connect(
self.search,
*CommandPalette,
searchStopped,
self,
.{},
);
_ = gtk.SearchEntry.signals.activate.connect(
self.search,
*CommandPalette,
searchActivated,
self,
.{},
);
_ = gtk.ListView.signals.activate.connect(
self.view,
*CommandPalette,
rowActivated,
self,
.{},
);
try self.updateConfig(&self.window.app.config);
}
pub fn deinit(self: *CommandPalette) void {
self.arena.deinit();
self.dialog.unref();
}
pub fn toggle(self: *CommandPalette) void {
self.dialog.present(self.window.window.as(gtk.Widget));
// Focus on the search bar when opening the dialog
_ = self.search.as(gtk.Widget).grabFocus();
}
pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void {
// Clear existing binds and clear allocated data
self.source.removeAll();
_ = self.arena.reset(.retain_capacity);
for (config.@"command-palette-entry".value.items) |command| {
// Filter out actions that are not implemented
// or don't make sense for GTK
switch (command.action) {
.close_all_windows,
.toggle_secure_input,
.check_for_updates,
.redo,
.undo,
.reset_window_size,
.toggle_window_float_on_top,
=> continue,
else => {},
}
const cmd = try Command.new(
self.arena.allocator(),
command,
config.keybind.set,
);
const cmd_ref = cmd.as(gobject.Object);
self.source.append(cmd_ref);
cmd_ref.unref();
}
}
fn activated(self: *CommandPalette, pos: c_uint) void {
// Use self.model and not self.source here to use the list of *visible* results
const object = self.model.as(gio.ListModel).getObject(pos) orelse return;
const cmd = gobject.ext.cast(Command, object) orelse return;
// Close before running the action in order to avoid being replaced by another
// dialog (such as the change title dialog). If that occurs then the command
// palette dialog won't be counted as having closed properly and cannot
// receive focus when reopened.
_ = self.dialog.close();
const action = inputpkg.Binding.Action.parse(
std.mem.span(cmd.cmd_c.action_key),
) catch |err| {
log.err("got invalid action={s} ({})", .{ cmd.cmd_c.action_key, err });
return;
};
self.window.performBindingAction(action);
}
fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void {
// ESC was pressed - close the palette
_ = self.dialog.close();
}
fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void {
// If Enter is pressed, activate the selected entry
self.activated(self.model.getSelected());
}
fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void {
self.activated(pos);
}
/// Object that wraps around a command.
///
/// As GTK list models only accept objects that are within the GObject hierarchy,
/// we have to construct a wrapper to be easily consumed by the list model.
const Command = extern struct {
parent: Parent,
cmd_c: inputpkg.Command.C,
pub const getGObjectType = gobject.ext.defineClass(Command, .{
.name = "GhosttyCommand",
.classInit = Class.init,
});
pub fn new(alloc: Allocator, cmd: inputpkg.Command, keybinds: inputpkg.Binding.Set) !*Command {
const self = gobject.ext.newInstance(Command, .{});
var buf: [64]u8 = undefined;
const action = action: {
const trigger = keybinds.getTrigger(cmd.action) orelse break :action null;
const accel = try key.accelFromTrigger(&buf, trigger) orelse break :action null;
break :action try alloc.dupeZ(u8, accel);
};
self.cmd_c = .{
.title = cmd.title.ptr,
.description = cmd.description.ptr,
.action = if (action) |v| v.ptr else "",
.action_key = try std.fmt.allocPrintZ(alloc, "{}", .{cmd.action}),
};
return self;
}
fn as(self: *Command, comptime T: type) *T {
return gobject.ext.as(T, self);
}
pub const Parent = gobject.Object;
pub const Class = extern struct {
parent: Parent.Class,
pub const Instance = Command;
pub fn init(class: *Class) callconv(.c) void {
const info = @typeInfo(inputpkg.Command.C).@"struct";
// Expose all fields on the Command.C struct as properties
// that can be accessed by the GObject type system
// (and by extension, blueprints)
const properties = comptime props: {
var props: [info.fields.len]type = undefined;
for (info.fields, 0..) |field, i| {
const accessor = struct {
fn getter(cmd: *Command) ?[:0]const u8 {
return std.mem.span(@field(cmd.cmd_c, field.name));
}
};
// "Canonicalize" field names into the format GObject expects
const prop_name = prop_name: {
var buf: [field.name.len:0]u8 = undefined;
_ = std.mem.replace(u8, field.name, "_", "-", &buf);
break :prop_name buf;
};
props[i] = gobject.ext.defineProperty(
&prop_name,
Command,
?[:0]const u8,
.{
.default = null,
.accessor = gobject.ext.typedAccessor(
Command,
?[:0]const u8,
.{
.getter = &accessor.getter,
},
),
},
);
}
break :props props;
};
gobject.ext.registerProperties(class, &properties);
}
};
};

View File

@@ -1,102 +0,0 @@
/// Configuration errors window.
const ConfigErrorsDialog = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const gobject = @import("gobject");
const gio = @import("gio");
const gtk = @import("gtk");
const adw = @import("adw");
const build_config = @import("../../build_config.zig");
const configpkg = @import("../../config.zig");
const Config = configpkg.Config;
const App = @import("App.zig");
const Window = @import("Window.zig");
const Builder = @import("Builder.zig");
const adw_version = @import("adw_version.zig");
const log = std.log.scoped(.gtk);
const DialogType = if (adw_version.supportsDialogs()) adw.AlertDialog else adw.MessageDialog;
builder: Builder,
dialog: *DialogType,
error_message: *gtk.TextBuffer,
pub fn maybePresent(app: *App, window: ?*Window) void {
if (app.config._diagnostics.empty()) return;
const config_errors_dialog = config_errors_dialog: {
if (app.config_errors_dialog) |config_errors_dialog| break :config_errors_dialog config_errors_dialog;
var builder: Builder = switch (DialogType) {
adw.AlertDialog => .init("config-errors-dialog", 1, 5),
adw.MessageDialog => .init("config-errors-dialog", 1, 2),
else => unreachable,
};
const dialog = builder.getObject(DialogType, "config_errors_dialog").?;
const error_message = builder.getObject(gtk.TextBuffer, "error_message").?;
_ = DialogType.signals.response.connect(dialog, *App, onResponse, app, .{});
app.config_errors_dialog = .{
.builder = builder,
.dialog = dialog,
.error_message = error_message,
};
break :config_errors_dialog app.config_errors_dialog.?;
};
{
var start = std.mem.zeroes(gtk.TextIter);
config_errors_dialog.error_message.getStartIter(&start);
var end = std.mem.zeroes(gtk.TextIter);
config_errors_dialog.error_message.getEndIter(&end);
config_errors_dialog.error_message.delete(&start, &end);
}
var msg_buf: [4095:0]u8 = undefined;
var fbs = std.io.fixedBufferStream(&msg_buf);
for (app.config._diagnostics.items()) |diag| {
fbs.reset();
diag.write(fbs.writer()) catch |err| {
log.warn(
"error writing diagnostic to buffer err={}",
.{err},
);
continue;
};
config_errors_dialog.error_message.insertAtCursor(&msg_buf, @intCast(fbs.pos));
config_errors_dialog.error_message.insertAtCursor("\n", 1);
}
switch (DialogType) {
adw.AlertDialog => {
const parent = if (window) |w| w.window.as(gtk.Widget) else null;
config_errors_dialog.dialog.as(adw.Dialog).present(parent);
},
adw.MessageDialog => config_errors_dialog.dialog.as(gtk.Window).present(),
else => unreachable,
}
}
fn onResponse(_: *DialogType, response: [*:0]const u8, app: *App) callconv(.c) void {
if (app.config_errors_dialog) |config_errors_dialog| config_errors_dialog.builder.deinit();
app.config_errors_dialog = null;
if (std.mem.orderZ(u8, response, "reload") == .eq) {
app.reloadConfig(.app, .{}) catch |err| {
log.warn("error reloading config error={}", .{err});
return;
};
}
}

View File

@@ -1,422 +0,0 @@
const GlobalShortcuts = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const App = @import("App.zig");
const configpkg = @import("../../config.zig");
const Binding = @import("../../input.zig").Binding;
const key = @import("key.zig");
const log = std.log.scoped(.global_shortcuts);
const Token = [16]u8;
app: *App,
arena: std.heap.ArenaAllocator,
dbus: *gio.DBusConnection,
/// A mapping from a unique ID to an action.
/// Currently the unique ID is simply the serialized representation of the
/// trigger that was used for the action as triggers are unique in the keymap,
/// but this may change in the future.
map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{},
/// The handle of the current global shortcuts portal session,
/// as a D-Bus object path.
handle: ?[:0]const u8 = null,
/// The D-Bus signal subscription for the response signal on requests.
/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null.
response_subscription: c_uint = 0,
/// The D-Bus signal subscription for the keybind activate signal.
/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null.
activate_subscription: c_uint = 0,
pub fn init(alloc: Allocator, gio_app: *gio.Application) ?GlobalShortcuts {
const dbus = gio_app.getDbusConnection() orelse return null;
return .{
// To be initialized later
.app = undefined,
.arena = .init(alloc),
.dbus = dbus,
};
}
pub fn deinit(self: *GlobalShortcuts) void {
self.close();
self.arena.deinit();
}
fn close(self: *GlobalShortcuts) void {
if (self.response_subscription != 0) {
self.dbus.signalUnsubscribe(self.response_subscription);
self.response_subscription = 0;
}
if (self.activate_subscription != 0) {
self.dbus.signalUnsubscribe(self.activate_subscription);
self.activate_subscription = 0;
}
if (self.handle) |handle| {
// Close existing session
self.dbus.call(
"org.freedesktop.portal.Desktop",
handle,
"org.freedesktop.portal.Session",
"Close",
null,
null,
.{},
-1,
null,
null,
null,
);
self.handle = null;
}
}
pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void {
// Ensure we have a valid reference to the app
// (it was left uninitialized in `init`)
self.app = app;
// Close any existing sessions
self.close();
// Update map
var trigger_buf: [256]u8 = undefined;
self.map.clearRetainingCapacity();
var it = self.app.config.keybind.set.bindings.iterator();
while (it.next()) |entry| {
const leaf = switch (entry.value_ptr.*) {
// Global shortcuts can't have leaders
.leader => continue,
.leaf => |leaf| leaf,
};
if (!leaf.flags.global) continue;
const trigger = try key.xdgShortcutFromTrigger(
&trigger_buf,
entry.key_ptr.*,
) orelse continue;
try self.map.put(
self.arena.allocator(),
try self.arena.allocator().dupeZ(u8, trigger),
leaf.action,
);
}
if (self.map.count() > 0) {
try self.request(.create_session);
}
}
fn shortcutActivated(
_: *gio.DBusConnection,
_: ?[*:0]const u8,
_: [*:0]const u8,
_: [*:0]const u8,
_: [*:0]const u8,
params: *glib.Variant,
ud: ?*anyopaque,
) callconv(.c) void {
const self: *GlobalShortcuts = @ptrCast(@alignCast(ud));
// 2nd value in the tuple is the activated shortcut ID
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated
var shortcut_id: [*:0]const u8 = undefined;
params.getChild(1, "&s", &shortcut_id);
log.debug("activated={s}", .{shortcut_id});
const action = self.map.get(std.mem.span(shortcut_id)) orelse return;
self.app.core_app.performAllAction(self.app, action) catch |err| {
log.err("failed to perform action={}", .{err});
};
}
const Method = enum {
create_session,
bind_shortcuts,
fn name(self: Method) [:0]const u8 {
return switch (self) {
.create_session => "CreateSession",
.bind_shortcuts => "BindShortcuts",
};
}
/// Construct the payload expected by the XDG portal call.
fn makePayload(
self: Method,
shortcuts: *GlobalShortcuts,
request_token: [:0]const u8,
) ?*glib.Variant {
switch (self) {
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession
.create_session => {
var session_token: Token = undefined;
return glib.Variant.newParsed(
"({'handle_token': <%s>, 'session_handle_token': <%s>},)",
request_token.ptr,
generateToken(&session_token).ptr,
);
},
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts
.bind_shortcuts => {
const handle = shortcuts.handle orelse return null;
const bind_type = glib.VariantType.new("a(sa{sv})");
defer glib.free(bind_type);
var binds: glib.VariantBuilder = undefined;
glib.VariantBuilder.init(&binds, bind_type);
var action_buf: [256]u8 = undefined;
var it = shortcuts.map.iterator();
while (it.next()) |entry| {
const trigger = entry.key_ptr.*.ptr;
const action = std.fmt.bufPrintZ(
&action_buf,
"{}",
.{entry.value_ptr.*},
) catch continue;
binds.addParsed(
"(%s, {'description': <%s>, 'preferred_trigger': <%s>})",
trigger,
action.ptr,
trigger,
);
}
return glib.Variant.newParsed(
"(%o, %*, '', {'handle_token': <%s>})",
handle.ptr,
binds.end(),
request_token.ptr,
);
},
}
}
fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void {
switch (self) {
.create_session => {
var handle: ?[*:0]u8 = null;
if (vardict.lookup("session_handle", "&s", &handle) == 0) {
log.err(
"session handle not found in response={s}",
.{vardict.print(@intFromBool(true))},
);
return;
}
shortcuts.handle = shortcuts.arena.allocator().dupeZ(u8, std.mem.span(handle.?)) catch {
log.err("out of memory: failed to clone session handle", .{});
return;
};
log.debug("session_handle={?s}", .{handle});
// Subscribe to keybind activations
shortcuts.activate_subscription = shortcuts.dbus.signalSubscribe(
null,
"org.freedesktop.portal.GlobalShortcuts",
"Activated",
"/org/freedesktop/portal/desktop",
handle,
.{ .match_arg0_path = true },
shortcutActivated,
shortcuts,
null,
);
shortcuts.request(.bind_shortcuts) catch |err| {
log.err("failed to bind shortcuts={}", .{err});
return;
};
},
.bind_shortcuts => {},
}
}
};
/// Submit a request to the global shortcuts portal.
fn request(
self: *GlobalShortcuts,
comptime method: Method,
) !void {
// NOTE(pluiedev):
// XDG Portals are really, really poorly-designed pieces of hot garbage.
// How the protocol is _initially_ designed to work is as follows:
//
// 1. The client calls a method which returns the path of a Request object;
// 2. The client waits for the Response signal under said object path;
// 3. When the signal arrives, the actual return value and status code
// become available for the client for further processing.
//
// THIS DOES NOT WORK. Once the first two steps are complete, the client
// needs to immediately start listening for the third step, but an overeager
// server implementation could easily send the Response signal before the
// client is even ready, causing communications to break down over a simple
// race condition/two generals' problem that even _TCP_ had figured out
// decades ago. Worse yet, you get exactly _one_ chance to listen for the
// signal, or else your communication attempt so far has all been in vain.
//
// And they know this. Instead of fixing their freaking protocol, they just
// ask clients to manually construct the expected object path and subscribe
// to the request signal beforehand, making the whole response value of
// the original call COMPLETELY MEANINGLESS.
//
// Furthermore, this is _entirely undocumented_ aside from one tiny
// paragraph under the documentation for the Request interface, and
// anyone would be forgiven for missing it without reading the libportal
// source code.
//
// When in Rome, do as the Romans do, I guess...?
const callbacks = struct {
fn gotResponseHandle(
source: ?*gobject.Object,
res: *gio.AsyncResult,
_: ?*anyopaque,
) callconv(.c) void {
const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?;
var err: ?*glib.Error = null;
defer if (err) |err_| err_.free();
const params_ = dbus_.callFinish(res, &err) orelse {
if (err) |err_| log.err("request failed={s} ({})", .{
err_.f_message orelse "(unknown)",
err_.f_code,
});
return;
};
defer params_.unref();
// TODO: XDG recommends updating the signal subscription if the actual
// returned request path is not the same as the expected request
// path, to retain compatibility with older versions of XDG portals.
// Although it suffers from the race condition outlined above,
// we should still implement this at some point.
}
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response
fn responded(
dbus: *gio.DBusConnection,
_: ?[*:0]const u8,
_: [*:0]const u8,
_: [*:0]const u8,
_: [*:0]const u8,
params_: *glib.Variant,
ud: ?*anyopaque,
) callconv(.c) void {
const self_: *GlobalShortcuts = @ptrCast(@alignCast(ud));
// Unsubscribe from the response signal
if (self_.response_subscription != 0) {
dbus.signalUnsubscribe(self_.response_subscription);
self_.response_subscription = 0;
}
var response: u32 = 0;
var vardict: ?*glib.Variant = null;
defer if (vardict) |v| v.unref();
params_.get("(u@a{sv})", &response, &vardict);
switch (response) {
0 => {
log.debug("request successful", .{});
method.onResponse(self_, vardict.?);
},
1 => log.debug("request was cancelled by user", .{}),
2 => log.warn("request ended unexpectedly", .{}),
else => log.err("unrecognized response code={}", .{response}),
}
}
};
var request_token_buf: Token = undefined;
const request_token = generateToken(&request_token_buf);
const payload = method.makePayload(self, request_token) orelse return;
const request_path = try self.getRequestPath(request_token);
self.response_subscription = self.dbus.signalSubscribe(
null,
"org.freedesktop.portal.Request",
"Response",
request_path,
null,
.{},
callbacks.responded,
self,
null,
);
self.dbus.call(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.GlobalShortcuts",
method.name(),
payload,
null,
.{},
-1,
null,
callbacks.gotResponseHandle,
null,
);
}
/// Generate a random token suitable for use in requests.
fn generateToken(buf: *Token) [:0]const u8 {
// u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL
// 7 + 8 + 1 = 16
return std.fmt.bufPrintZ(
buf,
"ghostty_{x:0<7}",
.{std.crypto.random.int(u28)},
) catch unreachable;
}
/// Get the XDG portal request path for the current Ghostty instance.
///
/// If this sounds like nonsense, see `request` for an explanation as to
/// why we need to do this.
fn getRequestPath(self: *GlobalShortcuts, token: [:0]const u8) ![:0]const u8 {
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html
// for the syntax XDG portals expect.
// `getUniqueName` should never return null here as we're using an ordinary
// message bus connection. If it doesn't, something is very wrong
const unique_name = std.mem.span(self.dbus.getUniqueName().?);
const object_path = try std.mem.joinZ(self.arena.allocator(), "/", &.{
"/org/freedesktop/portal/desktop/request",
unique_name[1..], // Remove leading `:`
token,
});
// Sanitize the unique name by replacing every `.` with `_`.
// In effect, this will turn a unique name like `:1.192` into `1_192`.
// Valid D-Bus object path components never contain `.`s anyway, so we're
// free to replace all instances of `.` here and avoid extra allocation.
std.mem.replaceScalar(u8, object_path, '.', '_');
return object_path;
}

View File

@@ -1,470 +0,0 @@
const ImguiWidget = @This();
const std = @import("std");
const assert = std.debug.assert;
const gdk = @import("gdk");
const gtk = @import("gtk");
const cimgui = @import("cimgui");
const gl = @import("opengl");
const key = @import("key.zig");
const input = @import("../../input.zig");
const log = std.log.scoped(.gtk_imgui_widget);
/// This is called every frame to populate the ImGui frame.
render_callback: ?*const fn (?*anyopaque) void = null,
render_userdata: ?*anyopaque = null,
/// Our OpenGL widget
gl_area: *gtk.GLArea,
im_context: *gtk.IMContext,
/// ImGui Context
ig_ctx: *cimgui.c.ImGuiContext,
/// Our previous instant used to calculate delta time for animations.
instant: ?std.time.Instant = null,
/// Initialize the widget. This must have a stable pointer for events.
pub fn init(self: *ImguiWidget) !void {
// Each widget gets its own imgui context so we can have multiple
// imgui views in the same application.
const ig_ctx = cimgui.c.igCreateContext(null) orelse return error.OutOfMemory;
errdefer cimgui.c.igDestroyContext(ig_ctx);
cimgui.c.igSetCurrentContext(ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
io.BackendPlatformName = "ghostty_gtk";
// Our OpenGL area for drawing
const gl_area = gtk.GLArea.new();
gl_area.setAutoRender(@intFromBool(true));
// The GL area has to be focusable so that it can receive events
gl_area.as(gtk.Widget).setFocusable(@intFromBool(true));
gl_area.as(gtk.Widget).setFocusOnClick(@intFromBool(true));
// Clicks
const gesture_click = gtk.GestureClick.new();
errdefer gesture_click.unref();
gesture_click.as(gtk.GestureSingle).setButton(0);
gl_area.as(gtk.Widget).addController(gesture_click.as(gtk.EventController));
// Mouse movement
const ec_motion = gtk.EventControllerMotion.new();
errdefer ec_motion.unref();
gl_area.as(gtk.Widget).addController(ec_motion.as(gtk.EventController));
// Scroll events
const ec_scroll = gtk.EventControllerScroll.new(.flags_both_axes);
errdefer ec_scroll.unref();
gl_area.as(gtk.Widget).addController(ec_scroll.as(gtk.EventController));
// Focus controller will tell us about focus enter/exit events
const ec_focus = gtk.EventControllerFocus.new();
errdefer ec_focus.unref();
gl_area.as(gtk.Widget).addController(ec_focus.as(gtk.EventController));
// Key event controller will tell us about raw keypress events.
const ec_key = gtk.EventControllerKey.new();
errdefer ec_key.unref();
gl_area.as(gtk.Widget).addController(ec_key.as(gtk.EventController));
errdefer gl_area.as(gtk.Widget).removeController(ec_key.as(gtk.EventController));
// The input method context that we use to translate key events into
// characters. This doesn't have an event key controller attached because
// we call it manually from our own key controller.
const im_context = gtk.IMMulticontext.new();
errdefer im_context.unref();
// Signals
_ = gtk.Widget.signals.realize.connect(
gl_area,
*ImguiWidget,
gtkRealize,
self,
.{},
);
_ = gtk.Widget.signals.unrealize.connect(
gl_area,
*ImguiWidget,
gtkUnrealize,
self,
.{},
);
_ = gtk.Widget.signals.destroy.connect(
gl_area,
*ImguiWidget,
gtkDestroy,
self,
.{},
);
_ = gtk.GLArea.signals.render.connect(
gl_area,
*ImguiWidget,
gtkRender,
self,
.{},
);
_ = gtk.GLArea.signals.resize.connect(
gl_area,
*ImguiWidget,
gtkResize,
self,
.{},
);
_ = gtk.EventControllerKey.signals.key_pressed.connect(
ec_key,
*ImguiWidget,
gtkKeyPressed,
self,
.{},
);
_ = gtk.EventControllerKey.signals.key_released.connect(
ec_key,
*ImguiWidget,
gtkKeyReleased,
self,
.{},
);
_ = gtk.EventControllerFocus.signals.enter.connect(
ec_focus,
*ImguiWidget,
gtkFocusEnter,
self,
.{},
);
_ = gtk.EventControllerFocus.signals.leave.connect(
ec_focus,
*ImguiWidget,
gtkFocusLeave,
self,
.{},
);
_ = gtk.GestureClick.signals.pressed.connect(
gesture_click,
*ImguiWidget,
gtkMouseDown,
self,
.{},
);
_ = gtk.GestureClick.signals.released.connect(
gesture_click,
*ImguiWidget,
gtkMouseUp,
self,
.{},
);
_ = gtk.EventControllerMotion.signals.motion.connect(
ec_motion,
*ImguiWidget,
gtkMouseMotion,
self,
.{},
);
_ = gtk.EventControllerScroll.signals.scroll.connect(
ec_scroll,
*ImguiWidget,
gtkMouseScroll,
self,
.{},
);
_ = gtk.IMContext.signals.commit.connect(
im_context,
*ImguiWidget,
gtkInputCommit,
self,
.{},
);
self.* = .{
.gl_area = gl_area,
.im_context = im_context.as(gtk.IMContext),
.ig_ctx = ig_ctx,
};
}
/// Deinitialize the widget. This should ONLY be called if the widget gl_area
/// was never added to a parent. Otherwise, cleanup automatically happens
/// when the widget is destroyed and this should NOT be called.
pub fn deinit(self: *ImguiWidget) void {
cimgui.c.igDestroyContext(self.ig_ctx);
}
/// This should be called anytime the underlying data for the UI changes
/// so that the UI can be refreshed.
pub fn queueRender(self: *const ImguiWidget) void {
self.gl_area.queueRender();
}
/// Initialize the frame. Expects that the context is already current.
fn newFrame(self: *ImguiWidget) !void {
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
// Determine our delta time
const now = try std.time.Instant.now();
io.DeltaTime = if (self.instant) |prev| delta: {
const since_ns = now.since(prev);
const since_s: f32 = @floatFromInt(since_ns / std.time.ns_per_s);
break :delta @max(0.00001, since_s);
} else (1 / 60);
self.instant = now;
}
fn translateMouseButton(button: c_uint) ?c_int {
return switch (button) {
1 => cimgui.c.ImGuiMouseButton_Left,
2 => cimgui.c.ImGuiMouseButton_Middle,
3 => cimgui.c.ImGuiMouseButton_Right,
else => null,
};
}
fn gtkDestroy(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
log.debug("imgui widget destroy", .{});
self.deinit();
}
fn gtkRealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
log.debug("gl surface realized", .{});
// We need to make the context current so we can call GL functions.
area.makeCurrent();
if (area.getError()) |err| {
log.err("surface failed to realize: {s}", .{err.f_message orelse "(unknown)"});
return;
}
// realize means that our OpenGL context is ready, so we can now
// initialize the ImgUI OpenGL backend for our context.
cimgui.c.igSetCurrentContext(self.ig_ctx);
_ = cimgui.ImGui_ImplOpenGL3_Init(null);
}
fn gtkUnrealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
_ = area;
log.debug("gl surface unrealized", .{});
cimgui.c.igSetCurrentContext(self.ig_ctx);
cimgui.ImGui_ImplOpenGL3_Shutdown();
}
fn gtkResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *ImguiWidget) callconv(.c) void {
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const scale_factor = area.as(gtk.Widget).getScaleFactor();
log.debug("gl resize width={} height={} scale={}", .{
width,
height,
scale_factor,
});
// Our display size is always unscaled. We'll do the scaling in the
// style instead. This creates crisper looking fonts.
io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) };
io.DisplayFramebufferScale = .{ .x = 1, .y = 1 };
// Setup a new style and scale it appropriately.
const style = cimgui.c.ImGuiStyle_ImGuiStyle();
defer cimgui.c.ImGuiStyle_destroy(style);
cimgui.c.ImGuiStyle_ScaleAllSizes(style, @floatFromInt(scale_factor));
const active_style = cimgui.c.igGetStyle();
active_style.* = style.*;
}
fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *ImguiWidget) callconv(.c) c_int {
cimgui.c.igSetCurrentContext(self.ig_ctx);
// Setup our frame. We render twice because some ImGui behaviors
// take multiple renders to process. I don't know how to make this
// more efficient.
for (0..2) |_| {
cimgui.ImGui_ImplOpenGL3_NewFrame();
self.newFrame() catch |err| {
log.err("failed to setup frame: {}", .{err});
return 0;
};
cimgui.c.igNewFrame();
// Build our UI
if (self.render_callback) |cb| cb(self.render_userdata);
// Render
cimgui.c.igRender();
}
// OpenGL final render
gl.clearColor(0x28 / 0xFF, 0x2C / 0xFF, 0x34 / 0xFF, 1.0);
gl.clear(gl.c.GL_COLOR_BUFFER_BIT);
cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.igGetDrawData());
return 1;
}
fn gtkMouseMotion(
_: *gtk.EventControllerMotion,
x: f64,
y: f64,
self: *ImguiWidget,
) callconv(.c) void {
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const scale_factor: f64 = @floatFromInt(self.gl_area.as(gtk.Widget).getScaleFactor());
cimgui.c.ImGuiIO_AddMousePosEvent(
io,
@floatCast(x * scale_factor),
@floatCast(y * scale_factor),
);
self.queueRender();
}
fn gtkMouseDown(
gesture: *gtk.GestureClick,
_: c_int,
_: f64,
_: f64,
self: *ImguiWidget,
) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton();
if (translateMouseButton(gdk_button)) |button| {
cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true);
}
}
fn gtkMouseUp(
gesture: *gtk.GestureClick,
_: c_int,
_: f64,
_: f64,
self: *ImguiWidget,
) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton();
if (translateMouseButton(gdk_button)) |button| {
cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false);
}
}
fn gtkMouseScroll(
_: *gtk.EventControllerScroll,
x: f64,
y: f64,
self: *ImguiWidget,
) callconv(.c) c_int {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddMouseWheelEvent(
io,
@floatCast(x),
@floatCast(-y),
);
return @intFromBool(true);
}
fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddFocusEvent(io, true);
}
fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddFocusEvent(io, false);
}
fn gtkInputCommit(
_: *gtk.IMMulticontext,
bytes: [*:0]u8,
self: *ImguiWidget,
) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes);
}
fn gtkKeyPressed(
ec_key: *gtk.EventControllerKey,
keyval: c_uint,
keycode: c_uint,
gtk_mods: gdk.ModifierType,
self: *ImguiWidget,
) callconv(.c) c_int {
return @intFromBool(self.keyEvent(
.press,
ec_key,
keyval,
keycode,
gtk_mods,
));
}
fn gtkKeyReleased(
ec_key: *gtk.EventControllerKey,
keyval: c_uint,
keycode: c_uint,
gtk_mods: gdk.ModifierType,
self: *ImguiWidget,
) callconv(.c) void {
_ = self.keyEvent(
.release,
ec_key,
keyval,
keycode,
gtk_mods,
);
}
fn keyEvent(
self: *ImguiWidget,
action: input.Action,
ec_key: *gtk.EventControllerKey,
keyval: c_uint,
keycode: c_uint,
gtk_mods: gdk.ModifierType,
) bool {
_ = keycode;
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const mods = key.translateMods(gtk_mods);
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift);
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftCtrl, mods.ctrl);
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftAlt, mods.alt);
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftSuper, mods.super);
// If our keyval has a key, then we send that key event
if (key.keyFromKeyval(keyval)) |inputkey| {
if (inputkey.imguiKey()) |imgui_key| {
cimgui.c.ImGuiIO_AddKeyEvent(io, imgui_key, action == .press);
}
}
// Try to process the event as text
if (ec_key.as(gtk.EventController).getCurrentEvent()) |event| {
_ = self.im_context.filterKeypress(event);
}
return true;
}

View File

@@ -1,165 +0,0 @@
//! Structure for managing GUI progress bar for a surface.
const ProgressBar = @This();
const std = @import("std");
const glib = @import("glib");
const gtk = @import("gtk");
const Surface = @import("./Surface.zig");
const terminal = @import("../../terminal/main.zig");
const log = std.log.scoped(.gtk_progress_bar);
/// The surface that we belong to.
surface: *Surface,
/// Widget for showing progress bar.
progress_bar: ?*gtk.ProgressBar = null,
/// Timer used to remove the progress bar if we have not received an update from
/// the TUI in a while.
progress_bar_timer: ?c_uint = null,
pub fn init(surface: *Surface) ProgressBar {
return .{
.surface = surface,
};
}
pub fn deinit(self: *ProgressBar) void {
self.stopProgressBarTimer();
}
/// Show (or update if it already exists) a GUI progress bar.
pub fn handleProgressReport(self: *ProgressBar, value: terminal.osc.Command.ProgressReport) error{}!bool {
// Remove the progress bar.
if (value.state == .remove) {
self.stopProgressBarTimer();
self.removeProgressBar();
return true;
}
const progress_bar = self.addProgressBar();
self.startProgressBarTimer();
switch (value.state) {
// already handled above
.remove => unreachable,
// Set the progress bar to a fixed value if one was provided, otherwise pulse.
// Remove the `error` CSS class so that the progress bar shows as normal.
.set => {
progress_bar.as(gtk.Widget).removeCssClass("error");
if (value.progress) |progress| {
progress_bar.setFraction(computeFraction(progress));
} else {
progress_bar.pulse();
}
},
// Set the progress bar to a fixed value if one was provided, otherwise pulse.
// Set the `error` CSS class so that the progress bar shows as an error color.
.@"error" => {
progress_bar.as(gtk.Widget).addCssClass("error");
if (value.progress) |progress| {
progress_bar.setFraction(computeFraction(progress));
} else {
progress_bar.pulse();
}
},
// The state of progress is unknown, so pulse the progress bar to
// indicate that things are still happening.
.indeterminate => {
progress_bar.pulse();
},
// If a progress value was provided, set the progress bar to that value.
// Don't pulse the progress bar as that would indicate that things were
// happening. Otherwise this is mainly used to keep the progress bar on
// screen instead of timing out.
.pause => {
if (value.progress) |progress| {
progress_bar.setFraction(computeFraction(progress));
}
},
}
return true;
}
/// Compute a fraction [0.0, 1.0] from the supplied progress, which is clamped
/// to [0, 100].
fn computeFraction(progress: u8) f64 {
return @as(f64, @floatFromInt(std.math.clamp(progress, 0, 100))) / 100.0;
}
test "computeFraction" {
try std.testing.expectEqual(1.0, computeFraction(100));
try std.testing.expectEqual(1.0, computeFraction(255));
try std.testing.expectEqual(0.0, computeFraction(0));
try std.testing.expectEqual(0.5, computeFraction(50));
}
/// Add a progress bar to our overlay.
fn addProgressBar(self: *ProgressBar) *gtk.ProgressBar {
if (self.progress_bar) |progress_bar| return progress_bar;
const progress_bar = gtk.ProgressBar.new();
self.progress_bar = progress_bar;
const progress_bar_widget = progress_bar.as(gtk.Widget);
progress_bar_widget.setHalign(.fill);
progress_bar_widget.setValign(.start);
progress_bar_widget.addCssClass("osd");
self.surface.overlay.addOverlay(progress_bar_widget);
return progress_bar;
}
/// Remove the progress bar from our overlay.
fn removeProgressBar(self: *ProgressBar) void {
if (self.progress_bar) |progress_bar| {
const progress_bar_widget = progress_bar.as(gtk.Widget);
self.surface.overlay.removeOverlay(progress_bar_widget);
self.progress_bar = null;
}
}
/// Start a timer that will remove the progress bar if the TUI forgets to remove
/// it.
fn startProgressBarTimer(self: *ProgressBar) void {
const progress_bar_timeout_seconds = 15;
// Remove an old timer that hasn't fired yet.
self.stopProgressBarTimer();
self.progress_bar_timer = glib.timeoutAdd(
progress_bar_timeout_seconds * std.time.ms_per_s,
handleProgressBarTimeout,
self,
);
}
/// Stop any existing timer for removing the progress bar.
fn stopProgressBarTimer(self: *ProgressBar) void {
if (self.progress_bar_timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove progress bar timer", .{});
}
self.progress_bar_timer = null;
}
}
/// The progress bar hasn't been updated by the TUI recently, remove it.
fn handleProgressBarTimeout(ud: ?*anyopaque) callconv(.c) c_int {
const self: *ProgressBar = @ptrCast(@alignCast(ud.?));
self.progress_bar_timer = null;
self.removeProgressBar();
return @intFromBool(glib.SOURCE_REMOVE);
}

View File

@@ -1,206 +0,0 @@
const ResizeOverlay = @This();
const std = @import("std");
const glib = @import("glib");
const gtk = @import("gtk");
const configpkg = @import("../../config.zig");
const Surface = @import("Surface.zig");
const log = std.log.scoped(.gtk);
/// local copy of configuration data
const DerivedConfig = struct {
resize_overlay: configpkg.Config.ResizeOverlay,
resize_overlay_position: configpkg.Config.ResizeOverlayPosition,
resize_overlay_duration: configpkg.Config.Duration,
pub fn init(config: *const configpkg.Config) DerivedConfig {
return .{
.resize_overlay = config.@"resize-overlay",
.resize_overlay_position = config.@"resize-overlay-position",
.resize_overlay_duration = config.@"resize-overlay-duration",
};
}
};
/// the surface that we are attached to
surface: *Surface,
/// a copy of the configuration that we need to operate
config: DerivedConfig,
/// If non-null this is the widget on the overlay that shows the size of the
/// surface when it is resized.
label: ?*gtk.Label = null,
/// If non-null this is a timer for dismissing the resize overlay.
timer: ?c_uint = null,
/// If non-null this is a timer for dismissing the resize overlay.
idler: ?c_uint = null,
/// If true, the next resize event will be the first one.
first: bool = true,
/// Initialize the ResizeOverlay. This doesn't do anything more than save a
/// pointer to the surface that we are a part of as all of the widget creation
/// is done later.
pub fn init(self: *ResizeOverlay, surface: *Surface, config: *const configpkg.Config) void {
self.* = .{
.surface = surface,
.config = .init(config),
};
}
pub fn updateConfig(self: *ResizeOverlay, config: *const configpkg.Config) void {
self.config = .init(config);
}
/// De-initialize the ResizeOverlay. This removes any pending idlers/timers that
/// may not have fired yet.
pub fn deinit(self: *ResizeOverlay) void {
if (self.idler) |idler| {
if (glib.Source.remove(idler) == 0) {
log.warn("unable to remove resize overlay idler", .{});
}
self.idler = null;
}
if (self.timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove resize overlay timer", .{});
}
self.timer = null;
}
}
/// If we're configured to do so, update the text in the resize overlay widget
/// and make it visible. Schedule a timer to hide the widget after the delay
/// expires.
///
/// If we're not configured to show the overlay, do nothing.
pub fn maybeShow(self: *ResizeOverlay) void {
switch (self.config.resize_overlay) {
.never => return,
.always => {},
.@"after-first" => if (self.first) {
self.first = false;
return;
},
}
self.first = false;
// When updating a widget, wait until GTK is "idle", i.e. not in the middle
// of doing any other updates. Since we are called in the middle of resizing
// GTK is doing a lot of work rearranging all of the widgets. Not doing this
// results in a lot of warnings from GTK and _horrible_ flickering of the
// resize overlay.
if (self.idler != null) return;
self.idler = glib.idleAdd(gtkUpdate, self);
}
/// Actually update the overlay widget. This should only be called from a GTK
/// idle handler.
fn gtkUpdate(ud: ?*anyopaque) callconv(.c) c_int {
const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0));
// No matter what our idler is complete with this callback
self.idler = null;
const grid_size = self.surface.core_surface.size.grid();
var buf: [32]u8 = undefined;
const text = std.fmt.bufPrintZ(
&buf,
"{d} x {d}",
.{
grid_size.columns,
grid_size.rows,
},
) catch |err| {
log.err("unable to format text: {}", .{err});
return 0;
};
if (self.label) |label| {
// The resize overlay widget already exists, just update it.
label.setText(text.ptr);
setPosition(label, &self.config);
show(label);
} else {
// Create the resize overlay widget.
const label = gtk.Label.new(text.ptr);
label.setJustify(gtk.Justification.center);
label.setSelectable(0);
setPosition(label, &self.config);
const widget = label.as(gtk.Widget);
widget.addCssClass("view");
widget.addCssClass("size-overlay");
widget.setFocusable(0);
widget.setCanTarget(0);
const overlay: *gtk.Overlay = @ptrCast(@alignCast(self.surface.overlay));
overlay.addOverlay(widget);
self.label = label;
}
if (self.timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove size overlay timer", .{});
}
}
self.timer = glib.timeoutAdd(
self.surface.app.config.@"resize-overlay-duration".asMilliseconds(),
gtkTimerExpired,
self,
);
return 0;
}
// This should only be called from a GTK idle handler or timer.
fn show(label: *gtk.Label) void {
const widget = label.as(gtk.Widget);
widget.removeCssClass("hidden");
}
// This should only be called from a GTK idle handler or timer.
fn hide(label: *gtk.Label) void {
const widget = label.as(gtk.Widget);
widget.addCssClass("hidden");
}
/// Update the position of the resize overlay widget. It might seem excessive to
/// do this often, but it should make hot config reloading of the position work.
/// This should only be called from a GTK idle handler.
fn setPosition(label: *gtk.Label, config: *DerivedConfig) void {
const widget = label.as(gtk.Widget);
widget.setHalign(
switch (config.resize_overlay_position) {
.center, .@"top-center", .@"bottom-center" => gtk.Align.center,
.@"top-left", .@"bottom-left" => gtk.Align.start,
.@"top-right", .@"bottom-right" => gtk.Align.end,
},
);
widget.setValign(
switch (config.resize_overlay_position) {
.center => gtk.Align.center,
.@"top-left", .@"top-center", .@"top-right" => gtk.Align.start,
.@"bottom-left", .@"bottom-center", .@"bottom-right" => gtk.Align.end,
},
);
}
/// If this fires, it means that the delay period has expired and the resize
/// overlay widget should be hidden.
fn gtkTimerExpired(ud: ?*anyopaque) callconv(.c) c_int {
const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0));
self.timer = null;
if (self.label) |label| hide(label);
return 0;
}

View File

@@ -1,441 +0,0 @@
/// Split represents a surface split where two surfaces are shown side-by-side
/// within the same window either vertically or horizontally.
const Split = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const gobject = @import("gobject");
const gtk = @import("gtk");
const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig");
const CoreSurface = @import("../../Surface.zig");
const Surface = @import("Surface.zig");
const Tab = @import("Tab.zig");
const log = std.log.scoped(.gtk);
/// The split orientation.
pub const Orientation = enum {
horizontal,
vertical,
pub fn fromDirection(direction: apprt.action.SplitDirection) Orientation {
return switch (direction) {
.right, .left => .horizontal,
.down, .up => .vertical,
};
}
pub fn fromResizeDirection(direction: apprt.action.ResizeSplit.Direction) Orientation {
return switch (direction) {
.up, .down => .vertical,
.left, .right => .horizontal,
};
}
};
/// Our actual GtkPaned widget
paned: *gtk.Paned,
/// The container for this split panel.
container: Surface.Container,
/// The orientation of this split panel.
orientation: Orientation,
/// The elements of this split panel.
top_left: Surface.Container.Elem,
bottom_right: Surface.Container.Elem,
/// Create a new split panel with the given sibling surface in the given
/// direction. The direction is where the new surface will be initialized.
///
/// The sibling surface can be in a split already or it can be within a
/// tab. This properly handles updating the surface container so that
/// it represents the new split.
pub fn create(
alloc: Allocator,
sibling: *Surface,
direction: apprt.action.SplitDirection,
) !*Split {
var split = try alloc.create(Split);
errdefer alloc.destroy(split);
try split.init(sibling, direction);
return split;
}
pub fn init(
self: *Split,
sibling: *Surface,
direction: apprt.action.SplitDirection,
) !void {
// If our sibling is too small to be split in half then we don't
// allow the split to happen. This avoids a situation where the
// split becomes too small.
//
// This is kind of a hack. Ideally we'd use gtk_widget_set_size_request
// properly along the path to ensure minimum sizes. I don't know if
// GTK even respects that all but any way GTK does this for us seems
// better than this.
{
// This is the min size of the sibling split. This means the
// smallest split is half of this.
const multiplier = 4;
const size = &sibling.core_surface.size;
const small = switch (direction) {
.right, .left => size.screen.width < size.cell.width * multiplier,
.down, .up => size.screen.height < size.cell.height * multiplier,
};
if (small) return error.SplitTooSmall;
}
// Create the new child surface for the other direction.
const alloc = sibling.app.core_app.alloc;
var surface = try Surface.create(alloc, sibling.app, .{
.parent = &sibling.core_surface,
});
errdefer surface.destroy(alloc);
sibling.dimSurface();
sibling.setSplitZoom(false);
// Create the actual GTKPaned, attach the proper children.
const orientation: gtk.Orientation = switch (direction) {
.right, .left => .horizontal,
.down, .up => .vertical,
};
const paned = gtk.Paned.new(orientation);
errdefer paned.unref();
// Keep a long-lived reference, which we unref in destroy.
paned.ref();
// Update all of our containers to point to the right place.
// The split has to point to where the sibling pointed to because
// we're inheriting its parent. The sibling points to its location
// in the split, and the surface points to the other location.
const container = sibling.container;
const tl: *Surface, const br: *Surface = switch (direction) {
.right, .down => right_down: {
sibling.container = .{ .split_tl = &self.top_left };
surface.container = .{ .split_br = &self.bottom_right };
break :right_down .{ sibling, surface };
},
.left, .up => left_up: {
sibling.container = .{ .split_br = &self.bottom_right };
surface.container = .{ .split_tl = &self.top_left };
break :left_up .{ surface, sibling };
},
};
self.* = .{
.paned = paned,
.container = container,
.top_left = .{ .surface = tl },
.bottom_right = .{ .surface = br },
.orientation = .fromDirection(direction),
};
// Replace the previous containers element with our split. This allows a
// non-split to become a split, a split to become a nested split, etc.
container.replace(.{ .split = self });
// Update our children so that our GL area is properly added to the paned.
self.updateChildren();
// The new surface should always grab focus
surface.grabFocus();
}
pub fn destroy(self: *Split, alloc: Allocator) void {
self.top_left.deinit(alloc);
self.bottom_right.deinit(alloc);
// Clean up our GTK reference. This will trigger all the destroy callbacks
// that are necessary for the surfaces to clean up.
self.paned.unref();
alloc.destroy(self);
}
/// Remove the top left child.
pub fn removeTopLeft(self: *Split) void {
self.removeChild(self.top_left, self.bottom_right);
}
/// Remove the top left child.
pub fn removeBottomRight(self: *Split) void {
self.removeChild(self.bottom_right, self.top_left);
}
fn removeChild(
self: *Split,
remove: Surface.Container.Elem,
keep: Surface.Container.Elem,
) void {
const window = self.container.window() orelse return;
const alloc = window.app.core_app.alloc;
// Remove our children since we are going to no longer be a split anyways.
// This prevents widgets with multiple parents.
self.removeChildren();
// Our container must become whatever our top left is
self.container.replace(keep);
// Grab focus of the left-over side
keep.grabFocus();
// When a child is removed we are no longer a split, so destroy ourself
remove.deinit(alloc);
alloc.destroy(self);
}
/// Move the divider in the given direction by the given amount.
pub fn moveDivider(
self: *Split,
direction: apprt.action.ResizeSplit.Direction,
amount: u16,
) void {
const min_pos = 10;
const pos = self.paned.getPosition();
const new = switch (direction) {
.up, .left => @max(pos - amount, min_pos),
.down, .right => new_pos: {
const max_pos: u16 = @as(u16, @intFromFloat(self.maxPosition())) - min_pos;
break :new_pos @min(pos + amount, max_pos);
},
};
self.paned.setPosition(new);
}
/// Equalize the splits in this split panel. Each split is equalized based on
/// its weight, i.e. the number of Surfaces it contains.
///
/// It works recursively by equalizing the children of each split.
///
/// It returns this split's weight.
pub fn equalize(self: *Split) f64 {
// Calculate weights of top_left/bottom_right
const top_left_weight = self.top_left.equalize();
const bottom_right_weight = self.bottom_right.equalize();
const weight = top_left_weight + bottom_right_weight;
// Ratio of top_left weight to overall weight, which gives the split ratio
const ratio = top_left_weight / weight;
// Convert split ratio into new position for divider
self.paned.setPosition(@intFromFloat(self.maxPosition() * ratio));
return weight;
}
// maxPosition returns the maximum position of the GtkPaned, which is the
// "max-position" attribute.
fn maxPosition(self: *Split) f64 {
var value: gobject.Value = std.mem.zeroes(gobject.Value);
defer value.unset();
_ = value.init(gobject.ext.types.int);
self.paned.as(gobject.Object).getProperty(
"max-position",
&value,
);
return @floatFromInt(value.getInt());
}
// This replaces the element at the given pointer with a new element.
// The ptr must be either top_left or bottom_right (asserted in debug).
// The memory of the old element must be freed or otherwise handled by
// the caller.
pub fn replace(
self: *Split,
ptr: *Surface.Container.Elem,
new: Surface.Container.Elem,
) void {
// We can write our element directly. There's nothing special.
assert(&self.top_left == ptr or &self.bottom_right == ptr);
ptr.* = new;
// Update our paned children. This will reset the divider
// position but we want to keep it in place so save and restore it.
const pos = self.paned.getPosition();
defer self.paned.setPosition(pos);
self.updateChildren();
}
// grabFocus grabs the focus of the top-left element.
pub fn grabFocus(self: *Split) void {
self.top_left.grabFocus();
}
/// Update the paned children to represent the current state.
/// This should be called anytime the top/left or bottom/right
/// element is changed.
pub fn updateChildren(self: *const Split) void {
// We have to set both to null. If we overwrite the pane with
// the same value, then GTK bugs out (the GL area unrealizes
// and never rerealizes).
self.removeChildren();
// Set our current children
self.paned.setStartChild(self.top_left.widget());
self.paned.setEndChild(self.bottom_right.widget());
}
/// A mapping of direction to the element (if any) in that direction.
pub const DirectionMap = std.EnumMap(
apprt.action.GotoSplit,
?*Surface,
);
pub const Side = enum { top_left, bottom_right };
/// Returns the map that can be used to determine elements in various
/// directions (primarily for gotoSplit).
pub fn directionMap(self: *const Split, from: Side) DirectionMap {
var result = DirectionMap.initFull(null);
if (self.directionPrevious(from)) |prev| {
result.put(.previous, prev.surface);
if (!prev.wrapped) {
result.put(.up, prev.surface);
}
}
if (self.directionNext(from)) |next| {
result.put(.next, next.surface);
if (!next.wrapped) {
result.put(.down, next.surface);
}
}
if (self.directionLeft(from)) |left| {
result.put(.left, left);
}
if (self.directionRight(from)) |right| {
result.put(.right, right);
}
return result;
}
fn directionLeft(self: *const Split, from: Side) ?*Surface {
switch (from) {
.bottom_right => {
switch (self.orientation) {
.horizontal => return self.top_left.deepestSurface(.bottom_right),
.vertical => return directionLeft(
self.container.split() orelse return null,
.bottom_right,
),
}
},
.top_left => return directionLeft(
self.container.split() orelse return null,
.bottom_right,
),
}
}
fn directionRight(self: *const Split, from: Side) ?*Surface {
switch (from) {
.top_left => {
switch (self.orientation) {
.horizontal => return self.bottom_right.deepestSurface(.top_left),
.vertical => return directionRight(
self.container.split() orelse return null,
.top_left,
),
}
},
.bottom_right => return directionRight(
self.container.split() orelse return null,
.top_left,
),
}
}
fn directionPrevious(self: *const Split, from: Side) ?struct {
surface: *Surface,
wrapped: bool,
} {
switch (from) {
// From the bottom right, our previous is the deepest surface
// in the top-left of our own split.
.bottom_right => return .{
.surface = self.top_left.deepestSurface(.bottom_right) orelse return null,
.wrapped = false,
},
// From the top left its more complicated. It is the de
.top_left => {
// If we have no parent split then there can be no unwrapped prev.
// We can still have a wrapped previous.
const parent = self.container.split() orelse return .{
.surface = self.bottom_right.deepestSurface(.bottom_right) orelse return null,
.wrapped = true,
};
// The previous value is the previous of the side that we are.
const side = self.container.splitSide() orelse return null;
return switch (side) {
.top_left => parent.directionPrevious(.top_left),
.bottom_right => parent.directionPrevious(.bottom_right),
};
},
}
}
fn directionNext(self: *const Split, from: Side) ?struct {
surface: *Surface,
wrapped: bool,
} {
switch (from) {
// From the top left, our next is the earliest surface in the
// top-left direction of the bottom-right side of our split. Fun!
.top_left => return .{
.surface = self.bottom_right.deepestSurface(.top_left) orelse return null,
.wrapped = false,
},
// From the bottom right is more compliated. It is the deepest
// (last) surface in the
.bottom_right => {
// If we have no parent split then there can be no next.
const parent = self.container.split() orelse return .{
.surface = self.top_left.deepestSurface(.top_left) orelse return null,
.wrapped = true,
};
// The previous value is the previous of the side that we are.
const side = self.container.splitSide() orelse return null;
return switch (side) {
.top_left => parent.directionNext(.top_left),
.bottom_right => parent.directionNext(.bottom_right),
};
},
}
}
pub fn detachTopLeft(self: *const Split) void {
self.paned.setStartChild(null);
}
pub fn detachBottomRight(self: *const Split) void {
self.paned.setEndChild(null);
}
fn removeChildren(self: *const Split) void {
self.detachTopLeft();
self.detachBottomRight();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,171 +0,0 @@
//! The state associated with a single tab in the window.
//!
//! A tab can contain one or more terminals due to splits.
const Tab = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const gobject = @import("gobject");
const gtk = @import("gtk");
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
const Surface = @import("Surface.zig");
const Window = @import("Window.zig");
const CloseDialog = @import("CloseDialog.zig");
const log = std.log.scoped(.gtk);
pub const GHOSTTY_TAB = "ghostty_tab";
/// The window that owns this tab.
window: *Window,
/// The tab label. The tab label is the text that appears on the tab.
label_text: *gtk.Label,
/// We'll put our children into this box instead of packing them
/// directly, so that we can send the box into `c.g_signal_connect_data`
/// for the close button
box: *gtk.Box,
/// The element of this tab so that we can handle splits and so on.
elem: Surface.Container.Elem,
// We'll update this every time a Surface gains focus, so that we have it
// when we switch to another Tab. Then when we switch back to this tab, we
// can easily re-focus that terminal.
focus_child: ?*Surface,
pub fn create(alloc: Allocator, window: *Window, parent_: ?*CoreSurface) !*Tab {
var tab = try alloc.create(Tab);
errdefer alloc.destroy(tab);
try tab.init(window, parent_);
return tab;
}
/// Initialize the tab, create a surface, and add it to the window. "self" needs
/// to be a stable pointer, since it is used for GTK events.
pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void {
self.* = .{
.window = window,
.label_text = undefined,
.box = undefined,
.elem = undefined,
.focus_child = null,
};
// Create a Box in which we'll later keep either Surface or Split. Using a
// box makes it easier to maintain the tab contents because we never need to
// change the root widget of the notebook page (tab).
const box = gtk.Box.new(.vertical, 0);
errdefer box.unref();
const box_widget = box.as(gtk.Widget);
box_widget.setHexpand(1);
box_widget.setVexpand(1);
self.box = box;
// Create the initial surface since all tabs start as a single non-split
var surface = try Surface.create(window.app.core_app.alloc, window.app, .{
.parent = parent_,
});
errdefer surface.unref();
surface.container = .{ .tab_ = self };
self.elem = .{ .surface = surface };
// Add Surface to the Tab
self.box.append(surface.primaryWidget());
// Set the userdata of the box to point to this tab.
self.box.as(gobject.Object).setData(GHOSTTY_TAB, self);
window.notebook.addTab(self, "Ghostty");
// Attach all events
_ = gtk.Widget.signals.destroy.connect(
self.box,
*Tab,
gtkDestroy,
self,
.{},
);
// We need to grab focus after Surface and Tab is added to the window. When
// creating a Tab we want to always focus on the widget.
surface.grabFocus();
}
/// Deinits tab by deiniting child elem.
pub fn deinit(self: *Tab, alloc: Allocator) void {
self.elem.deinit(alloc);
}
/// Deinit and deallocate the tab.
pub fn destroy(self: *Tab, alloc: Allocator) void {
self.deinit(alloc);
alloc.destroy(self);
}
// TODO: move this
/// Replace the surface element that this tab is showing.
pub fn replaceElem(self: *Tab, elem: Surface.Container.Elem) void {
// Remove our previous widget
self.box.remove(self.elem.widget());
// Add our new one
self.box.append(elem.widget());
self.elem = elem;
}
pub fn setTitleText(self: *Tab, title: [:0]const u8) void {
self.window.notebook.setTabTitle(self, title);
}
pub fn setTooltipText(self: *Tab, tooltip: [:0]const u8) void {
self.window.notebook.setTabTooltip(self, tooltip);
}
/// Remove this tab from the window.
pub fn remove(self: *Tab) void {
self.window.closeTab(self);
}
/// Helper function to check if any surface in the split hierarchy needs close confirmation
fn needsConfirm(elem: Surface.Container.Elem) bool {
return switch (elem) {
.surface => |s| s.core_surface.needsConfirmQuit(),
.split => |s| needsConfirm(s.top_left) or needsConfirm(s.bottom_right),
};
}
/// Close the tab, asking for confirmation if any surface requests it.
pub fn closeWithConfirmation(tab: *Tab) void {
switch (tab.elem) {
.surface => |s| s.closeWithConfirmation(
s.core_surface.needsConfirmQuit(),
.{ .tab = tab },
),
.split => |s| {
if (!needsConfirm(s.top_left) and !needsConfirm(s.bottom_right)) {
tab.remove();
return;
}
CloseDialog.show(.{ .tab = tab }) catch |err| {
log.err("failed to open close dialog={}", .{err});
};
},
}
}
fn gtkDestroy(_: *gtk.Box, self: *Tab) callconv(.c) void {
log.debug("tab box destroy", .{});
const alloc = self.window.app.core_app.alloc;
// When our box is destroyed, we want to destroy our tab, too.
self.destroy(alloc);
}

View File

@@ -1,284 +0,0 @@
/// An abstraction over the Adwaita tab view to manage all the terminal tabs in
/// a window.
const TabView = @This();
const std = @import("std");
const gtk = @import("gtk");
const adw = @import("adw");
const gobject = @import("gobject");
const glib = @import("glib");
const Window = @import("Window.zig");
const Tab = @import("Tab.zig");
const adw_version = @import("adw_version.zig");
const log = std.log.scoped(.gtk);
/// our window
window: *Window,
/// the tab view
tab_view: *adw.TabView,
/// Set to true so that the adw close-page handler knows we're forcing
/// and to allow a close to happen with no confirm. This is a bit of a hack
/// because we currently use GTK alerts to confirm tab close and they
/// don't carry with them the ADW state that we are confirming or not.
/// Long term we should move to ADW alerts so we can know if we are
/// confirming or not.
forcing_close: bool = false,
pub fn init(self: *TabView, window: *Window) void {
self.* = .{
.window = window,
.tab_view = adw.TabView.new(),
};
self.tab_view.as(gtk.Widget).addCssClass("notebook");
if (adw_version.atLeast(1, 2, 0)) {
// Adwaita enables all of the shortcuts by default.
// We want to manage keybindings ourselves.
self.tab_view.removeShortcuts(.{
.alt_digits = true,
.alt_zero = true,
.control_end = true,
.control_home = true,
.control_page_down = true,
.control_page_up = true,
.control_shift_end = true,
.control_shift_home = true,
.control_shift_page_down = true,
.control_shift_page_up = true,
.control_shift_tab = true,
.control_tab = true,
});
}
_ = adw.TabView.signals.page_attached.connect(
self.tab_view,
*TabView,
adwPageAttached,
self,
.{},
);
_ = adw.TabView.signals.close_page.connect(
self.tab_view,
*TabView,
adwClosePage,
self,
.{},
);
_ = adw.TabView.signals.create_window.connect(
self.tab_view,
*TabView,
adwTabViewCreateWindow,
self,
.{},
);
_ = gobject.Object.signals.notify.connect(
self.tab_view,
*TabView,
adwSelectPage,
self,
.{
.detail = "selected-page",
},
);
}
pub fn asWidget(self: *TabView) *gtk.Widget {
return self.tab_view.as(gtk.Widget);
}
pub fn nPages(self: *TabView) c_int {
return self.tab_view.getNPages();
}
/// Returns the index of the currently selected page.
/// Returns null if the notebook has no pages.
fn currentPage(self: *TabView) ?c_int {
const page = self.tab_view.getSelectedPage() orelse return null;
return self.tab_view.getPagePosition(page);
}
/// Returns the currently selected tab or null if there are none.
pub fn currentTab(self: *TabView) ?*Tab {
const page = self.tab_view.getSelectedPage() orelse return null;
const child = page.getChild().as(gobject.Object);
return @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return null));
}
pub fn gotoNthTab(self: *TabView, position: c_int) bool {
const page_to_select = self.tab_view.getNthPage(position);
self.tab_view.setSelectedPage(page_to_select);
return true;
}
pub fn getTabPage(self: *TabView, tab: *Tab) ?*adw.TabPage {
return self.tab_view.getPage(tab.box.as(gtk.Widget));
}
pub fn getTabPosition(self: *TabView, tab: *Tab) ?c_int {
return self.tab_view.getPagePosition(self.getTabPage(tab) orelse return null);
}
pub fn gotoPreviousTab(self: *TabView, tab: *Tab) bool {
const page_idx = self.getTabPosition(tab) orelse return false;
// The next index is the previous or we wrap around.
const next_idx = if (page_idx > 0) page_idx - 1 else next_idx: {
const max = self.nPages();
break :next_idx max -| 1;
};
// Do nothing if we have one tab
if (next_idx == page_idx) return false;
return self.gotoNthTab(next_idx);
}
pub fn gotoNextTab(self: *TabView, tab: *Tab) bool {
const page_idx = self.getTabPosition(tab) orelse return false;
const max = self.nPages() -| 1;
const next_idx = if (page_idx < max) page_idx + 1 else 0;
if (next_idx == page_idx) return false;
return self.gotoNthTab(next_idx);
}
pub fn moveTab(self: *TabView, tab: *Tab, position: c_int) void {
const page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1;
var new_position: c_int = page_idx + position;
if (new_position < 0) {
new_position = max + new_position + 1;
} else if (new_position > max) {
new_position = new_position - max - 1;
}
if (new_position == page_idx) return;
self.reorderPage(tab, new_position);
}
pub fn reorderPage(self: *TabView, tab: *Tab, position: c_int) void {
_ = self.tab_view.reorderPage(self.getTabPage(tab) orelse return, position);
}
pub fn setTabTitle(self: *TabView, tab: *Tab, title: [:0]const u8) void {
const page = self.getTabPage(tab) orelse return;
page.setTitle(title.ptr);
}
pub fn setTabTooltip(self: *TabView, tab: *Tab, tooltip: [:0]const u8) void {
const page = self.getTabPage(tab) orelse return;
page.setTooltip(tooltip.ptr);
}
fn newTabInsertPosition(self: *TabView, tab: *Tab) c_int {
const numPages = self.nPages();
return switch (tab.window.app.config.@"window-new-tab-position") {
.current => if (self.currentPage()) |page| page + 1 else numPages,
.end => numPages,
};
}
/// Adds a new tab with the given title to the notebook.
pub fn addTab(self: *TabView, tab: *Tab, title: [:0]const u8) void {
const position = self.newTabInsertPosition(tab);
const page = self.tab_view.insert(tab.box.as(gtk.Widget), position);
self.setTabTitle(tab, title);
self.tab_view.setSelectedPage(page);
}
pub fn closeTab(self: *TabView, tab: *Tab) void {
// closeTab always expects to close unconditionally so we mark this
// as true so that the close_page call below doesn't request
// confirmation.
self.forcing_close = true;
const n = self.nPages();
defer {
// self becomes invalid if we close the last page because we close
// the whole window
if (n > 1) self.forcing_close = false;
}
if (self.getTabPage(tab)) |page| self.tab_view.closePage(page);
// If we have no more tabs we close the window
if (self.nPages() == 0) {
// libadw versions < 1.5.1 leak the final page view
// which causes our surface to not properly cleanup. We
// unref to force the cleanup. This will trigger a critical
// warning from GTK, but I don't know any other workaround.
if (!adw_version.atLeast(1, 5, 1)) {
tab.box.unref();
}
self.window.close();
}
}
pub fn createWindow(window: *Window) !*Window {
const new_window = try Window.create(window.app.core_app.alloc, window.app);
new_window.present();
return new_window;
}
fn adwPageAttached(_: *adw.TabView, page: *adw.TabPage, _: c_int, self: *TabView) callconv(.c) void {
const child = page.getChild().as(gobject.Object);
const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return));
tab.window = self.window;
self.window.focusCurrentTab();
}
fn adwClosePage(
_: *adw.TabView,
page: *adw.TabPage,
self: *TabView,
) callconv(.c) c_int {
const child = page.getChild().as(gobject.Object);
const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0));
self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close));
if (!self.forcing_close) {
// We cannot trigger a close directly in here as the page will stay
// alive until this handler returns, breaking the assumption where
// no pages means they are all destroyed.
//
// Schedule the close request to happen in the next event cycle.
_ = glib.idleAddOnce(glibIdleOnceCloseTab, tab);
}
return 1;
}
fn adwTabViewCreateWindow(
_: *adw.TabView,
self: *TabView,
) callconv(.c) ?*adw.TabView {
const window = createWindow(self.window) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
};
return window.notebook.tab_view;
}
fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callconv(.c) void {
const page = self.tab_view.getSelectedPage() orelse return;
// If the tab was previously marked as needing attention
// (e.g. due to a bell character), we now unmark that
page.setNeedsAttention(@intFromBool(false));
const title = page.getTitle();
self.window.setTitle(std.mem.span(title));
}
fn glibIdleOnceCloseTab(data: ?*anyopaque) callconv(.c) void {
const tab: *Tab = @ptrCast(@alignCast(data orelse return));
tab.closeWithConfirmation();
}

View File

@@ -1,115 +0,0 @@
//! Represents the URL hover widgets that show the hovered URL.
//!
//! To explain a bit how this all works since its split across a few places:
//! We create a left/right pair of labels. The left label is shown by default,
//! and the right label is hidden. When the mouse enters the left label, we
//! show the right label. When the mouse leaves the left label, we hide the
//! right label.
//!
//! The hover and styling is done with a combination of GTK event controllers
//! and CSS in style.css.
const URLWidget = @This();
const gtk = @import("gtk");
/// The label that appears on the bottom left.
left: *gtk.Label,
/// The label that appears on the bottom right.
right: *gtk.Label,
pub fn init(
/// The overlay that we will attach our labels to.
overlay: *gtk.Overlay,
/// The URL to display.
str: [:0]const u8,
) URLWidget {
// Create the left
const left = left: {
const left = gtk.Label.new(str.ptr);
left.setEllipsize(.middle);
const widget = left.as(gtk.Widget);
widget.addCssClass("view");
widget.addCssClass("url-overlay");
widget.addCssClass("left");
widget.setHalign(.start);
widget.setValign(.end);
break :left left;
};
// Create the right
const right = right: {
const right = gtk.Label.new(str.ptr);
right.setEllipsize(.middle);
const widget = right.as(gtk.Widget);
widget.addCssClass("hidden");
widget.addCssClass("view");
widget.addCssClass("url-overlay");
widget.addCssClass("right");
widget.setHalign(.end);
widget.setValign(.end);
break :right right;
};
// Setup our mouse hover event controller for the left label.
const ec_motion = gtk.EventControllerMotion.new();
errdefer ec_motion.unref();
left.as(gtk.Widget).addController(ec_motion.as(gtk.EventController));
_ = gtk.EventControllerMotion.signals.enter.connect(
ec_motion,
*gtk.Label,
gtkLeftEnter,
right,
.{},
);
_ = gtk.EventControllerMotion.signals.leave.connect(
ec_motion,
*gtk.Label,
gtkLeftLeave,
right,
.{},
);
// Show it
overlay.addOverlay(left.as(gtk.Widget));
overlay.addOverlay(right.as(gtk.Widget));
return .{
.left = left,
.right = right,
};
}
/// Remove our labels from the overlay.
pub fn deinit(self: *URLWidget, overlay: *gtk.Overlay) void {
overlay.removeOverlay(self.left.as(gtk.Widget));
overlay.removeOverlay(self.right.as(gtk.Widget));
}
/// Change the URL that is displayed.
pub fn setText(self: *const URLWidget, str: [:0]const u8) void {
self.left.setText(str.ptr);
self.right.setText(str.ptr);
}
/// Callback for when the mouse enters the left label. That means that we should
/// show the right label. CSS will handle hiding the left label.
fn gtkLeftEnter(
_: *gtk.EventControllerMotion,
_: f64,
_: f64,
right: *gtk.Label,
) callconv(.c) void {
right.as(gtk.Widget).removeCssClass("hidden");
}
/// Callback for when the mouse leaves the left label. That means that we should
/// hide the right label. CSS will handle showing the left label.
fn gtkLeftLeave(
_: *gtk.EventControllerMotion,
right: *gtk.Label,
) callconv(.c) void {
right.as(gtk.Widget).addCssClass("hidden");
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,160 +0,0 @@
const std = @import("std");
pub const c = @cImport({
@cInclude("adwaita.h");
});
const adwaita_version = std.SemanticVersion{
.major = c.ADW_MAJOR_VERSION,
.minor = c.ADW_MINOR_VERSION,
.patch = c.ADW_MICRO_VERSION,
};
const required_blueprint_version = std.SemanticVersion{
.major = 0,
.minor = 16,
.patch = 0,
};
pub fn main() !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const alloc = debug_allocator.allocator();
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
_ = it.next();
const required_adwaita_version = std.SemanticVersion{
.major = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMajorVersion, 10),
.minor = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMinorVersion, 10),
.patch = 0,
};
const output = it.next() orelse return error.NoOutput;
const input = it.next() orelse return error.NoInput;
if (adwaita_version.order(required_adwaita_version) == .lt) {
std.debug.print(
\\`libadwaita` is too old.
\\
\\Ghostty requires a version {} or newer of `libadwaita` to
\\compile this blueprint. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
, .{required_adwaita_version});
std.posix.exit(1);
}
{
var stdout: std.ArrayListUnmanaged(u8) = .empty;
defer stdout.deinit(alloc);
var stderr: std.ArrayListUnmanaged(u8) = .empty;
defer stderr.deinit(alloc);
var blueprint_compiler = std.process.Child.init(
&.{
"blueprint-compiler",
"--version",
},
alloc,
);
blueprint_compiler.stdout_behavior = .Pipe;
blueprint_compiler.stderr_behavior = .Pipe;
try blueprint_compiler.spawn();
try blueprint_compiler.collectOutput(
alloc,
&stdout,
&stderr,
std.math.maxInt(u16),
);
const term = blueprint_compiler.wait() catch |err| switch (err) {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
\\
\\Ghostty requires version {} or newer of
\\`blueprint-compiler` as a build-time dependency starting
\\from version 1.2. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
\\
, .{required_blueprint_version});
std.posix.exit(1);
},
else => return err,
};
switch (term) {
.Exited => |rc| {
if (rc != 0) std.process.exit(1);
},
else => std.process.exit(1),
}
const version = try std.SemanticVersion.parse(std.mem.trim(u8, stdout.items, &std.ascii.whitespace));
if (version.order(required_blueprint_version) == .lt) {
std.debug.print(
\\`blueprint-compiler` is the wrong version.
\\
\\Ghostty requires version {} or newer of
\\`blueprint-compiler` as a build-time dependency starting
\\from version 1.2. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
\\
, .{required_blueprint_version});
std.posix.exit(1);
}
}
{
var stdout: std.ArrayListUnmanaged(u8) = .empty;
defer stdout.deinit(alloc);
var stderr: std.ArrayListUnmanaged(u8) = .empty;
defer stderr.deinit(alloc);
var blueprint_compiler = std.process.Child.init(
&.{
"blueprint-compiler",
"compile",
"--output",
output,
input,
},
alloc,
);
blueprint_compiler.stdout_behavior = .Pipe;
blueprint_compiler.stderr_behavior = .Pipe;
try blueprint_compiler.spawn();
try blueprint_compiler.collectOutput(
alloc,
&stdout,
&stderr,
std.math.maxInt(u16),
);
const term = blueprint_compiler.wait() catch |err| switch (err) {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
\\
\\Ghostty requires version {} or newer of
\\`blueprint-compiler` as a build-time dependency starting
\\from version 1.2. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
\\
, .{required_blueprint_version});
std.posix.exit(1);
},
else => return err,
};
switch (term) {
.Exited => |rc| {
if (rc != 0) {
std.debug.print("{s}", .{stderr.items});
std.process.exit(1);
}
},
else => {
std.debug.print("{s}", .{stderr.items});
std.process.exit(1);
},
}
}
}

View File

@@ -14,10 +14,10 @@ pub const app_id = "com.mitchellh.ghostty";
/// The path to the Blueprint files. The folder structure is expected to be
/// `{version}/{name}.blp` where `version` is the major and minor
/// minimum adwaita version.
pub const ui_path = "src/apprt/gtk-ng/ui";
pub const ui_path = "src/apprt/gtk/ui";
/// The path to the CSS files.
pub const css_path = "src/apprt/gtk-ng/css";
pub const css_path = "src/apprt/gtk/css";
/// The possible icon sizes we'll embed into the gresource file.
/// If any size doesn't exist then it will be an error. We could

View File

@@ -2,23 +2,34 @@
/// each individual surface into its own cgroup.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const Allocator = std.mem.Allocator;
const App = @import("App.zig");
const internal_os = @import("../../os/main.zig");
const log = std.log.scoped(.gtk_systemd_cgroup);
pub const Options = struct {
memory_high: ?u64 = null,
pids_max: ?u64 = null,
};
/// Initialize the cgroup for the app. This will create our
/// transient scope, initialize the cgroups we use for the app,
/// configure them, and return the cgroup path for the app.
pub fn init(app: *App) ![]const u8 {
///
/// Returns the path of the current cgroup for the app, which is
/// allocated with the given allocator.
pub fn init(
alloc: Allocator,
dbus: *gio.DBusConnection,
opts: Options,
) ![]const u8 {
const pid = std.os.linux.getpid();
const alloc = app.core_app.alloc;
// Get our initial cgroup. We need this so we can compare
// and detect when we've switched to our transient group.
@@ -31,7 +42,7 @@ pub fn init(app: *App) ![]const u8 {
// Create our transient scope. If this succeeds then the unit
// was created, but we may not have moved into it yet, so we need
// to do a dumb busy loop to wait for the move to complete.
try createScope(app, pid);
try createScope(dbus, pid);
const transient = transient: while (true) {
const current = try internal_os.cgroup.current(
alloc,
@@ -67,7 +78,7 @@ pub fn init(app: *App) ![]const u8 {
// of "max" because it's a soft limit that can be exceeded and
// can be monitored by things like systemd-oomd to kill if needed,
// versus an instant hard kill.
if (app.config.@"linux-cgroup-memory-limit") |limit| {
if (opts.memory_high) |limit| {
try internal_os.cgroup.configureLimit(surfaces, .{
.memory_high = limit,
});
@@ -75,7 +86,7 @@ pub fn init(app: *App) ![]const u8 {
// Configure the "max" pids limit. This is a hard limit and cannot be
// exceeded.
if (app.config.@"linux-cgroup-processes-limit") |limit| {
if (opts.pids_max) |limit| {
try internal_os.cgroup.configureLimit(surfaces, .{
.pids_max = limit,
});
@@ -108,15 +119,12 @@ fn enableControllers(alloc: Allocator, cgroup: []const u8) !void {
);
}
/// Create a transient systemd scope unit for the current process.
///
/// On success this will return the name of the transient scope
/// cgroup prefix, allocated with the given allocator.
fn createScope(app: *App, pid_: std.os.linux.pid_t) !void {
const gio_app = app.app.as(gio.Application);
const connection = gio_app.getDbusConnection() orelse
return error.DbusConnectionRequired;
/// Create a transient systemd scope unit for the current process and
/// move our process into it.
fn createScope(
dbus: *gio.DBusConnection,
pid_: std.os.linux.pid_t,
) !void {
const pid: u32 = @intCast(pid_);
// The unit name needs to be unique. We use the pid for this.
@@ -180,7 +188,7 @@ fn createScope(app: *App, pid_: std.os.linux.pid_t) !void {
const value = builder.end();
const reply = connection.callSync(
const reply = dbus.callSync(
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
"org.freedesktop.systemd1.Manager",

View File

@@ -223,10 +223,8 @@ pub const Application = extern struct {
const single_instance = switch (config.@"gtk-single-instance") {
.true => true,
.false => false,
.desktop => switch (config.@"launched-from".?) {
.desktop, .systemd, .dbus => true,
.cli => false,
},
// This should have been resolved to true/false during config loading.
.detect => unreachable,
};
// Setup the flags for our application.
@@ -418,9 +416,7 @@ pub const Application = extern struct {
// This just calls the `activate` signal but its part of the normal startup
// routine so we just call it, but only if the config allows it (this allows
// for launching Ghostty in the "background" without immediately opening
// a window). An initial window will not be immediately created if we were
// launched by D-Bus activation or systemd. D-Bus activation will send it's
// own `activate` or `new-window` signal later.
// a window).
//
// https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302
const priv = self.private();
@@ -428,15 +424,11 @@ pub const Application = extern struct {
// We need to scope any config access because once we run our
// event loop, this can change out from underneath us.
const config = priv.config.get();
if (config.@"initial-window") switch (config.@"launched-from".?) {
.desktop, .cli => self.as(gio.Application).activate(),
.dbus, .systemd => {},
};
if (config.@"initial-window") self.as(gio.Application).activate();
}
// If we are NOT the primary instance, then we never want to run.
// This means that another instance of the GTK app is running and
// our "activate" call above will open a window.
// This means that another instance of the GTK app is running.
if (self.as(gio.Application).getIsRemote() != 0) {
log.debug(
"application is remote, exiting run loop after activation",
@@ -476,7 +468,14 @@ pub const Application = extern struct {
break :q false;
};
if (must_quit) self.quit();
if (must_quit) {
// All must quit scenarios do not need confirmation.
// Furthermore, must quit scenarios may result in a situation
// where its unsafe to even access the app/surface memory
// since its in the process of being freed. We must simply
// begin our exit immediately.
self.quitNow();
}
}
}

View File

@@ -496,6 +496,9 @@ pub const Surface = extern struct {
/// if this is true, then it means the terminal is non-functional.
@"error": bool = false,
/// The source that handles setting our child property.
idle_rechild: ?c_uint = null,
/// A weak reference to an inspector window.
inspector: ?*InspectorWindow = null,
@@ -504,6 +507,8 @@ pub const Surface = extern struct {
context_menu: *gtk.PopoverMenu,
drop_target: *gtk.DropTarget,
progress_bar_overlay: *gtk.ProgressBar,
error_page: *adw.StatusPage,
terminal_page: *gtk.Overlay,
pub var offset: c_int = 0;
};
@@ -595,17 +600,6 @@ pub const Surface = extern struct {
return @intFromBool(config.@"bell-features".border);
}
fn closureStackChildName(
_: *Self,
error_: c_int,
) callconv(.c) ?[*:0]const u8 {
const err = error_ != 0;
return if (err)
glib.ext.dupeZ(u8, "error")
else
glib.ext.dupeZ(u8, "terminal");
}
pub fn toggleFullscreen(self: *Self) void {
signals.@"toggle-fullscreen".impl.emit(
self,
@@ -1370,6 +1364,19 @@ pub const Surface = extern struct {
priv.progress_bar_timer = null;
}
if (priv.idle_rechild) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove idle source", .{});
}
priv.idle_rechild = null;
}
// This works around a GTK double-free bug where if you bind
// to a top-level template child, it frees twice if the widget is
// also the root child of the template. By unsetting the child here,
// we avoid the double-free.
self.as(adw.Bin).setChild(null);
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
@@ -1651,8 +1658,26 @@ pub const Surface = extern struct {
self.as(gtk.Widget).removeCssClass("background");
}
// Note above: in both cases setting our error view is handled by
// a Gtk.Stack visible-child-name binding.
// We need to set our child property on an idle tick, because the
// error property can be triggered by signals that are in the middle
// of widget mapping and changing our child during that time
// results in a hard gtk crash.
if (priv.idle_rechild == null) priv.idle_rechild = glib.idleAdd(
onIdleRechild,
self,
);
}
fn onIdleRechild(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
const priv = self.private();
priv.idle_rechild = null;
if (priv.@"error") {
self.as(adw.Bin).setChild(priv.error_page.as(gtk.Widget));
} else {
self.as(adw.Bin).setChild(priv.terminal_page.as(gtk.Widget));
}
return 0;
}
fn propMouseHoverUrl(
@@ -2699,8 +2724,10 @@ pub const Surface = extern struct {
class.bindTemplateChildPrivate("url_right", .{});
class.bindTemplateChildPrivate("child_exited_overlay", .{});
class.bindTemplateChildPrivate("context_menu", .{});
class.bindTemplateChildPrivate("error_page", .{});
class.bindTemplateChildPrivate("progress_bar_overlay", .{});
class.bindTemplateChildPrivate("resize_overlay", .{});
class.bindTemplateChildPrivate("terminal_page", .{});
class.bindTemplateChildPrivate("drop_target", .{});
class.bindTemplateChildPrivate("im_context", .{});
@@ -2736,7 +2763,6 @@ pub const Surface = extern struct {
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
class.bindTemplateCallback("notify_bell_ringing", &propBellRinging);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
class.bindTemplateCallback("stack_child_name", &closureStackChildName);
// Properties
gobject.ext.registerProperties(class, &.{

View File

@@ -306,6 +306,13 @@ pub const Window = extern struct {
const config = config_obj.get();
if (config.maximize) self.as(gtk.Window).maximize();
if (config.fullscreen) self.as(gtk.Window).fullscreen();
// If we have an explicit title set, we set that immediately
// so that any applications inspecting the window states see
// an immediate title set when the window appears, rather than
// waiting possibly a few event loop ticks for it to sync from
// the surface.
if (config.title) |v| self.as(gtk.Window).setTitle(v);
}
// We always sync our appearance at the end because loading our

View File

@@ -1,29 +0,0 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const build_config = @import("../../build_config.zig");
const internal_os = @import("../../os/main.zig");
const glib = @import("glib");
pub fn resourcesDir(alloc: Allocator) !internal_os.ResourcesDir {
if (comptime build_config.flatpak) {
// Only consult Flatpak runtime data for host case.
if (internal_os.isFlatpak()) {
var result: internal_os.ResourcesDir = .{
.app_path = try alloc.dupe(u8, "/app/share/ghostty"),
};
errdefer alloc.free(result.app_path.?);
const keyfile = glib.KeyFile.new();
defer keyfile.unref();
if (keyfile.loadFromFile("/.flatpak-info", .{}, null) == 0) return result;
const app_dir = std.mem.span(keyfile.getString("Instance", "app-path", null)) orelse return result;
defer glib.free(app_dir.ptr);
result.host_path = try std.fs.path.join(alloc, &[_][]const u8{ app_dir, "share", "ghostty" });
return result;
}
}
return try internal_os.resourcesDir(alloc);
}

View File

@@ -1,168 +0,0 @@
const std = @import("std");
const css_files = [_][]const u8{
"style.css",
"style-dark.css",
"style-hc.css",
"style-hc-dark.css",
};
const icons = [_]struct {
alias: []const u8,
source: []const u8,
}{
.{
.alias = "16x16",
.source = "16",
},
.{
.alias = "16x16@2",
.source = "32",
},
.{
.alias = "32x32",
.source = "32",
},
.{
.alias = "32x32@2",
.source = "64",
},
.{
.alias = "128x128",
.source = "128",
},
.{
.alias = "128x128@2",
.source = "256",
},
.{
.alias = "256x256",
.source = "256",
},
.{
.alias = "256x256@2",
.source = "512",
},
.{
.alias = "512x512",
.source = "512",
},
.{
.alias = "1024x1024",
.source = "1024",
},
};
pub const VersionedBlueprint = struct {
major: u16,
minor: u16,
name: []const u8,
};
pub const blueprint_files = [_]VersionedBlueprint{
.{ .major = 1, .minor = 5, .name = "prompt-title-dialog" },
.{ .major = 1, .minor = 5, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 0, .name = "menu-headerbar-split_menu" },
.{ .major = 1, .minor = 5, .name = "command-palette" },
.{ .major = 1, .minor = 0, .name = "menu-surface-context_menu" },
.{ .major = 1, .minor = 0, .name = "menu-window-titlebar_menu" },
.{ .major = 1, .minor = 5, .name = "ccw-osc-52-read" },
.{ .major = 1, .minor = 5, .name = "ccw-osc-52-write" },
.{ .major = 1, .minor = 5, .name = "ccw-paste" },
.{ .major = 1, .minor = 2, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 2, .name = "ccw-osc-52-read" },
.{ .major = 1, .minor = 2, .name = "ccw-osc-52-write" },
.{ .major = 1, .minor = 2, .name = "ccw-paste" },
};
pub fn main() !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const alloc = debug_allocator.allocator();
var extra_ui_files: std.ArrayListUnmanaged([]const u8) = .empty;
defer {
for (extra_ui_files.items) |item| alloc.free(item);
extra_ui_files.deinit(alloc);
}
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
while (it.next()) |argument| {
if (std.mem.eql(u8, std.fs.path.extension(argument), ".ui")) {
try extra_ui_files.append(alloc, try alloc.dupe(u8, argument));
}
}
const writer = std.io.getStdOut().writer();
try writer.writeAll(
\\<?xml version="1.0" encoding="UTF-8"?>
\\<gresources>
\\ <gresource prefix="/com/mitchellh/ghostty">
\\
);
for (css_files) |css_file| {
try writer.print(
" <file compressed=\"true\" alias=\"{s}\">src/apprt/gtk/{s}</file>\n",
.{ css_file, css_file },
);
}
try writer.writeAll(
\\ </gresource>
\\ <gresource prefix="/com/mitchellh/ghostty/icons">
\\
);
for (icons) |icon| {
try writer.print(
" <file alias=\"{s}/apps/com.mitchellh.ghostty.png\">images/gnome/{s}.png</file>\n",
.{ icon.alias, icon.source },
);
}
try writer.writeAll(
\\ </gresource>
\\ <gresource prefix="/com/mitchellh/ghostty/ui">
\\
);
for (extra_ui_files.items) |ui_file| {
for (blueprint_files) |file| {
const expected = try std.fmt.allocPrint(alloc, "/{d}.{d}/{s}.ui", .{ file.major, file.minor, file.name });
defer alloc.free(expected);
if (!std.mem.endsWith(u8, ui_file, expected)) continue;
try writer.print(
" <file compressed=\"true\" preprocess=\"xml-stripblanks\" alias=\"{d}.{d}/{s}.ui\">{s}</file>\n",
.{ file.major, file.minor, file.name, ui_file },
);
break;
} else return error.BlueprintNotFound;
}
try writer.writeAll(
\\ </gresource>
\\</gresources>
\\
);
}
pub const dependencies = deps: {
const total = css_files.len + icons.len + blueprint_files.len;
var deps: [total][]const u8 = undefined;
var index: usize = 0;
for (css_files) |css_file| {
deps[index] = std.fmt.comptimePrint("src/apprt/gtk/{s}", .{css_file});
index += 1;
}
for (icons) |icon| {
deps[index] = std.fmt.comptimePrint("images/gnome/{s}.png", .{icon.source});
index += 1;
}
for (blueprint_files) |blueprint_file| {
deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{d}.{d}/{s}.blp", .{
blueprint_file.major,
blueprint_file.minor,
blueprint_file.name,
});
index += 1;
}
break :deps deps;
};

View File

@@ -1,54 +0,0 @@
const HeaderBar = @This();
const std = @import("std");
const adw = @import("adw");
const gtk = @import("gtk");
const Window = @import("Window.zig");
/// the Adwaita headerbar widget
headerbar: *adw.HeaderBar,
/// the Window that we belong to
window: *Window,
/// the Adwaita window title widget
title: *adw.WindowTitle,
pub fn init(self: *HeaderBar, window: *Window) void {
self.* = .{
.headerbar = adw.HeaderBar.new(),
.window = window,
.title = adw.WindowTitle.new(
window.window.as(gtk.Window).getTitle() orelse "Ghostty",
"",
),
};
self.headerbar.setTitleWidget(self.title.as(gtk.Widget));
}
pub fn setVisible(self: *const HeaderBar, visible: bool) void {
self.headerbar.as(gtk.Widget).setVisible(@intFromBool(visible));
}
pub fn asWidget(self: *const HeaderBar) *gtk.Widget {
return self.headerbar.as(gtk.Widget);
}
pub fn packEnd(self: *const HeaderBar, widget: *gtk.Widget) void {
self.headerbar.packEnd(widget);
}
pub fn packStart(self: *const HeaderBar, widget: *gtk.Widget) void {
self.headerbar.packStart(widget);
}
pub fn setTitle(self: *const HeaderBar, title: [:0]const u8) void {
self.window.window.as(gtk.Window).setTitle(title);
self.title.setTitle(title);
}
pub fn setSubtitle(self: *const HeaderBar, subtitle: [:0]const u8) void {
self.title.setSubtitle(subtitle);
}

View File

@@ -1,184 +0,0 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const gtk = @import("gtk");
const build_config = @import("../../build_config.zig");
const i18n = @import("../../os/main.zig").i18n;
const App = @import("App.zig");
const Surface = @import("Surface.zig");
const TerminalWindow = @import("Window.zig");
const ImguiWidget = @import("ImguiWidget.zig");
const CoreInspector = @import("../../inspector/main.zig").Inspector;
const log = std.log.scoped(.inspector);
/// Inspector is the primary stateful object that represents a terminal
/// inspector. An inspector is 1:1 with a Surface and is owned by a Surface.
/// Closing a surface must close its inspector.
pub const Inspector = struct {
/// The surface that owns this inspector.
surface: *Surface,
/// The current state of where this inspector is rendered. The Inspector
/// is the state of the inspector but this is the state of the GUI.
location: LocationState,
/// This is true if we want to destroy this inspector as soon as the
/// location is closed. For example: set this to true, request the
/// window be closed, let GTK do its cleanup, then note this to destroy
/// the inner state.
destroy_on_close: bool = true,
/// Location where the inspector will be launched.
pub const Location = union(LocationKey) {
hidden: void,
window: void,
};
/// The internal state for each possible location.
const LocationState = union(LocationKey) {
hidden: void,
window: Window,
};
const LocationKey = enum {
/// No GUI, but load the inspector state.
hidden,
/// A dedicated window for the inspector.
window,
};
/// Create an inspector for the given surface in the given location.
pub fn create(surface: *Surface, location: Location) !*Inspector {
const alloc = surface.app.core_app.alloc;
var ptr = try alloc.create(Inspector);
errdefer alloc.destroy(ptr);
try ptr.init(surface, location);
return ptr;
}
/// Destroy all memory associated with this inspector. You generally
/// should NOT call this publicly and should call `close` instead to
/// use the GTK lifecycle.
pub fn destroy(self: *Inspector) void {
assert(self.location == .hidden);
const alloc = self.allocator();
self.surface.inspector = null;
self.deinit();
alloc.destroy(self);
}
fn init(self: *Inspector, surface: *Surface, location: Location) !void {
self.* = .{
.surface = surface,
.location = undefined,
};
// Activate the inspector. If it doesn't work we ignore the error
// because we can just show an error in the inspector window.
self.surface.core_surface.activateInspector() catch |err| {
log.err("failed to activate inspector err={}", .{err});
};
switch (location) {
.hidden => self.location = .{ .hidden = {} },
.window => try self.initWindow(),
}
}
fn deinit(self: *Inspector) void {
self.surface.core_surface.deactivateInspector();
}
/// Request the inspector is closed.
pub fn close(self: *Inspector) void {
switch (self.location) {
.hidden => self.locationDidClose(),
.window => |v| v.close(),
}
}
fn locationDidClose(self: *Inspector) void {
self.location = .{ .hidden = {} };
if (self.destroy_on_close) self.destroy();
}
pub fn queueRender(self: *const Inspector) void {
switch (self.location) {
.hidden => {},
.window => |v| v.imgui_widget.queueRender(),
}
}
fn allocator(self: *const Inspector) Allocator {
return self.surface.app.core_app.alloc;
}
fn initWindow(self: *Inspector) !void {
self.location = .{ .window = undefined };
try self.location.window.init(self);
}
};
/// A dedicated window to hold an inspector instance.
const Window = struct {
inspector: *Inspector,
window: *gtk.ApplicationWindow,
imgui_widget: ImguiWidget,
pub fn init(self: *Window, inspector: *Inspector) !void {
// Initialize to undefined
self.* = .{
.inspector = inspector,
.window = undefined,
.imgui_widget = undefined,
};
// Create the window
self.window = .new(inspector.surface.app.app.as(gtk.Application));
errdefer self.window.as(gtk.Window).destroy();
self.window.as(gtk.Window).setTitle(i18n._("Ghostty: Terminal Inspector"));
self.window.as(gtk.Window).setDefaultSize(1000, 600);
self.window.as(gtk.Window).setIconName(build_config.bundle_id);
self.window.as(gtk.Widget).addCssClass("window");
self.window.as(gtk.Widget).addCssClass("inspector-window");
// Initialize our imgui widget
try self.imgui_widget.init();
errdefer self.imgui_widget.deinit();
self.imgui_widget.render_callback = &imguiRender;
self.imgui_widget.render_userdata = self;
CoreInspector.setup();
// Signals
_ = gtk.Widget.signals.destroy.connect(self.window, *Window, gtkDestroy, self, .{});
// Show the window
self.window.as(gtk.Window).setChild(self.imgui_widget.gl_area.as(gtk.Widget));
self.window.as(gtk.Window).present();
}
pub fn deinit(self: *Window) void {
self.inspector.locationDidClose();
}
pub fn close(self: *const Window) void {
self.window.as(gtk.Window).destroy();
}
fn imguiRender(ud: ?*anyopaque) void {
const self: *Window = @ptrCast(@alignCast(ud orelse return));
const surface = &self.inspector.surface.core_surface;
const inspector = surface.inspector orelse return;
inspector.render();
}
/// "destroy" signal for the window
fn gtkDestroy(_: *gtk.ApplicationWindow, self: *Window) callconv(.c) void {
log.debug("window destroy", .{});
self.deinit();
}
};

View File

@@ -1 +0,0 @@
pub const openNewWindow = @import("ipc/new_window.zig").openNewWindow;

View File

@@ -1,10 +1,10 @@
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const gio = @import("gio");
const glib = @import("glib");
const apprt = @import("../../../apprt.zig");
const DBus = @import("DBus.zig");
// Use a D-Bus method call to open a new window on GTK.
// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI
@@ -20,153 +20,43 @@ const apprt = @import("../../../apprt.zig");
// ```
// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["echo" "hello"]>]' []
// ```
pub fn openNewWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool {
const stderr = std.io.getStdErr().writer();
pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool {
var dbus = try DBus.init(
alloc,
target,
if (value.arguments == null)
"new-window"
else
"new-window-command",
);
defer dbus.deinit(alloc);
// Get the appropriate bus name and object path for contacting the
// Ghostty instance we're interested in.
const bus_name: [:0]const u8, const object_path: [:0]const u8 = switch (target) {
.class => |class| result: {
// Force the usage of the class specified on the CLI to determine the
// bus name and object path.
const object_path = try std.fmt.allocPrintZ(alloc, "/{s}", .{class});
if (value.arguments) |arguments| {
// If `-e` was specified on the command line, the first
// parameter is an array of strings that contain the arguments
// that came after `-e`, which will be interpreted as a command
// to run.
const as_variant_type = glib.VariantType.new("as");
defer as_variant_type.free();
std.mem.replaceScalar(u8, object_path, '.', '/');
std.mem.replaceScalar(u8, object_path, '-', '_');
const s_variant_type = glib.VariantType.new("s");
defer s_variant_type.free();
break :result .{ class, object_path };
},
.detect => switch (builtin.mode) {
.Debug, .ReleaseSafe => .{ "com.mitchellh.ghostty-debug", "/com/mitchellh/ghostty_debug" },
.ReleaseFast, .ReleaseSmall => .{ "com.mitchellh.ghostty", "/com/mitchellh/ghostty" },
},
};
defer {
switch (target) {
.class => alloc.free(object_path),
.detect => {},
var command: glib.VariantBuilder = undefined;
command.init(as_variant_type);
errdefer command.clear();
for (arguments) |argument| {
const bytes = glib.Bytes.new(argument.ptr, argument.len + 1);
defer bytes.unref();
const string = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true));
command.addValue(string);
}
dbus.addParameter(command.end());
}
if (gio.Application.idIsValid(bus_name.ptr) == 0) {
try stderr.print("D-Bus bus name is not valid: {s}\n", .{bus_name});
return error.IPCFailed;
}
if (glib.Variant.isObjectPath(object_path.ptr) == 0) {
try stderr.print("D-Bus object path is not valid: {s}\n", .{object_path});
return error.IPCFailed;
}
const dbus = dbus: {
var err_: ?*glib.Error = null;
defer if (err_) |err| err.free();
const dbus_ = gio.busGetSync(.session, null, &err_);
if (err_) |err| {
try stderr.print(
"Unable to establish connection to D-Bus session bus: {s}\n",
.{err.f_message orelse "(unknown)"},
);
return error.IPCFailed;
}
break :dbus dbus_ orelse {
try stderr.print("gio.busGetSync returned null\n", .{});
return error.IPCFailed;
};
};
defer dbus.unref();
// use a builder to create the D-Bus method call payload
const payload = payload: {
const builder_type = glib.VariantType.new("(sava{sv})");
defer glib.free(builder_type);
// Initialize our builder to build up our parameters
var builder: glib.VariantBuilder = undefined;
builder.init(builder_type);
errdefer builder.clear();
// action
if (value.arguments == null) {
builder.add("s", "new-window");
} else {
builder.add("s", "new-window-command");
}
// parameters
{
const av = glib.VariantType.new("av");
defer av.free();
var parameters: glib.VariantBuilder = undefined;
parameters.init(av);
errdefer parameters.clear();
if (value.arguments) |arguments| {
// If `-e` was specified on the command line, the first
// parameter is an array of strings that contain the arguments
// that came after `-e`, which will be interpreted as a command
// to run.
{
const as = glib.VariantType.new("as");
defer as.free();
var command: glib.VariantBuilder = undefined;
command.init(as);
errdefer command.clear();
for (arguments) |argument| {
command.add("s", argument.ptr);
}
parameters.add("v", command.end());
}
}
builder.addValue(parameters.end());
}
{
const platform_data = glib.VariantType.new("a{sv}");
defer platform_data.free();
builder.open(platform_data);
defer builder.close();
// we have no platform data
}
break :payload builder.end();
};
{
var err_: ?*glib.Error = null;
defer if (err_) |err| err.free();
const result_ = dbus.callSync(
bus_name,
object_path,
"org.gtk.Actions",
"Activate",
payload,
null, // We don't care about the return type, we don't do anything with it.
.{}, // no flags
-1, // default timeout
null, // not cancellable
&err_,
);
defer if (result_) |result| result.unref();
if (err_) |err| {
try stderr.print(
"D-Bus method call returned an error err={s}\n",
.{err.f_message orelse "(unknown)"},
);
return error.IPCFailed;
}
}
try dbus.send();
return true;
}

View File

@@ -9,7 +9,10 @@ const input = @import("../../input.zig");
const winproto = @import("winproto.zig");
/// Returns a GTK accelerator string from a trigger.
pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 {
pub fn accelFromTrigger(
buf: []u8,
trigger: input.Binding.Trigger,
) error{NoSpaceLeft}!?[:0]const u8 {
var buf_stream = std.io.fixedBufferStream(buf);
const writer = buf_stream.writer();
@@ -30,7 +33,10 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u
/// Returns a XDG-compliant shortcuts string from a trigger.
/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/
pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 {
pub fn xdgShortcutFromTrigger(
buf: []u8,
trigger: input.Binding.Trigger,
) error{NoSpaceLeft}!?[:0]const u8 {
var buf_stream = std.io.fixedBufferStream(buf);
const writer = buf_stream.writer();
@@ -54,7 +60,7 @@ pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]c
return slice[0 .. slice.len - 1 :0];
}
fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool {
fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) error{NoSpaceLeft}!bool {
switch (trigger.key) {
.physical => |k| {
const keyval = keyvalFromKey(k) orelse return false;

View File

@@ -1,139 +0,0 @@
const std = @import("std");
const gtk = @import("gtk");
const gdk = @import("gdk");
const gio = @import("gio");
const gobject = @import("gobject");
const apprt = @import("../../apprt.zig");
const App = @import("App.zig");
const Window = @import("Window.zig");
const Surface = @import("Surface.zig");
const Builder = @import("Builder.zig");
/// Abstract GTK menus to take advantage of machinery for buildtime/comptime
/// error checking.
pub fn Menu(
/// GTK apprt type that the menu is "for". Window and Surface are supported
/// right now.
comptime T: type,
/// Name of the menu. Along with the apprt type, this is used to look up the
/// builder ui definitions of the menu.
comptime menu_name: []const u8,
/// Should the popup have a pointer pointing to the location that it's
/// attached to.
comptime arrow: bool,
) type {
return struct {
const Self = @This();
/// parent apprt object
parent: *T,
/// our widget
menu_widget: *gtk.PopoverMenu,
/// initialize the menu
pub fn init(self: *Self, parent: *T) void {
const object_type = switch (T) {
Window => "window",
Surface => "surface",
else => unreachable,
};
var builder = Builder.init("menu-" ++ object_type ++ "-" ++ menu_name, 1, 0);
defer builder.deinit();
const menu_model = builder.getObject(gio.MenuModel, "menu").?;
const menu_widget = gtk.PopoverMenu.newFromModelFull(menu_model, .{ .nested = true });
// If this menu has an arrow, don't modify the horizontal alignment
// or you get visual anomalies. See PR #6087. Otherwise set the
// horizontal alignment to `start` so that the top left corner of
// the menu aligns with the point that the menu is popped up at.
if (!arrow) menu_widget.as(gtk.Widget).setHalign(.start);
menu_widget.as(gtk.Popover).setHasArrow(@intFromBool(arrow));
_ = gtk.Popover.signals.closed.connect(
menu_widget,
*Self,
gtkRefocusTerm,
self,
.{},
);
self.* = .{
.parent = parent,
.menu_widget = menu_widget,
};
}
pub fn setParent(self: *const Self, widget: *gtk.Widget) void {
self.menu_widget.as(gtk.Widget).setParent(widget);
}
pub fn asWidget(self: *const Self) *gtk.Widget {
return self.menu_widget.as(gtk.Widget);
}
pub fn isVisible(self: *const Self) bool {
return self.menu_widget.as(gtk.Widget).getVisible() != 0;
}
/// Refresh the menu. Right now that means enabling/disabling the "Copy"
/// menu item based on whether there is an active selection or not, but
/// that may change in the future.
pub fn refresh(self: *const Self) void {
const window: *gtk.Window, const has_selection: bool = switch (T) {
Window => window: {
const has_selection = if (self.parent.actionSurface()) |core_surface|
core_surface.hasSelection()
else
false;
break :window .{ self.parent.window.as(gtk.Window), has_selection };
},
Surface => surface: {
const window = self.parent.container.window() orelse return;
const has_selection = self.parent.core_surface.hasSelection();
break :surface .{ window.window.as(gtk.Window), has_selection };
},
else => unreachable,
};
const action_map: *gio.ActionMap = gobject.ext.cast(gio.ActionMap, window) orelse return;
const action: *gio.SimpleAction = gobject.ext.cast(
gio.SimpleAction,
action_map.lookupAction("copy") orelse return,
) orelse return;
action.setEnabled(@intFromBool(has_selection));
}
/// Pop up the menu at the given coordinates
pub fn popupAt(self: *const Self, x: c_int, y: c_int) void {
const rect: gdk.Rectangle = .{
.f_x = x,
.f_y = y,
.f_width = 1,
.f_height = 1,
};
const popover = self.menu_widget.as(gtk.Popover);
popover.setPointingTo(&rect);
self.refresh();
popover.popup();
}
/// Refocus tab that lost focus because of the popover menu
fn gtkRefocusTerm(_: *gtk.PopoverMenu, self: *Self) callconv(.c) void {
const window: *Window = switch (T) {
Window => self.parent,
Surface => self.parent.container.window() orelse return,
else => unreachable,
};
window.focusCurrentTab();
}
};
}

View File

@@ -1,8 +0,0 @@
.transparent {
background-color: transparent;
}
.terminal-window .notebook paned > separator {
background-color: rgba(36, 36, 36, 1);
background-clip: content-box;
}

View File

@@ -1,3 +0,0 @@
.transparent {
background-color: transparent;
}

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