mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00
Compare commits
46 Commits
8d11c08db3
...
main
Author | SHA1 | Date | |
---|---|---|---|
![]() |
15777050f3 | ||
![]() |
12bd7baaeb | ||
![]() |
0333a6f1d2 | ||
![]() |
ef822612d3 | ||
![]() |
a88e6cd428 | ||
![]() |
1ef220a679 | ||
![]() |
0492cd16fa | ||
![]() |
587f47a587 | ||
![]() |
d10e474860 | ||
![]() |
4552ea9104 | ||
![]() |
19b76df80e | ||
![]() |
e024b77ad5 | ||
![]() |
a7da96faee | ||
![]() |
93debc439c | ||
![]() |
bb78adbd93 | ||
![]() |
968b9d536d | ||
![]() |
ac52af27d3 | ||
![]() |
8a2ab8ff21 | ||
![]() |
ee573ebd36 | ||
![]() |
e2504d9cbf | ||
![]() |
93744a4002 | ||
![]() |
a590194cd7 | ||
![]() |
3ac2da99f4 | ||
![]() |
43ee3cc8c6 | ||
![]() |
aeae54072c | ||
![]() |
5c1d87fda6 | ||
![]() |
7c4b45ecee | ||
![]() |
2464728851 | ||
![]() |
c3e7857a2c | ||
![]() |
e67db2a01c | ||
![]() |
befee07f16 | ||
![]() |
c8243ffd99 | ||
![]() |
084ff2de67 | ||
![]() |
e1f3f52686 | ||
![]() |
fe3dab9467 | ||
![]() |
b90c72aea6 | ||
![]() |
e6d60dee07 | ||
![]() |
508e36bc03 | ||
![]() |
6a9b8b70cc | ||
![]() |
1dee9e7cb2 | ||
![]() |
291d4ed423 | ||
![]() |
5eb69b405d | ||
![]() |
7d5be8e960 | ||
![]() |
d05ec81b86 | ||
![]() |
f016b79f22 | ||
![]() |
52f5ab1a36 |
64
.agents/commands/gh-issue
Executable file
64
.agents/commands/gh-issue
Executable 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
|
||||
}
|
55
.github/workflows/test.yml
vendored
55
.github/workflows/test.yml
vendored
@@ -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
23
AGENTS.md
Normal 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
|
@@ -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
|
||||
|
@@ -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.
|
||||
|
28
HACKING.md
28
HACKING.md
@@ -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
|
||||
|
@@ -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
6
build.zig.zon.json
generated
@@ -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
6
build.zig.zon.nix
generated
@@ -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
2
build.zig.zon.txt
generated
@@ -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
|
||||
|
4
dist/linux/app.desktop.in
vendored
4
dist/linux/app.desktop.in
vendored
@@ -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
|
||||
|
2
dist/linux/dbus.service.flatpak.in
vendored
2
dist/linux/dbus.service.flatpak.in
vendored
@@ -1,3 +1,3 @@
|
||||
[D-BUS Service]
|
||||
Name=@APPID@
|
||||
Exec=@GHOSTTY@ --launched-from=dbus
|
||||
Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false
|
||||
|
2
dist/linux/dbus.service.in
vendored
2
dist/linux/dbus.service.in
vendored
@@ -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
|
||||
|
2
dist/linux/systemd.service.in
vendored
2
dist/linux/systemd.service.in
vendored
@@ -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
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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) {
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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])
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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 ""
|
||||
|
@@ -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.
|
||||
|
@@ -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" },
|
||||
),
|
||||
|
@@ -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;
|
||||
|
@@ -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");
|
||||
}
|
@@ -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();
|
||||
}
|
@@ -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();
|
||||
}
|
@@ -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);
|
||||
}
|
@@ -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();
|
||||
}
|
@@ -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));
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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
|
||||
};
|
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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),
|
||||
}
|
||||
}
|
||||
};
|
@@ -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 {}
|
||||
};
|
@@ -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;
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
@@ -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,
|
||||
};
|
@@ -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
@@ -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();
|
||||
}
|
@@ -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));
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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);
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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
@@ -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);
|
||||
}
|
@@ -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();
|
||||
}
|
@@ -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
@@ -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);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
@@ -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",
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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, &.{
|
@@ -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
|
@@ -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);
|
||||
}
|
@@ -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;
|
||||
};
|
@@ -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);
|
||||
}
|
@@ -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();
|
||||
}
|
||||
};
|
@@ -1 +0,0 @@
|
||||
pub const openNewWindow = @import("ipc/new_window.zig").openNewWindow;
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
@@ -1,8 +0,0 @@
|
||||
.transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.terminal-window .notebook paned > separator {
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
background-clip: content-box;
|
||||
}
|
@@ -1,3 +0,0 @@
|
||||
.transparent {
|
||||
background-color: transparent;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user