131 Commits

Author SHA1 Message Date
Mitchell Hashimoto
e4c3a56242 Micro-optimize GlyphKey Context (#8536)
Use fast hash function on key for better distribution.

Direct compare glyph in eql to avoid Packed.from() if not neccessary.

16% -> 6.4% reduction during profiling runs.
2025-09-05 15:25:14 -07:00
Jesse Miller
cf39d5c512 Glphkey.hash CityHash64 -> hash.int 2025-09-05 15:21:47 -07:00
Mitchell Hashimoto
6b21662219 Fix off-by-one error & adjust overline pos in cell height mod (#8022)
I noticed that there was an off-by-one error in cell height adjustment
when the number of pixels to add/subtract is odd. The metrics measured
from the top would be shifted by one less than they should, so, for
example, the underline position would move one pixel closer to the
baseline than it had been (or one pixel further away if subtracting).

Also noticed that the overline position was missing here, so added that.
2025-09-05 13:10:17 -07:00
Jesse Miller
8824256059 Micro-optimize GlyphKey Context
Use fast hash function on key for better distribution.

Direct compare glyph in eql to avoid Packed.from() if not neccessary.

16% -> 6.4% reduction during profiling runs.
2025-09-05 13:37:39 -06:00
Mitchell Hashimoto
15777050f3 apprt/gtk: set the title on the window immediately if set (#8535)
Fixes #5934

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Example diagnosing #8523:

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

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

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

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

Example diagnosing #8523:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

We were incorrectly calling graceful quit under must quit scenarios.
This would do things like confirm quit by inspecting for running
processes. However, must quit scenarios (namely when all windows are
destroyed) should quit immediately without any checks because the
dispose process takes more event loop ticks to fully finish.
2025-09-02 20:42:01 -07:00
Ivan Bastrakov
52f5ab1a36 i18n: update Russian translation 2025-09-03 01:57:25 +03:00
Mitchell Hashimoto
8d11c08db3 feat: add selection-clear-on-copy configuration option (#8462)
Addresses issue: Add selection-clear-on-copy configuration #8407

Added configuration option `selection-clear-on-copy` that matches with
the `selection-clear-on-typing` option.
And `copy-on-select` is ignored when `selection-clear-on-copy` is true
regardless of whether `copy-on-select` is set to true or clipboard.
Also `.copy_to_clipboard` binding action was refactored to use
`copySelectionToClipboards` for consistent behavior.

> Consulted with Copilot (Claude Sonnet 4) to understand the control
flow of copy operations and help write the docs. Solution was authored
and implemented by me.
2025-09-02 14:55:46 -07:00
Toufiq Shishir
90c0fc2590 feat: add selection-clear-on-copy configuration 2025-09-02 14:48:47 -07:00
Mitchell Hashimoto
e909e28876 Compare fields directly instead of PackedStyle (#8489)
This is a small, but I think worthwhile micro optimization in style.zig,
I uncovered while investigating wider ranging optimizations in the
rendering section.

For me it results in ~4-5% increase in fps for DOOM-fire-zig benchmark,
which maximally stresses this code path.

Comparing the fields directly is actually faster than PackedStyle.

I wrote the code in style.zig, claude 4 wrote the benchmark, all PR
responses will be generated by jcm-slow-1


Style.eql Benchmark Comparison
==============================

Test: Small (1K pairs, 50% equal)
--------------------------------------------------
New implementation:
  Iterations: 49937
  Duration: 500.01 ms
  Throughput: 99872402 comparisons/sec
Old implementation:
  Iterations: 8508
  Duration: 500.06 ms
  Throughput: 17014026 comparisons/sec
Performance improvement:
  Speedup: 5.87x
  Improvement: +487.0%

Test: Medium (10K pairs, 50% equal)
--------------------------------------------------
New implementation:
  Iterations: 4435
  Duration: 500.09 ms
  Throughput: 88684746 comparisons/sec
Old implementation:
  Iterations: 850
  Duration: 500.50 ms
  Throughput: 16983017 comparisons/sec
Performance improvement:
  Speedup: 5.22x
  Improvement: +422.2%

Test: Large (50K pairs, 50% equal)
--------------------------------------------------
New implementation:
  Iterations: 861
  Duration: 500.41 ms
  Throughput: 86030144 comparisons/sec
Old implementation:
  Iterations: 171
  Duration: 501.70 ms
  Throughput: 17041989 comparisons/sec
Performance improvement:
  Speedup: 5.05x
  Improvement: +404.8%

Test: Mostly equal (10K pairs, 90% equal)
--------------------------------------------------
New implementation:
  Iterations: 4608
  Duration: 500.03 ms
  Throughput: 92154471 comparisons/sec
Old implementation:
  Iterations: 854
  Duration: 500.45 ms
  Throughput: 17064744 comparisons/sec
Performance improvement:
  Speedup: 5.40x
  Improvement: +440.0%

Test: Mostly different (10K pairs, 10% equal)
--------------------------------------------------
New implementation:
  Iterations: 4065
  Duration: 500.03 ms
  Throughput: 81294960 comparisons/sec
Old implementation:
  Iterations: 848
  Duration: 500.21 ms
  Throughput: 16952948 comparisons/sec
Performance improvement:
  Speedup: 4.80x
  Improvement: +379.5%

Test: Same flags (10K pairs, 50% equal)
--------------------------------------------------
New implementation:
  Iterations: 2799
  Duration: 500.00 ms
  Throughput: 55979776 comparisons/sec
Old implementation:
  Iterations: 859
  Duration: 500.13 ms
  Throughput: 17175672 comparisons/sec
Performance improvement:
  Speedup: 3.26x
  Improvement: +225.9%
2025-09-02 14:37:30 -07:00
Jesse Miller
cf0390bab5 Use comptime for eql() to ensure Style struct coverage. 2025-09-02 15:14:42 -06:00
Jesse Miller
4614e5fdad Zig 0.14+ can directly compare packed structs. 2025-09-02 14:58:21 -06:00
Mitchell Hashimoto
ce94bb9f6a macOS: firstRect should return full rect width/height (#8492)
Fixes #2473

This commit changes `ghostty_surface_ime_point` to return a full rect
with the width/height calculated for the preedit.

The `firstRect` function, which calls `ghostty_surface_ime_point` was
previously setting the width/height to zero. macOS didn't like this. We
then changed it to just hardcode it to width/height of one cell. This
worked but made it so the IME cursor didn't follow the preedit.

The result is shown in the video below. Notice the dictation icon
follows the text properly:



https://github.com/user-attachments/assets/81be8c63-9f0a-49b7-ac30-2db930beb238
2025-09-02 13:28:08 -07:00
Jesse Miller
ac104a3dfc zig fmt 2025-09-02 14:14:06 -06:00
Mitchell Hashimoto
16e47e7586 fix(font): detect and reject improper advance for icwidth (#8491)
Fixes #8481

Explained in code comments, basically the NF patcher can produce fonts
that have CJK characters with 1-cell advances, which screws up fallback
font scaling; fixed by not counting the ic width metric if the width of
the glyph is greater than the advance width.

> [!NOTE]
> As follow-on work to this it may be worth setting limits for scaling,
so you can't have one font scaled like twice as large as the primary
font, since that's almost always going to indicate something is very
wrong.
2025-09-02 13:09:07 -07:00
Mitchell Hashimoto
e8217aa007 macOS: firstRect should return full rect width/height
Fixes #2473

This commit changes `ghostty_surface_ime_point` to return a full rect
with the width/height calculated for the preedit.

The `firstRect` function, which calls `ghostty_surface_ime_point` was
previously setting the width/height to zero. macOS didn't like this. We
then changed it to just hardcode it to width/height of one cell. This
worked but made it so the IME cursor didn't follow the preedit.
2025-09-02 13:08:46 -07:00
Qwerasd
9aa1698e5a font: log warning when rejecting ic_width 2025-09-02 13:47:59 -06:00
Mitchell Hashimoto
3664ee9f87 macOS: Notify macOS of cell width/height for firstRect (#8490)
Related to #2473

This fixes an issue where the dictation icon didn't show the language
picker.
2025-09-02 12:38:50 -07:00
Qwerasd
a72995590b fix(font): detect and reject improper advance for icwidth 2025-09-02 13:33:33 -06:00
Mitchell Hashimoto
2bf0d3f4c7 macOS: Notify macOS of cell width/height for firstRect
Related to #2473

This fixes an issue where the dictation icon didn't show the language 
picker.
2025-09-02 12:26:52 -07:00
Mitchell Hashimoto
4af290d5f0 fix(renderer): kitty images should all be processed (#8488)
When processing kitty images in a loop in a few places we were returning
under certain conditions where we should instead have just continued the
loop. This caused serious problems for kitty images, especially for apps
that used multiple images on screen at once.

... I have no clue how I originally wrote this code and didn't see such
a trivial mistake, I think I was sleep deprived or something.

Should fix #8471
2025-09-02 12:14:11 -07:00
Qwerasd
ef7857f9be fix(renderer): kitty images should all be processed
When processing kitty images in a loop in a few places we were returning
under certain conditions where we should instead have just continued the
loop. This caused serious problems for kitty images, especially for apps
that used multiple images on screen at once.

... I have no clue how I originally wrote this code and didn't see such
a trivial mistake, I think I was sleep deprived or something.
2025-09-02 12:42:34 -06:00
Jesse Miller
7dcf2c9b62 Compare fields directly instead of PackedStyle
Comparing the fields directly is actually faster than PackedStyle
2025-09-02 12:05:30 -06:00
Mitchell Hashimoto
d316449ebf config: bind both physical digit plus unicode digit for goto_tab (#8486)
Fixes #8478

The comments explain this.
2025-09-02 09:12:08 -07:00
Mitchell Hashimoto
650028fa9f config: bind both physical digit plus unicode digit for goto_tab
Fixes #8478

The comments explain this.
2025-09-02 09:00:58 -07:00
Mitchell Hashimoto
5ef6412823 macOS: Progress bar for OSC9 progress reports (#8477)
#7975 but for macOS. The behavior and even the look is almost identical:


https://github.com/user-attachments/assets/b7e0b370-3a30-443d-89ae-08209d2c9b89

Similar to GTK, it'll remove the progress bar state after 15 seconds. 

AI disclaimer: Claude code produced most of this code. I reviewed all
the lines and understand them completely.
2025-08-31 20:51:54 -07:00
Mitchell Hashimoto
0b58830882 macOS: Progress bar for OSC9 progress reports 2025-08-31 20:42:34 -07:00
Mitchell Hashimoto
a41ec17b61 build(deps): bump actions/checkout from 4.3.0 to 5.0.0 (#8476)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.0
to 5.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/releases">actions/checkout's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update actions checkout to use node 24 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2226">actions/checkout#2226</a></li>
<li>Prepare v5.0.0 release by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2238">actions/checkout#2238</a></li>
</ul>
<h2>⚠️ Minimum Compatible Runner Version</h2>
<p><strong>v2.327.1</strong><br />
<a
href="https://github.com/actions/runner/releases/tag/v2.327.1">Release
Notes</a></p>
<p>Make sure your runner is updated to this version or newer to use this
release.</p>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v4...v5.0.0">https://github.com/actions/checkout/compare/v4...v5.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/blob/main/CHANGELOG.md">actions/checkout's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>V5.0.0</h2>
<ul>
<li>Update actions checkout to use node 24 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2226">actions/checkout#2226</a></li>
</ul>
<h2>V4.3.0</h2>
<ul>
<li>docs: update README.md by <a
href="https://github.com/motss"><code>@​motss</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1971">actions/checkout#1971</a></li>
<li>Add internal repos for checking out multiple repositories by <a
href="https://github.com/mouismail"><code>@​mouismail</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1977">actions/checkout#1977</a></li>
<li>Documentation update - add recommended permissions to Readme by <a
href="https://github.com/benwells"><code>@​benwells</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2043">actions/checkout#2043</a></li>
<li>Adjust positioning of user email note and permissions heading by <a
href="https://github.com/joshmgross"><code>@​joshmgross</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2044">actions/checkout#2044</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2194">actions/checkout#2194</a></li>
<li>Update CODEOWNERS for actions by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2224">actions/checkout#2224</a></li>
<li>Update package dependencies by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2236">actions/checkout#2236</a></li>
</ul>
<h2>v4.2.2</h2>
<ul>
<li><code>url-helper.ts</code> now leverages well-known environment
variables by <a href="https://github.com/jww3"><code>@​jww3</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/1941">actions/checkout#1941</a></li>
<li>Expand unit test coverage for <code>isGhes</code> by <a
href="https://github.com/jww3"><code>@​jww3</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1946">actions/checkout#1946</a></li>
</ul>
<h2>v4.2.1</h2>
<ul>
<li>Check out other refs/* by commit if provided, fall back to ref by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1924">actions/checkout#1924</a></li>
</ul>
<h2>v4.2.0</h2>
<ul>
<li>Add Ref and Commit outputs by <a
href="https://github.com/lucacome"><code>@​lucacome</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1180">actions/checkout#1180</a></li>
<li>Dependency updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>- <a
href="https://redirect.github.com/actions/checkout/pull/1777">actions/checkout#1777</a>,
<a
href="https://redirect.github.com/actions/checkout/pull/1872">actions/checkout#1872</a></li>
</ul>
<h2>v4.1.7</h2>
<ul>
<li>Bump the minor-npm-dependencies group across 1 directory with 4
updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1739">actions/checkout#1739</a></li>
<li>Bump actions/checkout from 3 to 4 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1697">actions/checkout#1697</a></li>
<li>Check out other refs/* by commit by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1774">actions/checkout#1774</a></li>
<li>Pin actions/checkout's own workflows to a known, good, stable
version. by <a href="https://github.com/jww3"><code>@​jww3</code></a> in
<a
href="https://redirect.github.com/actions/checkout/pull/1776">actions/checkout#1776</a></li>
</ul>
<h2>v4.1.6</h2>
<ul>
<li>Check platform to set archive extension appropriately by <a
href="https://github.com/cory-miller"><code>@​cory-miller</code></a> in
<a
href="https://redirect.github.com/actions/checkout/pull/1732">actions/checkout#1732</a></li>
</ul>
<h2>v4.1.5</h2>
<ul>
<li>Update NPM dependencies by <a
href="https://github.com/cory-miller"><code>@​cory-miller</code></a> in
<a
href="https://redirect.github.com/actions/checkout/pull/1703">actions/checkout#1703</a></li>
<li>Bump github/codeql-action from 2 to 3 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1694">actions/checkout#1694</a></li>
<li>Bump actions/setup-node from 1 to 4 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1696">actions/checkout#1696</a></li>
<li>Bump actions/upload-artifact from 2 to 4 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1695">actions/checkout#1695</a></li>
<li>README: Suggest <code>user.email</code> to be
<code>41898282+github-actions[bot]@users.noreply.github.com</code> by <a
href="https://github.com/cory-miller"><code>@​cory-miller</code></a> in
<a
href="https://redirect.github.com/actions/checkout/pull/1707">actions/checkout#1707</a></li>
</ul>
<h2>v4.1.4</h2>
<ul>
<li>Disable <code>extensions.worktreeConfig</code> when disabling
<code>sparse-checkout</code> by <a
href="https://github.com/jww3"><code>@​jww3</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1692">actions/checkout#1692</a></li>
<li>Add dependabot config by <a
href="https://github.com/cory-miller"><code>@​cory-miller</code></a> in
<a
href="https://redirect.github.com/actions/checkout/pull/1688">actions/checkout#1688</a></li>
<li>Bump the minor-actions-dependencies group with 2 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1693">actions/checkout#1693</a></li>
<li>Bump word-wrap from 1.2.3 to 1.2.5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1643">actions/checkout#1643</a></li>
</ul>
<h2>v4.1.3</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="08c6903cd8"><code>08c6903</code></a>
Prepare v5.0.0 release (<a
href="https://redirect.github.com/actions/checkout/issues/2238">#2238</a>)</li>
<li><a
href="9f265659d3"><code>9f26565</code></a>
Update actions checkout to use node 24 (<a
href="https://redirect.github.com/actions/checkout/issues/2226">#2226</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/checkout/compare/v4.3.0...08c6903cd8c0fde910a37f88322edcfb5dd907a8">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4.3.0&new-version=5.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
2025-08-31 19:50:03 -07:00
dependabot[bot]
c535d0a664 build(deps): bump actions/checkout from 4.3.0 to 5.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.0 to 5.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.3.0...08c6903cd8c0fde910a37f88322edcfb5dd907a8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 00:47:33 +00:00
Mitchell Hashimoto
2009ea511d feat: added faint-opacity option (#8472)
This pull request adds the `--faint-opacity` option, as discussed in
#7637.

The default value of the option is also changed from `0.68` to `0.5` for
greater consistency with other popular terminal emulators.
2025-08-31 13:45:26 -07:00
Mitchell Hashimoto
d8578a9ee2 fix: correct the cursor Y position value exposed to shader uniforms (#8122)
Fix for discussion #8113

The cursor Y position value exposed to the shader uniforms was
incorrectly calculated. As per the doc in cell_text.v.glsl: In order to
get the top left of the glyph, we compute an offset based on the
bearings. The Y bearing is the distance from the bottom of the cell to
the top of the glyph, so we subtract it from the cell height to get the
y offset.

This calculation was mistakenly left out of the original code.

This will ensure that the custom shaders using
iCurrentCursor/iPreviousCursor get the correct Y coordinate representing
the top-left corner of the cursor rectangle, matching the documented
uniform behavior
2025-08-31 11:32:59 -07:00
Qwerasd
0d30f859bd renderer: clarify and correct custom shader cursor position math
This math was incorrect from the start, the previous fix helped OpenGL
but broke positioning under Metal; this commit fixes the math to be
correct under both backends and adds comments explaining exactly what's
going on.
2025-08-31 11:44:10 -06:00
Pavel Ivanov
650095e7e9 fix: changed default faint-opacity value to 0.5 2025-08-31 17:21:00 +02:00
Pavel Ivanov
6319464cfb refactor: move faint-opacity clamping to config finalization 2025-08-31 17:19:51 +02:00
Pavel Ivanov
fc6266133f feat: added faint-opacity option 2025-08-31 15:00:29 +02:00
Mitchell Hashimoto
a51a956bdb Update iTerm2 colorschemes (#8470)
Upstream revision:
6cdbc8501d
2025-08-30 21:01:10 -07:00
mitchellh
c94805f0aa deps: Update iTerm2 color schemes 2025-08-31 00:16:30 +00:00
Mitchell Hashimoto
937d17cc35 ci: add freebsd tests (#8466) 2025-08-30 12:59:01 -07:00
Jeffrey C. Ollie
0bc90b2a20 ci: build on freebsd 2025-08-30 13:58:25 -05:00
Jeffrey C. Ollie
75e3835a9e gtk-ng: ensure CSS works on both 4.14 and 4.16+ (#8459)
Ghostty 1.2 needs to support GTK 4.14 because that's the version that
ships with Ubuntu 24.04.

This PR ensures that any GTK 4.16 CSS features are not used in any
static CSS and that the runtime CSS loading handles both 4.14 and 4.16+
appropriately.
2025-08-30 10:41:59 -05:00
Jeffrey C. Ollie
d1e01ec5c3 gtk-ng: ensure CSS works on both 4.14 and 4.16+
Ghostty 1.2 needs to support GTK 4.14 because that's the version that
ships with Ubuntu 24.04.

This PR ensures that any GTK 4.16 CSS features are not used in any
static CSS and that the runtime CSS loading handles both 4.14 and 4.16+
appropriately.
2025-08-30 09:54:40 -05:00
Mitchell Hashimoto
b0d9b0dee0 build(deps): bump namespacelabs/nscloud-cache-action from 1.2.16 to 1.2.17 (#8449)
Bumps
[namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action)
from 1.2.16 to 1.2.17.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/namespacelabs/nscloud-cache-action/releases">namespacelabs/nscloud-cache-action's
releases</a>.</em></p>
<blockquote>
<h2>v1.2.17</h2>
<h2>What's Changed</h2>
<ul>
<li>Delete existing files at path before creating bind mount, this was
already handled correctly for existing directories but not for
files</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/namespacelabs/nscloud-cache-action/compare/v1.2.16...v1.2.17">https://github.com/namespacelabs/nscloud-cache-action/compare/v1.2.16...v1.2.17</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="a289cf5d2f"><code>a289cf5</code></a>
Merge pull request <a
href="https://redirect.github.com/namespacelabs/nscloud-cache-action/issues/33">#33</a>
from namespacelabs/delete-existing-files-at-path-befor...</li>
<li><a
href="3851f57081"><code>3851f57</code></a>
Delete existing files at path before creating bind mount</li>
<li><a
href="58efedf646"><code>58efedf</code></a>
Merge pull request <a
href="https://redirect.github.com/namespacelabs/nscloud-cache-action/issues/32">#32</a>
from namespacelabs/delete-existing-files-at-path-befor...</li>
<li><a
href="5e60691b8f"><code>5e60691</code></a>
Delete existing files at path before creating bind mount</li>
<li>See full diff in <a
href="305bfa7ea9...a289cf5d2f">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=namespacelabs/nscloud-cache-action&package-manager=github_actions&previous-version=1.2.16&new-version=1.2.17)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
2025-08-29 07:08:26 -07:00
Mitchell Hashimoto
e6b019b197 build(deps): bump cachix/install-nix-action from 31.5.2 to 31.6.0 (#8450)
Bumps
[cachix/install-nix-action](https://github.com/cachix/install-nix-action)
from 31.5.2 to 31.6.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cachix/install-nix-action/releases">cachix/install-nix-action's
releases</a>.</em></p>
<blockquote>
<h2>v31.6.0</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps): bump actions/checkout from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/cachix/install-nix-action/pull/249">cachix/install-nix-action#249</a></li>
<li>docs: add example for <code>nix develop</code> by <a
href="https://github.com/jennydaman"><code>@​jennydaman</code></a> in <a
href="https://redirect.github.com/cachix/install-nix-action/pull/248">cachix/install-nix-action#248</a></li>
<li>nix: 2.30.2 -&gt; 2.31.0 by <a
href="https://github.com/github-actions"><code>@​github-actions</code></a>[bot]
in <a
href="https://redirect.github.com/cachix/install-nix-action/pull/250">cachix/install-nix-action#250</a>
Release notes: <a
href="https://discourse.nixos.org/t/nix-2-31-0-released/68465">https://discourse.nixos.org/t/nix-2-31-0-released/68465</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/jennydaman"><code>@​jennydaman</code></a> made
their first contribution in <a
href="https://redirect.github.com/cachix/install-nix-action/pull/248">cachix/install-nix-action#248</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/cachix/install-nix-action/compare/v31.5.2...v31.6.0">https://github.com/cachix/install-nix-action/compare/v31.5.2...v31.6.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="56a7bb7b56"><code>56a7bb7</code></a>
Merge pull request <a
href="https://redirect.github.com/cachix/install-nix-action/issues/250">#250</a>
from cachix/create-pull-request/patch</li>
<li><a
href="c04e864467"><code>c04e864</code></a>
nix: 2.30.2 -&gt; 2.31.0</li>
<li><a
href="9aaadd8b85"><code>9aaadd8</code></a>
Merge pull request <a
href="https://redirect.github.com/cachix/install-nix-action/issues/248">#248</a>
from jennydaman/patch-1</li>
<li><a
href="a23271bac0"><code>a23271b</code></a>
Reword README.md section on <code>nix develop</code></li>
<li><a
href="f02d365678"><code>f02d365</code></a>
Merge pull request <a
href="https://redirect.github.com/cachix/install-nix-action/issues/249">#249</a>
from cachix/dependabot/github_actions/actions/checkout-5</li>
<li><a
href="b4dc112147"><code>b4dc112</code></a>
chore(deps): bump actions/checkout from 4 to 5</li>
<li><a
href="ca6a0fa535"><code>ca6a0fa</code></a>
Add example for <code>nix develop</code></li>
<li><a
href="96bd9f39e4"><code>96bd9f3</code></a>
ci: update nixpkgs channel used in tests</li>
<li><a
href="92ffed7f0d"><code>92ffed7</code></a>
ci: make test workflow dispatchable</li>
<li>See full diff in <a
href="fc6e360bed...56a7bb7b56">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cachix/install-nix-action&package-manager=github_actions&previous-version=31.5.2&new-version=31.6.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
2025-08-29 07:08:09 -07:00
Guilherme Nandi Tiscoski
5761f66f35 i18n: update pt_BR translations (#8391)
relative to https://github.com/ghostty-org/ghostty/issues/8344
2025-08-29 13:26:37 +00:00
trag1c
a5eef1d227 i18n: Updating Irish translation for Ghostty 1.2 (#8349)
This Pull Request updates 5 translation strings for Ghostty 1.2 as per
#8344
2025-08-29 11:01:00 +02:00
dependabot[bot]
85e642097a build(deps): bump cachix/install-nix-action from 31.5.2 to 31.6.0
Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.5.2 to 31.6.0.
- [Release notes](https://github.com/cachix/install-nix-action/releases)
- [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md)
- [Commits](fc6e360bed...56a7bb7b56)

---
updated-dependencies:
- dependency-name: cachix/install-nix-action
  dependency-version: 31.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-29 00:08:00 +00:00
dependabot[bot]
bed350f0be build(deps): bump namespacelabs/nscloud-cache-action
Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.16 to 1.2.17.
- [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases)
- [Commits](305bfa7ea9...a289cf5d2f)

---
updated-dependencies:
- dependency-name: namespacelabs/nscloud-cache-action
  dependency-version: 1.2.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-29 00:07:51 +00:00
Mitchell Hashimoto
460fcc1344 docs: reorganize and separate out HACKING.md for technical instructions (#8445)
Also did some copy-editing and removed some things that haven't been
present in the codebase for a while (I thought we nuked conformance
stuff in 1.1 already...?)
2025-08-28 15:20:23 -07:00
Leah Amelia Chen
f91e6f1764 docs: better integrate CONTRIBUTING into the README 2025-08-29 06:14:41 +08:00
Leah Amelia Chen
f802d33652 docs: divide content more evenly between CONTRIBUTING and HACKING
CONTRIBUTING should now solely be about the contribution *process*
while HACKING goes into the technical details
2025-08-29 06:14:41 +08:00
Leah Amelia Chen
2701932475 docs: separate out HACKING.md from README.md
Also did some copy-editing and removed some things that haven't been
present in the codebase for a while (I thought we nuked conformance
stuff in 1.1 already...?)
2025-08-29 06:14:36 +08:00
Mitchell Hashimoto
6cfd89e248 macOS: Always require confirmation when executing a script via open (Finder, etc.) (#8442)
Right now, passing a file path to Ghostty will always execute it
unconditionally. This has various risks associated with it. I think we
can mitigate a lot of risks in the future by inspecting what is being
executed, but to be safe now we should always ask for confirmation.
2025-08-28 13:12:48 -07:00
Mitchell Hashimoto
9962e523a8 some typos 2025-08-28 12:58:07 -07:00
Mitchell Hashimoto
04956f3dc1 macos: require confirmation to run any script 2025-08-28 12:34:04 -07:00
Mitchell Hashimoto
f1ea30dcf1 macos: when executing a script directly, always wait after command 2025-08-28 11:26:33 -07:00
trag1c
d3cadf2495 i18n: update bg_BG translations (#8345)
Part of https://github.com/ghostty-org/ghostty/issues/8344
2025-08-27 23:16:15 +02:00
Mitchell Hashimoto
7106d71a42 apprt/gtk-ng: "cancel" should be default close response for dialog (#8427)
Fixes #8424

This is the response that will be sent when "escape" is pressed.

This also fixes a null ptr deref that was possible when this fix wasn't
in.
2025-08-27 09:48:19 -07:00
Mitchell Hashimoto
f54f2dc7f3 apprt/gtk-ng: "cancel" should be default close response for dialog
Fixes #8424

This is the response that will be sent when "escape" is pressed.

This also fixes a null ptr deref that was possible when this fix wasn't
in.
2025-08-27 09:42:35 -07:00
Mitchell Hashimoto
5013d028a3 terminal: fix csi parsing (#8417)
Make `MAX_PARAMS` public and increase CSI parameter limit from 16 to 24.
Fix potential out-of-bounds read in SGR partial sequence extraction.

Related discussion:
https://github.com/ghostty-org/ghostty/discussions/5198

DISCLAIMER: the tests were written with Claude Code's help.
2025-08-27 07:20:16 -07:00
Mitchell Hashimoto
adfc93047c terminal: fix up some tests to be more robust 2025-08-27 07:15:42 -07:00
Adrià Arrufat
a3f4997fbc fix(terminal): handle CSI/SGR with many parameters
Adds tests to ensure CSI and SGR sequences with 17 or more parameters are correctly parsed, fixing a bug where later parameters were previously dropped.
2025-08-27 07:10:17 -07:00
Adrià Arrufat
56d3fd872e fix(terminal): improve CSI parameter parsing
Make `MAX_PARAMS` public and increase CSI parameter limit from 16 to 24.
Fix potential out-of-bounds read in SGR partial sequence extraction.
2025-08-27 07:10:17 -07:00
Mitchell Hashimoto
58e85bf133 macos: use visible frame for quick terminal sizing calculation (#8423)
Fixes #8418

This fixes issues where left/right positions would be cut off from the
menu bar. And makes it so that size 100%,100% doesn't overflow into the
non-visible space of the edge of the screen.

I didn't just copy and paste, I tested each of these code paths.
2025-08-27 07:06:55 -07:00
Mitchell Hashimoto
19a27383f8 macos: use visible frame for quick terminal sizing calculation
Fixes #8418

This fixes issues where left/right positions would be cut off from the
menu bar. And makes it so that size 100%,100% doesn't overflow into the
non-visible space of the edge of the screen.
2025-08-27 06:59:02 -07:00
reo101
8fa065512f i18n: update bg_BG translations 2025-08-27 16:39:58 +03:00
Jeffrey C. Ollie
6530107e3b config: add entry for scroll-to-bottom (#8412)
Related #8408

(EDIT @mitchellh: Removed "Fixes" to avoid closing)
2025-08-26 22:26:03 -05:00
Jeffrey C. Ollie
87056a2600 surface: store entire scroll-to-bottom struct 2025-08-26 22:04:23 -05:00
Jeffrey C. Ollie
6a128189e3 osc: conemu cleanup (#8413)
- Add more comments, and make existing ones more consistent.
- Rename commands so they consitently have a `conemu_` prefix.
- Ensure that OSC 9 desktop notifications can be sent in the maximum
number of circumstances. There are still many notifications that can't
be sent because of our support for the ConEmu OSCs but that's the
tradeoff we have chosen. We recommend that you switch to OSC 777 to
ensure desktop notifications can be sent in all circumstances.
- Make sure that the tests that exercise the ConEmu OSCs have a
consistent naming structure. That will make them easier to find through
searching as well as make it easier to filter only the ConEmu OSC tests.
- Add more tests to make sure that desktop notifications are sent
properly.
2025-08-26 22:03:42 -05:00
Jeffrey C. Ollie
2490171304 surface: implement scroll-to-bottom=keystroke 2025-08-26 21:48:18 -05:00
Jeffrey C. Ollie
31c96d906a config: add entry for scroll-to-bottom
Fixes #8408
2025-08-26 21:48:18 -05:00
Jeffrey C. Ollie
64d8492836 osc: conemu cleanup
- Add more comments, and make existing ones more consistent.
- Rename commands so they consitently have a `conemu_` prefix.
- Ensure that OSC 9 desktop notifications can be sent in the maximum
  number of circumstances. There are still many notifications that can't
  be sent because of our support for the ConEmu OSCs but that's the
  tradeoff we have chosen. We recommend that you switch to OSC 777 to
  ensure desktop notifications can be sent in all circumstances.
- Make sure that the tests that exercise the ConEmu OSCs have a
  consistent naming structure. That will make them easier to find
  through searching as well as make it easier to filter only the ConEmu
  OSC tests.
- Add more tests to make sure that desktop notifications are sent
  properly.
2025-08-26 21:28:50 -05:00
Mitchell Hashimoto
c1ab41afac osc: parse OSC 9;6 gui macros (#8410) 2025-08-26 14:56:42 -07:00
Jeffrey C. Ollie
f047db6a3b osc: parse OSC 9;6 gui macros 2025-08-26 16:28:25 -05:00
Mitchell Hashimoto
cd8455c24b apprt/gtk-ng: show error widget if GLArea fails to initialize (#8390)
If GTK can't acquire an OpenGL context, this shows a message.
Previously, we would only log a warning which was difficult to find. The
GUI previously was the default GTK view which showed "Failed to acquire
EGL display" which was equally confusing.

This is a draft. There are TODOs (listed below).

## TODO

- [x] Disable context menu in error state
- [x] Use property to bind to unhealthy state instead of directly
setting stack child
- [x] Create a web page and put that in the error description
- [x] Set non-transparent background in error state
- [x] Bug where closing the window isn't exiting Ghostty
2025-08-26 12:07:42 -07:00
Mitchell Hashimoto
4d6269a859 apprt/gtk-ng: show error widget if GLArea fails to initialize
If GTK can't acquire an OpenGL context, this shows a message.
Previously, we would only log a warning which was difficult to find. The
GUI previously was the default GTK view which showed "Failed to acquire
EGL display" which was equally confusing.
2025-08-26 12:03:02 -07:00
Mitchell Hashimoto
3fb17dc802 scroll: round up fractional mouse scroll ticks (#7185)
Scrolling with a mouse on macos doesn't work very well when doing small,
single tick scrolls. macos attempts to mimic precision scrolling by
changing the magnitude of the scroll deltas based on scrolling speed.
Slow scrolls only send deltas with a magnitude of 0.1, which isn't
enough to send a single scroll event with the default scroll multiplier
of 3. Changing the scroll multiplier to 10 as a workaround (so even
single small scroll ticks are enough to register a scroll event) cause
scrolling to be way too fast if the scroll speed is ramped up.

This commit causes the yoffset delta to be rounded out to at least a
magnitude of 1 in the appropriate direction. Single scroll ticks now
register as a single vertical cell scroll event, but as scroll speed is
ramped up, the true delta reported to the surface is used again. Setting
a scroll multiplier of 1 with the changes here makes mouse scrolling
feel just as good as trackpad precision scrolling.
2025-08-26 11:07:12 -07:00
John Drouhard
6cf636b1ad scroll: round up fractional mouse scroll ticks
Scrolling with a mouse on macos doesn't work very well when doing small,
single tick scrolls. macos attempts to mimic precision scrolling by
changing the magnitude of the scroll deltas based on scrolling speed.
Slow scrolls only send deltas with a magnitude of 0.1, which isn't
enough to send a single scroll event with the default scroll multiplier
of 3. Changing the scroll multiplier to 10 as a workaround (so even
single small scroll ticks are enough to register a scroll event) cause
scrolling to be way too fast if the scroll speed is ramped up.

This commit causes the yoffset delta to be rounded out to at least a
magnitude of 1 in the appropriate direction. For small single scroll
ticks, it's enough to register a scroll event, but as scroll speed is
ramped up, the true delta reported to the surface is used again. Setting
a scroll multiplier of 1 with the changes here makes mouse scrolling
feel just as good as trackpad precision scrolling.
2025-08-26 11:02:36 -07:00
Mitchell Hashimoto
673afd193b macos: fix quick terminal fullscreen crash bug (#8093)
Fullscreen on quick terminal was failing with a crash, when it tried
to save the state of a non-existent toolbar and its accessory view
controllers.

See #7980
2025-08-26 10:51:31 -07:00
Mitchell Hashimoto
ff61cad1e2 gtk-ng: implement close_tab:other keybind (#8403) 2025-08-26 10:48:55 -07:00
Mitchell Hashimoto
520eaec61c macos: fix quick terminal issue where closing while fullscreen 2025-08-26 10:40:41 -07:00
Alexander Lais
e676eae640 macos: fix quick terminal fullscreen
Fullscreen on quick terminal was failing with a crash, when it tried
to save the state of a non-existent toolbar and its accessory view
controllers.
2025-08-26 10:36:06 -07:00
Mitchell Hashimoto
830194d436 Quick Terminal Sizing on macOS (#8402)
Fixes #8398
Fixes #2384 

This is just #7576 rebased with some style changes. For some reason I
couldn't update that PR.
2025-08-26 10:31:02 -07:00
Jeffrey C. Ollie
6f630a27be gtk-ng: implement close_tab:other keybind 2025-08-26 12:22:45 -05:00
Aindriú Mac Giolla Eoin
9c725187e1 Updating two strings 2025-08-26 15:47:16 +01:00
Aindriú Mac Giolla Eoin
35102ddb5a Updating Irish translation for Ghostty 1.2 2025-08-22 14:53:56 +01:00
ClearAspect
f7994e6412 fix: correct the cursor Y position value exposed to shader uniforms
Fix for discussion #8113

The cursor Y position value exposed to the shader uniforms was
incorrectly calculated. As per the doc in cell_text.v.glsl: In order to
get the top left of the glyph, we compute an offset based on the
bearings. The Y bearing is the distance from the bottom of the cell to
the top of the glyph, so we subtract it from the cell height to get the
y offset.

This calculation was mistakenly left out of the original code.

This will ensure that the custom shaders using
iCurrentCursor/iPreviousCursor get the correct Y coordinate representing
the top-left corner of the cursor rectangle, matching the documented
uniform behavior
2025-08-21 10:08:49 -07:00
Daniel Wennberg
54b56af570 Rewrite comment for clarity 2025-07-22 09:30:43 -07:00
Daniel Wennberg
53b029284d Fix off-by-one error & adjust overline pos in cell height mod 2025-07-22 09:17:07 -07:00
192 changed files with 3328 additions and 14596 deletions

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

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

View File

@@ -36,13 +36,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- name: Setup Nix
uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16

View File

@@ -83,13 +83,13 @@ jobs:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable

View File

@@ -107,12 +107,12 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16

View File

@@ -13,6 +13,7 @@ jobs:
- build-bench
- build-dist
- build-flatpak
- build-freebsd
- build-linux
- build-linux-libghostty
- build-nix
@@ -22,7 +23,6 @@ jobs:
- build-windows
- test
- test-gtk
- test-gtk-ng
- test-sentry-linux
- test-macos
- pinact
@@ -69,14 +69,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -100,14 +100,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -136,14 +136,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -165,14 +165,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -198,14 +198,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -242,14 +242,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -377,7 +377,7 @@ jobs:
mkdir dist
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
@@ -473,14 +473,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -494,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
@@ -518,14 +515,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -550,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@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
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
@@ -615,14 +563,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -673,12 +621,12 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -701,12 +649,12 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -728,12 +676,12 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -755,12 +703,12 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -782,12 +730,12 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -809,12 +757,12 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -843,12 +791,12 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -870,12 +818,12 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -905,14 +853,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -992,14 +940,14 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
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@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
- uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@@ -1015,3 +963,57 @@ jobs:
- name: valgrind
run: |
nix develop -c zig build test-valgrind
build-freebsd:
name: Build on FreeBSD
needs: test
runs-on: namespace-profile-mitchellh-sm-systemd
strategy:
matrix:
release:
- "14.3"
# - "15.0" # disable until fixed: https://github.com/vmactions/freebsd-vm/issues/108
steps:
- name: Checkout Ghostty
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Start SSH
run: |
sudo systemctl start ssh
- name: Set up FreeBSD VM
uses: vmactions/freebsd-vm@05856381fab64eeee9b038a0818f6cec649ca17a # v1.2.3
with:
release: ${{ matrix.release }}
copyback: false
usesh: true
prepare: |
pkg install -y \
devel/blueprint-compiler \
devel/gettext \
devel/git \
devel/pkgconf \
graphics/wayland \
lang/zig \
security/ca_root_nss \
textproc/hs-pandoc \
x11-fonts/jetbrains-mono \
x11-toolkits/libadwaita \
x11-toolkits/gtk40 \
x11-toolkits/gtk4-layer-shell
run: |
zig env
- name: Run tests
shell: freebsd {0}
run: |
cd $GITHUB_WORKSPACE
zig build test
- name: Build GTK app runtime
shell: freebsd {0}
run: |
cd $GITHUB_WORKSPACE
zig build
./zig-out/bin/ghostty +version

View File

@@ -22,14 +22,14 @@ jobs:
fetch-depth: 0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
- name: Setup Nix
uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2
uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16

23
AGENTS.md Normal file
View File

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

View File

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

View File

@@ -1,9 +1,9 @@
# Ghostty Development Process
# Contributing to Ghostty
This document describes the development process for Ghostty. It is intended for
anyone considering opening an **issue** or **pull request**. If in doubt,
please open a [discussion](https://github.com/ghostty-org/ghostty/discussions);
we can always convert that to an issue later.
This document describes the process of contributing to Ghostty. It is intended
for anyone considering opening an **issue**, **discussion** or **pull request**.
For people who are interested in developing Ghostty and technical details behind
it, please check out our ["Developing Ghostty"](HACKING.md) document as well.
> [!NOTE]
>
@@ -45,17 +45,25 @@ 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. Pick one and start working on it. Thank you.
If you need help or guidance, comment on the issue. Issues that are extra
friendly to new contributors are tagged with "contributor friendly".
[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.
Issues that are extra friendly to new contributors are tagged with
["contributor friendly"].
**I'd like to translate Ghostty to my language!**
["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
We have written a [Translator's Guide](po/README_TRANSLATORS.md) for
everyone interested in contributing translations to Ghostty.
@@ -64,25 +72,39 @@ 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!**
### I have a bug! / Something isn't working
1. Search the issue tracker and discussions for similar issues.
2. If you don't have steps to reproduce, open a discussion.
3. If you have steps to reproduce, open an issue.
1. Search the issue tracker and discussions for similar issues. Tip: also
search for [closed issues] and [discussions] — your issue might have already
been fixed!
2. If your issue hasn't been reported already, open an ["Issue Triage" discussion]
and make sure to fill in the template **completely**. They are vital for
maintainers to figure out important details about your setup. Because of
this, please make sure that you _only_ use the "Issue Triage" category for
reporting bugs — thank you!
**I have an idea for a feature!**
[closed issues]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20state%3Aclosed
[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
1. Open a discussion.
### I have an idea for a feature
**I've implemented a feature!**
Open a discussion in the ["Feature Requests, Ideas" category](https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas).
1. If there is an issue for the feature, open a pull request.
### 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.
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
1. Open a discussion or use Discord.
Open an [Q&A discussion], or join our [Discord Server] and ask away in the
`#help` channel.
[Q&A discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=q-a
[Discord Server]: https://discord.gg/ghostty
## General Patterns
@@ -120,209 +142,3 @@ pull request will be accepted with a high degree of certainty.
> **Pull requests are NOT a place to discuss feature design.** Please do
> not open a WIP pull request to discuss a feature. Instead, use a discussion
> and link to your branch.
# Developer Guide
> [!NOTE]
>
> **The remainder of this file is dedicated to developers actively
> working on Ghostty.** If you're a user reporting an issue, you can
> ignore the rest of this document.
## Including and Updating Translations
See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details.
## Checking for Memory Leaks
While Zig does an amazing job of finding and preventing memory leaks,
Ghostty uses many third-party libraries that are written in C. Improper usage
of those libraries or bugs in those libraries can cause memory leaks that
Zig cannot detect by itself.
### On Linux
On Linux the recommended tool to check for memory leaks is Valgrind. The
recommended way to run Valgrind is via `zig build`:
```sh
zig build run-valgrind
```
This builds a Ghostty executable with Valgrind support and runs Valgrind
with the proper flags to ensure we're suppressing known false positives.
You can combine the same build args with `run-valgrind` that you can with
`run`, such as specifying additional configurations after a trailing `--`.
## Input Stack Testing
The input stack is the part of the codebase that starts with a
key event and ends with text encoding being sent to the pty (it
does not include _rendering_ the text, which is part of the
font or rendering stack).
If you modify any part of the input stack, you must manually verify
all the following input cases work properly. We unfortunately do
not automate this in any way, but if we can do that one day that'd
save a LOT of grief and time.
Note: this list may not be exhaustive, I'm still working on it.
### Linux IME
IME (Input Method Editors) are a common source of bugs in the input stack,
especially on Linux since there are multiple different IME systems
interacting with different windowing systems and application frameworks
all written by different organizations.
The following matrix should be tested to ensure that all IME input works
properly:
1. Wayland, X11
2. ibus, fcitx, none
3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex
4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors)
> [!NOTE]
>
> This is a **work in progress**. I'm still working on this list and it
> is not complete. As I find more test cases, I will add them here.
#### Dead Key Input
Set your keyboard layout to "Spanish" (or another layout that uses dead keys).
1. Launch Ghostty
2. Press `'`
3. Press `a`
4. Verify that `á` is displayed
Note that the dead key may or may not show a preedit state visually.
For ibus and fcitx it does but for the "none" case it does not. Importantly,
the text should be correct when it is sent to the pty.
We should also test canceling dead key input:
1. Launch Ghostty
2. Press `'`
3. Press escape
4. Press `a`
5. Verify that `a` is displayed (no diacritic)
#### CJK Input
Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The
exact layout doesn't matter.
1. Launch Ghostty
2. Press `Ctrl+Shift` to switch to "Hiragana"
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
4. Press `Enter`
5. Verify that `こん` is displayed in the terminal.
We should also test switching input methods while preedit is active, which
should commit the text:
1. Launch Ghostty
2. Press `Ctrl+Shift` to switch to "Hiragana"
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
4. Press `Ctrl+Shift` to switch to another layout (any)
5. Verify that `こん` is displayed in the terminal as committed text.
## Nix Virtual Machines
Several Nix virtual machine definitions are provided by the project for testing
and developing Ghostty against multiple different Linux desktop environments.
Running these requires a working Nix installation, either Nix on your
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
requirements for macOS are detailed below.
VMs should only be run on your local desktop and then powered off when not in
use, which will discard any changes to the VM.
The VM definitions provide minimal software "out of the box" but additional
software can be installed by using standard Nix mechanisms like `nix run nixpkgs#<package>`.
### Linux
1. Check out the Ghostty source and change to the directory.
2. Run `nix run .#<vmtype>`. `<vmtype>` can be any of the VMs defined in the
`nix/vm` directory (without the `.nix` suffix) excluding any file prefixed
with `common` or `create`.
3. The VM will build and then launch. Depending on the speed of your system, this
can take a while, but eventually you should get a new VM window.
4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending
on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be
writable by the VM user, so be careful!
### macOS
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
blog post for more information about the Linux builder and how to tune the performance.
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
above to launch a VM.
### Custom VMs
To easily create a custom VM without modifying the Ghostty source, create a new
directory, then create a file called `flake.nix` with the following text in the
new directory.
```
{
inputs = {
nixpkgs.url = "nixpkgs/nixpkgs-unstable";
ghostty.url = "github:ghostty-org/ghostty";
};
outputs = {
nixpkgs,
ghostty,
...
}: {
nixosConfigurations.custom-vm = ghostty.create-gnome-vm {
nixpkgs = nixpkgs;
system = "x86_64-linux";
overlay = ghostty.overlays.releasefast;
# module = ./configuration.nix # also works
module = {pkgs, ...}: {
environment.systemPackages = [
pkgs.btop
];
};
};
};
}
```
The custom VM can then be run with a command like this:
```
nix run .#nixosConfigurations.custom-vm.config.system.build.vm
```
A file named `ghostty.qcow2` will be created that is used to persist any changes
made in the VM. To "reset" the VM to default delete the file and it will be
recreated the next time you run the VM.
### Contributing new VM definitions
#### VM Acceptance Criteria
We welcome the contribution of new VM definitions, as long as they meet the following criteria:
1. They should be different enough from existing VM definitions that they represent a distinct
user (and developer) experience.
2. There's a significant Ghostty user population that uses a similar environment.
3. The VMs can be built using only packages from the current stable NixOS release.
#### VM Definition Criteria
1. VMs should be as minimal as possible so that they build and launch quickly.
Additional software can be added at runtime with a command like `nix run nixpkgs#<package name>`.
2. VMs should not expose any services to the network, or run any remote access
software like SSH daemons, VNC or RDP.
3. VMs should auto-login using the "ghostty" user.

355
HACKING.md Normal file
View File

@@ -0,0 +1,355 @@
# Developing Ghostty
This document describes the technical details behind Ghostty's development.
If you'd like to open any pull requests or would like to implement new features
into Ghostty, please make sure to read our ["Contributing to Ghostty"](CONTRIBUTING.md)
document first.
To start development on Ghostty, you need to build Ghostty from a Git checkout,
which is very similar in process to [building Ghostty from a source tarball](http://ghostty.org/docs/install/build). One key difference is that obviously
you need to clone the Git repository instead of unpacking the source tarball:
```shell
git clone https://github.com/ghostty-org/ghostty
cd ghostty
```
> [!NOTE]
>
> Ghostty may require [extra dependencies](#extra-dependencies)
> when building from a Git checkout compared to a source tarball.
> Tip versions may also require a different version of Zig or other toolchains
> (e.g. the Xcode SDK on macOS) compared to stable versions — make sure to
> follow the steps closely!
When you're developing Ghostty, it's very likely that you will want to build a
_debug_ build to diagnose issues more easily. This is already the default for
Zig builds, so simply run `zig build` **without any `-Doptimize` flags**.
There are many more build steps than just `zig build`, some of which are listed
here:
| Command | Description |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `zig build run` | Runs Ghostty |
| `zig build run-valgrind` | Runs Ghostty under Valgrind to [check for memory leaks](#checking-for-memory-leaks) |
| `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` | Builds and validates a source tarball |
## Extra Dependencies
Building Ghostty from a Git checkout on Linux requires some additional
dependencies:
- `blueprint-compiler` (version 0.16.0 or newer)
macOS users don't require any additional dependencies.
## Xcode Version and SDKs
Building the Ghostty macOS app requires that Xcode, the macOS SDK,
and the iOS SDK are all installed.
A common issue is that the incorrect version of Xcode is either
installed or selected. Use the `xcode-select` command to
ensure that the correct version of Xcode is selected:
```shell-session
sudo xcode-select --switch /Applications/Xcode-beta.app
```
> [!IMPORTANT]
>
> Main branch development of Ghostty is preparing for the next major
> macOS release, Tahoe (macOS 26). Therefore, the main branch requires
> **Xcode 26 and the macOS 26 SDK**.
>
> 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
Ghostty's docs and resources (not including Zig code) are linted using
[Prettier](https://prettier.io) with out-of-the-box settings. A Prettier CI
check will fail builds with improper formatting. Therefore, if you are
modifying anything Prettier will lint, you may want to install it locally and
run this from the repo root before you commit:
```
prettier --write .
```
Make sure your Prettier version matches the version of Prettier in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix).
Nix users can use the following command to format with Prettier:
```
nix develop -c prettier --write .
```
### Alejandra
Nix modules are formatted with [Alejandra](https://github.com/kamadorueda/alejandra/). An Alejandra CI check
will fail builds with improper formatting.
Nix users can use the following command to format with Alejandra:
```
nix develop -c alejandra .
```
Non-Nix users should install Alejandra and use the following command to format with Alejandra:
```
alejandra .
```
Make sure your Alejandra version matches the version of Alejandra in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix).
### Updating the Zig Cache Fixed-Output Derivation Hash
The Nix package depends on a [fixed-output
derivation](https://nix.dev/manual/nix/stable/language/advanced-attributes.html#adv-attr-outputHash)
that manages the Zig package cache. This allows the package to be built in the
Nix sandbox.
Occasionally (usually when `build.zig.zon` is updated), the hash that
identifies the cache will need to be updated. There are jobs that monitor the
hash in CI, and builds will fail if it drifts.
To update it, you can run the following in the repository root:
```
./nix/build-support/check-zig-cache-hash.sh --update
```
This will write out the `nix/zigCacheHash.nix` file with the updated hash
that can then be committed and pushed to fix the builds.
## Including and Updating Translations
See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details.
## Checking for Memory Leaks
While Zig does an amazing job of finding and preventing memory leaks,
Ghostty uses many third-party libraries that are written in C. Improper usage
of those libraries or bugs in those libraries can cause memory leaks that
Zig cannot detect by itself.
### On Linux
On Linux the recommended tool to check for memory leaks is Valgrind. The
recommended way to run Valgrind is via `zig build`:
```sh
zig build run-valgrind
```
This builds a Ghostty executable with Valgrind support and runs Valgrind
with the proper flags to ensure we're suppressing known false positives.
You can combine the same build args with `run-valgrind` that you can with
`run`, such as specifying additional configurations after a trailing `--`.
## Input Stack Testing
The input stack is the part of the codebase that starts with a
key event and ends with text encoding being sent to the pty (it
does not include _rendering_ the text, which is part of the
font or rendering stack).
If you modify any part of the input stack, you must manually verify
all the following input cases work properly. We unfortunately do
not automate this in any way, but if we can do that one day that'd
save a LOT of grief and time.
Note: this list may not be exhaustive, I'm still working on it.
### Linux IME
IME (Input Method Editors) are a common source of bugs in the input stack,
especially on Linux since there are multiple different IME systems
interacting with different windowing systems and application frameworks
all written by different organizations.
The following matrix should be tested to ensure that all IME input works
properly:
1. Wayland, X11
2. ibus, fcitx, none
3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex
4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors)
> [!NOTE]
>
> This is a **work in progress**. I'm still working on this list and it
> is not complete. As I find more test cases, I will add them here.
#### Dead Key Input
Set your keyboard layout to "Spanish" (or another layout that uses dead keys).
1. Launch Ghostty
2. Press `'`
3. Press `a`
4. Verify that `á` is displayed
Note that the dead key may or may not show a preedit state visually.
For ibus and fcitx it does but for the "none" case it does not. Importantly,
the text should be correct when it is sent to the pty.
We should also test canceling dead key input:
1. Launch Ghostty
2. Press `'`
3. Press escape
4. Press `a`
5. Verify that `a` is displayed (no diacritic)
#### CJK Input
Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The
exact layout doesn't matter.
1. Launch Ghostty
2. Press `Ctrl+Shift` to switch to "Hiragana"
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
4. Press `Enter`
5. Verify that `こん` is displayed in the terminal.
We should also test switching input methods while preedit is active, which
should commit the text:
1. Launch Ghostty
2. Press `Ctrl+Shift` to switch to "Hiragana"
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
4. Press `Ctrl+Shift` to switch to another layout (any)
5. Verify that `こん` is displayed in the terminal as committed text.
## Nix Virtual Machines
Several Nix virtual machine definitions are provided by the project for testing
and developing Ghostty against multiple different Linux desktop environments.
Running these requires a working Nix installation, either Nix on your
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
requirements for macOS are detailed below.
VMs should only be run on your local desktop and then powered off when not in
use, which will discard any changes to the VM.
The VM definitions provide minimal software "out of the box" but additional
software can be installed by using standard Nix mechanisms like `nix run nixpkgs#<package>`.
### Linux
1. Check out the Ghostty source and change to the directory.
2. Run `nix run .#<vmtype>`. `<vmtype>` can be any of the VMs defined in the
`nix/vm` directory (without the `.nix` suffix) excluding any file prefixed
with `common` or `create`.
3. The VM will build and then launch. Depending on the speed of your system, this
can take a while, but eventually you should get a new VM window.
4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending
on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be
writable by the VM user, so be careful!
### macOS
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
blog post for more information about the Linux builder and how to tune the performance.
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
above to launch a VM.
### Custom VMs
To easily create a custom VM without modifying the Ghostty source, create a new
directory, then create a file called `flake.nix` with the following text in the
new directory.
```
{
inputs = {
nixpkgs.url = "nixpkgs/nixpkgs-unstable";
ghostty.url = "github:ghostty-org/ghostty";
};
outputs = {
nixpkgs,
ghostty,
...
}: {
nixosConfigurations.custom-vm = ghostty.create-gnome-vm {
nixpkgs = nixpkgs;
system = "x86_64-linux";
overlay = ghostty.overlays.releasefast;
# module = ./configuration.nix # also works
module = {pkgs, ...}: {
environment.systemPackages = [
pkgs.btop
];
};
};
};
}
```
The custom VM can then be run with a command like this:
```
nix run .#nixosConfigurations.custom-vm.config.system.build.vm
```
A file named `ghostty.qcow2` will be created that is used to persist any changes
made in the VM. To "reset" the VM to default delete the file and it will be
recreated the next time you run the VM.
### Contributing new VM definitions
#### VM Acceptance Criteria
We welcome the contribution of new VM definitions, as long as they meet the following criteria:
1. They should be different enough from existing VM definitions that they represent a distinct
user (and developer) experience.
2. There's a significant Ghostty user population that uses a similar environment.
3. The VMs can be built using only packages from the current stable NixOS release.
#### VM Definition Criteria
1. VMs should be as minimal as possible so that they build and launch quickly.
Additional software can be added at runtime with a command like `nix run nixpkgs#<package name>`.
2. VMs should not expose any services to the network, or run any remote access
software like SSH daemons, VNC or RDP.
3. VMs should auto-login using the "ghostty" user.

128
README.md
View File

@@ -13,7 +13,9 @@
·
<a href="https://ghostty.org/docs">Documentation</a>
·
<a href="#developing-ghostty">Developing</a>
<a href="CONTRIBUTING.md">Contributing</a>
·
<a href="HACKING.md">Developing</a>
</p>
</p>
@@ -49,6 +51,14 @@ See the [download page](https://ghostty.org/download) on the Ghostty website.
See the [documentation](https://ghostty.org/docs) on the Ghostty website.
## Contributing and Developing
If you have any ideas, issues, etc. regarding Ghostty, or would like to
contribute to Ghostty through pull requests, please check out our
["Contributing to Ghostty"](CONTRIBUTING.md) document. Those who would like
to get involved with Ghostty's development as well should also read the
["Developing Ghostty"](HACKING.md) document for more technical details.
## Roadmap and Status
The high-level ambitious plan for the project, in order:
@@ -184,119 +194,3 @@ SENTRY_DSN=https://e914ee84fd895c4fe324afa3e53dac76@o4507352570920960.ingest.us.
> stack memory of each thread at the time of the crash. This information
> is used to rebuild the stack trace but can also contain sensitive data
> depending when the crash occurred.
## Developing Ghostty
See the documentation on the Ghostty website for
[building Ghostty from a source tarball](http://ghostty.org/docs/install/build).
Building Ghostty from a Git checkout is very similar, except you want to
omit the `-Doptimize` flag to build a debug build, and you may require
additional dependencies since the source tarball includes some processed
files that are not in the Git repository.
Other useful commands:
- `zig build test` for running unit tests.
- `zig build test -Dtest-filter=<filter>` for running a specific subset of those unit tests
- `zig build run -Dconformance=<name>` runs a conformance test case from
the `conformance` directory. The `name` is the name of the file. This runs
in the current running terminal emulator so if you want to check the
behavior of this project, you must run this command in Ghostty.
### Extra Dependencies
Building Ghostty from a Git checkout on Linux requires some additional
dependencies:
- `blueprint-compiler`
macOS users don't require any additional dependencies.
> [!NOTE]
> This only applies to building from a _Git checkout_. This section does
> not apply if you're building from a released _source tarball_. For
> source tarballs, see the
> [website](http://ghostty.org/docs/install/build).
### Xcode Version and SDKs
Building the Ghostty macOS app requires that Xcode, the macOS SDK,
and the iOS SDK are all installed.
A common issue is that the incorrect version of Xcode is either
installed or selected. Use the `xcode-select` command to
ensure that the correct version of Xcode is selected:
```shell-session
sudo xcode-select --switch /Applications/Xcode-beta.app
```
> [!IMPORTANT]
>
> Main branch development of Ghostty is preparing for the next major
> macOS release, Tahoe (macOS 26). Therefore, the main branch requires
> **Xcode 26 and the macOS 26 SDK**.
>
> You do not need to be running on macOS 26 to build Ghostty, you can
> still use Xcode 26 beta on macOS 15 stable.
### Linting
#### Prettier
Ghostty's docs and resources (not including Zig code) are linted using
[Prettier](https://prettier.io) with out-of-the-box settings. A Prettier CI
check will fail builds with improper formatting. Therefore, if you are
modifying anything Prettier will lint, you may want to install it locally and
run this from the repo root before you commit:
```
prettier --write .
```
Make sure your Prettier version matches the version of Prettier in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix).
Nix users can use the following command to format with Prettier:
```
nix develop -c prettier --write .
```
#### Alejandra
Nix modules are formatted with [Alejandra](https://github.com/kamadorueda/alejandra/). An Alejandra CI check
will fail builds with improper formatting.
Nix users can use the following command to format with Alejandra:
```
nix develop -c alejandra .
```
Non-Nix users should install Alejandra and use the following command to format with Alejandra:
```
alejandra .
```
Make sure your Alejandra version matches the version of Alejandra in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix).
#### Updating the Zig Cache Fixed-Output Derivation Hash
The Nix package depends on a [fixed-output
derivation](https://nix.dev/manual/nix/stable/language/advanced-attributes.html#adv-attr-outputHash)
that manages the Zig package cache. This allows the package to be built in the
Nix sandbox.
Occasionally (usually when `build.zig.zon` is updated), the hash that
identifies the cache will need to be updated. There are jobs that monitor the
hash in CI, and builds will fail if it drifts.
To update it, you can run the following in the repository root:
```
./nix/build-support/check-zig-cache-hash.sh --update
```
This will write out the `nix/zigCacheHash.nix` file with the updated hash
that can then be committed and pushed to fix the builds.

View File

@@ -55,8 +55,8 @@
.gobject = .{
// https://github.com/jcollie/ghostty-gobject based on zig_gobject
// Temporary until we generate them at build time automatically.
.url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst",
.hash = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM",
.url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst",
.hash = "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z",
.lazy = true,
},
@@ -112,8 +112,8 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e4c0090a654e081e953b63d0fa50dee075726023.tar.gz",
.hash = "N-V-__8AADBlXwQadkTTsE4iezfphTwqGtFpvJbzMub5lpri",
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6cdbc8501d48601302e32d6b53e9e7934bf354b4.tar.gz",
.hash = "N-V-__8AAAtjXwSdhZq_xYbCXo0SZMqoNoQuHFkC07sijQME",
.lazy = true,
},
},

12
build.zig.zon.json generated
View File

@@ -24,10 +24,10 @@
"url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz",
"hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U="
},
"gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM": {
"gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z": {
"name": "gobject",
"url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst",
"hash": "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI="
"url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst",
"hash": "sha256-h6aKUerGlX2ATVEeoN03eWaqDqvUmKdedgpxrSoHvrY="
},
"N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": {
"name": "gtk4_layer_shell",
@@ -49,10 +49,10 @@
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
},
"N-V-__8AADBlXwQadkTTsE4iezfphTwqGtFpvJbzMub5lpri": {
"N-V-__8AAAtjXwSdhZq_xYbCXo0SZMqoNoQuHFkC07sijQME": {
"name": "iterm2_themes",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e4c0090a654e081e953b63d0fa50dee075726023.tar.gz",
"hash": "sha256-LQIa9siNICX5zzajvrJNKBmgDqAlBDY7QEmHihs65d0="
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6cdbc8501d48601302e32d6b53e9e7934bf354b4.tar.gz",
"hash": "sha256-NlUXcBOmaA8W+7RXuXcn9TIhm964dXO2Op4QCQxhDyc="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",

12
build.zig.zon.nix generated
View File

@@ -123,11 +123,11 @@ in
};
}
{
name = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM";
name = "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z";
path = fetchZigArtifact {
name = "gobject";
url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst";
hash = "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI=";
url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst";
hash = "sha256-h6aKUerGlX2ATVEeoN03eWaqDqvUmKdedgpxrSoHvrY=";
};
}
{
@@ -163,11 +163,11 @@ in
};
}
{
name = "N-V-__8AADBlXwQadkTTsE4iezfphTwqGtFpvJbzMub5lpri";
name = "N-V-__8AAAtjXwSdhZq_xYbCXo0SZMqoNoQuHFkC07sijQME";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e4c0090a654e081e953b63d0fa50dee075726023.tar.gz";
hash = "sha256-LQIa9siNICX5zzajvrJNKBmgDqAlBDY7QEmHihs65d0=";
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6cdbc8501d48601302e32d6b53e9e7934bf354b4.tar.gz";
hash = "sha256-NlUXcBOmaA8W+7RXuXcn9TIhm964dXO2Op4QCQxhDyc=";
};
}
{

4
build.zig.zon.txt generated
View File

@@ -27,8 +27,8 @@ 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/mbadolato/iTerm2-Color-Schemes/archive/e4c0090a654e081e953b63d0fa50dee075726023.tar.gz
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
https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,9 +31,9 @@
},
{
"type": "archive",
"url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst",
"dest": "vendor/p/gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM",
"sha256": "074ce22f32ae77e91d2aee53d414c4f46321f043ccfca861868349972b3940f2"
"url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst",
"dest": "vendor/p/gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z",
"sha256": "87a68a51eac6957d804d511ea0dd377966aa0eabd498a75e760a71ad2a07beb6"
},
{
"type": "archive",
@@ -61,9 +61,9 @@
},
{
"type": "archive",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e4c0090a654e081e953b63d0fa50dee075726023.tar.gz",
"dest": "vendor/p/N-V-__8AADBlXwQadkTTsE4iezfphTwqGtFpvJbzMub5lpri",
"sha256": "2d021af6c88d2025f9cf36a3beb24d2819a00ea02504363b4049878a1b3ae5dd"
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6cdbc8501d48601302e32d6b53e9e7934bf354b4.tar.gz",
"dest": "vendor/p/N-V-__8AAAtjXwSdhZq_xYbCXo0SZMqoNoQuHFkC07sijQME",
"sha256": "3655177013a6680f16fbb457b97727f532219bdeb87573b63a9e10090c610f27"
},
{
"type": "archive",

View File

@@ -419,6 +419,7 @@ typedef struct {
ghostty_env_var_s* env_vars;
size_t env_var_count;
const char* initial_input;
bool wait_after_command;
} ghostty_surface_config_s;
typedef struct {
@@ -721,15 +722,15 @@ typedef enum {
GHOSTTY_PROGRESS_STATE_ERROR,
GHOSTTY_PROGRESS_STATE_INDETERMINATE,
GHOSTTY_PROGRESS_STATE_PAUSE,
} ghostty_terminal_osc_command_progressreport_state_e;
} ghostty_action_progress_report_state_e;
// terminal.osc.Command.ProgressReport.C
typedef struct {
ghostty_terminal_osc_command_progressreport_state_e state;
ghostty_action_progress_report_state_e state;
// -1 if no progress was reported, otherwise 0-100 indicating percent
// completeness.
int8_t progress;
} ghostty_terminal_osc_command_progressreport_s;
} ghostty_action_progress_report_s;
// apprt.Action.Key
typedef enum {
@@ -816,7 +817,7 @@ typedef union {
ghostty_action_open_url_s open_url;
ghostty_action_close_tab_mode_e close_tab_mode;
ghostty_surface_message_childexited_s child_exited;
ghostty_terminal_osc_command_progressreport_s progress_report;
ghostty_action_progress_report_s progress_report;
} ghostty_action_u;
typedef struct {
@@ -963,7 +964,7 @@ void ghostty_surface_mouse_scroll(ghostty_surface_t,
double,
ghostty_input_scroll_mods_t);
void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double);
void ghostty_surface_ime_point(ghostty_surface_t, double*, double*);
void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*);
void ghostty_surface_request_close(ghostty_surface_t);
void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e);
void ghostty_surface_split_focus(ghostty_surface_t,

View File

@@ -127,6 +127,7 @@
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */; };
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; };
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; };
A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */; };
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; };
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; };
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; };
@@ -987,6 +988,7 @@
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */,
A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */,
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */,
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */,
A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */,

View File

@@ -398,6 +398,10 @@ class AppDelegate: NSObject,
var isDirectory = ObjCBool(true)
guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false }
// Set to true if confirmation is required before starting up the
// new terminal.
var requiresConfirm: Bool = false
// Initialize the surface config which will be used to create the tab or window for the opened file.
var config = Ghostty.SurfaceConfiguration()
@@ -406,6 +410,13 @@ class AppDelegate: NSObject,
// whether to open in a new tab or new window.
config.workingDirectory = filename
} else {
// Unconditionally require confirmation in the file execution case.
// In the future I have ideas about making this more fine-grained if
// we can not inherit of unsandboxed state. For now, we need to confirm
// because there is a sandbox escape possible if a sandboxed application
// somehow is tricked into `open`-ing a non-sandboxed application.
requiresConfirm = true
// When opening a file, we want to execute the file. To do this, we
// don't override the command directly, because it won't load the
// profile/rc files for the shell, which is super important on macOS
@@ -413,11 +424,34 @@ class AppDelegate: NSObject,
// `<filename>; exit` which is what Terminal and iTerm2 do.
config.initialInput = "\(filename); exit\n"
// For commands executed directly, we want to ensure we wait after exit
// because in most cases scripts don't block on exit and we don't want
// the window to just flash closed once complete.
config.waitAfterCommand = true
// Set the parent directory to our working directory so that relative
// paths in scripts work.
config.workingDirectory = (filename as NSString).deletingLastPathComponent
}
if requiresConfirm {
// Confirmation required. We use an app-wide NSAlert for now. In the future we
// may want to show this as a sheet on the focused window (especially if we're
// opening a tab). I'm not sure.
let alert = NSAlert()
alert.messageText = "Allow Ghostty to execute \"\(filename)\"?"
alert.addButton(withTitle: "Allow")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
switch (alert.runModal()) {
case .alertFirstButtonReturn:
break
default:
return false
}
}
switch ghostty.config.macosDockDropBehavior {
case .new_tab: _ = TerminalController.newTab(ghostty, withBaseConfig: config)
case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
@@ -903,7 +937,7 @@ class AppDelegate: NSObject,
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
for c in TerminalController.all {
for view in c.surfaceTree {
if view.uuid == uuid {
if view.id == uuid {
return view
}
}

View File

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

View File

@@ -481,6 +481,12 @@ class QuickTerminalController: BaseTerminalController {
}
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
// we have th correct window.frame for the save state below.
if let fullscreenStyle, fullscreenStyle.isFullscreen {
fullscreenStyle.exit()
}
// Save the current window frame before animating out. This preserves
// the user's preferred window size and position for when the quick
// terminal is reactivated with a new surface. Without this, SwiftUI
@@ -503,11 +509,6 @@ class QuickTerminalController: BaseTerminalController {
// We always animate out to whatever screen the window is actually on.
guard let screen = window.screen ?? NSScreen.main else { return }
// If we are in fullscreen, then we exit fullscreen.
if let fullscreenStyle, fullscreenStyle.isFullscreen {
fullscreenStyle.exit()
}
// If we have a previously active application, restore focus to it. We
// do this BEFORE the animation below because when the animation completes
// macOS will bring forward another window.

View File

@@ -13,7 +13,7 @@ enum QuickTerminalPosition : String {
guard let screen = window.screen ?? NSScreen.main else { return }
window.setFrame(.init(
origin: window.frame.origin,
size: size.calculate(position: self, screenDimensions: screen.frame.size)
size: size.calculate(position: self, screenDimensions: screen.visibleFrame.size)
), display: false)
}
@@ -58,7 +58,7 @@ enum QuickTerminalPosition : String {
/// Get the configured frame size for initial positioning and animations.
func configuredFrameSize(on screen: NSScreen, terminalSize: QuickTerminalSize) -> NSSize {
let dimensions = terminalSize.calculate(position: self, screenDimensions: screen.frame.size)
let dimensions = terminalSize.calculate(position: self, screenDimensions: screen.visibleFrame.size)
return NSSize(width: dimensions.width, height: dimensions.height)
}
@@ -66,16 +66,24 @@ enum QuickTerminalPosition : String {
func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch (self) {
case .top:
return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: screen.frame.maxY)
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: screen.visibleFrame.maxY)
case .bottom:
return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: -window.frame.height)
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: -window.frame.height)
case .left:
return .init(x: screen.frame.minX-window.frame.width, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2))
return .init(
x: screen.visibleFrame.minX-window.frame.width,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .right:
return .init(x: screen.frame.maxX, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2))
return .init(
x: screen.visibleFrame.maxX,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .center:
return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: screen.visibleFrame.height - window.frame.width)
@@ -86,16 +94,24 @@ enum QuickTerminalPosition : String {
func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch (self) {
case .top:
return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: screen.visibleFrame.maxY - window.frame.height)
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: screen.visibleFrame.maxY - window.frame.height)
case .bottom:
return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: screen.frame.minY)
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: screen.visibleFrame.minY)
case .left:
return .init(x: screen.frame.minX, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2))
return .init(
x: screen.visibleFrame.minX,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .right:
return .init(x: screen.visibleFrame.maxX - window.frame.width, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2))
return .init(
x: screen.visibleFrame.maxX - window.frame.width,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .center:
return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
@@ -125,13 +141,13 @@ enum QuickTerminalPosition : String {
switch self {
case .top:
return CGPoint(
x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2),
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: window.frame.origin.y // Keep the same Y position
)
case .bottom:
return CGPoint(
x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2),
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: window.frame.origin.y // Keep the same Y position
)
@@ -153,13 +169,13 @@ enum QuickTerminalPosition : String {
case .left:
return CGPoint(
x: window.frame.origin.x, // Keep the same X position
y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2)
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .right:
return CGPoint(
x: window.frame.origin.x, // Keep the same X position
y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2)
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .top, .bottom, .center:

View File

@@ -1,7 +1,7 @@
import AppKit
/// SplitTree represents a tree of views that can be divided.
struct SplitTree<ViewType: NSView & Codable>: Codable {
struct SplitTree<ViewType: NSView & Codable & Identifiable> {
/// The root of the tree. This can be nil to indicate the tree is empty.
let root: Node?
@@ -29,12 +29,12 @@ struct SplitTree<ViewType: NSView & Codable>: Codable {
}
/// The path to a specific node in the tree.
struct Path {
struct Path: Codable {
let path: [Component]
var isEmpty: Bool { path.isEmpty }
enum Component {
enum Component: Codable {
case left
case right
}
@@ -127,6 +127,13 @@ 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.
@@ -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) {

View File

@@ -439,8 +439,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
continue
}
let action = "goto_tab:\(tab)"
if let equiv = ghostty.config.keyboardShortcut(for: action) {
if let equiv = ghostty.config.keyboardShortcut(for: "goto_tab:\(tab)") {
window.keyEquivalent = "\(equiv)"
} else {
window.keyEquivalent = ""
@@ -861,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)
}
@@ -876,7 +875,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
return .init(
frame: window.frame,
surfaceTree: surfaceTree,
focusedSurface: focusedSurface?.uuid,
focusedSurface: focusedSurface?.id,
tabIndex: window.tabGroup?.windows.firstIndex(of: window),
tabGroup: window.tabGroup)
}

View File

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

View File

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

View File

@@ -70,4 +70,39 @@ extension Ghostty.Action {
}
}
}
struct ProgressReport {
enum State {
case remove
case set
case error
case indeterminate
case pause
init(_ c: ghostty_action_progress_report_state_e) {
switch c {
case GHOSTTY_PROGRESS_STATE_REMOVE:
self = .remove
case GHOSTTY_PROGRESS_STATE_SET:
self = .set
case GHOSTTY_PROGRESS_STATE_ERROR:
self = .error
case GHOSTTY_PROGRESS_STATE_INDETERMINATE:
self = .indeterminate
case GHOSTTY_PROGRESS_STATE_PAUSE:
self = .pause
default:
self = .remove
}
}
}
let state: State
let progress: UInt8?
init(c: ghostty_action_progress_report_s) {
self.state = State(c.state)
self.progress = c.progress >= 0 ? UInt8(c.progress) : nil
}
}
}

View File

@@ -544,6 +544,9 @@ extension Ghostty {
case GHOSTTY_ACTION_KEY_SEQUENCE:
keySequence(app, target: target, v: action.action.key_sequence)
case GHOSTTY_ACTION_PROGRESS_REPORT:
progressReport(app, target: target, v: action.action.progress_report)
case GHOSTTY_ACTION_CONFIG_CHANGE:
configChange(app, target: target, v: action.action.config_change)
@@ -1524,6 +1527,33 @@ extension Ghostty {
}
}
private static func progressReport(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_progress_report_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("progress report does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
let progressReport = Ghostty.Action.ProgressReport(c: v)
DispatchQueue.main.async {
if progressReport.state == .remove {
surfaceView.progressReport = nil
} else {
surfaceView.progressReport = progressReport
}
}
default:
assertionFailure()
}
}
private static func configReload(
_ app: ghostty_app_t,
target: ghostty_target_s,

View File

@@ -114,6 +114,11 @@ extension Ghostty {
}
.ghosttySurfaceView(surfaceView)
// Progress report overlay
if let progressReport = surfaceView.progressReport {
ProgressReportOverlay(report: progressReport)
}
#if canImport(AppKit)
// If we are in the middle of a key sequence, then we show a visual element. We only
// support this on macOS currently although in theory we can support mobile with keyboards!
@@ -267,6 +272,49 @@ extension Ghostty {
}
}
// Progress report overlay that shows a progress bar at the top of the terminal
struct ProgressReportOverlay: View {
let report: Action.ProgressReport
@ViewBuilder
private var progressBar: some View {
if let progress = report.progress {
// Determinate progress bar
ProgressView(value: Double(progress), total: 100)
.progressViewStyle(.linear)
.tint(report.state == .error ? .red : report.state == .pause ? .orange : nil)
.animation(.easeInOut(duration: 0.2), value: progress)
} else {
// Indeterminate states
switch report.state {
case .indeterminate:
ProgressView()
.progressViewStyle(.linear)
case .error:
ProgressView()
.progressViewStyle(.linear)
.tint(.red)
case .pause:
Rectangle().fill(Color.orange)
default:
EmptyView()
}
}
}
var body: some View {
VStack(spacing: 0) {
progressBar
.scaleEffect(x: 1, y: 0.5, anchor: .center)
.frame(height: 2)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.allowsHitTesting(false)
}
}
// This is the resize overlay that shows on top of a surface to show the current
// size during a resize operation.
struct SurfaceResizeOverlay: View {
@@ -425,6 +473,9 @@ extension Ghostty {
/// Extra input to send as stdin
var initialInput: String? = nil
/// Wait after the command
var waitAfterCommand: Bool = false
init() {}
init(from config: ghostty_surface_config_s) {
@@ -476,6 +527,9 @@ extension Ghostty {
// Zero is our default value that means to inherit the font size.
config.font_size = fontSize ?? 0
// Set wait after command
config.wait_after_command = waitAfterCommand
// Use withCString to ensure strings remain valid for the duration of the closure
return try workingDirectory.withCString { cWorkingDir in
config.working_directory = cWorkingDir

View File

@@ -6,9 +6,11 @@ import GhosttyKit
extension Ghostty {
/// The NSView implementation for a terminal surface.
class SurfaceView: OSView, ObservableObject, Codable {
class SurfaceView: OSView, ObservableObject, Codable, Identifiable {
typealias ID = UUID
/// Unique ID per surface
let uuid: UUID
let id: UUID
// The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go
@@ -42,6 +44,23 @@ extension Ghostty {
// The hovered URL string
@Published var hoverUrl: String? = nil
// The progress report (if any)
@Published var progressReport: Action.ProgressReport? = nil {
didSet {
// Cancel any existing timer
progressReportTimer?.invalidate()
progressReportTimer = nil
// If we have a new progress report, start a timer to remove it after 15 seconds
if progressReport != nil {
progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
self?.progressReport = nil
self?.progressReportTimer = nil
}
}
}
}
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [KeyboardShortcut] = []
@@ -143,6 +162,9 @@ extension Ghostty {
// A timer to fallback to ghost emoji if no title is set within the grace period
private var titleFallbackTimer: Timer?
// Timer to remove progress report after 15 seconds
private var progressReportTimer: Timer?
// This is the title from the terminal. This is nil if we're currently using
// the terminal title as the main title property. If the title is set manually
// by the user, this is set to the prior value (which may be empty, but non-nil).
@@ -160,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 {
@@ -348,6 +370,9 @@ extension Ghostty {
// Remove any notifications associated with this surface
let identifiers = Array(self.notificationIdentifiers)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
// Cancel progress report timer
progressReportTimer?.invalidate()
}
func focusDidChange(_ focused: Bool) {
@@ -1445,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(
@@ -1553,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)
}
@@ -1660,8 +1685,10 @@ extension Ghostty.SurfaceView: NSTextInputClient {
}
// Ghostty will tell us where it thinks an IME keyboard should render.
var x: Double = 0;
var y: Double = 0;
var x: Double = 0
var y: Double = 0
var width: Double = cellSize.width
var height: Double = cellSize.height
// QuickLook never gives us a matching range to our selection so if we detect
// this then we return the top-left selection point rather than the cursor point.
@@ -1679,15 +1706,19 @@ extension Ghostty.SurfaceView: NSTextInputClient {
// Free our text
ghostty_surface_free_text(surface, &text)
} else {
ghostty_surface_ime_point(surface, &x, &y)
ghostty_surface_ime_point(surface, &x, &y, &width, &height)
}
} else {
ghostty_surface_ime_point(surface, &x, &y)
ghostty_surface_ime_point(surface, &x, &y, &width, &height)
}
// Ghostty coordinates are in top-left (0, 0) so we have to convert to
// bottom-left since that is what UIKit expects
let viewRect = NSMakeRect(x, frame.size.height - y, 0, 0)
let viewRect = NSMakeRect(
x,
frame.size.height - y,
max(width, cellSize.width),
max(height, cellSize.height))
// Convert the point to the window coordinates
let winRect = self.convert(viewRect, to: nil)

View File

@@ -31,6 +31,9 @@ extension Ghostty {
// The hovered URL
@Published var hoverUrl: String? = nil
// The progress report (if any)
@Published var progressReport: Action.ProgressReport? = nil
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@Published var focusInstant: ContinuousClock.Instant? = nil

View File

@@ -407,9 +407,15 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
self.styleMask = window.styleMask
self.toolbar = window.toolbar
self.toolbarStyle = window.toolbarStyle
self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers
self.dock = window.screen?.hasDock ?? false
self.titlebarAccessoryViewControllers = if (window.hasTitleBar) {
// Accessing titlebarAccessoryViewControllers without a titlebar triggers a crash.
window.titlebarAccessoryViewControllers
} else {
[]
}
if let cgWindowId = window.cgWindowId {
// We hide the menu only if this window is not on any fullscreen
// spaces. We do this because fullscreen spaces already hide the

View File

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

View File

@@ -2,14 +2,15 @@
# Copyright (C) 2025 Mitchell Hashimoto
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Damyan Bogoev <damyan.bogoev@gmail.com>, 2025.
# reo101 <pavel.atanasov2001@gmail.com>, 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-05-19 11:34+0300\n"
"Last-Translator: Damyan Bogoev <damyan.bogoev@gmail.com>\n"
"PO-Revision-Date: 2025-08-22 14:52+0300\n"
"Last-Translator: reo101 <pavel.atanasov2001@gmail.com>\n"
"Language-Team: Bulgarian <dict@ludost.net>\n"
"Language: bg\n"
"MIME-Version: 1.0\n"
@@ -208,12 +209,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
@@ -278,15 +279,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"

View File

@@ -8,7 +8,7 @@ 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-06-29 21:15+0100\n"
"PO-Revision-Date: 2025-08-26 15:46+0100\n"
"Last-Translator: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>\n"
"Language-Team: Irish <gaeilge-gnulinux@lists.sourceforge.net>\n"
"Language: ga\n"
@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n==2 ? 1 : 2;\n"
"X-Generator: Poedit 3.4.4\n"
"X-Generator: Poedit 3.4.2\n"
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5
msgid "Change Terminal Title"
@@ -209,12 +209,12 @@ msgstr "Ceadaigh"
#: 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 "Sábháil an rogha don scoilt seo"
#: 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 "Athlódáil an chumraíocht chun an teachtaireacht seo a thaispeáint arís"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7
@@ -280,15 +280,15 @@ msgstr "Cóipeáilte chuig an ghearrthaisce"
#: src/apprt/gtk/Surface.zig:1268
msgid "Cleared clipboard"
msgstr ""
msgstr "Gearrthaisce glanta"
#: src/apprt/gtk/Surface.zig:2525
msgid "Command succeeded"
msgstr ""
msgstr "D'éirigh leis an ordú"
#: src/apprt/gtk/Surface.zig:2527
msgid "Command failed"
msgstr ""
msgstr "Theip ar an ordú"
#: src/apprt/gtk/Window.zig:216
msgid "Main Menu"

View File

@@ -3,14 +3,15 @@
# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Gustavo Peres <gsodevel@gmail.com>, 2025.
# Guilherme Tiscoski <github@guilhermetiscoski.com>, 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-06-20 10:19-0300\n"
"Last-Translator: Mário Victor Ribeiro Silva <mariovictorrs@gmail.com>\n"
"PO-Revision-Date: 2025-08-25 11:46-0500\n"
"Last-Translator: Guilherme Tiscoski <github@guihermetiscoski.com>\n"
"Language-Team: Brazilian Portuguese <ldpbr-translation@lists.sourceforge."
"net>\n"
"Language: pt_BR\n"
@@ -89,7 +90,7 @@ msgstr "Dividir à direita"
#: src/apprt/gtk/ui/1.5/command-palette.blp:16
msgid "Execute a command…"
msgstr ""
msgstr "Executar um comando…"
#: 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 "Abrir configuração"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85
msgid "Command Palette"
msgstr ""
msgstr "Paleta de comandos"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90
msgid "Terminal Inspector"
@@ -210,12 +211,12 @@ msgstr "Permitir"
#: 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 "Lembrar escolha para esta divisão"
#: 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 "Recarregue a configuração para mostrar este aviso novamente"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7
@@ -280,15 +281,15 @@ msgstr "Copiado para a área de transferência"
#: src/apprt/gtk/Surface.zig:1268
msgid "Cleared clipboard"
msgstr ""
msgstr "Área de transferência limpa"
#: src/apprt/gtk/Surface.zig:2525
msgid "Command succeeded"
msgstr ""
msgstr "Comando executado com sucesso"
#: src/apprt/gtk/Surface.zig:2527
msgid "Command failed"
msgstr ""
msgstr "Comando falhou"
#: src/apprt/gtk/Window.zig:216
msgid "Main Menu"

View File

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

View File

@@ -258,6 +258,7 @@ const DerivedConfig = struct {
mouse_shift_capture: configpkg.MouseShiftCapture,
macos_non_native_fullscreen: configpkg.NonNativeFullscreen,
macos_option_as_alt: ?configpkg.OptionAsAlt,
selection_clear_on_copy: bool,
selection_clear_on_typing: bool,
vt_kam_allowed: bool,
wait_after_command: bool,
@@ -272,6 +273,7 @@ const DerivedConfig = struct {
title_report: bool,
links: []Link,
link_previews: configpkg.LinkPreviews,
scroll_to_bottom: configpkg.Config.ScrollToBottom,
const Link = struct {
regex: oni.Regex,
@@ -326,6 +328,7 @@ const DerivedConfig = struct {
.mouse_shift_capture = config.@"mouse-shift-capture",
.macos_non_native_fullscreen = config.@"macos-non-native-fullscreen",
.macos_option_as_alt = config.@"macos-option-as-alt",
.selection_clear_on_copy = config.@"selection-clear-on-copy",
.selection_clear_on_typing = config.@"selection-clear-on-typing",
.vt_kam_allowed = config.@"vt-kam-allowed",
.wait_after_command = config.@"wait-after-command",
@@ -340,6 +343,7 @@ const DerivedConfig = struct {
.title_report = config.@"title-report",
.links = links,
.link_previews = config.@"link-previews",
.scroll_to_bottom = config.@"scroll-to-bottom",
// Assignments happen sequentially so we have to do this last
// so that the memory is captured from allocs above.
@@ -1728,6 +1732,7 @@ pub fn pwd(
pub fn imePoint(self: *const Surface) apprt.IMEPos {
self.renderer_state.mutex.lock();
const cursor = self.renderer_state.terminal.screen.cursor;
const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0;
self.renderer_state.mutex.unlock();
// TODO: need to handle when scrolling and the cursor is not
@@ -1762,7 +1767,38 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos {
break :y y;
};
return .{ .x = x, .y = y };
// Our height for now is always just the cell height because our preedit
// rendering only renders in a single line.
const height: f64 = height: {
var height: f64 = @floatFromInt(self.size.cell.height);
height /= content_scale.y;
break :height height;
};
const width: f64 = width: {
var width: f64 = @floatFromInt(preedit_width * self.size.cell.width);
// Our max width is the remaining screen width after the cursor.
// We don't have to deal with wrapping because the preedit doesn't
// wrap right now.
const screen_width: f64 = @floatFromInt(self.size.terminal().width);
const x_offset: f64 = @floatFromInt((cursor.x + 1) * self.size.cell.width);
const max = screen_width - x_offset;
width = @min(width, max);
// Note: we don't apply content scale here because it looks like
// for some reason in macOS its already scaled. I'm not sure why
// that is so I'm going to just leave this comment here so its known
// that I left this out on purpose pending more investigation.
break :width width;
};
return .{
.x = x,
.y = y,
.width = width,
.height = height,
};
}
fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) !void {
@@ -2280,7 +2316,8 @@ pub fn keyCallback(
try self.setSelection(null);
}
try self.io.terminal.scrollViewport(.{ .bottom = {} });
if (self.config.scroll_to_bottom.keystroke) try self.io.terminal.scrollViewport(.bottom);
try self.queueRender();
}
@@ -2766,8 +2803,21 @@ pub fn scrollCallback(
// that a wheel tick of 1 results in single scroll event.
const yoff_adjusted: f64 = if (scroll_mods.precision)
yoff
else
yoff * cell_size * self.config.mouse_scroll_multiplier;
else yoff_adjusted: {
// Round out the yoff to an absolute minimum of 1. macos tries to
// simulate precision scrolling with non precision events by
// ramping up the magnitude of the offsets as it detects faster
// scrolling. Single click (very slow) scrolls are reported with a
// magnitude of 0.1 which would normally require a few clicks
// before we register an actual scroll event (depending on cell
// height and the mouse_scroll_multiplier setting).
const yoff_max: f64 = if (yoff > 0)
@max(yoff, 1)
else
@min(yoff, -1);
break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier;
};
// Add our previously saved pending amount to the offset to get the
// new offset value. The signs of the pending and yoff should match
@@ -4496,6 +4546,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
return true;
};
// Clear the selection if configured to do so.
if (self.config.selection_clear_on_copy) {
if (self.setSelection(null)) {
self.queueRender() catch |err| {
log.warn("failed to queue render after clear selection err={}", .{err});
};
} else |err| {
log.warn("failed to clear selection after copy err={}", .{err});
}
}
return true;
}

View File

@@ -17,7 +17,6 @@ const structs = @import("apprt/structs.zig");
pub const action = @import("apprt/action.zig");
pub const ipc = @import("apprt/ipc.zig");
pub const gtk = @import("apprt/gtk.zig");
pub const gtk_ng = @import("apprt/gtk-ng.zig");
pub const none = @import("apprt/none.zig");
pub const browser = @import("apprt/browser.zig");
pub const embedded = @import("apprt/embedded.zig");
@@ -44,7 +43,6 @@ pub const runtime = switch (build_config.artifact) {
.exe => switch (build_config.app_runtime) {
.none => none,
.gtk => gtk,
.@"gtk-ng" => gtk_ng,
},
.lib => embedded,
.wasm_module => browser,
@@ -62,21 +60,18 @@ 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 {
// The Linux default is GTK because it is a full featured application.
if (target.os.tag == .linux) return .@"gtk-ng";
// 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.
return .none;
return switch (target.os.tag) {
// The Linux and FreeBSD default is GTK because it is a full
// featured application.
.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.
else => .none,
};
}
};

View File

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

View File

@@ -447,6 +447,9 @@ pub const Surface = struct {
/// Input to send to the command after it is started.
initial_input: ?[*:0]const u8 = null,
/// Wait after the command exits
wait_after_command: bool = false,
};
pub fn init(self: *Surface, app: *App, opts: Options) !void {
@@ -540,6 +543,11 @@ pub const Surface = struct {
);
}
// Wait after command
if (opts.wait_after_command) {
config.@"wait-after-command" = true;
}
// Initialize our surface right away. We're given a view that is
// ready to use.
try self.core_surface.init(
@@ -901,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;
@@ -1814,10 +1819,18 @@ pub const CAPI = struct {
surface.mousePressureCallback(stage, pressure);
}
export fn ghostty_surface_ime_point(surface: *Surface, x: *f64, y: *f64) void {
export fn ghostty_surface_ime_point(
surface: *Surface,
x: *f64,
y: *f64,
width: *f64,
height: *f64,
) void {
const pos = surface.core_surface.imePoint();
x.* = pos.x;
y.* = pos.y;
width.* = pos.width;
height.* = pos.height;
}
/// Request that the surface become closed. This will go through the

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -116,6 +116,11 @@ pub const Application = extern struct {
/// and initialization was successful.
transient_cgroup_base: ?[]const u8 = null,
/// This is set to true so long as we request a window exactly
/// once. This prevents quitting the app before we've shown one
/// window.
requested_window: bool = false,
/// This is set to false internally when the event loop
/// should exit and the application should quit. This must
/// only be set by the main loop thread.
@@ -218,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.
@@ -413,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();
@@ -423,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",
@@ -461,11 +458,24 @@ pub const Application = extern struct {
// If the quit timer has expired, quit.
if (priv.quit_timer == .expired) break :q true;
// There's no quit timer running, or it hasn't expired, don't quit.
// If we have no windows attached to our app, also quit.
if (priv.requested_window and @as(
?*glib.List,
self.as(gtk.Application).getWindows(),
) == null) break :q true;
// No quit conditions met
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();
}
}
}
@@ -488,7 +498,15 @@ pub const Application = extern struct {
const parent: ?*gtk.Widget = parent: {
const list = gtk.Window.listToplevels();
defer list.free();
const focused = list.findCustom(null, findActiveWindow);
const focused = @as(?*glib.List, list.findCustom(
null,
findActiveWindow,
)) orelse {
// If we have an active surface then we should have
// a window available but in the rare case we don't we
// should exit so we don't crash.
break :parent null;
};
break :parent @ptrCast(@alignCast(focused.f_data));
};
@@ -713,27 +731,24 @@ pub const Application = extern struct {
}
}
fn loadRuntimeCss(
self: *Self,
) Allocator.Error!void {
fn loadRuntimeCss(self: *Self) Allocator.Error!void {
const alloc = self.allocator();
var buf: std.ArrayListUnmanaged(u8) = .empty;
const config = self.private().config.get();
var buf: std.ArrayListUnmanaged(u8) = try .initCapacity(alloc, 2048);
defer buf.deinit(alloc);
const writer = buf.writer(alloc);
const config = self.private().config.get();
const window_theme = config.@"window-theme";
const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background;
const headerbar_background = config.@"window-titlebar-background" orelse config.background;
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
try writer.print(
\\widget.unfocused-split {{
\\ opacity: {d:.2};
\\ background-color: rgb({d},{d},{d});
\\}}
\\
, .{
1.0 - config.@"unfocused-split-opacity",
unfocused_fill.r,
@@ -747,6 +762,7 @@ pub const Application = extern struct {
\\ color: rgb({[r]d},{[g]d},{[b]d});
\\ background: rgb({[r]d},{[g]d},{[b]d});
\\}}
\\
, .{
.r = color.r,
.g = color.g,
@@ -759,9 +775,129 @@ pub const Application = extern struct {
\\.window headerbar {{
\\ font-family: "{[font_family]s}";
\\}}
\\
, .{ .font_family = font_family });
}
try loadRuntimeCss414(config, &writer);
try loadRuntimeCss416(config, &writer);
// ensure that we have a sentinel
try writer.writeByte(0);
const data = buf.items[0 .. buf.items.len - 1 :0];
log.debug("runtime CSS is {d} bytes", .{data.len + 1});
// Clears any previously loaded CSS from this provider
loadCssProviderFromData(
self.private().css_provider,
data,
);
}
/// Load runtime CSS for older than GTK 4.16
fn loadRuntimeCss414(
config: *const CoreConfig,
writer: *const std.ArrayListUnmanaged(u8).Writer,
) Allocator.Error!void {
if (gtk_version.runtimeAtLeast(4, 16, 0)) return;
const window_theme = config.@"window-theme";
const headerbar_background = config.@"window-titlebar-background" orelse config.background;
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
switch (window_theme) {
.ghostty => try writer.print(
\\windowhandle {{
\\ background-color: rgb({d},{d},{d});
\\ color: rgb({d},{d},{d});
\\}}
\\windowhandle:backdrop {{
\\ background-color: oklab(from rgb({d},{d},{d}) calc(l * 0.9) a b / alpha);
\\}}
\\
, .{
headerbar_background.r,
headerbar_background.g,
headerbar_background.b,
headerbar_foreground.r,
headerbar_foreground.g,
headerbar_foreground.b,
headerbar_background.r,
headerbar_background.g,
headerbar_background.b,
}),
else => {},
}
}
/// Load runtime for GTK 4.16 and newer
fn loadRuntimeCss416(
config: *const CoreConfig,
writer: *const std.ArrayListUnmanaged(u8).Writer,
) Allocator.Error!void {
if (gtk_version.runtimeUntil(4, 16, 0)) return;
const window_theme = config.@"window-theme";
const headerbar_background = config.@"window-titlebar-background" orelse config.background;
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
try writer.writeAll(
\\/*
\\ * Child Exited Overlay
\\ */
\\
\\.child-exited.normal revealer widget {
\\ background-color: color-mix(
\\ in srgb,
\\ var(--success-bg-color),
\\ transparent 50%
\\ );
\\}
\\
\\.child-exited.abnormal revealer widget {
\\ background-color: color-mix(
\\ in srgb,
\\ var(--error-bg-color),
\\ transparent 50%
\\ );
\\}
\\
\\/*
\\ * Surface
\\ */
\\
\\.surface progressbar.error trough progress {
\\ background-color: color-mix(
\\ in srgb,
\\ var(--error-bg-color),
\\ transparent 50%
\\ );
\\}
\\
\\.surface .bell-overlay {
\\ border-color: color-mix(
\\ in srgb,
\\ var(--accent-color),
\\ transparent 50%
\\ );
\\}
\\
\\/*
\\ * Splits
\\ */
\\
\\.window .split paned > separator {
\\ background-color: color-mix(
\\ in srgb,
\\ var(--window-bg-color),
\\ transparent 0%
\\ );
\\}
\\
);
switch (window_theme) {
.ghostty => try writer.print(
\\:root {{
@@ -794,15 +930,6 @@ pub const Application = extern struct {
}),
else => {},
}
const data = try alloc.dupeZ(u8, buf.items);
defer alloc.free(data);
// Clears any previously loaded CSS from this provider
loadCssProviderFromData(
self.private().css_provider,
data,
);
}
fn loadCustomCss(self: *Self) !void {
@@ -1858,6 +1985,13 @@ const Action = struct {
self: *Application,
parent: ?*CoreSurface,
) !void {
// Note that we've requested a window at least once. This is used
// to trigger quit on no windows. Note I'm not sure if this is REALLY
// necessary, but I don't want to risk a bug where on a slow machine
// or something we quit immediately after starting up because there
// was a delay in the event loop before we created a Window.
self.private().requested_window = true;
const win = Window.new(self);
initAndShowWindow(self, win, parent);
}

View File

@@ -10,7 +10,7 @@ const Common = @import("../class.zig").Common;
const Config = @import("config.zig").Config;
const Dialog = @import("dialog.zig").Dialog;
const log = std.log.scoped(.gtk_ghostty_config_errors_dialog);
const log = std.log.scoped(.gtk_ghostty_close_confirmation_dialog);
pub const CloseConfirmationDialog = extern struct {
const Self = @This();

View File

@@ -105,6 +105,24 @@ pub const Surface = extern struct {
);
};
pub const @"error" = struct {
pub const name = "error";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"error",
),
},
);
};
pub const @"font-size-request" = struct {
pub const name = "font-size-request";
const impl = gobject.ext.defineProperty(
@@ -472,6 +490,15 @@ pub const Surface = extern struct {
// false by a parent widget.
bell_ringing: bool = false,
/// True if this surface is in an error state. This is currently
/// a simple boolean with no additional information on WHAT the
/// error state is, because we don't yet need it or use it. For now,
/// 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,
@@ -480,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;
};
@@ -1335,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(),
@@ -1540,6 +1582,12 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"bell-ringing".impl.param_spec);
}
pub fn setError(self: *Self, v: bool) void {
const priv = self.private();
priv.@"error" = v;
self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec);
}
fn propConfig(
self: *Self,
_: *gobject.ParamSpec,
@@ -1592,6 +1640,46 @@ pub const Surface = extern struct {
}
}
fn propError(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
if (priv.@"error") {
// Ensure we have an opaque background. The window will NOT set
// this if we have transparency set and we need an opaque
// background for the error message to be readable.
self.as(gtk.Widget).addCssClass("background");
} else {
// Regardless of transparency setting, we remove the background
// CSS class from this widget. Parent widgets will set it
// appropriately (see window.zig for example).
self.as(gtk.Widget).removeCssClass("background");
}
// 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(
self: *Self,
_: *gobject.ParamSpec,
@@ -1942,8 +2030,11 @@ pub const Surface = extern struct {
// Bell stops ringing if any mouse button is pressed.
self.setBellRinging(false);
// If we don't have focus, grab it.
// Get our surface. If we don't have one, ignore this.
const priv = self.private();
const core_surface = priv.core_surface orelse return;
// If we don't have focus, grab it.
const gl_area_widget = priv.gl_area.as(gtk.Widget);
if (gl_area_widget.hasFocus() == 0) {
_ = gl_area_widget.grabFocus();
@@ -1951,10 +2042,10 @@ pub const Surface = extern struct {
// Report the event
const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton());
const consumed = if (priv.core_surface) |surface| consumed: {
const consumed = consumed: {
const gtk_mods = event.getModifierState();
const mods = gtk_key.translateMods(gtk_mods);
break :consumed surface.mouseButtonCallback(
break :consumed core_surface.mouseButtonCallback(
.press,
button,
mods,
@@ -1962,7 +2053,7 @@ pub const Surface = extern struct {
log.warn("error in key callback err={}", .{err});
break :err false;
};
} else false;
};
// If a right click isn't consumed, mouseButtonCallback selects the hovered
// word and returns false. We can use this to handle the context menu
@@ -2303,21 +2394,23 @@ pub const Surface = extern struct {
) callconv(.c) void {
log.debug("realize", .{});
// Make the GL area current so we can detect any OpenGL errors. If
// we have errors here we can't render and we switch to the error
// state.
const priv = self.private();
priv.gl_area.makeCurrent();
if (priv.gl_area.getError()) |err| {
log.warn("failed to make GL context current: {s}", .{err.f_message orelse "(no message)"});
log.warn("this error is almost always due to a library, driver, or GTK issue", .{});
log.warn("this is a common cause of this issue: https://ghostty.org/docs/help/gtk-opengl-context", .{});
self.setError(true);
return;
}
// If we already have an initialized surface then we notify it.
// If we don't, we'll initialize it on the first resize so we have
// our proper initial dimensions.
const priv = self.private();
if (priv.core_surface) |v| realize: {
// We need to make the context current so we can call GL functions.
// This is required for all surface operations.
priv.gl_area.makeCurrent();
if (priv.gl_area.getError()) |err| {
log.warn("failed to make GL context current: {s}", .{err.f_message orelse "(no message)"});
log.warn("this error is usually due to a driver or gtk bug", .{});
log.warn("this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950", .{});
break :realize;
}
v.renderer.displayRealized() catch |err| {
log.warn("core displayRealized failed err={}", .{err});
break :realize;
@@ -2631,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", .{});
@@ -2662,6 +2757,7 @@ pub const Surface = extern struct {
class.bindTemplateCallback("child_exited_close", &childExitedClose);
class.bindTemplateCallback("context_menu_closed", &contextMenuClosed);
class.bindTemplateCallback("notify_config", &propConfig);
class.bindTemplateCallback("notify_error", &propError);
class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
@@ -2674,6 +2770,7 @@ pub const Surface = extern struct {
properties.config.impl,
properties.@"child-exited".impl,
properties.@"default-size".impl,
properties.@"error".impl,
properties.@"font-size-request".impl,
properties.focused.impl,
properties.@"min-size".impl,

View File

@@ -18,7 +18,6 @@ const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const Config = @import("config.zig").Config;
const Application = @import("application.zig").Application;
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
const SplitTree = @import("split_tree.zig").SplitTree;
const Surface = @import("surface.zig").Surface;
@@ -353,7 +352,7 @@ pub const Tab = extern struct {
// a close-page signal that the parent can intercept.
switch (mode) {
.this => tab_view.closePage(page),
.other => log.warn("close-tab:other is not implemented", .{}),
.other => tab_view.closeOtherPages(page),
}
}

View File

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

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