Compare commits

...

95 Commits
main ... v1.2.3

Author SHA1 Message Date
Mitchell Hashimoto 6d2dd585a5
os: allow the mac-address-as-hostname parsing to happen everywhere 2025-10-23 10:14:48 -07:00
Mitchell Hashimoto 28952a7bb0
macos: use TextEditor instead of Text for clipboard confirmation (#9324)
Fixes #9322

SwiftUI `Text` has huge performance issues. On my maxed out MBP it hangs
for any text more than 100KB (it took ~8s to display it!). `TextEditor`
with a constant value works much better and handles scrolling for us,
too!
2025-10-23 09:54:46 -07:00
Mitchell Hashimoto b9df743e04
macos: window-position-x/y works with window-width/height (#9313)
Fixes #9132

We were processing our window size defaults separate from our window
position and the result was that you'd get some incorrect behavior.
Unify the logic more to fix the positioning.

Note there is room to improve this further, I think that all initial
positioning could go into the controller completely. But I wanted to
minimize the diff for a backport.
2025-10-23 09:54:41 -07:00
Jared Gizersky 8d2d557da9
os: handle nil languageCode/countryCode in setLangFromCocoa (#9290)
Fixes a crash when NSLocale returns nil for languageCode or countryCode
properties. This can happen when the app launches without locale
environment variables set.

The crash occurs at `src/os/locale.zig:87-88` when trying to call
`getProperty()` on a nil object. The fix adds a null check and falls
back to `en_US.UTF-8` instead of dereferencing null.

## Testing
Tested by running with locale variables unset:
```bash
unset LC_ALL && ./zig-out/Ghostty.app/Contents/MacOS/ghostty
```

Before: segmentation fault  
After: launches successfully with fallback locale
2025-10-23 09:54:36 -07:00
Mitchell Hashimoto 36f647e875
input: modify other keys 2 should use all mods, ignore consumed mods (#9289)
Fixes #8900

Our xterm modify other keys state 2 encoding was stripped consumed mods
from the keyboard event. This doesn't match xterm or other popular
terminal emulators (but most importantly: xterm). Use the full set of
mods and add a test to verify this.

Reproduction:

```
printf '\033[>4;2m'
cat
```

Then press `ctrl+shift+h` and compare across terminals.
2025-10-23 09:54:31 -07:00
Mitchell Hashimoto d2a459b7c2
build: add -Demit-themes option to emit/omit theme resources (#9288)
We'll backport this to 1.2.x for distros that would prefer this.
2025-10-23 09:49:48 -07:00
Mitchell Hashimoto bbbb52ed75
macos: goto_split direction is performable (#9284)
Fixes #9283

There was a comment here noting this deficiency. GTK implements this
properly.
2025-10-23 09:48:44 -07:00
Mitchell Hashimoto ed91bdadd6
cli: fix +ssh-cache IPv6 address validation (#9281)
The host validation code previously expected IPv6 addresses to be
enclosed in [brackets], but that's not how ssh(1) expects them.

This change removes that requirement and reimplements the host
validation routine to check for valid hostnames and IP addresses (IPv4
and IPv6) using standard routines rather than custom logic.

Fixes #9251
2025-10-23 09:47:57 -07:00
Mitchell Hashimoto 508cc8300a
os: add RFC 1123-compliant hostname.isValid (#9276)
std.net.isValidHostname is currently too generous. It considers strings
like ".example.com", "exa..mple.com", and "-example.com" to be valid
hostnames, which is incorrect according to RFC 1123 (the currently
accepted standard).

Until the standard library function is improved, we can use this local
implementation that does follow the RFC 1123 standard.

I asked Claude to perform an audit of the code based on its understand
of the RFC. It suggested some additional test cases and considers the
overall implementation to be robust (its words) and standards compliant.

Ref: https://www.rfc-editor.org/rfc/rfc1123
2025-10-23 09:46:30 -07:00
Mitchell Hashimoto 0aa016f948
fix: fish shell integration should not update the universal `fish_user_paths` variable (#9273)
`fish_add_path` by default updates the `fish_user_paths` universal
variable which makes the modification persist across shell sessions.

The integration also tries to update the `fish_user_paths` when the
desired path already appears in the `PATH` environment variable but not
in `fish_user_paths`. Because `fish_user_paths` will always be inserted
before the inherited `PATH` env. This makes the added path
unintentionally has a higher priority.

This patch fixes the above issues by adding `--global` and `--path`
options to `fish_user_paths` which limits the modification scope and
ensures that the path won't be added if it already exists in `PATH`.

Ref: https://fishshell.com/docs/current/cmds/fish_add_path.html
2025-10-23 09:46:01 -07:00
Mitchell Hashimoto 61f74158be
macos: use stable display UUID for quick terminal screen tracking (#9256)
NSScreen instances can be garbage collected at any time, even for
screens that remain connected, making NSMapTable with weak keys
unreliable for tracking per-screen state.

This changes the quick terminal to use CGDisplay UUIDs as stable
identifiers, keyed in a strong dictionary. Each entry stores the window
frame along with screen dimensions, scale factor, and last-seen
timestamp.

**This should make quick terminal size restore more stable than 1.2.2.**

Rules for pruning:
- Entries are invalidated when screens shrink or change scale
- Entries persist and update when screens grow (allowing cached state to
work with larger resolutions)
- Stale entries for disconnected screens expire after 14 days.
- Maximum of 10 screen entries to prevent unbounded growth
2025-10-23 09:45:44 -07:00
Mitchell Hashimoto d3128243ec
renderer: force a full rebuild on any font grid change (#9252)
Fixes #2731 (again)

This regressed in 1.2 due to the renderer rework missing porting this. I
believe this issue is still valid even with the rework since the font
grid changes the atlas and if there are still cached cells that
reference the old atlas coordinates it will produce garbage.
2025-10-23 09:42:02 -07:00
Mitchell Hashimoto 62df12c040
termio: color change operations must gracefully handle renderer mailbox full (#9224)
Fixes #9191

This changes our color change operations from writing to the renderer
mailbox directly to using our `rendererMailboxWriter` function which
handles the scenario where the mailbox is full by yielding the lock,
waking up the renderer, and retrying later.

This is a known deadlock scenario we've worked around since the private
beta days, but unfortunately this slipped through and I didn't catch it
in review.

What happens here is it's possible with certain escape sequences for the
IO thread to saturate other mailboxes with messages while holding the
terminal state lock. This can happen to any thread. This ultimately
leads to starvation and all threads deadlock.

Our IO thread is the only thread that produces this kind of massive
stream of events while holding the lock, so we have helpers in it to
attempt to queue (cheap, fast) and if that fails then to yield the lock,
wakeup the target thread, requeue, and grab the lock again (expensive,
slow).
2025-10-23 09:41:48 -07:00
Mitchell Hashimoto 56896311a6
Selection dragging should not process when terminal screen changes (#9223)
This hasn't caused any known bugs but leads to selection memory
corruption and assertion failures in runtime safe modes. When the
terminal screen changes (primary to secondary) and we have an active
dragging mode going either by moving the mouse or our selection tick
timer, we should halt.

We still keep the mouse state active which lets selection continue once
the screen switches back.
2025-10-23 09:41:31 -07:00
Mitchell Hashimoto 2ebbe39b9e
termio: don't start scroll timer if its already active (#9195)
This might fix #9191, but since I don't have a reproduction I can't be
sure. In any case, this is a bad bug that should be fixed.

The issue is that we weren't checking our scroll timer completion state.
This meant that if `start_scroll_timer` was called multiple times within
a single loop tick, we'd enqueue our completion multiple times, leading
to various undefined behaviors.

If we don't enqueue anything else in the interim, this is safe by
chance. But if we enqueue something else, then we'd hit a queue
assertion failure and honestly I'm not totally sure what would happen.

I wasn't able to trigger the "bad" case, but I was able to trigger the
benign case very easily. Our other timers such as the renderer cursor
timer already have this protection.

Let's fix this and continue looking...
2025-10-23 09:41:20 -07:00
Xiangbao Meng 0af32d06b5
macOS: fix title misalignment in tabs (#9168)
While I was testing #9166, noticed another edge case🤯. 

This appears both in Tahoe and Sequoia👇🏻


https://github.com/user-attachments/assets/9cecea35-1241-4f31-9c15-0f2a7a6f342a
2025-10-23 09:32:24 -07:00
Xiangbao Meng 5c9ceab0bf
macOS: Fix more `macos-titlebar-style` related issues (#9163)
Ghosty is active will result in similar nastiness.

https://github.com/user-attachments/assets/fcd7761e-a521-4382-8d7a-9d93dc0806bc

- [Sequoia/Ventura] Fix flickering new tab icon, and it also didn't
respond to window's key status change correctly
- [Sequoia/Ventura] Fix after changing appearance, tab bar may disappear
or have inconsistent background colour
- Fix initial tint of reset zoom button on Sequoia/Ventura with
`macos-titlebar-style=tabs` and all `native/transparent` titlebars
- Fix title alignment with custom font with `native/transparent`
titlebar
2025-10-23 09:31:39 -07:00
Daniel Wennberg 537b304cd6
font: Apply `adjust-icon-height` to both large and small icons (#9160)
As pointed out in #9156, an unintended consequence of all the work to
get icon sizing right is that `adjust-icon-height` now only applies to
the small icons you get when the next cell is not whitespace. Large
icons are unaffected.

With this PR, `adjust-icon-height` affects the maximum height of every
symbol specifying the `.icon` constraint height, regardless of
constraint width. This includes most Nerd Font icons, but excludes emoji
and other unicode symbols, and also excludes terminal graphics-oriented
Nerd Font symbols such as Powerline symbols.

In the following screenshots, **Baseline** is without
`adjust-icon-height`, while **Before** and **After** are with
`adjust-icon-height = -25%`.

**Baseline**
<img width="711" height="95" alt="Screenshot 2025-10-11 at 23 28 20"
src="https://github.com/user-attachments/assets/7499db4d-75a4-4dbd-b107-8cb5849e31a3"
/>

**Before** (only small icons affected)
<img width="711" height="95" alt="Screenshot 2025-10-11 at 23 20 12"
src="https://github.com/user-attachments/assets/9afd9fbf-ef25-44cc-9d8e-c39a69875163"
/>


**After** (both small and large icons affected, but not emoji)
<img width="711" height="95" alt="Screenshot 2025-10-11 at 23 21 05"
src="https://github.com/user-attachments/assets/90999f59-3b43-4684-9c8e-2c3c1edd6d18"
/>
2025-10-23 09:30:43 -07:00
Alan Wu 7203f735c8
Fix fish shell cursor integration in fish vi mode (#9157)
Previously, the fish shell integration interfered with fish's builtin vi
mode cursor switching configurations such as `$fish_cursor_default` and
`$fish_cursor_insert`.

```console
$ ghostty --config-default-files=false -e fish --no-config --init-command 'source "$GHOSTTY_RESOURCES_DIR"/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish; fish_vi_key_bindings'
```

The above command starts fish in vi mode with Ghostty shell
integrations. Manually loading the integration is necessary due to
`--no-config` blocking auto injection.

1. At the prompt, fish is in insert mode, and the cursor is a blinking
beam. However, press escape and then "i" to exit then re-enter insert
mode, and the cursor will be a solid beam due to the
`$fish_cursor_unknown` setting. Without the shell integration, insert
mode always uses a solid beam cursor.

2. A similar problem shows if we start fish with `fish_vi_key_bindings
default`. The cursor ends up as a blinking beam in normal mode only due
to the shell integration interfering. This glitch can also be reset away
by entering then exiting insert mode.

3. Also, `$fish_cursor_external` has no effect when used with shell
integration. After `fish_vi_key_bindings`, set it to `line`, run cat(1),
and shell integration will give you a blinking block, not the asked for
line/beam.

I verified that this patch makes the shell integration stop interfering
in three scenarios above, and it still changes the cursor when not using
fish's vi mode.

Note that `$fish_cursor_*` variables can be set when fish isn't in vi
mode, so they're not great signals for the shell integration hooks.
2025-10-23 09:30:37 -07:00
Daniel Wennberg 877206660d
fix(font): Additional scale group tweaks (#9152)
Of course #9142 would require a minor follow-up!

* Scale groups can cut across patch sets, but not across fonts. We had
some scale group mixing between Font Awesome and the weather symbols,
which is removed by this PR.[^cp_table_full]
* There's one case where a scale group includes a glyph that's not part
of any patch sets, just for padding out the group bounding box.
Previously, an unrelated glyph from a different font would be pulled in.
Now we use an appropriate stand-in. (See code comment for details.)
* I noticed overlaps weren't being split between each side of the
bounding box, they were added to both sides, resulting in twice as much
padding as specified.

Screenshots showing the extra vertical padding for progress bar elements
due to the second bullet point:

**Before**
<img width="191" height="42" alt="Screenshot 2025-10-11 at 15 33 54"
src="https://github.com/user-attachments/assets/cf288cce-86d3-46fd-ae86-18e5c274b0e4"
/>

**After**
<img width="191" height="42" alt="Screenshot 2025-10-11 at 15 33 20"
src="https://github.com/user-attachments/assets/7ac799c7-bf50-4e65-a74a-f8a2c42d2441"
/>

[^cp_table_full]: Forming and using the merged `cp_table_full` table
should have been a red flag. Such a table doesn't make sense, it would
be a one-to-many map. You need the names of the original fonts to
disambiguate.
2025-10-23 09:30:29 -07:00
Daniel Wennberg 1658c86eba
font(fix): Extract and apply Nerd Font codepoint mapping table (#9142)
Fixes #9076

**Before** 
<img width="128" height="57" alt="Screenshot 2025-10-11 at 00 07 09"
src="https://github.com/user-attachments/assets/a6b416d5-dae1-4cea-a836-00640ceaf39b"
/>

**After**
<img width="128" height="57" alt="Screenshot 2025-10-11 at 00 07 31"
src="https://github.com/user-attachments/assets/7d2df7b1-4767-4e2d-84d2-8301da5c6602"
/>

These screenshots show the chevrons mentioned in
https://github.com/ghostty-org/ghostty/discussions/7820#discussioncomment-14617170,
which should be scaled as a group but were not until this PR.

The added code downloads each individual symbol font file from the Nerd
Fonts github repo (making sure to get the version corresponding to the
vendored `font-patcher.py`) and iterates over all of them to build the
correct and complete codepoint mapping table. The table is saved to
`nerd_font_codepoint_tables.py`, which `nerd_font_codegen.py` will reuse
if possible instead of downloading the font files again.

I'm not going to utter any famous last words or anything, but... after
this, I don't think the number of remaining issues with icon
scaling/alignment is _large._
2025-10-23 09:28:29 -07:00
Brice 5ecb00fd48
GTK fix quick terminal autohide (#9139)
This is pretty much a direct port of the previous GTK app. still inside
of the `isActive` handler for a window


7e429d73d6/src/apprt/gtk/Window.zig (L822-L837)

Fixes: https://github.com/ghostty-org/ghostty/discussions/9137
2025-10-23 09:28:11 -07:00
Xiangbao Meng 6a2eb6757b
macOS: Fix New Tab behaviours (#9124)
- Fix `macos-dock-drop-behavior = new-tab` not working, which also
affects `open /path/to/directory -a Ghostty.app`
- Fix 'New Tab' in dock icon not working **when Ghostty's not active**

### Issue preview with `1.2.2(12187)`


https://github.com/user-attachments/assets/18068cc2-c25d-4360-97ab-cec22d5d3ff4
2025-10-23 09:28:04 -07:00
Mitchell Hashimoto e8bcd4031c
apprt/gtk: set the correct window title from the start (#9120)
Previous PR: #8535 (merged but problem persists)
Issues: #5934

The Ghostty window will always start with the title "Ghostty" at
startup, and then immediately change to the correct window title. This
is a problem when using compositors like Hyprland and Niri if you want
to create rules for floating windows and similar, as the window title
isn't detected at startup.

This fixes the bad behaviour both for title configured in the config
file, and for processes started with the --title argument.

In this fix I've updated the `tags.zig` `closureComputedTitle()`
function to get the title from the passed in config, and use that as a
fallback before the default `Ghostty` fallback.

Previous behaviour as logged by `niri msg event-stream`:

> Window opened or changed: Window { id: 19, title: Some("Ghostty"),
app_id: Some("com.mitchellh.ghostty-debug"), pid: Some(802495),
workspace_id: Some(1), is_focused: true, is_floating: false, is_urgent:
false, layout: WindowLayout { pos_in_scrolling_layout: Some((3, 1)),
tile_size: (2266.0, 1365.0), window_size: (2266, 1365),
tile_pos_in_workspace_view: None, window_offset_in_tile: (0.0, 0.0) } }
Window layouts changed: [(6, WindowLayout { pos_in_scrolling_layout:
Some((4, 1)), tile_size: (2266.0, 1365.0), window_size: (2266, 1365),
tile_pos_in_workspace_view: None, window_offset_in_tile: (0.0, 0.0) })]
Window opened or changed: Window { id: 19, title:
Some("pr-test-title-fix"), app_id: Some("com.mitchellh.ghostty-debug"),
pid: Some(802495), workspace_id: Some(1), is_focused: true, is_floating:
false, is_urgent: false, layout: WindowLayout { pos_in_scrolling_layout:
Some((3, 1)), tile_size: (2266.0, 1365.0), window_size: (2266, 1365),
tile_pos_in_workspace_view: None, window_offset_in_tile: (0.0, 0.0) } }

New behaviour:

> Window opened or changed: Window { id: 20, title:
Some("pr-test-title-fix"), app_id: Some("com.mitchellh.ghostty-debug"),
pid: Some(804534), workspace_id: Some(1), is_focused: true, is_floating:
false, is_urgent: false, layout: WindowLayout { pos_in_scrolling_layout:
Some((3, 1)), tile_size: (2266.0, 1365.0), window_size: (2266, 1365),
tile_pos_in_workspace_view: None, window_offset_in_tile: (0.0, 0.0) } }
Window layouts changed: [(6, WindowLayout { pos_in_scrolling_layout:
Some((4, 1)), tile_size: (2266.0, 1365.0), window_size: (2266, 1365),
tile_pos_in_workspace_view: None, window_offset_in_tile: (0.0, 0.0) })]

This fixes the problem as shown in the output. I have only tested this
on Linux (Arch with Niri).
2025-10-23 09:27:54 -07:00
Xiangbao Meng d6df0d145c
Fix `macos-titlebar-tabs` related issues (#9090)
### This pr fixes multiple issues related to `macos-titlebar-tabs`

- [Window title clipping **on
Tahoe**](https://github.com/ghostty-org/ghostty/discussions/9027#discussion-8981483)
- Clipped tab bar **on Tahoe** when creating new ones in fullscreen
> Sequoia doesn't seem to have this issue (at least I didn't reproduce
myself)
- [Title missing **on Tahoe** after dragging a tab into a separate
window](https://github.com/ghostty-org/ghostty/discussions/9027#discussioncomment-14617088)
- [Clipped tab bar **on Tahoe** after dragging from one tab group to
another](https://github.com/ghostty-org/ghostty/discussions/9027#discussioncomment-14626078)
- [Stretched tab bar after switching system
appearance](https://github.com/ghostty-org/ghostty/discussions/9027#discussioncomment-14626918)


### Related issues
I checked all of the open sub-issues in #2349 , most of them should be
fixed in latest main branch (I didn't reproduce)

- [#1692](https://github.com/ghostty-org/ghostty/issues/1692)
@peteschaffner
- [#1945](https://github.com/ghostty-org/ghostty/issues/1945) this one I
reproduce only on Tahoe, and fixed in this pr, @injust
- [#1813](https://github.com/ghostty-org/ghostty/issues/1813) @jacakira
- [#1787](https://github.com/ghostty-org/ghostty/issues/1787)
@roguesherlock
- [#1691](https://github.com/ghostty-org/ghostty/issues/1691) ~**haven't
found a solution yet**(building zig on VM is a pain**)~
> Tried commenting out `isOpaque` check in
`TitlebarTabsVenturaTerminalWindow`, which would fix the issue, but I
see the note there about transparency issue.
  >
> After commenting it out, it worked fine for me with blur and opacity
config, so **I might need some more background on this**. Didn't include
this change yet.
  > 
> [See screenshot
here](https://github.com/user-attachments/assets/eb17642d-b0de-46b2-b42a-19fb38a2c7f0)



### Minor improvements

- Match window title style with `window-title-font-family` and focus
state
  
<img width="843" height="198" alt="image"
src="https://github.com/user-attachments/assets/0138c4fa-1a4b-4bab-a415-b32695899ccf"
/>

---------

Co-authored-by: Mitchell Hashimoto <m@mitchellh.com>
2025-10-23 09:27:41 -07:00
Mitchell Hashimoto a04730650f
terminal: add semi-colon character to word boundary list for easier selection (#9069)
Sorry, I'm living dangerously here and haven't started a discussion.

New ghostty user. When working interactively with SQL clients you're
often writing semi-colons at the end of statements, e.g. `select * from
table;`

It's super annoying when you double-click to select the word `table` it
actually selects `table;` Anecdotally, this behaviour disagrees with
other terminals I've tried (tho not exhaustive).

Disclosure: Claude wrote this code but, ironically, I "assisted it" by
pointing to the file and function after uncovering issue #30 and
relevant PR.
2025-10-23 09:27:26 -07:00
Mitchell Hashimoto 262c8517be
Bump version to 1.2.3 2025-10-23 09:26:01 -07:00
Mitchell Hashimoto 7071a22cb5
v1.2.2 2025-10-08 10:02:24 -07:00
Mitchell Hashimoto a586b47dc9
Implement and use generic approx equality tester (#8979)
Seems like there needs to be a general, easy-to-use solution for
approximate equality testing of containers holding floats (see, e.g.,
https://github.com/ghostty-org/ghostty/pull/8563#pullrequestreview-3281357931).
How's this?
2025-10-08 09:59:22 -07:00
Mitchell Hashimoto c8efb2a8c9
font: Add comprehensive constraint tests (#9023)
As promised in #8990.

I opted for hardcoded metrics and bounding boxes rather than actually
loading fonts and glyphs, both to avoid backend dependence and limit the
focus to the constraint calculations themselves, and because I wanted to
test a case that isn't exhibited by any of the fonts available in the
repo.

This also fixes an error from #8990, probably due to a botched
cherry-pick or rebase.
2025-10-08 09:59:12 -07:00
Mitchell Hashimoto 62ed472d9e
Revert "renderer: slightly optimize screen copy" (#8974)
This reverts commit fcea09e413 because it
appears to be causing memory leaks.
2025-10-08 09:57:14 -07:00
Mitchell Hashimoto 436bc4c2b9
Set version to 1.2.1 2025-10-06 10:07:14 -07:00
Mitchell Hashimoto 0993fef615
macos: fix missing file for iOS 2025-10-06 09:47:44 -07:00
Mitchell Hashimoto 3cf81f64bd
apprt/gtk: only close with no windows active if close delay is off (#9053)
Fixes #9052
2025-10-06 09:22:22 -07:00
Mitchell Hashimoto 15dc72e26f
Update iTerm2 2025-10-06 09:08:20 -07:00
Mitchell Hashimoto c583505430
Expand `~` in `macos-custom-icon` (#9024)
Since #8999, `macos-custom-icon` works when its a fully expanded
absolute path like `/Users/username/dir/icon.icns`, but not when it's
abbreviated as `~/dir/icon.icns`. Users were understandably surprised
and confused by this. This PR adds tilde expansion using `NSString`s
built-in property for this.

Also removed a line from the config docs that seemed erroneous. Given
that the option has a functional default, it seems incorrect to say that
it's required.
2025-10-06 09:05:34 -07:00
Mitchell Hashimoto d8d232e5a2
macos: avoid any zero-sized content size increments (#9020)
Fixes #9016
2025-10-06 09:05:22 -07:00
Mitchell Hashimoto 8dd810521c
Fix Weird Behavior in CoreText Shaper (#9002)
You can pretty simply reproduce a crash on `main` in `Debug` mode by
running `printf "مرحبًا \n"` with your primary font set to one that
supports Arabic such as Cascadia Code/Mono or Kawkab Mono, which will
cause CoreText to output the shaped glyphs non-monotonically which hits
the assert we have in the renderer.

In `ReleaseFast` this assert is skipped and because we already moved
ahead to the space glyph (which belongs at the end but is emitted first)
all of the glyphs up to that point are lost. I believe this is probably
the cause of #8280, I tested and this change seems to fix it at least.

Included in this PR is a little optimization: we were allocating buffers
to copy glyphs etc. from runs to every time, even though CoreText
provides `CTRunGet*Ptr` functions which get *pointers* to the internal
storage of these values- these aren't guaranteed to return a usable
pointer but in that case we can always fall back to allocating again.
Also avoided allocation while processing glyphs by ensuring capacity
beforehand immediately after creating the `CTLine`.

The performance impact of this PR is negligible on my machine and
actually seems to be positive, probably due to avoiding allocations if I
had to guess.
2025-10-06 09:04:48 -07:00
Mitchell Hashimoto 24f883904d
gtk: fix duplicate signal handlers (#9001)
Signal handlers are connected to surface objects in two spots - when a
tab is added to a page and when the split tree changes. This resulted in
duplicate signal handlers being added for each surface. This was most
noticeable when copying the selection to the clipboard - you would see
two "Copied to clipboard" toasts. Ensure that there is only one signal
handler by removing any old ones before adding the new ones.
2025-10-06 09:04:44 -07:00
Mitchell Hashimoto 3ff4b6c062
fix(config): Make `macos-custom-icon` null-terminated (#8999)
The config option `macos-custom-icon` wasn't working because, to pass
successfully through the C API to Swift, the string must be
null-terminated.

Fixes
https://discord.com/channels/1005603569187160125/1423192859112116224
2025-10-06 09:04:01 -07:00
Mitchell Hashimoto 630c5981b7
fix(font): Let powerline glyphs be wide (#8994)
regressed in the handling of the Powerline glyphs themselves by letting
them get caught in an early exit that imposes a constraint width of 1.
This PR fixes the regression and adds corresponding tests. Tried to be
somewhat principled about why the special treatment is warranted, hence
the new helper function `isGraphicsElement`.

**Before**
<img width="270" height="44" alt="Screenshot 2025-10-02 at 00 16 54"
src="https://github.com/user-attachments/assets/9e975434-114c-44d5-a4ed-ac6a954b9d00"
/>

**After**
<img width="270" height="44" alt="Screenshot 2025-10-02 at 00 16 11"
src="https://github.com/user-attachments/assets/20545e74-c9f9-4a6b-9bf0-a7cf1d38c3a0"
/>
2025-10-06 09:02:58 -07:00
Mitchell Hashimoto fd326d6af4
fix(font): Fix positioning of scaled glyphs that don’t specify alignment (#8990)
Follow-up to #8563, which broke scaling without alignment. This change
recovers the behavior from before #8563, such that a scaled group is
clamped to the constraint width and height if necessary, and otherwise,
scaling does not shift the center of the group bounding box.

As a part of this change, horizontal alignment was rewritten to assume
the face is flush with the left edge of the cell. The cell-to-face
offset in the rendering code is then applied regardless of the value of
`align_horizontal`. This both simplifies the code and improves
consistency, as it ensures that the offset is the same for all
non-bitmap glyphs (rounded in FreeType, not rounded in CoreText). It's
the right thing to do following the align-to-face changes in #8563.
2025-10-06 09:02:33 -07:00
Mitchell Hashimoto 3184187f2d
feat: add GHOSTTY_BIN_DIR to path via shell integration (#8976)
Closes #8956

Elvish written by Copilot, the rest was written by me with AI
documentation.
2025-10-06 09:01:41 -07:00
Mitchell Hashimoto ee82baadde
gtk: some bell features need to happen on receipt of every BEL (#8962)
Some bell features should be triggered on the receipt of every BEL
character, namely `audio` and `system`. However, Ghostty was setting a
boolean to `true` upon the receipt of the first BEL. Subsequent BEL
characters would be ignored until that boolean was reset to `false`,
usually by keyboard/mouse activity.

This PR fixes the problem by ensuring that the `audio` and `system`
features are triggered every time a BEL is received. Other features
continue to be triggered only when the `bell-ringing` boolean state
changes.

Fixes #8957
2025-10-06 09:01:34 -07:00
Mitchell Hashimoto e974d58615
Inline All The Things (#8946)
I used the new CPU counter mode in Instruments.app to track down
functions that had instruction delivery bottlenecks (indicating i-cache
misses) and picked a bunch of trivial functions to mark as inline (plus
a couple that are only used once or twice and which benefit from
inlining).

The size of `macos-arm64/libghostty-fat.a` built with `zig build
-Doptimize=ReleaseFast -Dxcframework-target=native` goes from
`145,538,856` bytes on `main` to `145,595,952` on this branch, a
negligible increase.

These changes resulted in some pretty sizable improvements in vtebench
results on my machine (Apple M3 Max):
<img width="983" height="696" alt="image"
src="https://github.com/user-attachments/assets/cac595ca-7616-48ed-983c-208c2ca2023f"
/>

With this, the only vtebench test we're slower than Alacritty in (on my
machine, at 130x51 window size) is `dense_cells` (which, IMO, is so
artificial that optimizing for it might actually negatively impact real
world performance).

I also did a pretty simple improvement to how we copy the screen in the
renderer, gave it its own page pool for less memory churn. Further
optimization in that area should be explored since in some scenarios it
seems like as much as 35% of the time on the `io-reader` thread is spent
waiting for the lock.

> [!NOTE]
> Before this is merged, someone really ought to test this on an x86
processor to see how the performance compares there, since this *is*
tuning for my processor specifically, and I know that M chips have
pretty big i-cache compared to some x86 processors which could impact
the performance characteristics of these changes.
2025-10-06 08:59:22 -07:00
Mitchell Hashimoto 1a94e7b016
fix(font): Final font patcher fixes (#8847)
This is my final set of fixes to the font patcher/icon scaling code. It
builds on #8563 and there's not much reason to pay attention here until
that one has been reviewed (the unique changes in this PR only touch the
two `nerd_font_*` files; the other 8 files in the diff are just #8563).
However, I wanted to make sure the full set of changes/fixes I propose
are out in the open, such that any substantial edits by maintainers
(like in #7953) can take into account the full context.

I think this and the related patches should be considered fixes, not
features, so I hope they can be considered for a 1.2.x release.

This PR fixes some bugs in the extraction of scale and alignment rules
from the `font_patcher` script. Roughly in order of importance:

* Nerd fonts apply an offset to some codepoint ranges when extracting
glyphs from their original font (e.g., Font Awesome) and placing them in
a Nerd Font. Rules are specified in terms of the former codepoints, but
must be applied to the latter. This offset was previously not taken into
account, so rules were applied to the wrong glyphs, and some glyphs that
should have rules didn't get any.
* Previously, the rules from every single patch set was included, but
the embedded Symbols Only font doesn't contain all of them. Most
importantly, there's a legacy patch set that only exists for historical
reasons and is never used anymore, which was overwriting some other
rules because of overlapping codepoint ranges. Also, the Symbols Only
font contains no box drawing characters, so those rules should not be
included. With this PR, irrelevant patch sets are filtered out.
* Some patch sets specify overlapping codepoint ranges, though in
reality the original fonts don't actually cover the full ranges and the
overlaps just imply that they're filling each other's gaps. During font
patching, the presence/absence of a glyph at each codepoint in the
original font takes care of the ambiguity. Since we don't have that
information, we need to hardcode which patch set "wins" for each case
(it's not always the latest set in the list). Luckily, there are only
two cases.
* Many glyphs belong to scale groups that should be scaled and aligned
as a unit. However, in `font_patcher`, the scale group is _not_ used for
_horizontal_ alignment, _unless_ the entire scale group has a single
advance width (remember, the original symbol fonts are not monospace).
This PR implements this rule by only setting `relative_width` and
`relative_x` if the group is monospace.

There are some additional tweaks to ensure that each codepoint actually
gets the rule it's supposed to when it belongs to multiple scale groups
or patch sets, and to avoid setting rules for codepoints that don't
exist in the embedded font.
2025-10-06 08:57:52 -07:00
Mitchell Hashimoto bdc1dc4363
fix(font): Apply glyph constraints before thickening and centering before quantizing (#8580)
In CoreText, when thickening (font smoothing) is enabled or Ghostty is
synthesizing a bold face, the glyph bounding box is padded to make sure
the thicker glyph can fit. Currently, this happens before applying
constraints (scaling and alignment), which makes the size and position
of constrained glyphs dependent on font size, font thickening strength,
and display DPI.

With this PR, constraints are applied before any other adjustments, and
padding is applied directly to the rasterization canvas without
modifying any metrics.

For consistency, I also moved constraint application above emboldening
in the FreeType code, although under that API, the two operations are
orthogonal as far as I can tell.

Secondly, this PR moves glyph centering above bitmap quantization, as
centering is generally fractional and will therefore undo the quantizing
if done after.

Supersedes #8552.
2025-10-06 08:57:39 -07:00
Mitchell Hashimoto 055281febf
apprt/gtk: do not close window if tab overview is open with no tabs (#8955)
Fixes #8944

When we drag the only tab out of the tab overview, this triggers an
`n-pages` signal with 0 pages. If we close the window in this state, it
causes both Ghostty to exit AND the drag/drop to fail. Even if we
pre-empt Ghostty exiting by modifying the application class, the
drag/drop still fails and the application leaks memory and enters a bad
state.

The solution is to keep the window open if we go to `n-pages == 0` and
we have the tab overview open.

Interestingly, if you click to close the final tab from the tab
overview, Adwaita closes the tab overview so it still triggers the
window closing behavior (this is good, this is desired).
2025-09-29 13:03:12 -07:00
Mitchell Hashimoto 64edc95e92
gtk: make Enter confirm "Change Terminal Title" (#8949)
Fixes https://github.com/ghostty-org/ghostty/discussions/8697 by making
`OK` the suggested default and activating it by default.

Previously `OK` was `destructive` which imo is not a good approach for
just setting a terminal title.
2025-09-29 13:03:05 -07:00
Mitchell Hashimoto 359d735213
feat: enable scaling mouse-scroll-multiplier for both precision and discrete scrolling (#8927)
Resolves Issue: #8670 

Now precision and discrete scrolling can be scaled independently.
Supports following configuration,

```code
# Apply everywhere
mouse-scroll-multiplier = 3

# Apply separately
mouse-scroll-multiplier = precision:0.1,discrete:3 (default)

# Also it's order agnostic
mouse-scroll-multiplier = discrete:3,precision:2

# Apply one, default other
mouse-scroll-multiplier = precision:2
```

The default precision value is set 0.1, as it felt natural to me at
least on my track-pad. I've also set the min clamp value precision to
0.1 as 0.01 felt kind of useless to me but I'm unsure.
2025-09-29 13:02:54 -07:00
Mitchell Hashimoto e10eb8a2fd
build: limit cpu affinity to 32 cpus on Linux (#8925)
Related to #8924

Zig currenly has a bug where it crashes when compiling Ghostty on
systems with more than 32 cpus (See the linked issue for the gory
details). As a temporary hack, use `sched_setaffinity` on Linux systems
to limit the compile to the first 32 cores. Note that this affects the
build only. The resulting Ghostty executable is not limited in any way.

This is a more general fix than wrapping the Zig compiler with
`taskset`. First of all, it requires no action from the user or
packagers. Second, it will be easier for us to remove once the upstream
Zig bug is fixed.
2025-09-29 13:02:37 -07:00
Mitchell Hashimoto 8b047fb570
vim: use :setf to set the filetype (#8914)
This is nicer because it only sets the filetype if it hasn't already
been set. :setf[iletype] has been available since vim version 6.

See: https://vimhelp.org/options.txt.html#%3Asetf
2025-09-29 13:01:54 -07:00
Mitchell Hashimoto f764c070bd
cli: use sh to launch editor (#8901)
Fixes #8898
2025-09-29 13:01:48 -07:00
Mitchell Hashimoto d06c9c7aae
fix: file creation when directory already exists (#8892)
Resolves #8890 

If you try to create the config file when the directory already exists,
you (I) get an error that the _file_ path already exists.
```
warning(config): error creating template config file err=error.PathAlreadyExists
```
Even though the file does not exist. By changing the API entry point,
this error goes away.

I have no solid explanation for why this change works.


| State | Old Behavior | New Behavior |
|--------|--------|--------|
| A config file exists | N/A | N/A |
| No config file, no directory | create directory and config file | N/A
|
| No config file, yes directory | fail to create on config file | create
config file |

This behavior is confirmed on my macOS 26 machine. It is the least
intrusive change I could make, and in all other situations should be a
no-op.
2025-09-29 13:01:43 -07:00
Mitchell Hashimoto eb0814c680
fix: alloc free off by one (#8886)
Fix provided by @jcollie 

The swift `open_config` action was triggering an allocation error
`error(gpa): Allocation size 41 bytes does not match free size 40.`.

> A string that was created as a `[:0]const u8` was cast to `[]const u8`
and then freed. The sentinel is the off-by-one.

@jcollie 

For full context, see
https://discord.com/channels/1005603569187160125/1420367156071239820

Co-authored-by: Jeffrey C. Ollie <jcollie@dmacc.edu>
2025-09-29 13:01:27 -07:00
Mitchell Hashimoto 7aff259fee
config: smarter parsing in autoParseStruct (#8873)
Fixes #8849

Previously, the `parseAutoStruct` function that was used to parse
generic structs for the config simply split the input value on commas
without taking into account quoting or escapes. This led to problems
because it was impossible to include a comma in the value of config
entries that were parsed by `parseAutoStruct`. This is particularly
problematic because `ghostty +show-config --default` would produce
output like the following:

```
command-palette-entry = title:Focus Split: Next,description:Focus the next split, if any.,action:goto_split:next
```

Because the `description` contains a comma, Ghostty is unable to parse
this correctly. The value would be split into four parts:

```
title:Focus Split: Next
description:Focus the next split
 if any.
action:goto_split:next
```

Instead of three parts:

```
title:Focus Split: Next
description:Focus the next split, if any.
action:goto_split:next
```

Because `parseAutoStruct` simply looked for commas to split on, no
amount of quoting or escaping would allow that to be parsed correctly.

This is fixed by (1) introducing a parser that will split the input to
`parseAutoStruct` into fields while taking into account quotes and
escaping. And (2) changing the `ghostty +show-config` output to put the
values in `command-palette-entry` into quotes so that Ghostty can parse
it's own output.

`parseAutoStruct` will also now parse double quoted values as a Zig
string literal. This makes it easier to embed control codes, whitespace,
and commas in values.
2025-09-29 13:01:16 -07:00
Mitchell Hashimoto a2b6a9cf99
chore: pin zig 0.14 in build.zig.zon (#8871)
Hi!

I'm a full Zig noob but [Mitchell's recent
post](https://mitchellh.com/writing/libghostty-is-coming) made me want
to clone the repo and take a look at the tooling.

My first attempt at running examples though VSCode failed because the
latest version of Zig is 0.15.1, but Ghostty requires Zig 0.14*. When
configuring the extension to use a compatible version if Zig, it
suggested pinning the version in a .zigversion file. I'm not familiar
with the pattern, but if it can help someone else's onboarding, I
figured I'd open a PR to suggest the change.

Cheers

*edit: I had a hard time figuring that out
2025-09-29 13:01:09 -07:00
Mitchell Hashimoto 4cb3aaece4
GTK: Fix split-divider-color (#8853)
The `loadRuntimeCss416` overrode a color option for the split divider
color

https://github.com/ghostty-org/ghostty/blob/main/src/apprt/gtk/class/application.zig#L959-L965

I moved the user config options until the other runtime css is loaded so
they will always take priority
2025-09-29 13:00:59 -07:00
Mitchell Hashimoto 7a3bbe0107
feat: list-themes cursor and selection colors (#8848)
Closes #8446

Adds the remaining theme colors: cursor-color, cursor-text,
selection-background, and selection-foreground.

## Before
<img width="1840" height="1195" alt="image"
src="https://github.com/user-attachments/assets/f39f0cf1-f1c4-468c-a706-a39e3efe2883"
/>

## After
<img width="1840" height="1195" alt="image"
src="https://github.com/user-attachments/assets/a6995c35-070d-4971-9caf-ebae994deba5"
/>
2025-09-29 13:00:53 -07:00
Mitchell Hashimoto 5110ad053e
Workaround for #8669 (#8838)
Changing `supportedModes` to `background` seems to have fixed #8669.

> Debugging AppIntents with Xcode is a pain. I had to delete all the
local builds to make it take effect. The build product of `zig` might
cause confusion if none of your changes reflect in the Shortcuts app.
There were too many ghosts on my computer. 👻👻👻

- Tahoe


https://github.com/user-attachments/assets/88d0d567-edf5-4a7e-b0a3-720e50053746

- Sequoia 


https://github.com/user-attachments/assets/a77f1431-ca92-4450-bce9-5f37ef232d4f
2025-09-29 13:00:46 -07:00
Mitchell Hashimoto 2be16d2242
xdg: treat empty env vars as not existing (#8830)
Replaces #8786 

The author of the original PR used AI agents to create that PR. To the
extent that this PR borrows code from that PR (mostly in the tests) AI
was used in the creation of this PR.
2025-09-29 13:00:22 -07:00
Mitchell Hashimoto 7053f5a537
fix(font): Treat Powerline glyphs as normal characters for constraint width purposes (#8829)
Powerline glyphs were treated as whitespace, giving the preceding cell a
constraint width of 2 and cutting off icons in people's prompts and
statuslines. It is however correct to not treat Powerline glyphs like
other Nerd Font symbols; they should simply be treated as normal
characters, just like their relatives in the block elements unicode
block.

This resolves
https://discord.com/channels/1005603569187160125/1417236683266592798
(never promoted to an issue, but real and easy to reproduce).

**Tip**
<img width="215" height="63" alt="Screenshot 2025-09-21 at 16 57 58"
src="https://github.com/user-attachments/assets/81e770c5-d688-4d8e-839c-1f4288703c06"
/>

**This PR**
<img width="215" height="63" alt="Screenshot 2025-09-21 at 16 58 42"
src="https://github.com/user-attachments/assets/5d2dd770-0314-46f6-99b5-237a0933998e"
/>

The constraint width logic was untested but contains some quite subtle
interactions, so I wrote a suite of tests covering the cases I'm aware
of.

While working on this code I also resolved a TODO comment to add all the
box drawing/block element type characters to the set of codepoints
excluded from the minimum contrast settings.
2025-09-29 13:00:15 -07:00
Mitchell Hashimoto a905e14cc4
gtk: restore flatpak-aware resource directory support (#8816)
This was not ported to gtk-ng before old runtime was removed, breaking
shell integration on Flatpak.

This implementation is copied verbatim from old runtime.
2025-09-29 13:00:08 -07:00
Mitchell Hashimoto e89036f716
GTK Fix unfocused-split-fill (#8813)
Attempts a resolution for
https://github.com/ghostty-org/ghostty/discussions/8572

This matches the behavior of the old GTK apprt where
unfocused-split-fill /opacity doesn't apply when there is only one
active surface.
2025-09-29 13:00:02 -07:00
Mitchell Hashimoto 5880fa5321
macos: quick terminal stores the last closed size by screen (#8796)
Fixes #8713

This stores the last closed state of the quick terminal by screen
pointer. We use a weak mapping so if a screen is unplugged we'll clear
the memory. We will not remember the size if you unplug and replug in a
monitor.
2025-09-29 12:59:18 -07:00
Mitchell Hashimoto 38503e7c33
macos: set the app icon in syncAppearance to delay the icon update (#8792)
Fixes #8734

This forces the app icon to be set on another event loop tick from the
main startup.

In the future, we should load and set the icon completely in another
thread. It appears that all the logic we have is totally thread-safe.
2025-09-29 12:59:04 -07:00
Mitchell Hashimoto 5429d1e3e2
macos: correct SurfaceView supported send/receive types for services (#8790)
Fixes #8785

This is the callback AppKit sends when it wants to know if our
application can handle sending and receiving certain types of data.

The prior implementaiton was incorrect and would erroneously claim
support over combinations that we couldn't handle (at least, at the
SurfaceView layer).

This corrects the implementation. The services we expect still show up
and the error in 8785 goes away.
2025-09-29 12:58:56 -07:00
Mitchell Hashimoto b6c3781cdc
macos: "new tab" service should set preferred parent to ensure tab (#8784)
Fixes #8783

Our new tab flow will never have a previously focused window because its
triggered by a service so we need to use the "preferred parent" logic we
have to open this in the last focused window.
2025-09-29 12:58:47 -07:00
Mitchell Hashimoto 12446d7d50
renderer/opengl: minimum contrast for black sets proper color (#8782)
Fixes #8745

When rendering black for minimum contrast we were setting opacity to 0
making it invisible.
2025-09-29 12:58:40 -07:00
Mitchell Hashimoto d231e94535
Snap: Do not leak snap variables or snap paths into children (#8771)
Avoid leaking snap environment values and in particular the `$SNAP*`
values to the children that we run from the terminal.

Do this programmatically instead of the launcher, since ghostty needs
know the environment it runs in, while it must not leak the info to the
children.

We've also another leak on snap, that creates a more visible problem
(wrong matching of children applications) that is the apparmor security
profile.

I've handled it at
cc3b46f600
but that requires some love in order to fully decouple the snap option
to the build, to avoid including it in non-snap builds, so an help would
be appreciated there.

> This PR was contains code (in `filterSnapPaths`) that was improved by
DeepSeek.
2025-09-29 12:58:24 -07:00
Mitchell Hashimoto e3cdf0faae
macos: implement bell-features=border on macOS 2025-09-29 12:54:22 -07:00
عبد الرحمن صباهي a9f4d4941a slightly improve logs 2025-09-29 12:54:05 -07:00
Mitchell Hashimoto 3d0846051f
macos: bell-features=title works again
This was a regression we didn't fix before 1.2.
2025-09-29 12:53:42 -07:00
Mitchell Hashimoto 6e5419c561
macos: opening filepaths should make proper file URLs
Fixes #8763
2025-09-29 12:53:05 -07:00
Mitchell Hashimoto 1041a4cc9b
macos: set initial window in TerminalWindow awakeFromNib
Maybe fixes #8736

I thought `windowDidLoad` was early on because its before the window is
shown but apparently not. Let's try `awakeFromNib` which is called
just after the window is loaded from the nib. It is hard to get any
earlier than that.
2025-09-29 12:52:49 -07:00
Mitchell Hashimoto a09b39fb57
macos: window-position-x/y are from top-left corner
Fixes #8672

Almost fully written by AI: https://ampcode.com/threads/T-86df68a3-578c-4a1c-91f3-788f8b8f0aae

I reviewed all the code.
2025-09-29 12:52:40 -07:00
Mitchell Hashimoto 093a72da05
macos: custom progress bar to workaround macOS 26 ProgressView bugs (#8753)
Fixes #8731

The progress view in macOS 26 is broken in ways we can't work around
directly. Instead, we must create our own custom progress bar. Luckily,
our usage of the progress view is very simple.



https://github.com/user-attachments/assets/fb3dd271-0830-49fa-97ce-48eb5514e781

This was written mostly by Amp. I made my own modifications and fully
understand the code. Threads below.

Amp threads:
https://ampcode.com/threads/T-88b550b7-5e0d-4ab9-97d9-36fb63d18f21
https://ampcode.com/threads/T-721d6085-21d5-497d-b6ac-9f203aae0b94
2025-09-29 12:52:28 -07:00
Matthias von Arx e0905ac794 documentation: fix MacOSDockDropBehavior valid values 2025-09-29 12:52:03 -07:00
Mitchell Hashimoto b34f3f7208
renderer: create explicit sampler state for custom shaders
The GLSL to MSL conversion process uses a passed-in sampler state for
the `iChannel0` parameter and we weren't providing it. This magically
worked on Apple Silicon for unknown reasons but failed on Intel GPUs.

In normal, hand-written MSL, we'd explicitly create the sampler state as
a normal variable (we do this in `shaders.metal` already!), but the
Shadertoy conversion stuff doesn't do this, probably because the exact
sampler parameters can't be safely known.

This fixes a Metal validation error when using custom shaders:

```
-[MTLDebugRenderCommandEncoder validateCommonDrawErrors:]:5970: failed 
assertion `Draw Errors Validation Fragment Function(main0): missing Sampler 
binding at index 0 for iChannel0Smplr[0].
```
2025-09-29 12:51:48 -07:00
Mitchell Hashimoto 51292a9793
renderer/metal: provide MTLTextureUsage render target for custom shaders (#8749)
This fixes a Metal validation error in Xcode when using custom shaders.
I suspect this is one part of custom shaders not working properly on
Intel macs (probably anything with a discrete GPU).

This happens to work on Apple Silicon but this is undefined behavior and
we're just getting lucky.

There is one more issue I'm chasing down that I think is also still
blocking custom shaders working on Intel macs.
2025-09-29 12:51:33 -07:00
Mitchell Hashimoto 1cd0fb5dab
fix(font): Improve FreeType glyph measurements and add unit tests for face metrics (#8738)
Follow-up to #8720 adding

* Two improvements to FreeType glyph measurements:
- Ensuring that glyphs are measured with the same hinting as they are
rendered, ref
[#8720#issuecomment-3305408157](https://github.com/ghostty-org/ghostty/pull/8720#issuecomment-3305408157);
- For outline glyphs, using the outline bbox instead of the built-in
metrics, like `renderGlyph()`.
* Basic unit tests for face metrics and their estimators, using the
narrowest and widest fonts from the resource directory, Cozette Vector
and Geist Mono.

---

I also made one unrelated change to `freetype.zig`, replacing
`@alignCast(@ptrCast(...))` with `@ptrCast(@alignCast(...))` on line
173. Autoformatting has been making this change on every save for weeks,
and reverting the hunk before each commit is getting old, so I hope it's
OK that I use this PR to upstream this decree from the formatter.
2025-09-29 12:50:49 -07:00
Daniel Wennberg 5c6a766ff6 Measure ascii height and use to upper bound ic_width 2025-09-29 12:50:01 -07:00
Leah Amelia Chen 6b1fd76b7d Default config template be explicit that you do not copy the default values (#8701) 2025-09-29 12:49:35 -07:00
Mitchell Hashimoto 581846992d
selection scrolling should only depend on y value
Fixes #8683

The selection scrolling logic should only depend on the y value of the
cursor position, not the x value. This presents unwanted scroll
behaviors, such as reversing the scroll direction which was just a side
effect of attempting to scroll tick to begin with.
2025-09-29 12:49:22 -07:00
Mitchell Hashimoto 86e5ec8ba5
font-size reloads at runtime if the font wasn't manually set
This was a very common pitfall for users. The new logic will reload the
font-size at runtime, but only if the font wasn't manually set by the
user using actions such as `increase_font_size`, `decrease_font_size`,
or `set_font_size`. The `reset_font_size` action will reset our state
to assume the font-size wasn't manually set.

I also updated a comment about `font-family` not reloading at runtime;
this wasn't true even prior to this commit.
2025-09-29 12:49:00 -07:00
Mitchell Hashimoto 5a0bd8d1fa
config: fix binding parsing to allow values containing `=`
Fixes #8667

The binding `a=text:=` didn't parse properly.

This is a band-aid solution. It works and we have test coverage for it
thankfully. Longer term we should move the parser to a fully
state-machine based parser that parses the trigger first then the
action, to avoid these kind of things.
2025-09-29 12:48:45 -07:00
Filip Milković 28cdbe4f22 i18n: add Croatian hr_HR translation (#8668) 2025-09-29 12:48:34 -07:00
Simon Olofsson a4126d025b config: update theme names in docs
They were renamed, see: https://github.com/mbadolato/iTerm2-Color-Schemes/commits/master/ghostty/Rose%20Pine
2025-09-29 12:48:06 -07:00
カワリミ人形 b4345d151a docs: add lacking version information
`quick-terminal-size` option is available since 1.2.0
2025-09-29 12:47:56 -07:00
rhodes-b af77332871 mark ssh shell-integration wrapper as a function this matches other features + fixes a case where users alias to some other command 2025-09-29 12:47:27 -07:00
dmunozv04 c33ea2757c Docs: add undo-timeout configuration setting name 2025-09-29 12:47:16 -07:00
Caleb Hearth 6753507826 Pass config to splits in NewTerminalConfig
Config contains the command, working directory, and environment
variables intended to be passed to the new split, but it looks like we
forgot to include it as an argument in this branch.

Discussion: https://github.com/ghostty-org/ghostty/discussions/8637
2025-09-29 12:47:05 -07:00
Nilton Perim Neto 7884909253 Some portuguese translation updates (#8633)
Added some prepositions not previously added and
changed a word to be more accurate to the portuguese meaning

---------

Signed-off-by: Nilton Perim Neto <niltonperimneto@gmail.com>
2025-09-29 12:46:45 -07:00
Daniel Wennberg 812dc7cf2f Rewrite constraint code for improved icon scaling/alignment 2025-09-29 12:45:56 -07:00
Peter Dave Hello 81027f2211 Add zh_TW Traditional Chinese locale 2025-09-29 12:45:21 -07:00
112 changed files with 18095 additions and 1851 deletions

View File

@ -19,6 +19,7 @@ jobs:
- build-nix - build-nix
- build-macos - build-macos
- build-macos-matrix - build-macos-matrix
- build-snap
- build-windows - build-windows
- test - test
- test-gtk - test-gtk
@ -118,7 +119,41 @@ jobs:
run: | run: |
nix develop -c \ nix develop -c \
zig build \ zig build \
-Dflatpak=true -Dflatpak
build-snap:
strategy:
fail-fast: false
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@7be5dee1421f63d07e71ce6e0a9f8a4b07c2a487 # v31.6.1
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build with Snap
run: |
nix develop -c \
zig build \
-Dsnap
build-linux: build-linux:
strategy: strategy:
@ -275,7 +310,7 @@ jobs:
trigger-snap: trigger-snap:
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
runs-on: namespace-profile-ghostty-xsm runs-on: namespace-profile-ghostty-xsm
needs: build-dist needs: [build-dist, build-snap]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

View File

@ -184,6 +184,8 @@
/po/ko_KR.UTF-8.po @ghostty-org/ko_KR /po/ko_KR.UTF-8.po @ghostty-org/ko_KR
/po/he_IL.UTF-8.po @ghostty-org/he_IL /po/he_IL.UTF-8.po @ghostty-org/he_IL
/po/it_IT.UTF-8.po @ghostty-org/it_IT /po/it_IT.UTF-8.po @ghostty-org/it_IT
/po/zh_TW.UTF-8.po @ghostty-org/zh_TW
/po/hr_HR.UTF-8.po @ghostty-org/hr_HR
# Packaging - Snap # Packaging - Snap
/snap/ @ghostty-org/snap /snap/ @ghostty-org/snap

View File

@ -8,7 +8,13 @@ comptime {
} }
pub fn build(b: *std.Build) !void { pub fn build(b: *std.Build) !void {
// This defines all the available build options (e.g. `-D`). // Works around a Zig but still present in 0.15.1. Remove when fixed.
// https://github.com/ghostty-org/ghostty/issues/8924
try limitCoresForZigBug();
// This defines all the available build options (e.g. `-D`). If you
// want to know what options are available, you can run `--help` or
// you can read `src/build/Config.zig`.
const config = try buildpkg.Config.init(b); const config = try buildpkg.Config.init(b);
const test_filter = b.option( const test_filter = b.option(
[]const u8, []const u8,
@ -258,3 +264,13 @@ pub fn build(b: *std.Build) !void {
try translations_step.addError("cannot update translations when i18n is disabled", .{}); try translations_step.addError("cannot update translations when i18n is disabled", .{});
} }
} }
// WARNING: Remove this when https://github.com/ghostty-org/ghostty/issues/8924 is resolved!
// Limit ourselves to 32 cpus on Linux because of an upstream Zig bug.
fn limitCoresForZigBug() !void {
if (comptime builtin.os.tag != .linux) return;
const pid = std.os.linux.getpid();
var set: std.bit_set.ArrayBitSet(usize, std.os.linux.CPU_SETSIZE * 8) = .initEmpty();
for (0..32) |cpu| set.set(cpu);
try std.os.linux.sched_setaffinity(pid, &set.masks);
}

View File

@ -1,8 +1,9 @@
.{ .{
.name = .ghostty, .name = .ghostty,
.version = "1.2.0", .version = "1.2.3",
.paths = .{""}, .paths = .{""},
.fingerprint = 0x64407a2a0b4147e5, .fingerprint = 0x64407a2a0b4147e5,
.minimum_zig_version = "0.14.1",
.dependencies = .{ .dependencies = .{
// Zig libs // Zig libs
@ -112,8 +113,8 @@
// Other // Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" }, .apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{ .iterm2_themes = .{
.url = "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz", .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz",
.hash = "N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B", .hash = "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y",
.lazy = true, .lazy = true,
}, },
}, },

6
build.zig.zon.json generated
View File

@ -49,10 +49,10 @@
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
}, },
"N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B": { "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y": {
"name": "iterm2_themes", "name": "iterm2_themes",
"url": "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz", "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz",
"hash": "sha256-6rKNFpaUvSbvNZ0/+u0h4I/RRaV5V7xIPQ9y7eNVbCA=" "hash": "sha256-GsEWVt4wMzp6+7N5I+QVuhCVJ70cFrdADwUds59AKnw="
}, },
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono", "name": "jetbrains_mono",

6
build.zig.zon.nix generated
View File

@ -163,11 +163,11 @@ in
}; };
} }
{ {
name = "N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B"; name = "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y";
path = fetchZigArtifact { path = fetchZigArtifact {
name = "iterm2_themes"; name = "iterm2_themes";
url = "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz"; url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz";
hash = "sha256-6rKNFpaUvSbvNZ0/+u0h4I/RRaV5V7xIPQ9y7eNVbCA="; hash = "sha256-GsEWVt4wMzp6+7N5I+QVuhCVJ70cFrdADwUds59AKnw=";
}; };
} }
{ {

2
build.zig.zon.txt generated
View File

@ -8,7 +8,6 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
https://deps.files.ghostty.org/gettext-0.24.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz
https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
@ -29,6 +28,7 @@ https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21a
https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.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/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/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz
https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz
https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz
https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz

View File

@ -52,8 +52,8 @@
<releases> <releases>
<!-- TODO: Generate this automatically --> <!-- TODO: Generate this automatically -->
<release version="1.0.1" date="2024-12-31"> <release version="1.2.3" date="2025-10-08">
<url type="details">https://ghostty.org/docs/install/release-notes/1-0-1</url> <url type="details">https://ghostty.org/docs/install/release-notes/1-2-3</url>
</release> </release>
</releases> </releases>
</component> </component>

View File

@ -61,9 +61,9 @@
}, },
{ {
"type": "archive", "type": "archive",
"url": "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz", "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz",
"dest": "vendor/p/N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B", "dest": "vendor/p/N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y",
"sha256": "eab28d169694bd26ef359d3ffaed21e08fd145a57957bc483d0f72ede3556c20" "sha256": "1ac11656de30333a7afbb37923e415ba109527bd1c16b7400f051db39f402a7c"
}, },
{ {
"type": "archive", "type": "archive",

View File

@ -353,6 +353,7 @@ typedef struct {
typedef struct { typedef struct {
const char* ptr; const char* ptr;
uintptr_t len; uintptr_t len;
bool sentinel;
} ghostty_string_s; } ghostty_string_s;
typedef struct { typedef struct {

View File

@ -54,6 +54,8 @@
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
A53F889C2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53F889B2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift */; };
A53F889E2EAA932B00F1C56B /* UUID+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53F889D2EAA932B00F1C56B /* UUID+Extension.swift */; };
A546F1142D7B68D7003B11A0 /* locale in Resources */ = {isa = PBXBuildFile; fileRef = A546F1132D7B68D7003B11A0 /* locale */; }; A546F1142D7B68D7003B11A0 /* locale in Resources */ = {isa = PBXBuildFile; fileRef = A546F1132D7B68D7003B11A0 /* locale */; };
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */; }; A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */; };
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */; }; A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */; };
@ -143,6 +145,8 @@
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; }; A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; };
A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; }; A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; };
A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; }; A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; };
A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */; };
A5FB3A882E942A1B00A919E5 /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */; };
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; };
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
@ -205,6 +209,8 @@
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; }; A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
A53F889B2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalScreenStateCache.swift; sourceTree = "<group>"; };
A53F889D2EAA932B00F1C56B /* UUID+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UUID+Extension.swift"; sourceTree = "<group>"; };
A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; }; A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; };
A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconView.swift; sourceTree = "<group>"; }; A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconView.swift; sourceTree = "<group>"; };
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extension.swift"; sourceTree = "<group>"; }; A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extension.swift"; sourceTree = "<group>"; };
@ -293,6 +299,7 @@
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; }; A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; };
A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; }; A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; };
A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; }; A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; };
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceProgressBar.swift; sourceTree = "<group>"; };
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
@ -492,6 +499,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A55B7BB729B6F53A0055DE60 /* Package.swift */, A55B7BB729B6F53A0055DE60 /* Package.swift */,
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */,
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */, A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */, A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */,
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */, A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
@ -561,6 +569,7 @@
A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */, A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */, A58636722DF4813000E04A10 /* UndoManager+Extension.swift */,
A53F889D2EAA932B00F1C56B /* UUID+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
); );
path = Extensions; path = Extensions;
@ -640,6 +649,7 @@
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */, CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */,
A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */, A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */,
A53F889B2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift */,
A5BB78B82DF9D8CE009AC3FA /* QuickTerminalSize.swift */, A5BB78B82DF9D8CE009AC3FA /* QuickTerminalSize.swift */,
A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */,
); );
@ -892,6 +902,7 @@
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */,
A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */, A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
@ -903,6 +914,7 @@
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
A53F889C2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift in Sources */,
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */, A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */, A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */,
@ -947,6 +959,7 @@
A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */, A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */,
A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */,
A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */, A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */,
A53F889E2EAA932B00F1C56B /* UUID+Extension.swift in Sources */,
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */,
@ -986,6 +999,7 @@
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */, A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */, A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */, A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
A5FB3A882E942A1B00A919E5 /* SurfaceProgressBar.swift in Sources */,
A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */, A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */,
A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */, A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */,

View File

@ -471,7 +471,12 @@ class AppDelegate: NSObject,
} }
switch ghostty.config.macosDockDropBehavior { switch ghostty.config.macosDockDropBehavior {
case .new_tab: _ = TerminalController.newTab(ghostty, withBaseConfig: config) case .new_tab:
_ = TerminalController.newTab(
ghostty,
from: TerminalController.preferredParent?.window,
withBaseConfig: config
)
case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config) case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
} }
@ -860,6 +865,11 @@ class AppDelegate: NSObject,
} else { } else {
GlobalEventTap.shared.disable() GlobalEventTap.shared.disable()
} }
}
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance(config: Ghostty.Config) {
NSApplication.shared.appearance = .init(ghosttyConfig: config)
switch (config.macosIcon) { switch (config.macosIcon) {
case .official: case .official:
@ -909,11 +919,6 @@ class AppDelegate: NSObject,
} }
} }
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance(config: Ghostty.Config) {
NSApplication.shared.appearance = .init(ghosttyConfig: config)
}
//MARK: - Restorable State //MARK: - Restorable State
/// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways. /// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways.
@ -1012,7 +1017,10 @@ class AppDelegate: NSObject,
} }
@IBAction func newTab(_ sender: Any?) { @IBAction func newTab(_ sender: Any?) {
_ = TerminalController.newTab(ghostty) _ = TerminalController.newTab(
ghostty,
from: TerminalController.preferredParent?.window
)
} }
@IBAction func closeAllWindows(_ sender: Any?) { @IBAction func closeAllWindows(_ sender: Any?) {

View File

@ -43,11 +43,13 @@ struct NewTerminalIntent: AppIntent {
) )
var parent: TerminalEntity? var parent: TerminalEntity?
// Performing in the background can avoid opening multiple windows at the same time
// using `foreground` will cause `perform` and `AppDelegate.applicationDidBecomeActive(_:)`/`AppDelegate.applicationShouldHandleReopen(_:hasVisibleWindows:)` running at the 'same' time
@available(macOS 26.0, *) @available(macOS 26.0, *)
static var supportedModes: IntentModes = .foreground(.immediate) static var supportedModes: IntentModes = .background
@available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes") @available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes")
static var openAppWhenRun = true static var openAppWhenRun = false
@MainActor @MainActor
func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> { func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> {
@ -96,6 +98,11 @@ struct NewTerminalIntent: AppIntent {
parent = nil parent = nil
} }
defer {
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
}
}
switch location { switch location {
case .window: case .window:
let newController = TerminalController.newWindow( let newController = TerminalController.newWindow(
@ -123,7 +130,8 @@ struct NewTerminalIntent: AppIntent {
if let view = controller.newSplit( if let view = controller.newSplit(
at: parent, at: parent,
direction: location.splitDirection! direction: location.splitDirection!,
baseConfig: config
) { ) {
return .result(value: TerminalEntity(view)) return .result(value: TerminalEntity(view))
} }

View File

@ -51,12 +51,8 @@ struct ClipboardConfirmationView: View {
.padding() .padding()
} }
ScrollView { TextEditor(text: .constant(contents))
Text(contents)
.textSelection(.enabled)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
.padding(.all, 4)
}
HStack { HStack {
Spacer() Spacer()

View File

@ -21,13 +21,8 @@ class QuickTerminalController: BaseTerminalController {
// The active space when the quick terminal was last shown. // The active space when the quick terminal was last shown.
private var previousActiveSpace: CGSSpace? = nil private var previousActiveSpace: CGSSpace? = nil
/// The window frame saved when the quick terminal's surface tree becomes empty. /// Cache for per-screen window state.
/// private let screenStateCache = QuickTerminalScreenStateCache()
/// This preserves the user's window size and position when all terminal surfaces
/// are closed (e.g., via the `exit` command). When a new surface is created,
/// the window will be restored to this frame, preventing SwiftUI from resetting
/// the window to its default minimum size.
private var lastClosedFrame: NSRect? = nil
/// Non-nil if we have hidden dock state. /// Non-nil if we have hidden dock state.
private var hiddenDock: HiddenDock? = nil private var hiddenDock: HiddenDock? = nil
@ -359,16 +354,15 @@ class QuickTerminalController: BaseTerminalController {
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return } guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
// Grab our last closed frame to use, and clear our state since we're animating in. // Grab our last closed frame to use from the cache.
let lastClosedFrame = self.lastClosedFrame let closedFrame = screenStateCache.frame(for: screen)
self.lastClosedFrame = nil
// Move our window off screen to the initial animation position. // Move our window off screen to the initial animation position.
position.setInitial( position.setInitial(
in: window, in: window,
on: screen, on: screen,
terminalSize: derivedConfig.quickTerminalSize, terminalSize: derivedConfig.quickTerminalSize,
closedFrame: lastClosedFrame) closedFrame: closedFrame)
// We need to set our window level to a high value. In testing, only // We need to set our window level to a high value. In testing, only
// popUpMenu and above do what we want. This gets it above the menu bar // popUpMenu and above do what we want. This gets it above the menu bar
@ -403,7 +397,7 @@ class QuickTerminalController: BaseTerminalController {
in: window.animator(), in: window.animator(),
on: screen, on: screen,
terminalSize: derivedConfig.quickTerminalSize, terminalSize: derivedConfig.quickTerminalSize,
closedFrame: lastClosedFrame) closedFrame: closedFrame)
}, completionHandler: { }, completionHandler: {
// There is a very minor delay here so waiting at least an event loop tick // There is a very minor delay here so waiting at least an event loop tick
// keeps us safe from the view not being on the window. // keeps us safe from the view not being on the window.
@ -491,8 +485,8 @@ class QuickTerminalController: BaseTerminalController {
// the user's preferred window size and position for when the quick // the user's preferred window size and position for when the quick
// terminal is reactivated with a new surface. Without this, SwiftUI // terminal is reactivated with a new surface. Without this, SwiftUI
// would reset the window to its minimum content size. // would reset the window to its minimum content size.
if window.frame.width > 0 && window.frame.height > 0 { if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
lastClosedFrame = window.frame screenStateCache.save(frame: window.frame, for: screen)
} }
// If we hid the dock then we unhide it. // If we hid the dock then we unhide it.
@ -577,7 +571,6 @@ class QuickTerminalController: BaseTerminalController {
alert.alertStyle = .warning alert.alertStyle = .warning
alert.beginSheetModal(for: window) alert.beginSheetModal(for: window)
} }
// MARK: First Responder // MARK: First Responder
@IBAction override func closeWindow(_ sender: Any) { @IBAction override func closeWindow(_ sender: Any) {

View File

@ -0,0 +1,113 @@
import Foundation
import Cocoa
/// Manages cached window state per screen for the quick terminal.
///
/// This cache tracks the last closed window frame for each screen, allowing the quick terminal
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
/// to survive NSScreen garbage collection and automatically prunes stale entries.
class QuickTerminalScreenStateCache {
/// The maximum number of saved screen states we retain. This is to avoid some kind of
/// pathological memory growth in case we get our screen state serializing wrong. I don't
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
private static let maxSavedScreens = 10
/// Time-to-live for screen entries that are no longer present (14 days).
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
/// Keyed by display UUID to survive NSScreen garbage collection.
private var stateByDisplay: [UUID: DisplayEntry] = [:]
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(onScreensChanged(_:)),
name: NSApplication.didChangeScreenParametersNotification,
object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// Save the window frame for a screen.
func save(frame: NSRect, for screen: NSScreen) {
guard let key = screen.displayUUID else { return }
let entry = DisplayEntry(
frame: frame,
screenSize: screen.frame.size,
scale: screen.backingScaleFactor,
lastSeen: Date()
)
stateByDisplay[key] = entry
pruneCapacity()
}
/// Retrieve the last closed frame for a screen, if valid.
func frame(for screen: NSScreen) -> NSRect? {
guard let key = screen.displayUUID, var entry = stateByDisplay[key] else { return nil }
// Drop on dimension/scale change that makes the entry invalid
if !entry.isValid(for: screen) {
stateByDisplay.removeValue(forKey: key)
return nil
}
entry.lastSeen = Date()
stateByDisplay[key] = entry
return entry.frame
}
@objc private func onScreensChanged(_ note: Notification) {
let screens = NSScreen.screens
let now = Date()
let currentIDs = Set(screens.compactMap { $0.displayUUID })
for screen in screens {
guard let key = screen.displayUUID else { continue }
if var entry = stateByDisplay[key] {
// Drop on dimension/scale change that makes the entry invalid
if !entry.isValid(for: screen) {
stateByDisplay.removeValue(forKey: key)
} else {
// Update the screen size if it grew (keep entry valid for larger screens)
entry.screenSize = screen.frame.size
entry.lastSeen = now
stateByDisplay[key] = entry
}
}
}
// TTL prune for non-present screens
stateByDisplay = stateByDisplay.filter { key, entry in
currentIDs.contains(key) || now.timeIntervalSince(entry.lastSeen) < Self.screenStaleTTL
}
pruneCapacity()
}
private func pruneCapacity() {
guard stateByDisplay.count > Self.maxSavedScreens else { return }
let toRemove = stateByDisplay
.sorted { $0.value.lastSeen < $1.value.lastSeen }
.prefix(stateByDisplay.count - Self.maxSavedScreens)
for (key, _) in toRemove {
stateByDisplay.removeValue(forKey: key)
}
}
private struct DisplayEntry {
var frame: NSRect
var screenSize: CGSize
var scale: CGFloat
var lastSeen: Date
/// Returns true if this entry is still valid for the given screen.
/// Valid if the scale matches and the cached size is not larger than the current screen size.
/// This allows entries to persist when screens grow, but invalidates them when screens shrink.
func isValid(for screen: NSScreen) -> Bool {
guard scale == screen.backingScaleFactor else { return false }
return screenSize.width <= screen.frame.size.width && screenSize.height <= screen.frame.size.height
}
}
}

View File

@ -55,7 +55,10 @@ class ServiceProvider: NSObject {
_ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config) _ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config)
case .tab: case .tab:
_ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config) _ = TerminalController.newTab(
delegate.ghostty,
from: TerminalController.preferredParent?.window,
withBaseConfig: config)
} }
} }

View File

@ -552,22 +552,11 @@ class BaseTerminalController: NSWindowController,
guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return } guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return }
guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return } guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return }
// Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection
let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection
switch direction {
case .previous: focusDirection = .previous
case .next: focusDirection = .next
case .up: focusDirection = .spatial(.up)
case .down: focusDirection = .spatial(.down)
case .left: focusDirection = .spatial(.left)
case .right: focusDirection = .spatial(.right)
}
// Find the node for the target surface // Find the node for the target surface
guard let targetNode = surfaceTree.root?.node(view: target) else { return } guard let targetNode = surfaceTree.root?.node(view: target) else { return }
// Find the next surface to focus // Find the next surface to focus
guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else { guard let nextSurface = surfaceTree.focusTarget(for: direction.toSplitTreeFocusDirection(), from: targetNode) else {
return return
} }
@ -688,6 +677,8 @@ class BaseTerminalController: NSWindowController,
surfaceTree.contains(titleSurface) { surfaceTree.contains(titleSurface) {
// If we have a surface, we want to listen for title changes. // If we have a surface, we want to listen for title changes.
titleSurface.$title titleSurface.$title
.combineLatest(titleSurface.$bell)
.map { [weak self] in self?.computeTitle(title: $0, bell: $1) ?? "" }
.sink { [weak self] in self?.titleDidChange(to: $0) } .sink { [weak self] in self?.titleDidChange(to: $0) }
.store(in: &focusedSurfaceCancellables) .store(in: &focusedSurfaceCancellables)
} else { } else {
@ -696,7 +687,16 @@ class BaseTerminalController: NSWindowController,
} }
} }
func titleDidChange(to: String) { private func computeTitle(title: String, bell: Bool) -> String {
var result = title
if (bell && ghostty.config.bellFeatures.contains(.title)) {
result = "🔔 \(result)"
}
return result
}
private func titleDidChange(to: String) {
guard let window else { return } guard let window else { return }
// Set the main window title // Set the main window title
@ -717,6 +717,10 @@ class BaseTerminalController: NSWindowController,
func cellSizeDidChange(to: NSSize) { func cellSizeDidChange(to: NSSize) {
guard derivedConfig.windowStepResize else { return } guard derivedConfig.windowStepResize else { return }
// Stage manager can sometimes present windows in such a way that the
// cell size is temporarily zero due to the window being tiny. We can't
// set content resize increments to this value, so avoid an assertion failure.
guard to.width > 0 && to.height > 0 else { return }
self.window?.contentResizeIncrements = to self.window?.contentResizeIncrements = to
} }
@ -863,14 +867,6 @@ class BaseTerminalController: NSWindowController,
// Everything beyond here is setting up the window // Everything beyond here is setting up the window
guard let window else { return } guard let window else { return }
// If there is a hardcoded title in the configuration, we set that
// immediately. Future `set_title` apprt actions will override this
// if necessary but this ensures our window loads with the proper
// title immediately rather than on another event loop tick (see #5934)
if let title = derivedConfig.title {
window.title = title
}
// We always initialize our fullscreen style to native if we can because // We always initialize our fullscreen style to native if we can because
// initialization sets up some state (i.e. observers). If its set already // initialization sets up some state (i.e. observers). If its set already
// somehow we don't do this. // somehow we don't do this.
@ -1072,20 +1068,17 @@ class BaseTerminalController: NSWindowController,
} }
private struct DerivedConfig { private struct DerivedConfig {
let title: String?
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
let windowStepResize: Bool let windowStepResize: Bool
let focusFollowsMouse: Bool let focusFollowsMouse: Bool
init() { init() {
self.title = nil
self.macosTitlebarProxyIcon = .visible self.macosTitlebarProxyIcon = .visible
self.windowStepResize = false self.windowStepResize = false
self.focusFollowsMouse = false self.focusFollowsMouse = false
} }
init(_ config: Ghostty.Config) { init(_ config: Ghostty.Config) {
self.title = config.title
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
self.windowStepResize = config.windowStepResize self.windowStepResize = config.windowStepResize
self.focusFollowsMouse = config.focusFollowsMouse self.focusFollowsMouse = config.focusFollowsMouse

View File

@ -184,9 +184,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
static var preferredParent: TerminalController? { static var preferredParent: TerminalController? {
all.first { all.first {
$0.window?.isMainWindow ?? false $0.window?.isMainWindow ?? false
} ?? all.last } ?? lastMain ?? all.last
} }
// The last controller to be main. We use this when paired with "preferredParent"
// to find the preferred window to attach new tabs, perform actions, etc. We
// always prefer the main window but if there isn't any (because we're triggered
// by something like an App Intent) then we prefer the most previous main.
static private(set) weak var lastMain: TerminalController? = nil
/// The "new window" action. /// The "new window" action.
static func newWindow( static func newWindow(
_ ghostty: Ghostty.App, _ ghostty: Ghostty.App,
@ -521,7 +527,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
return frame return adjustForWindowPosition(frame: frame, on: screen)
} }
guard let initialFrame else { return nil } guard let initialFrame else { return nil }
@ -539,7 +545,30 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
return frame return adjustForWindowPosition(frame: frame, on: screen)
}
/// Adjusts the given frame for the configured window position.
func adjustForWindowPosition(frame: NSRect, on screen: NSScreen) -> NSRect {
guard let x = derivedConfig.windowPositionX else { return frame }
guard let y = derivedConfig.windowPositionY else { return frame }
// Convert top-left coordinates to bottom-left origin using our utility extension
let origin = screen.origin(
fromTopLeftOffsetX: CGFloat(x),
offsetY: CGFloat(y),
windowSize: frame.size)
// Clamp the origin to ensure the window stays fully visible on screen
var safeOrigin = origin
let vf = screen.visibleFrame
safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width)
safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height)
// Return our new origin
var result = frame
result.origin = safeOrigin
return result
} }
/// This is called anytime a node in the surface tree is being removed. /// This is called anytime a node in the surface tree is being removed.
@ -1036,6 +1065,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
if let window { if let window {
LastWindowPosition.shared.save(window) LastWindowPosition.shared.save(window)
} }
// Remember our last main
Self.lastMain = self
} }
// Called when the window will be encoded. We handle the data encoding here in the // Called when the window will be encoded. We handle the data encoding here in the
@ -1349,12 +1381,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
let macosWindowButtons: Ghostty.MacOSWindowButtons let macosWindowButtons: Ghostty.MacOSWindowButtons
let macosTitlebarStyle: String let macosTitlebarStyle: String
let maximize: Bool let maximize: Bool
let windowPositionX: Int16?
let windowPositionY: Int16?
init() { init() {
self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundColor = Color(NSColor.windowBackgroundColor)
self.macosWindowButtons = .visible self.macosWindowButtons = .visible
self.macosTitlebarStyle = "system" self.macosTitlebarStyle = "system"
self.maximize = false self.maximize = false
self.windowPositionX = nil
self.windowPositionY = nil
} }
init(_ config: Ghostty.Config) { init(_ config: Ghostty.Config) {
@ -1362,6 +1398,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
self.macosWindowButtons = config.macosWindowButtons self.macosWindowButtons = config.macosWindowButtons
self.macosTitlebarStyle = config.macosTitlebarStyle self.macosTitlebarStyle = config.macosTitlebarStyle
self.maximize = config.maximize self.maximize = config.maximize
self.windowPositionX = config.windowPositionX
self.windowPositionY = config.windowPositionY
} }
} }
} }

View File

@ -50,6 +50,14 @@ class TerminalWindow: NSWindow {
// Setup our initial config // Setup our initial config
derivedConfig = .init(config) derivedConfig = .init(config)
// If there is a hardcoded title in the configuration, we set that
// immediately. Future `set_title` apprt actions will override this
// if necessary but this ensures our window loads with the proper
// title immediately rather than on another event loop tick (see #5934)
if let title = derivedConfig.title {
self.title = title
}
// If window decorations are disabled, remove our title // If window decorations are disabled, remove our title
if (!config.windowDecorations) { styleMask.remove(.titled) } if (!config.windowDecorations) { styleMask.remove(.titled) }
@ -57,8 +65,7 @@ class TerminalWindow: NSWindow {
// fallback to original centering behavior // fallback to original centering behavior
setInitialWindowPosition( setInitialWindowPosition(
x: config.windowPositionX, x: config.windowPositionX,
y: config.windowPositionY, y: config.windowPositionY)
windowDecorations: config.windowDecorations)
// If our traffic buttons should be hidden, then hide them // If our traffic buttons should be hidden, then hide them
if config.macosWindowButtons == .hidden { if config.macosWindowButtons == .hidden {
@ -116,6 +123,12 @@ class TerminalWindow: NSWindow {
} else { } else {
tabBarDidDisappear() tabBarDidDisappear()
} }
viewModel.isMainWindow = true
}
override func resignMain() {
super.resignMain()
viewModel.isMainWindow = false
} }
override func mergeAllWindows(_ sender: Any?) { override func mergeAllWindows(_ sender: Any?) {
@ -156,9 +169,16 @@ class TerminalWindow: NSWindow {
/// Returns true if there is a tab bar visible on this window. /// Returns true if there is a tab bar visible on this window.
var hasTabBar: Bool { var hasTabBar: Bool {
// TODO: use titlebarView to find it instead
contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil
} }
var hasMoreThanOneTabs: Bool {
/// accessing ``tabGroup?.windows`` here
/// will cause other edge cases, be careful
(tabbedWindows?.count ?? 0) > 1
}
func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool { func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool {
if childViewController.identifier == nil { if childViewController.identifier == nil {
// The good case // The good case
@ -252,7 +272,7 @@ class TerminalWindow: NSWindow {
button.isBordered = false button.isBordered = false
button.allowsExpansionToolTips = true button.allowsExpansionToolTips = true
button.toolTip = "Reset Zoom" button.toolTip = "Reset Zoom"
button.contentTintColor = .controlAccentColor button.contentTintColor = isMainWindow ? .controlAccentColor : .secondaryLabelColor
button.state = .on button.state = .on
button.image = NSImage(named:"ResetZoom") button.image = NSImage(named:"ResetZoom")
button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) button.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
@ -269,6 +289,12 @@ class TerminalWindow: NSWindow {
// Whenever we change the window title we must also update our // Whenever we change the window title we must also update our
// tab title if we're using custom fonts. // tab title if we're using custom fonts.
tab.attributedTitle = attributedTitle tab.attributedTitle = attributedTitle
/// We also needs to update this here, just in case
/// the value is not what we want
///
/// Check ``titlebarFont`` down below
/// to see why we need to check `hasMoreThanOneTabs` here
titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs
} }
} }
@ -278,6 +304,12 @@ class TerminalWindow: NSWindow {
let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
titlebarTextField?.font = font titlebarTextField?.font = font
/// We check `hasMoreThanOneTabs` here because the system
/// may copy this setting to the tabs text field at some point(e.g. entering/exiting fullscreen),
/// which can cause the title to be vertically misaligned (shifted downward).
///
/// This behaviour is the opposite of what happens in the title bars text field, which is quite odd...
titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs
tab.attributedTitle = attributedTitle tab.attributedTitle = attributedTitle
} }
} }
@ -392,7 +424,7 @@ class TerminalWindow: NSWindow {
return derivedConfig.backgroundColor.withAlphaComponent(alpha) return derivedConfig.backgroundColor.withAlphaComponent(alpha)
} }
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { private func setInitialWindowPosition(x: Int16?, y: Int16?) {
// If we don't have an X/Y then we try to use the previously saved window pos. // If we don't have an X/Y then we try to use the previously saved window pos.
guard let x, let y else { guard let x, let y else {
if (!LastWindowPosition.shared.restore(self)) { if (!LastWindowPosition.shared.restore(self)) {
@ -408,11 +440,14 @@ class TerminalWindow: NSWindow {
return return
} }
// Orient based on the top left of the primary monitor // We have an X/Y, use our controller function to set it up.
let frame = screen.visibleFrame guard let terminalController else {
setFrameOrigin(.init( center()
x: frame.minX + CGFloat(x), return
y: frame.maxY - (CGFloat(y) + frame.height))) }
let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen)
setFrameOrigin(frame.origin)
} }
private func hideWindowButtons() { private func hideWindowButtons() {
@ -424,17 +459,20 @@ class TerminalWindow: NSWindow {
// MARK: Config // MARK: Config
struct DerivedConfig { struct DerivedConfig {
let title: String?
let backgroundColor: NSColor let backgroundColor: NSColor
let backgroundOpacity: Double let backgroundOpacity: Double
let macosWindowButtons: Ghostty.MacOSWindowButtons let macosWindowButtons: Ghostty.MacOSWindowButtons
init() { init() {
self.title = nil
self.backgroundColor = NSColor.windowBackgroundColor self.backgroundColor = NSColor.windowBackgroundColor
self.backgroundOpacity = 1 self.backgroundOpacity = 1
self.macosWindowButtons = .visible self.macosWindowButtons = .visible
} }
init(_ config: Ghostty.Config) { init(_ config: Ghostty.Config) {
self.title = config.title
self.backgroundColor = NSColor(config.backgroundColor) self.backgroundColor = NSColor(config.backgroundColor)
self.backgroundOpacity = config.backgroundOpacity self.backgroundOpacity = config.backgroundOpacity
self.macosWindowButtons = config.macosWindowButtons self.macosWindowButtons = config.macosWindowButtons
@ -448,6 +486,7 @@ extension TerminalWindow {
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
@Published var isSurfaceZoomed: Bool = false @Published var isSurfaceZoomed: Bool = false
@Published var hasToolbar: Bool = false @Published var hasToolbar: Bool = false
@Published var isMainWindow: Bool = true
} }
struct ResetZoomAccessoryView: View { struct ResetZoomAccessoryView: View {
@ -469,7 +508,7 @@ extension TerminalWindow {
VStack { VStack {
Button(action: action) { Button(action: action) {
Image("ResetZoom") Image("ResetZoom")
.foregroundColor(.accentColor) .foregroundColor(viewModel.isMainWindow ? .accentColor : .secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Reset Split Zoom") .help("Reset Split Zoom")

View File

@ -15,9 +15,21 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
// MARK: NSWindow // MARK: NSWindow
override var titlebarFont: NSFont? {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.viewModel.titleFont = self.titlebarFont
}
}
}
override var title: String { override var title: String {
didSet { didSet {
viewModel.title = title DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.viewModel.title = self.title
}
} }
} }
@ -42,17 +54,33 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
// Check if we have a tab bar and set it up if we have to. See the comment // Check if we have a tab bar and set it up if we have to. See the comment
// on this function to learn why we need to check this here. // on this function to learn why we need to check this here.
setupTabBar() setupTabBar()
viewModel.isMainWindow = true
} }
override func resignMain() {
super.resignMain()
viewModel.isMainWindow = false
}
// This is called by macOS for native tabbing in order to add the tab bar. We hook into // This is called by macOS for native tabbing in order to add the tab bar. We hook into
// this, detect the tab bar being added, and override its behavior. // this, detect the tab bar being added, and override its behavior.
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
// If this is the tab bar then we need to set it up for the titlebar // If this is the tab bar then we need to set it up for the titlebar
guard isTabBar(childViewController) else { guard isTabBar(childViewController) else {
// After dragging a tab into a new window, `hasTabBar` needs to be
// updated to properly review window title
viewModel.hasTabBar = false
super.addTitlebarAccessoryViewController(childViewController) super.addTitlebarAccessoryViewController(childViewController)
return return
} }
// When an existing tab is being dragged in to another tab group,
// system will also try to add tab bar to this window, so we want to reset observer,
// to put tab bar where we want again
tabBarObserver = nil
// Some setup needs to happen BEFORE it is added, such as layout. If // Some setup needs to happen BEFORE it is added, such as layout. If
// we don't do this before the call below, we'll trigger an AppKit // we don't do this before the call below, we'll trigger an AppKit
// assertion. // assertion.
@ -112,19 +140,34 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
guard tabBarObserver == nil else { return } guard tabBarObserver == nil else { return }
// Find our tab bar. If it doesn't exist we don't do anything. // Find our tab bar. If it doesn't exist we don't do anything.
guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return } //
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
guard let themeFrameView = contentView?.rootView else { return }
let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) {
themeFrameView.value(forKey: "titlebarView") as? NSView
} else {
NSView?.none
}
guard let tabBar = titlebarView?.firstDescendant(withClassName: "NSTabBar") else { return }
// View model updates must happen on their own ticks. // View model updates must happen on their own ticks.
DispatchQueue.main.async { DispatchQueue.main.async { [weak self] in
self.viewModel.hasTabBar = true self?.viewModel.hasTabBar = true
} }
// Find our clip view // Find our clip view
guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
guard let accessoryView = clipView.subviews[safe: 0] else { return } guard let accessoryView = clipView.subviews[safe: 0] else { return }
guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } guard let titlebarView else { return }
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
// Make sure tabBar's height won't be stretched
guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return }
tabBar.frame.size.height = newTabButton.frame.width
// The container is the view that we'll constrain our tab bar within. // The container is the view that we'll constrain our tab bar within.
let container = toolbarView let container = toolbarView
@ -205,6 +248,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
case .title: case .title:
let item = NSToolbarItem(itemIdentifier: .title) let item = NSToolbarItem(itemIdentifier: .title)
item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel)) item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel))
// Fix: https://github.com/ghostty-org/ghostty/discussions/9027
item.view?.setContentCompressionResistancePriority(.required, for: .horizontal)
item.visibilityPriority = .user item.visibilityPriority = .user
item.isEnabled = true item.isEnabled = true
@ -221,8 +266,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
// MARK: SwiftUI // MARK: SwiftUI
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
@Published var titleFont: NSFont?
@Published var title: String = "👻 Ghostty" @Published var title: String = "👻 Ghostty"
@Published var hasTabBar: Bool = false @Published var hasTabBar: Bool = false
@Published var isMainWindow: Bool = true
} }
} }
@ -245,15 +292,24 @@ extension TitlebarTabsTahoeTerminalWindow {
var body: some View { var body: some View {
if !viewModel.hasTabBar { if !viewModel.hasTabBar {
Text(title) titleText
.lineLimit(1)
.truncationMode(.tail)
} else { } else {
// 1x1.gif strikes again! For real: if we render a zero-sized // 1x1.gif strikes again! For real: if we render a zero-sized
// view here then the toolbar just disappears our view. I don't // view here then the toolbar just disappears our view. I don't
// know. // know. This appears fixed in 26.1 Beta but keep it safe for 26.0.
Color.clear.frame(width: 1, height: 1) Color.clear.frame(width: 1, height: 1)
} }
} }
@ViewBuilder
var titleText: some View {
Text(title)
.font(viewModel.titleFont.flatMap(Font.init(_:)))
.foregroundStyle(viewModel.isMainWindow ? .primary : .secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .greatestFiniteMagnitude, alignment: .center)
.opacity(viewModel.hasTabBar ? 0 : 1) // hide when in fullscreen mode, where title bar will appear in the leading area under window buttons
}
} }
} }

View File

@ -141,6 +141,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
super.syncAppearance(surfaceConfig) super.syncAppearance(surfaceConfig)
// Update our window light/darkness based on our updated background color // Update our window light/darkness based on our updated background color
let themeChanged = isLightTheme != OSColor(surfaceConfig.backgroundColor).isLightColor
isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
// Update our titlebar color // Update our titlebar color
@ -150,7 +151,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity)
} }
if (isOpaque) { if (isOpaque || themeChanged) {
// If there is transparency, calling this will make the titlebar opaque // If there is transparency, calling this will make the titlebar opaque
// so we only call this if we are opaque. // so we only call this if we are opaque.
updateTabBar() updateTabBar()
@ -183,41 +184,33 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
// so we need to do it manually. // so we need to do it manually.
private func updateNewTabButtonOpacity() { private func updateNewTabButtonOpacity() {
guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { guard let newTabButtonImageView = newTabButton.firstDescendant(withClassName: "NSImageView") as? NSImageView else { return }
$0 as? NSImageView != nil
}) as? NSImageView else { return }
newTabButtonImageView.alphaValue = isKeyWindow ? 1 : 0.5 newTabButtonImageView.alphaValue = isKeyWindow ? 1 : 0.5
} }
// Color the new tab button's image to match the color of the tab title/keyboard shortcut labels, /// Update: This method only add a vibrant overlay now,
// just as it does in the stock tab bar. /// since the image itself supports light/dark tint,
/// and system could restore it any time,
/// altering it will only cause maintenance burden for us.
///
/// And if we hide original image,
/// ``updateNewTabButtonOpacity`` will not work
///
/// ~~Color the new tab button's image to match the color of the tab title/keyboard shortcut labels,~~
/// ~~just as it does in the stock tab bar.~~
private func updateNewTabButtonImage() { private func updateNewTabButtonImage() {
guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { guard let newTabButtonImageView = newTabButton.firstDescendant(withClassName: "NSImageView") as? NSImageView else { return }
$0 as? NSImageView != nil
}) as? NSImageView else { return }
guard let newTabButtonImage = newTabButtonImageView.image else { return } guard let newTabButtonImage = newTabButtonImageView.image else { return }
let imageLayer = newTabButtonImageLayer ?? VibrantLayer(forAppearance: isLightTheme ? .light : .dark)!
if newTabButtonImageLayer == nil {
let fillColor: NSColor = isLightTheme ? .black.withAlphaComponent(0.85) : .white.withAlphaComponent(0.85)
let newImage = NSImage(size: newTabButtonImage.size, flipped: false) { rect in
newTabButtonImage.draw(in: rect)
fillColor.setFill()
rect.fill(using: .sourceAtop)
return true
}
let imageLayer = VibrantLayer(forAppearance: isLightTheme ? .light : .dark)!
imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size) imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size)
imageLayer.contentsGravity = .resizeAspect imageLayer.contentsGravity = .resizeAspect
imageLayer.contents = newImage
imageLayer.opacity = 0.5 imageLayer.opacity = 0.5
newTabButtonImageLayer = imageLayer newTabButtonImageLayer = imageLayer
}
newTabButtonImageView.isHidden = true
newTabButton.layer?.sublayers?.first(where: { $0.className == "VibrantLayer" })?.removeFromSuperlayer() newTabButton.layer?.sublayers?.first(where: { $0.className == "VibrantLayer" })?.removeFromSuperlayer()
newTabButton.layer?.addSublayer(newTabButtonImageLayer!) newTabButton.layer?.addSublayer(newTabButtonImageLayer!)
} }
@ -448,6 +441,13 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
} }
private func addWindowButtonsBackdrop(titlebarView: NSView, toolbarView: NSView) { private func addWindowButtonsBackdrop(titlebarView: NSView, toolbarView: NSView) {
guard windowButtonsBackdrop?.superview != titlebarView else {
/// replacing existing backdrop aggressively
/// may cause incorrect hierarchy
///
/// because multiple windows are adding this around the 'same time'
return
}
windowButtonsBackdrop?.removeFromSuperview() windowButtonsBackdrop?.removeFromSuperview()
windowButtonsBackdrop = nil windowButtonsBackdrop = nil
@ -466,16 +466,11 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
private func addWindowDragHandle(titlebarView: NSView, toolbarView: NSView) { private func addWindowDragHandle(titlebarView: NSView, toolbarView: NSView) {
// If we already made the view, just make sure it's unhidden and correctly placed as a subview. // If we already made the view, just make sure it's unhidden and correctly placed as a subview.
if let view = windowDragHandle { guard windowDragHandle?.superview != titlebarView.superview else {
view.removeFromSuperview() // similar to `addWindowButtonsBackdrop`
view.isHidden = false
titlebarView.superview?.addSubview(view)
view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true
view.rightAnchor.constraint(equalTo: toolbarView.rightAnchor).isActive = true
view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
view.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: 12).isActive = true
return return
} }
windowDragHandle?.removeFromSuperview()
let view = WindowDragView() let view = WindowDragView()
view.identifier = NSUserInterfaceItemIdentifier("_windowDragHandle") view.identifier = NSUserInterfaceItemIdentifier("_windowDragHandle")
@ -536,7 +531,10 @@ fileprivate class WindowButtonsBackdropView: NSView {
// This must be weak because the window has this view. Otherwise // This must be weak because the window has this view. Otherwise
// a retain cycle occurs. // a retain cycle occurs.
private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow? private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow?
private let isLightTheme: Bool private var isLightTheme: Bool {
// using up-to-date value from hosting window directly
terminalWindow?.isLightTheme ?? false
}
private let overlayLayer = VibrantLayer() private let overlayLayer = VibrantLayer()
var isHighlighted: Bool = true { var isHighlighted: Bool = true {
@ -565,7 +563,6 @@ fileprivate class WindowButtonsBackdropView: NSView {
init(window: TitlebarTabsVenturaTerminalWindow) { init(window: TitlebarTabsVenturaTerminalWindow) {
self.terminalWindow = window self.terminalWindow = window
self.isLightTheme = window.isLightTheme
super.init(frame: .zero) super.init(frame: .zero)

View File

@ -99,10 +99,13 @@ extension Ghostty.Action {
let state: State let state: State
let progress: UInt8? let progress: UInt8?
}
}
// Putting the initializer in an extension preserves the automatic one.
extension Ghostty.Action.ProgressReport {
init(c: ghostty_action_progress_report_s) { init(c: ghostty_action_progress_report_s) {
self.state = State(c.state) self.state = State(c.state)
self.progress = c.progress >= 0 ? UInt8(c.progress) : nil self.progress = c.progress >= 0 ? UInt8(c.progress) : nil
} }
} }
}

View File

@ -624,10 +624,15 @@ extension Ghostty {
) -> Bool { ) -> Bool {
let action = Ghostty.Action.OpenURL(c: v) let action = Ghostty.Action.OpenURL(c: v)
// Convert the URL string to a URL object // If the URL doesn't have a valid scheme we assume its a file path. The URL
guard let url = URL(string: action.url) else { // initializer will gladly take invalid URLs (e.g. plain file paths) and turn
Ghostty.logger.warning("invalid URL for open URL action: \(action.url)") // them into schema-less URLs, but these won't open properly in text editors.
return false // See: https://github.com/ghostty-org/ghostty/issues/8763
let url: URL
if let candidate = URL(string: action.url), candidate.scheme != nil {
url = candidate
} else {
url = URL(filePath: action.url)
} }
switch action.kind { switch action.kind {
@ -1020,26 +1025,38 @@ extension Ghostty {
guard let surfaceView = self.surfaceView(from: surface) else { return false } guard let surfaceView = self.surfaceView(from: surface) else { return false }
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false } guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
// For now, we return false if the window has no splits and we return // If the window has no splits, the action is not performable
// true if the window has ANY splits. This isn't strictly correct because
// we should only be returning true if we actually performed the action,
// but this handles the most common case of caring about goto_split performability
// which is the no-split case.
guard controller.surfaceTree.isSplit else { return false } guard controller.surfaceTree.isSplit else { return false }
// Convert the C API direction to our Swift type
guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return false }
// Find the current node in the tree
guard let targetNode = controller.surfaceTree.root?.node(view: surfaceView) else { return false }
// Check if a split actually exists in the target direction before
// returning true. This ensures performable keybinds only consume
// the key event when we actually perform navigation.
let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection = splitDirection.toSplitTreeFocusDirection()
guard controller.surfaceTree.focusTarget(for: focusDirection, from: targetNode) != nil else {
return false
}
// We have a valid target, post the notification to perform the navigation
NotificationCenter.default.post( NotificationCenter.default.post(
name: Notification.ghosttyFocusSplit, name: Notification.ghosttyFocusSplit,
object: surfaceView, object: surfaceView,
userInfo: [ userInfo: [
Notification.SplitDirectionKey: SplitFocusDirection.from(direction: direction) as Any, Notification.SplitDirectionKey: splitDirection as Any,
] ]
) )
return true
default: default:
assertionFailure() assertionFailure()
return false
} }
return true
} }
private static func resizeSplit( private static func resizeSplit(

View File

@ -314,17 +314,14 @@ extension Ghostty {
var macosCustomIcon: String { var macosCustomIcon: String {
#if os(macOS) #if os(macOS)
let homeDirURL = FileManager.default.homeDirectoryForCurrentUser let defaultValue = NSString("~/.config/ghostty/Ghostty.icns").expandingTildeInPath
let ghosttyConfigIconPath = homeDirURL.appendingPathComponent(
".config/ghostty/Ghostty.icns",
conformingTo: .fileURL).path()
let defaultValue = ghosttyConfigIconPath
guard let config = self.config else { return defaultValue } guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = nil var v: UnsafePointer<Int8>? = nil
let key = "macos-custom-icon" let key = "macos-custom-icon"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
guard let ptr = v else { return defaultValue } guard let ptr = v else { return defaultValue }
return String(cString: ptr) guard let path = NSString(utf8String: ptr) else { return defaultValue }
return path.expandingTildeInPath
#else #else
return "" return ""
#endif #endif
@ -625,6 +622,7 @@ extension Ghostty.Config {
static let audio = BellFeatures(rawValue: 1 << 1) static let audio = BellFeatures(rawValue: 1 << 1)
static let attention = BellFeatures(rawValue: 1 << 2) static let attention = BellFeatures(rawValue: 1 << 2)
static let title = BellFeatures(rawValue: 1 << 3) static let title = BellFeatures(rawValue: 1 << 3)
static let border = BellFeatures(rawValue: 1 << 4)
} }
enum MacDockDropBehavior: String { enum MacDockDropBehavior: String {

View File

@ -223,7 +223,38 @@ extension Ghostty {
} }
} }
} }
}
#if canImport(AppKit)
// MARK: SplitFocusDirection Extensions
extension Ghostty.SplitFocusDirection {
/// Convert to a SplitTree.FocusDirection for the given ViewType.
func toSplitTreeFocusDirection<ViewType>() -> SplitTree<ViewType>.FocusDirection {
switch self {
case .previous:
return .previous
case .next:
return .next
case .up:
return .spatial(.up)
case .down:
return .spatial(.down)
case .left:
return .spatial(.left)
case .right:
return .spatial(.right)
}
}
}
#endif
extension Ghostty {
/// The type of a clipboard request /// The type of a clipboard request
enum ClipboardRequest { enum ClipboardRequest {
/// A direct paste of clipboard contents /// A direct paste of clipboard contents

View File

@ -0,0 +1,113 @@
import SwiftUI
/// The progress bar to show a surface progress report. We implement this from scratch because the
/// standard ProgressView is broken on macOS 26 and this is simple anyways and gives us a ton of
/// control.
struct SurfaceProgressBar: View {
let report: Ghostty.Action.ProgressReport
private var color: Color {
switch report.state {
case .error: return .red
case .pause: return .orange
default: return .accentColor
}
}
private var progress: UInt8? {
// If we have an explicit progress use that.
if let v = report.progress { return v }
// Otherwise, if we're in the pause state, we act as if we're at 100%.
if report.state == .pause { return 100 }
return nil
}
private var accessibilityLabel: String {
switch report.state {
case .error: return "Terminal progress - Error"
case .pause: return "Terminal progress - Paused"
case .indeterminate: return "Terminal progress - In progress"
default: return "Terminal progress"
}
}
private var accessibilityValue: String {
if let progress {
return "\(progress) percent complete"
} else {
switch report.state {
case .error: return "Operation failed"
case .pause: return "Operation paused at completion"
case .indeterminate: return "Operation in progress"
default: return "Indeterminate progress"
}
}
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
if let progress {
// Determinate progress bar with specific percentage
Rectangle()
.fill(color)
.frame(
width: geometry.size.width * CGFloat(progress) / 100,
height: geometry.size.height
)
.animation(.easeInOut(duration: 0.2), value: progress)
} else {
// Indeterminate states without specific progress - all use bouncing animation
BouncingProgressBar(color: color)
}
}
}
.frame(height: 2)
.clipped()
.allowsHitTesting(false)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.updatesFrequently)
.accessibilityLabel(accessibilityLabel)
.accessibilityValue(accessibilityValue)
}
}
/// Bouncing progress bar for indeterminate states
private struct BouncingProgressBar: View {
let color: Color
@State private var position: CGFloat = 0
private let barWidthRatio: CGFloat = 0.25
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(color.opacity(0.3))
Rectangle()
.fill(color)
.frame(
width: geometry.size.width * barWidthRatio,
height: geometry.size.height
)
.offset(x: position * (geometry.size.width * (1 - barWidthRatio)))
}
}
.onAppear {
withAnimation(
.easeInOut(duration: 1.2)
.repeatForever(autoreverses: true)
) {
position = 1
}
}
.onDisappear {
position = 0
}
}
}

View File

@ -57,15 +57,6 @@ extension Ghostty {
@EnvironmentObject private var ghostty: Ghostty.App @EnvironmentObject private var ghostty: Ghostty.App
var title: String {
var result = surfaceView.title
if (surfaceView.bell && ghostty.config.bellFeatures.contains(.title)) {
result = "🔔 \(result)"
}
return result
}
var body: some View { var body: some View {
let center = NotificationCenter.default let center = NotificationCenter.default
@ -114,9 +105,15 @@ extension Ghostty {
} }
.ghosttySurfaceView(surfaceView) .ghosttySurfaceView(surfaceView)
// Progress report overlay // Progress report
if let progressReport = surfaceView.progressReport { if let progressReport = surfaceView.progressReport, progressReport.state != .remove {
ProgressReportOverlay(report: progressReport) VStack(spacing: 0) {
SurfaceProgressBar(report: progressReport)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.allowsHitTesting(false)
.transition(.opacity)
} }
#if canImport(AppKit) #if canImport(AppKit)
@ -202,6 +199,11 @@ extension Ghostty {
} }
#endif #endif
// Show bell border if enabled
if (ghostty.config.bellFeatures.contains(.border)) {
BellBorderOverlay(bell: surfaceView.bell)
}
// If our surface is not healthy, then we render an error view over it. // If our surface is not healthy, then we render an error view over it.
if (!surfaceView.healthy) { if (!surfaceView.healthy) {
Rectangle().fill(ghostty.config.backgroundColor) Rectangle().fill(ghostty.config.backgroundColor)
@ -272,48 +274,7 @@ 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 // This is the resize overlay that shows on top of a surface to show the current
// size during a resize operation. // size during a resize operation.
@ -570,6 +531,22 @@ extension Ghostty {
} }
} }
/// Visual overlay that shows a border around the edges when the bell rings with border feature enabled.
struct BellBorderOverlay: View {
let bell: Bool
var body: some View {
Rectangle()
.strokeBorder(
Color(red: 1.0, green: 0.8, blue: 0.0).opacity(0.5),
lineWidth: 3
)
.allowsHitTesting(false)
.opacity(bell ? 1.0 : 0.0)
.animation(.easeInOut(duration: 0.3), value: bell)
}
}
#if canImport(AppKit) #if canImport(AppKit)
/// When changing the split state, or going full screen (native or non), the terminal view /// When changing the split state, or going full screen (native or non), the terminal view
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't

View File

@ -1815,18 +1815,39 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
forSendType sendType: NSPasteboard.PasteboardType?, forSendType sendType: NSPasteboard.PasteboardType?,
returnType: NSPasteboard.PasteboardType? returnType: NSPasteboard.PasteboardType?
) -> Any? { ) -> Any? {
// Types that we accept sent to us // This function confused me a bit so I'm going to add my own commentary on
let accepted: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")] // how this works. macOS sends this callback with the given send/return types and
// we must return the responder capable of handling the COMBINATION of those send
// and return types (or super up if we can't handle it).
//
// The "COMBINATION" bit is key: we might get sent a string (we can handle that)
// but get requested an image (we can't handle that at the time of writing this),
// so we must bubble up.
// We can always receive the accepted types // Types we can receive
if (returnType == nil || accepted.contains(returnType!)) { let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
return self
// Types that we can send. Currently the same as receivable but I'm separating
// this out so we can modify this in the future.
let sendable: [NSPasteboard.PasteboardType] = receivable
// The sendable types that require a selection (currently all)
let sendableRequiresSelection = sendable
// If we expect no data to be sent/received we can obviously handle it (that's
// the nil check), otherwise it must conform to the types we support on both sides.
if (returnType == nil || receivable.contains(returnType!)) &&
(sendType == nil || sendable.contains(sendType!)) {
// If we're expected to send back a type that requires selection, then
// verify that we have a selection. We do this within this block because
// validateRequestor is called a LOT and we want to prevent unnecessary
// performance hits because `ghostty_surface_has_selection` isn't free.
if let sendType, sendableRequiresSelection.contains(sendType) {
if surface == nil || !ghostty_surface_has_selection(surface) {
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
} }
// If we have a selection we can send the accepted types too
if ((self.surface != nil && ghostty_surface_has_selection(self.surface)) &&
(sendType == nil || accepted.contains(sendType!))
) {
return self return self
} }

View File

@ -6,6 +6,13 @@ extension NSScreen {
deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32
} }
/// The stable UUID for this display, suitable for tracking across reconnects and NSScreen garbage collection.
var displayUUID: UUID? {
guard let displayID = displayID else { return nil }
guard let cfuuid = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() else { return nil }
return UUID(cfuuid)
}
// Returns true if the given screen has a visible dock. This isn't // Returns true if the given screen has a visible dock. This isn't
// point-in-time visible, this is true if the dock is always visible // point-in-time visible, this is true if the dock is always visible
// AND present on this screen. // AND present on this screen.
@ -41,4 +48,20 @@ extension NSScreen {
// know any other situation this is true. // know any other situation this is true.
return safeAreaInsets.top > 0 return safeAreaInsets.top > 0
} }
/// Converts top-left offset coordinates to bottom-left origin coordinates for window positioning.
/// - Parameters:
/// - x: X offset from top-left corner
/// - y: Y offset from top-left corner
/// - windowSize: Size of the window to be positioned
/// - Returns: CGPoint suitable for setFrameOrigin that positions the window as requested
func origin(fromTopLeftOffsetX x: CGFloat, offsetY y: CGFloat, windowSize: CGSize) -> CGPoint {
let vf = visibleFrame
// Convert top-left coordinates to bottom-left origin
let originX = vf.minX + x
let originY = vf.maxY - y - windowSize.height
return CGPoint(x: originX, y: originY)
}
} }

View File

@ -0,0 +1,9 @@
import Foundation
extension UUID {
/// Initialize a UUID from a CFUUID.
init?(_ cfuuid: CFUUID) {
guard let uuidString = CFUUIDCreateString(nil, cfuuid) as String? else { return nil }
self.init(uuidString: uuidString)
}
}

View File

@ -0,0 +1,99 @@
//
// WindowPositionTests.swift
// GhosttyTests
//
// Tests for window positioning coordinate conversion functionality.
//
import Testing
import AppKit
@testable import Ghostty
struct NSScreenExtensionTests {
/// Test positive coordinate conversion from top-left to bottom-left
@Test func testPositiveCoordinateConversion() async throws {
// Mock screen with 1000x800 visible frame starting at (0, 100)
let mockScreenFrame = NSRect(x: 0, y: 100, width: 1000, height: 800)
let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame)
// Mock window size
let windowSize = CGSize(width: 400, height: 300)
// Test top-left positioning: x=15, y=15
let origin = mockScreen.origin(
fromTopLeftOffsetX: 15,
offsetY: 15,
windowSize: windowSize)
// Expected: x = 0 + 15 = 15, y = (100 + 800) - 15 - 300 = 585
#expect(origin.x == 15)
#expect(origin.y == 585)
}
/// Test zero coordinates (exact top-left corner)
@Test func testZeroCoordinates() async throws {
let mockScreenFrame = NSRect(x: 0, y: 100, width: 1000, height: 800)
let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame)
let windowSize = CGSize(width: 400, height: 300)
let origin = mockScreen.origin(
fromTopLeftOffsetX: 0,
offsetY: 0,
windowSize: windowSize)
// Expected: x = 0, y = (100 + 800) - 0 - 300 = 600
#expect(origin.x == 0)
#expect(origin.y == 600)
}
/// Test with offset screen (not starting at origin)
@Test func testOffsetScreen() async throws {
// Secondary monitor at position (1440, 0) with 1920x1080 resolution
let mockScreenFrame = NSRect(x: 1440, y: 0, width: 1920, height: 1080)
let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame)
let windowSize = CGSize(width: 600, height: 400)
let origin = mockScreen.origin(
fromTopLeftOffsetX: 100,
offsetY: 50,
windowSize: windowSize)
// Expected: x = 1440 + 100 = 1540, y = (0 + 1080) - 50 - 400 = 630
#expect(origin.x == 1540)
#expect(origin.y == 630)
}
/// Test large coordinates
@Test func testLargeCoordinates() async throws {
let mockScreenFrame = NSRect(x: 0, y: 0, width: 1920, height: 1080)
let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame)
let windowSize = CGSize(width: 400, height: 300)
let origin = mockScreen.origin(
fromTopLeftOffsetX: 500,
offsetY: 200,
windowSize: windowSize)
// Expected: x = 0 + 500 = 500, y = (0 + 1080) - 200 - 300 = 580
#expect(origin.x == 500)
#expect(origin.y == 580)
}
}
/// Mock NSScreen class for testing coordinate conversion
private class MockNSScreen: NSScreen {
private let mockVisibleFrame: NSRect
init(visibleFrame: NSRect) {
self.mockVisibleFrame = visibleFrame
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var visibleFrame: NSRect {
return mockVisibleFrame
}
}

View File

@ -40,7 +40,7 @@
in in
stdenv.mkDerivation (finalAttrs: { stdenv.mkDerivation (finalAttrs: {
pname = "ghostty"; pname = "ghostty";
version = "1.2.0"; version = "1.2.3";
# We limit source like this to try and reduce the amount of rebuilds as possible # We limit source like this to try and reduce the amount of rebuilds as possible
# thus we only provide the source that is needed for the build # thus we only provide the source that is needed for the build

View File

@ -9,6 +9,7 @@ pub const Library = @import("Library.zig");
pub const Error = errors.Error; pub const Error = errors.Error;
pub const Face = face.Face; pub const Face = face.Face;
pub const LoadFlags = face.LoadFlags;
pub const Tag = tag.Tag; pub const Tag = tag.Tag;
pub const mulFix = computations.mulFix; pub const mulFix = computations.mulFix;

View File

@ -4,6 +4,7 @@ const font_descriptor = @import("text/font_descriptor.zig");
const font_manager = @import("text/font_manager.zig"); const font_manager = @import("text/font_manager.zig");
const frame = @import("text/frame.zig"); const frame = @import("text/frame.zig");
const framesetter = @import("text/framesetter.zig"); const framesetter = @import("text/framesetter.zig");
const typesetter = @import("text/typesetter.zig");
const line = @import("text/line.zig"); const line = @import("text/line.zig");
const paragraph_style = @import("text/paragraph_style.zig"); const paragraph_style = @import("text/paragraph_style.zig");
const run = @import("text/run.zig"); const run = @import("text/run.zig");
@ -23,6 +24,7 @@ pub const createFontDescriptorsFromData = font_manager.createFontDescriptorsFrom
pub const createFontDescriptorFromData = font_manager.createFontDescriptorFromData; pub const createFontDescriptorFromData = font_manager.createFontDescriptorFromData;
pub const Frame = frame.Frame; pub const Frame = frame.Frame;
pub const Framesetter = framesetter.Framesetter; pub const Framesetter = framesetter.Framesetter;
pub const Typesetter = typesetter.Typesetter;
pub const Line = line.Line; pub const Line = line.Line;
pub const ParagraphStyle = paragraph_style.ParagraphStyle; pub const ParagraphStyle = paragraph_style.ParagraphStyle;
pub const ParagraphStyleSetting = paragraph_style.ParagraphStyleSetting; pub const ParagraphStyleSetting = paragraph_style.ParagraphStyleSetting;

View File

@ -15,10 +15,13 @@ pub const Run = opaque {
return @intCast(c.CTRunGetGlyphCount(@ptrCast(self))); return @intCast(c.CTRunGetGlyphCount(@ptrCast(self)));
} }
pub fn getGlyphsPtr(self: *Run) []const graphics.Glyph { pub fn getGlyphsPtr(self: *Run) ?[]const graphics.Glyph {
const len = self.getGlyphCount(); const len = self.getGlyphCount();
if (len == 0) return &.{}; if (len == 0) return &.{};
const ptr = c.CTRunGetGlyphsPtr(@ptrCast(self)) orelse &.{}; const ptr: [*c]const graphics.Glyph = @ptrCast(
c.CTRunGetGlyphsPtr(@ptrCast(self)),
);
if (ptr == null) return null;
return ptr[0..len]; return ptr[0..len];
} }
@ -34,10 +37,13 @@ pub const Run = opaque {
return ptr; return ptr;
} }
pub fn getPositionsPtr(self: *Run) []const graphics.Point { pub fn getPositionsPtr(self: *Run) ?[]const graphics.Point {
const len = self.getGlyphCount(); const len = self.getGlyphCount();
if (len == 0) return &.{}; if (len == 0) return &.{};
const ptr = c.CTRunGetPositionsPtr(@ptrCast(self)) orelse &.{}; const ptr: [*c]const graphics.Point = @ptrCast(
c.CTRunGetPositionsPtr(@ptrCast(self)),
);
if (ptr == null) return null;
return ptr[0..len]; return ptr[0..len];
} }
@ -53,10 +59,13 @@ pub const Run = opaque {
return ptr; return ptr;
} }
pub fn getAdvancesPtr(self: *Run) []const graphics.Size { pub fn getAdvancesPtr(self: *Run) ?[]const graphics.Size {
const len = self.getGlyphCount(); const len = self.getGlyphCount();
if (len == 0) return &.{}; if (len == 0) return &.{};
const ptr = c.CTRunGetAdvancesPtr(@ptrCast(self)) orelse &.{}; const ptr: [*c]const graphics.Size = @ptrCast(
c.CTRunGetAdvancesPtr(@ptrCast(self)),
);
if (ptr == null) return null;
return ptr[0..len]; return ptr[0..len];
} }
@ -72,10 +81,13 @@ pub const Run = opaque {
return ptr; return ptr;
} }
pub fn getStringIndicesPtr(self: *Run) []const usize { pub fn getStringIndicesPtr(self: *Run) ?[]const usize {
const len = self.getGlyphCount(); const len = self.getGlyphCount();
if (len == 0) return &.{}; if (len == 0) return &.{};
const ptr = c.CTRunGetStringIndicesPtr(@ptrCast(self)) orelse &.{}; const ptr: [*c]const usize = @ptrCast(
c.CTRunGetStringIndicesPtr(@ptrCast(self)),
);
if (ptr == null) return null;
return ptr[0..len]; return ptr[0..len];
} }
@ -90,4 +102,16 @@ pub const Run = opaque {
); );
return ptr; return ptr;
} }
pub fn getStatus(self: *Run) Status {
return @bitCast(c.CTRunGetStatus(@ptrCast(self)));
}
};
/// https://developer.apple.com/documentation/coretext/ctrunstatus?language=objc
pub const Status = packed struct(u32) {
right_to_left: bool,
non_monotonic: bool,
has_non_identity_matrix: bool,
_pad: u29 = 0,
}; };

View File

@ -0,0 +1,36 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const foundation = @import("../foundation.zig");
const graphics = @import("../graphics.zig");
const text = @import("../text.zig");
const c = @import("c.zig").c;
pub const Typesetter = opaque {
pub fn createWithAttributedStringAndOptions(
str: *foundation.AttributedString,
opts: *foundation.Dictionary,
) Allocator.Error!*Typesetter {
return @as(
?*Typesetter,
@ptrFromInt(@intFromPtr(c.CTTypesetterCreateWithAttributedStringAndOptions(
@ptrCast(str),
@ptrCast(opts),
))),
) orelse Allocator.Error.OutOfMemory;
}
pub fn release(self: *Typesetter) void {
foundation.CFRelease(self);
}
pub fn createLine(
self: *Typesetter,
range: foundation.c.CFRange,
) *text.Line {
return @ptrFromInt(@intFromPtr(c.CTTypesetterCreateLine(
@ptrCast(self),
range,
)));
}
};

43
pkg/opengl/Sampler.zig Normal file
View File

@ -0,0 +1,43 @@
const Sampler = @This();
const std = @import("std");
const c = @import("c.zig").c;
const errors = @import("errors.zig");
const glad = @import("glad.zig");
const Texture = @import("Texture.zig");
id: c.GLuint,
/// Create a single sampler.
pub fn create() errors.Error!Sampler {
var id: c.GLuint = undefined;
glad.context.GenSamplers.?(1, &id);
try errors.getError();
return .{ .id = id };
}
/// glBindSampler
pub fn bind(v: Sampler, index: c_uint) !void {
glad.context.BindSampler.?(index, v.id);
try errors.getError();
}
pub fn parameter(
self: Sampler,
name: Texture.Parameter,
value: anytype,
) errors.Error!void {
switch (@TypeOf(value)) {
c.GLint => glad.context.SamplerParameteri.?(
self.id,
@intFromEnum(name),
value,
),
else => unreachable,
}
try errors.getError();
}
pub fn destroy(v: Sampler) void {
glad.context.DeleteSamplers.?(1, &v.id);
}

View File

@ -18,6 +18,7 @@ pub const Buffer = @import("Buffer.zig");
pub const Framebuffer = @import("Framebuffer.zig"); pub const Framebuffer = @import("Framebuffer.zig");
pub const Renderbuffer = @import("Renderbuffer.zig"); pub const Renderbuffer = @import("Renderbuffer.zig");
pub const Program = @import("Program.zig"); pub const Program = @import("Program.zig");
pub const Sampler = @import("Sampler.zig");
pub const Shader = @import("Shader.zig"); pub const Shader = @import("Shader.zig");
pub const Texture = @import("Texture.zig"); pub const Texture = @import("Texture.zig");
pub const VertexArray = @import("VertexArray.zig"); pub const VertexArray = @import("VertexArray.zig");

321
po/hr_HR.UTF-8.po Normal file
View File

@ -0,0 +1,321 @@
# Croatian translations for com.mitchellh.ghostty package
# Hrvatski prijevod za paket com.mitchellh.ghostty.
# Copyright (C) 2025 "Mitchell Hashimoto, Ghostty contributors"
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Filip <filipm7@protonmail.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-09-16 17:47+0200\n"
"Last-Translator: Filip7 <filipm7@protonmail.com>\n"
"Language-Team: Croatian <lokalizacija@linux.hr>\n"
"Language: hr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5
msgid "Change Terminal Title"
msgstr "Promijeni naslov terminala"
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6
msgid "Leave blank to restore the default title."
msgstr "Ostavi prazno za povratak zadanog naslova."
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10
#: src/apprt/gtk/CloseDialog.zig:44
msgid "Cancel"
msgstr "Otkaži"
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10
msgid "OK"
msgstr "OK"
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:5
msgid "Configuration Errors"
msgstr "Greške u postavkama"
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6
msgid ""
"One or more configuration errors were found. Please review the errors below, "
"and either reload your configuration or ignore these errors."
msgstr ""
"Pronađene su jedna ili više grešaka u postavkama. Pregledaj niže navedene greške"
"te ponovno učitaj postavke ili zanemari ove greške."
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9
msgid "Ignore"
msgstr "Zanemari"
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10
msgid "Reload Configuration"
msgstr "Ponovno učitaj postavke"
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:6
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50
msgid "Split Up"
msgstr "Podijeli gore"
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55
msgid "Split Down"
msgstr "Podijeli dolje"
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60
msgid "Split Left"
msgstr "Podijeli lijevo"
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65
msgid "Split Right"
msgstr "Podijeli desno"
#: src/apprt/gtk/ui/1.5/command-palette.blp:16
msgid "Execute a command…"
msgstr "Izvrši naredbu…"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6
msgid "Copy"
msgstr "Kopiraj"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11
msgid "Paste"
msgstr "Zalijepi"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:18
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:73
msgid "Clear"
msgstr "Očisti"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:23
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:78
msgid "Reset"
msgstr "Resetiraj"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42
msgid "Split"
msgstr "Podijeli"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45
msgid "Change Title…"
msgstr "Promijeni naslov…"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59
msgid "Tab"
msgstr "Kartica"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30
#: src/apprt/gtk/Window.zig:265
msgid "New Tab"
msgstr "Nova kartica"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35
msgid "Close Tab"
msgstr "Zatvori karticu"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73
msgid "Window"
msgstr "Prozor"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:76
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:18
msgid "New Window"
msgstr "Novi prozor"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:81
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:23
msgid "Close Window"
msgstr "Zatvori prozor"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89
msgid "Config"
msgstr "Postavke"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95
msgid "Open Configuration"
msgstr "Otvori postavke"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85
msgid "Command Palette"
msgstr "Paleta naredbi"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90
msgid "Terminal Inspector"
msgstr "Inspektor terminala"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107
#: src/apprt/gtk/Window.zig:1038
msgid "About Ghostty"
msgstr "O Ghosttyju"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112
msgid "Quit"
msgstr "Izađi"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:6
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:6
msgid "Authorize Clipboard Access"
msgstr "Dopusti pristup međuspremniku"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:7
msgid ""
"An application is attempting to read from the clipboard. The current "
"clipboard contents are shown below."
msgstr ""
"Program pokušava pročitati vrijednost međuspremnika. Trenutna"
"vrijednost međuspremnika je prikazana niže."
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:10
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:10
msgid "Deny"
msgstr "Odbij"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:11
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:11
msgid "Allow"
msgstr "Dopusti"
#: 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 "Zapamti izbor za ovu podjelu"
#: 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 "Ponovno učitaj postavke za prikaz ovog upita"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7
msgid ""
"An application is attempting to write to the clipboard. The current "
"clipboard contents are shown below."
msgstr ""
"Aplikacija pokušava pisati u međuspremnik. Trenutačna vrijednost "
"međuspremnika prikazana je niže."
#: 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"
msgstr "Upozorenje: Potencijalno opasno lijepljenje"
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7
msgid ""
"Pasting this text into the terminal may be dangerous as it looks like some "
"commands may be executed."
msgstr ""
"Lijepljenje ovog teksta u terminal može biti opasno jer se čini da "
"neke naredbe mogu biti izvršene."
#: src/apprt/gtk/CloseDialog.zig:47 src/apprt/gtk/Surface.zig:2531
msgid "Close"
msgstr "Zatvori"
#: src/apprt/gtk/CloseDialog.zig:87
msgid "Quit Ghostty?"
msgstr "Zatvori Ghostty?"
#: src/apprt/gtk/CloseDialog.zig:88
msgid "Close Window?"
msgstr "Zatvori prozor?"
#: src/apprt/gtk/CloseDialog.zig:89
msgid "Close Tab?"
msgstr "Zatvori karticu?"
#: src/apprt/gtk/CloseDialog.zig:90
msgid "Close Split?"
msgstr "Zatvori podjelu?"
#: src/apprt/gtk/CloseDialog.zig:96
msgid "All terminal sessions will be terminated."
msgstr "Sve sesije terminala će biti prekinute."
#: src/apprt/gtk/CloseDialog.zig:97
msgid "All terminal sessions in this window will be terminated."
msgstr "Sve sesije terminala u ovom prozoru će biti prekinute."
#: src/apprt/gtk/CloseDialog.zig:98
msgid "All terminal sessions in this tab will be terminated."
msgstr "Sve sesije terminala u ovoj kartici će biti prekinute."
#: src/apprt/gtk/CloseDialog.zig:99
msgid "The currently running process in this split will be terminated."
msgstr "Pokrenuti procesi u ovom odjeljku će biti prekinuti."
#: src/apprt/gtk/Surface.zig:1266
msgid "Copied to clipboard"
msgstr "Kopirano u međuspremnik"
#: src/apprt/gtk/Surface.zig:1268
msgid "Cleared clipboard"
msgstr "Očišćen međuspremnik"
#: src/apprt/gtk/Surface.zig:2525
msgid "Command succeeded"
msgstr "Naredba je uspjela"
#: src/apprt/gtk/Surface.zig:2527
msgid "Command failed"
msgstr "Naredba nije uspjela"
#: src/apprt/gtk/Window.zig:216
msgid "Main Menu"
msgstr "Glavni izbornik"
#: src/apprt/gtk/Window.zig:239
msgid "View Open Tabs"
msgstr "Pregledaj otvorene kartice"
#: src/apprt/gtk/Window.zig:266
msgid "New Split"
msgstr "Nova podjela"
#: src/apprt/gtk/Window.zig:329
msgid ""
"⚠️ You're running a debug build of Ghostty! Performance will be degraded."
msgstr ""
"⚠️ Pokrenuta je debug verzija Ghosttyja! Performanse će biti smanjene."
#: src/apprt/gtk/Window.zig:775
msgid "Reloaded the configuration"
msgstr "Ponovno učitane postavke"
#: src/apprt/gtk/Window.zig:1019
msgid "Ghostty Developers"
msgstr "Razvijatelji Ghosttyja"
#: src/apprt/gtk/inspector.zig:144
msgid "Ghostty: Terminal Inspector"
msgstr "Ghostty: inspektor terminala"

View File

@ -4,14 +4,15 @@
# This file is distributed under the same license as the com.mitchellh.ghostty package. # This file is distributed under the same license as the com.mitchellh.ghostty package.
# Gustavo Peres <gsodevel@gmail.com>, 2025. # Gustavo Peres <gsodevel@gmail.com>, 2025.
# Guilherme Tiscoski <github@guilhermetiscoski.com>, 2025. # Guilherme Tiscoski <github@guilhermetiscoski.com>, 2025.
# Nilton Perim Neto <niltonperimneto@gmail.com>, 2025.
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n" "Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2025-07-22 17:18+0000\n" "POT-Creation-Date: 2025-07-22 17:18+0000\n"
"PO-Revision-Date: 2025-08-25 11:46-0500\n" "PO-Revision-Date: 2025-09-15 13:57-0300\n"
"Last-Translator: Guilherme Tiscoski <github@guihermetiscoski.com>\n" "Last-Translator: Nilton Perim Neto <niltonperimneto@gmail.com>\n"
"Language-Team: Brazilian Portuguese <ldpbr-translation@lists.sourceforge." "Language-Team: Brazilian Portuguese <ldpbr-translation@lists.sourceforge."
"net>\n" "net>\n"
"Language: pt_BR\n" "Language: pt_BR\n"
@ -26,7 +27,7 @@ msgstr "Mudar título do Terminal"
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6
msgid "Leave blank to restore the default title." msgid "Leave blank to restore the default title."
msgstr "Deixe em branco para restaurar o título original." msgstr "Deixe em branco para restaurar o título padrão."
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10 #: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10
@ -315,8 +316,8 @@ msgstr "Configuração recarregada"
#: src/apprt/gtk/Window.zig:1019 #: src/apprt/gtk/Window.zig:1019
msgid "Ghostty Developers" msgid "Ghostty Developers"
msgstr "Desenvolvedores Ghostty" msgstr "Desenvolvedores do Ghostty"
#: src/apprt/gtk/inspector.zig:144 #: src/apprt/gtk/inspector.zig:144
msgid "Ghostty: Terminal Inspector" msgid "Ghostty: Terminal Inspector"
msgstr "Ghostty: Inspetor de terminal" msgstr "Ghostty: Inspetor do terminal"

314
po/zh_TW.UTF-8.po Normal file
View File

@ -0,0 +1,314 @@
# Traditional Chinese (Taiwan) translation for com.mitchellh.ghostty package.
# Copyright (C) 2025 Mitchell Hashimoto
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Peter Dave Hello <hsu@peterdavehello.org>, 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-09-21 18:59+0800\n"
"Last-Translator: Peter Dave Hello <hsu@peterdavehello.org>\n"
"Language-Team: Chinese (traditional)\n"
"Language: zh_TW\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5
msgid "Change Terminal Title"
msgstr "變更終端機標題"
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6
msgid "Leave blank to restore the default title."
msgstr "留空即可還原為預設標題。"
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10
#: src/apprt/gtk/CloseDialog.zig:44
msgid "Cancel"
msgstr "取消"
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10
msgid "OK"
msgstr "確定"
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:5
msgid "Configuration Errors"
msgstr "設定錯誤"
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6
msgid ""
"One or more configuration errors were found. Please review the errors below, "
"and either reload your configuration or ignore these errors."
msgstr ""
"發現有設定錯誤。請檢視以下錯誤,並重新載入設定或忽略這些錯誤。"
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9
msgid "Ignore"
msgstr "忽略"
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10
msgid "Reload Configuration"
msgstr "重新載入設定"
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:6
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50
msgid "Split Up"
msgstr "向上分割"
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55
msgid "Split Down"
msgstr "向下分割"
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60
msgid "Split Left"
msgstr "向左分割"
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65
msgid "Split Right"
msgstr "向右分割"
#: src/apprt/gtk/ui/1.5/command-palette.blp:16
msgid "Execute a command…"
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
msgid "Copy"
msgstr "複製"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11
msgid "Paste"
msgstr "貼上"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:18
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:73
msgid "Clear"
msgstr "清除"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:23
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:78
msgid "Reset"
msgstr "重設"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42
msgid "Split"
msgstr "分割"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45
msgid "Change Title…"
msgstr "變更標題…"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59
msgid "Tab"
msgstr "分頁"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30
#: src/apprt/gtk/Window.zig:265
msgid "New Tab"
msgstr "開新分頁"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35
msgid "Close Tab"
msgstr "關閉分頁"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73
msgid "Window"
msgstr "視窗"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:76
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:18
msgid "New Window"
msgstr "開新視窗"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:81
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:23
msgid "Close Window"
msgstr "關閉視窗"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89
msgid "Config"
msgstr "設定"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95
msgid "Open Configuration"
msgstr "開啟設定"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85
msgid "Command Palette"
msgstr "命令面板"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90
msgid "Terminal Inspector"
msgstr "終端機檢查工具"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107
#: src/apprt/gtk/Window.zig:1038
msgid "About Ghostty"
msgstr "關於 Ghostty"
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112
msgid "Quit"
msgstr "結束"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:6
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:6
msgid "Authorize Clipboard Access"
msgstr "授權存取剪貼簿"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:7
msgid ""
"An application is attempting to read from the clipboard. The current "
"clipboard contents are shown below."
msgstr ""
"有應用程式正嘗試讀取剪貼簿,目前的剪貼簿內容顯示如下。"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:10
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:10
msgid "Deny"
msgstr "拒絕"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:11
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:11
msgid "Allow"
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 "記住此窗格的選擇"
#: 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 "重新載入設定以再次顯示此提示"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7
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"
msgstr "警告:可能有潛在安全風險的貼上操作"
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7
msgid ""
"Pasting this text into the terminal may be dangerous as it looks like some "
"commands may be executed."
msgstr ""
"將這段文字貼到終端機具有潛在風險,因為它看起來像是可能會被執行的命令。"
#: src/apprt/gtk/CloseDialog.zig:47 src/apprt/gtk/Surface.zig:2531
msgid "Close"
msgstr "關閉"
#: src/apprt/gtk/CloseDialog.zig:87
msgid "Quit Ghostty?"
msgstr "要結束 Ghostty 嗎?"
#: src/apprt/gtk/CloseDialog.zig:88
msgid "Close Window?"
msgstr "是否要關閉視窗?"
#: src/apprt/gtk/CloseDialog.zig:89
msgid "Close Tab?"
msgstr "是否要關閉分頁?"
#: src/apprt/gtk/CloseDialog.zig:90
msgid "Close Split?"
msgstr "是否要關閉窗格?"
#: src/apprt/gtk/CloseDialog.zig:96
msgid "All terminal sessions will be terminated."
msgstr "所有終端機工作階段都將被終止。"
#: src/apprt/gtk/CloseDialog.zig:97
msgid "All terminal sessions in this window will be terminated."
msgstr "此視窗中的所有終端機工作階段都將被終止。"
#: src/apprt/gtk/CloseDialog.zig:98
msgid "All terminal sessions in this tab will be terminated."
msgstr "此分頁中的所有終端機工作階段都將被終止。"
#: src/apprt/gtk/CloseDialog.zig:99
msgid "The currently running process in this split will be terminated."
msgstr "此窗格中目前執行的處理程序將被終止。"
#: src/apprt/gtk/Surface.zig:1266
msgid "Copied to clipboard"
msgstr "已複製到剪貼簿"
#: src/apprt/gtk/Surface.zig:1268
msgid "Cleared clipboard"
msgstr "已清除剪貼簿"
#: src/apprt/gtk/Surface.zig:2525
msgid "Command succeeded"
msgstr "命令執行成功"
#: src/apprt/gtk/Surface.zig:2527
msgid "Command failed"
msgstr "命令執行失敗"
#: src/apprt/gtk/Window.zig:216
msgid "Main Menu"
msgstr "主選單"
#: src/apprt/gtk/Window.zig:239
msgid "View Open Tabs"
msgstr "檢視已開啟的分頁"
#: src/apprt/gtk/Window.zig:266
msgid "New Split"
msgstr "新增窗格"
#: src/apprt/gtk/Window.zig:329
msgid ""
"⚠️ You're running a debug build of Ghostty! Performance will be degraded."
msgstr ""
"⚠️ 您正在執行 Ghostty 的除錯版本!程式運作效能將會受到影響。"
#: src/apprt/gtk/Window.zig:775
msgid "Reloaded the configuration"
msgstr "已重新載入設定"
#: src/apprt/gtk/Window.zig:1019
msgid "Ghostty Developers"
msgstr "Ghostty 開發者"
#: src/apprt/gtk/inspector.zig:144
msgid "Ghostty: Terminal Inspector"
msgstr "Ghostty終端機檢查工具"

View File

@ -61,10 +61,4 @@ fi
[ "$needs_update" = true ] && echo "LAST_REVISION=$SNAP_REVISION" > "$SNAP_USER_DATA/.last_revision" [ "$needs_update" = true ] && echo "LAST_REVISION=$SNAP_REVISION" > "$SNAP_USER_DATA/.last_revision"
# Unset all SNAP specific environment variables to keep them from leaking
# into other snaps that might get executed from within the shell
for var in $(printenv | grep SNAP_ | cut -d= -f1); do
unset $var
done
exec "$@" exec "$@"

View File

@ -20,7 +20,7 @@ platforms:
apps: apps:
ghostty: ghostty:
command: bin/ghostty command: bin/ghostty
command-chain: [bin/launcher] command-chain: [app/launcher]
completer: share/bash-completion/completions/ghostty.bash completer: share/bash-completion/completions/ghostty.bash
desktop: share/applications/com.mitchellh.ghostty.desktop desktop: share/applications/com.mitchellh.ghostty.desktop
#refresh-mode: ignore-running # Store rejects this, needs fix in review-tools #refresh-mode: ignore-running # Store rejects this, needs fix in review-tools
@ -35,7 +35,7 @@ parts:
source: snap/local source: snap/local
source-type: local source-type: local
organize: organize:
launcher: bin/ launcher: app/
zig: zig:
plugin: nil plugin: nil
@ -79,7 +79,12 @@ parts:
# TODO: Remove -fno-sys=gtk4-layer-shell when we upgrade to a version that packages it Ubuntu 24.10+ # TODO: Remove -fno-sys=gtk4-layer-shell when we upgrade to a version that packages it Ubuntu 24.10+
override-build: | override-build: |
craftctl set version=$(cat VERSION) craftctl set version=$(cat VERSION)
$CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline -fno-sys=gtk4-layer-shell $CRAFT_PART_SRC/../../zig/src/zig build \
-Dsnap \
-Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR \
-Doptimize=ReleaseFast \
-Dcpu=baseline \
-fno-sys=gtk4-layer-shell
cp -rp zig-out/* $CRAFT_PART_INSTALL/ cp -rp zig-out/* $CRAFT_PART_INSTALL/
sed -i 's|Icon=com.mitchellh.ghostty|Icon=${SNAP}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop sed -i 's|Icon=com.mitchellh.ghostty|Icon=${SNAP}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop

View File

@ -66,6 +66,12 @@ font_grid_key: font.SharedGridSet.Key,
font_size: font.face.DesiredSize, font_size: font.face.DesiredSize,
font_metrics: font.Metrics, font_metrics: font.Metrics,
/// This keeps track of if the font size was ever modified. If it wasn't,
/// then config reloading will change the font. If it was manually adjusted,
/// we don't change it on config reload since we assume the user wants
/// a specific size.
font_size_adjusted: bool,
/// The renderer for this surface. /// The renderer for this surface.
renderer: Renderer, renderer: Renderer,
@ -254,7 +260,7 @@ const DerivedConfig = struct {
font: font.SharedGridSet.DerivedConfig, font: font.SharedGridSet.DerivedConfig,
mouse_interval: u64, mouse_interval: u64,
mouse_hide_while_typing: bool, mouse_hide_while_typing: bool,
mouse_scroll_multiplier: f64, mouse_scroll_multiplier: configpkg.MouseScrollMultiplier,
mouse_shift_capture: configpkg.MouseShiftCapture, mouse_shift_capture: configpkg.MouseShiftCapture,
macos_non_native_fullscreen: configpkg.NonNativeFullscreen, macos_non_native_fullscreen: configpkg.NonNativeFullscreen,
macos_option_as_alt: ?configpkg.OptionAsAlt, macos_option_as_alt: ?configpkg.OptionAsAlt,
@ -514,6 +520,7 @@ pub fn init(
.rt_surface = rt_surface, .rt_surface = rt_surface,
.font_grid_key = font_grid_key, .font_grid_key = font_grid_key,
.font_size = font_size, .font_size = font_size,
.font_size_adjusted = false,
.font_metrics = font_grid.metrics, .font_metrics = font_grid.metrics,
.renderer = renderer_impl, .renderer = renderer_impl,
.renderer_thread = render_thread, .renderer_thread = render_thread,
@ -997,6 +1004,16 @@ fn selectionScrollTick(self: *Surface) !void {
defer self.renderer_state.mutex.unlock(); defer self.renderer_state.mutex.unlock();
const t: *terminal.Terminal = self.renderer_state.terminal; const t: *terminal.Terminal = self.renderer_state.terminal;
// If our screen changed while this is happening, we stop our
// selection scroll.
if (self.mouse.left_click_screen != t.active_screen) {
self.io.queueMessage(
.{ .selection_scroll = false },
.locked,
);
return;
}
// Scroll the viewport as required // Scroll the viewport as required
try t.scrollViewport(.{ .delta = delta }); try t.scrollViewport(.{ .delta = delta });
@ -1446,7 +1463,21 @@ pub fn updateConfig(
// but this is easier and pretty rare so it's not a performance concern. // but this is easier and pretty rare so it's not a performance concern.
// //
// (Calling setFontSize builds and sends a new font grid to the renderer.) // (Calling setFontSize builds and sends a new font grid to the renderer.)
try self.setFontSize(self.font_size); try self.setFontSize(font_size: {
// If we have manually adjusted the font size, keep it that way.
if (self.font_size_adjusted) {
log.info("font size manually adjusted, preserving previous size on config reload", .{});
break :font_size self.font_size;
}
// If we haven't, then we update to the configured font size.
// This allows config changes to update the font size. We used to
// never do this but it was a common source of confusion and people
// assumed that Ghostty was broken! This logic makes more sense.
var size = self.font_size;
size.points = std.math.clamp(config.@"font-size", 1.0, 255.0);
break :font_size size;
});
// We need to store our configs in a heap-allocated pointer so that // We need to store our configs in a heap-allocated pointer so that
// our messages aren't huge. // our messages aren't huge.
@ -2808,7 +2839,7 @@ pub fn scrollCallback(
// scroll events to pixels by multiplying the wheel tick value and the cell size. This means // scroll events to pixels by multiplying the wheel tick value and the cell size. This means
// that a wheel tick of 1 results in single scroll event. // that a wheel tick of 1 results in single scroll event.
const yoff_adjusted: f64 = if (scroll_mods.precision) const yoff_adjusted: f64 = if (scroll_mods.precision)
yoff yoff * self.config.mouse_scroll_multiplier.precision
else yoff_adjusted: { else yoff_adjusted: {
// Round out the yoff to an absolute minimum of 1. macos tries to // Round out the yoff to an absolute minimum of 1. macos tries to
// simulate precision scrolling with non precision events by // simulate precision scrolling with non precision events by
@ -2822,7 +2853,7 @@ pub fn scrollCallback(
else else
@min(yoff, -1); @min(yoff, -1);
break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier; break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier.discrete;
}; };
// Add our previously saved pending amount to the offset to get the // Add our previously saved pending amount to the offset to get the
@ -3991,7 +4022,7 @@ pub fn cursorPosCallback(
// Stop selection scrolling when inside the viewport within a 1px buffer // Stop selection scrolling when inside the viewport within a 1px buffer
// for fullscreen windows, but only when selection scrolling is active. // for fullscreen windows, but only when selection scrolling is active.
if (pos.x >= 1 and pos.y >= 1 and self.selection_scroll_active) { if (pos.y >= 1 and self.selection_scroll_active) {
self.io.queueMessage( self.io.queueMessage(
.{ .selection_scroll = false }, .{ .selection_scroll = false },
.locked, .locked,
@ -4070,6 +4101,12 @@ pub fn cursorPosCallback(
// count because we don't want to handle selection. // count because we don't want to handle selection.
if (self.mouse.left_click_count == 0) break :select; if (self.mouse.left_click_count == 0) break :select;
// If our terminal screen changed then we don't process this. We don't
// invalidate our pin or mouse state because if the screen switches
// back then we can continue our selection.
const t: *terminal.Terminal = self.renderer_state.terminal;
if (self.mouse.left_click_screen != t.active_screen) break :select;
// All roads lead to requiring a re-render at this point. // All roads lead to requiring a re-render at this point.
try self.queueRender(); try self.queueRender();
@ -4093,7 +4130,7 @@ pub fn cursorPosCallback(
} }
// Convert to points // Convert to points
const screen = &self.renderer_state.terminal.screen; const screen = &t.screen;
const pin = screen.pages.pin(.{ const pin = screen.pages.pin(.{
.viewport = .{ .viewport = .{
.x = pos_vp.x, .x = pos_vp.x,
@ -4637,10 +4674,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
log.debug("increase font size={}", .{clamped_delta}); log.debug("increase font size={}", .{clamped_delta});
var size = self.font_size;
// Max point size is somewhat arbitrary. // Max point size is somewhat arbitrary.
var size = self.font_size;
size.points = @min(size.points + clamped_delta, 255); size.points = @min(size.points + clamped_delta, 255);
try self.setFontSize(size); try self.setFontSize(size);
// Mark that we manually adjusted the font size
self.font_size_adjusted = true;
}, },
.decrease_font_size => |delta| { .decrease_font_size => |delta| {
@ -4652,6 +4692,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
var size = self.font_size; var size = self.font_size;
size.points = @max(1, size.points - clamped_delta); size.points = @max(1, size.points - clamped_delta);
try self.setFontSize(size); try self.setFontSize(size);
// Mark that we manually adjusted the font size
self.font_size_adjusted = true;
}, },
.reset_font_size => { .reset_font_size => {
@ -4660,6 +4703,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
var size = self.font_size; var size = self.font_size;
size.points = self.config.original_font_size; size.points = self.config.original_font_size;
try self.setFontSize(size); try self.setFontSize(size);
// Reset font size also resets the manual adjustment state
self.font_size_adjusted = false;
}, },
.set_font_size => |points| { .set_font_size => |points| {
@ -4668,6 +4714,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
var size = self.font_size; var size = self.font_size;
size.points = std.math.clamp(points, 1.0, 255.0); size.points = std.math.clamp(points, 1.0, 255.0);
try self.setFontSize(size); try self.setFontSize(size);
// Mark that we manually adjusted the font size
self.font_size_adjusted = true;
}, },
.prompt_surface_title => return try self.rt_app.performAction( .prompt_surface_title => return try self.rt_app.performAction(

View File

@ -569,6 +569,15 @@ pub const SetTitle = struct {
.title = self.title.ptr, .title = self.title.ptr,
}; };
} }
pub fn format(
value: @This(),
comptime _: []const u8,
_: std.fmt.FormatOptions,
writer: anytype,
) !void {
try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.title });
}
}; };
pub const Pwd = struct { pub const Pwd = struct {
@ -584,6 +593,15 @@ pub const Pwd = struct {
.pwd = self.pwd.ptr, .pwd = self.pwd.ptr,
}; };
} }
pub fn format(
value: @This(),
comptime _: []const u8,
_: std.fmt.FormatOptions,
writer: anytype,
) !void {
try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.pwd });
}
}; };
/// The desktop notification to show. /// The desktop notification to show.
@ -603,6 +621,19 @@ pub const DesktopNotification = struct {
.body = self.body.ptr, .body = self.body.ptr,
}; };
} }
pub fn format(
value: @This(),
comptime _: []const u8,
_: std.fmt.FormatOptions,
writer: anytype,
) !void {
try writer.print("{s}{{ title: {s}, body: {s} }}", .{
@typeName(@This()),
value.title,
value.body,
});
}
}; };
pub const KeySequence = union(enum) { pub const KeySequence = union(enum) {

View File

@ -3,7 +3,7 @@ const internal_os = @import("../os/main.zig");
// The required comptime API for any apprt. // The required comptime API for any apprt.
pub const App = @import("gtk/App.zig"); pub const App = @import("gtk/App.zig");
pub const Surface = @import("gtk/Surface.zig"); pub const Surface = @import("gtk/Surface.zig");
pub const resourcesDir = internal_os.resourcesDir; pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir;
// The exported API, custom for the apprt. // The exported API, custom for the apprt.
pub const class = @import("gtk/class.zig"); pub const class = @import("gtk/class.zig");

View File

@ -456,13 +456,23 @@ pub const Application = extern struct {
if (!config.@"quit-after-last-window-closed") break :q false; if (!config.@"quit-after-last-window-closed") break :q false;
// If the quit timer has expired, quit. // If the quit timer has expired, quit.
if (priv.quit_timer == .expired) break :q true; if (priv.quit_timer == .expired) {
log.debug("must_quit due to quit timer expired", .{});
break :q true;
}
// If we have no windows attached to our app, also quit. // If we have no windows attached to our app, also quit.
// We only do this if we don't have the closed delay set,
// because with the closed delay set we'll exit eventually.
if (config.@"quit-after-last-window-closed-delay" == null) {
if (priv.requested_window and @as( if (priv.requested_window and @as(
?*glib.List, ?*glib.List,
self.as(gtk.Application).getWindows(), self.as(gtk.Application).getWindows(),
) == null) break :q true; ) == null) {
log.debug("must_quit due to no app windows", .{});
break :q true;
}
}
// No quit conditions met // No quit conditions met
break :q false; break :q false;
@ -741,6 +751,10 @@ pub const Application = extern struct {
const writer = buf.writer(alloc); const writer = buf.writer(alloc);
// Load standard css first as it can override some of the user configured styling.
try loadRuntimeCss414(config, &writer);
try loadRuntimeCss416(config, &writer);
const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background; const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background;
try writer.print( try writer.print(
@ -779,9 +793,6 @@ pub const Application = extern struct {
, .{ .font_family = font_family }); , .{ .font_family = font_family });
} }
try loadRuntimeCss414(config, &writer);
try loadRuntimeCss416(config, &writer);
// ensure that we have a sentinel // ensure that we have a sentinel
try writer.writeByte(0); try writer.writeByte(0);

View File

@ -112,6 +112,25 @@ pub const SplitTree = extern struct {
}, },
); );
}; };
pub const @"is-split" = struct {
pub const name = "is-split";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{
.getter = getIsSplit,
},
),
},
);
};
}; };
pub const signals = struct { pub const signals = struct {
@ -210,6 +229,14 @@ pub const SplitTree = extern struct {
} }
} }
// Bind is-split property for new surface
_ = self.as(gobject.Object).bindProperty(
"is-split",
surface.as(gobject.Object),
"is-split",
.{ .sync_create = true },
);
// Create our tree // Create our tree
var single_tree = try Surface.Tree.init(alloc, surface); var single_tree = try Surface.Tree.init(alloc, surface);
defer single_tree.deinit(); defer single_tree.deinit();
@ -511,6 +538,18 @@ pub const SplitTree = extern struct {
)); ));
} }
fn getIsSplit(self: *Self) bool {
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
if (tree.isEmpty()) return false;
const root_handle: Surface.Tree.Node.Handle = .root;
const root = tree.nodes[root_handle.idx()];
return switch (root) {
.leaf => false,
.split => true,
};
}
//--------------------------------------------------------------- //---------------------------------------------------------------
// Virtual methods // Virtual methods
@ -816,6 +855,9 @@ pub const SplitTree = extern struct {
v.grabFocus(); v.grabFocus();
} }
// Our split status may have changed
self.as(gobject.Object).notifyByPspec(properties.@"is-split".impl.param_spec);
// Our active surface may have changed // Our active surface may have changed
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
@ -873,6 +915,7 @@ pub const SplitTree = extern struct {
properties.@"has-surfaces".impl, properties.@"has-surfaces".impl,
properties.@"is-zoomed".impl, properties.@"is-zoomed".impl,
properties.tree.impl, properties.tree.impl,
properties.@"is-split".impl,
}); });
// Bindings // Bindings

View File

@ -9,6 +9,7 @@ const gobject = @import("gobject");
const gtk = @import("gtk"); const gtk = @import("gtk");
const apprt = @import("../../../apprt.zig"); const apprt = @import("../../../apprt.zig");
const build_config = @import("../../../build_config.zig");
const datastruct = @import("../../../datastruct/main.zig"); const datastruct = @import("../../../datastruct/main.zig");
const font = @import("../../../font/main.zig"); const font = @import("../../../font/main.zig");
const input = @import("../../../input.zig"); const input = @import("../../../input.zig");
@ -50,6 +51,13 @@ pub const Surface = extern struct {
pub const Tree = datastruct.SplitTree(Self); pub const Tree = datastruct.SplitTree(Self);
pub const properties = struct { pub const properties = struct {
/// This property is set to true when the bell is ringing. Note that
/// this property will only emit a changed signal when there is a
/// full state change. If a bell is ringing and another bell event
/// comes through, the change notification will NOT be emitted.
///
/// If you need to know every scenario the bell is triggered,
/// listen to the `bell` signal instead.
pub const @"bell-ringing" = struct { pub const @"bell-ringing" = struct {
pub const name = "bell-ringing"; pub const name = "bell-ringing";
const impl = gobject.ext.defineProperty( const impl = gobject.ext.defineProperty(
@ -274,9 +282,40 @@ pub const Surface = extern struct {
}, },
); );
}; };
pub const @"is-split" = struct {
pub const name = "is-split";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"is_split",
),
},
);
};
}; };
pub const signals = struct { pub const signals = struct {
/// Emitted whenever the bell event is received. Unlike the
/// `bell-ringing` property, this is emitted every time the event
/// is received and not just on state changes.
pub const bell = struct {
pub const name = "bell";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
/// Emitted whenever the surface would like to be closed for any /// Emitted whenever the surface would like to be closed for any
/// reason. /// reason.
/// ///
@ -502,6 +541,10 @@ pub const Surface = extern struct {
/// A weak reference to an inspector window. /// A weak reference to an inspector window.
inspector: ?*InspectorWindow = null, inspector: ?*InspectorWindow = null,
// True if the current surface is a split, this is used to apply
// unfocused-split-* options
is_split: bool = false,
// Template binds // Template binds
child_exited_overlay: *ChildExited, child_exited_overlay: *ChildExited,
context_menu: *gtk.PopoverMenu, context_menu: *gtk.PopoverMenu,
@ -600,6 +643,16 @@ pub const Surface = extern struct {
return @intFromBool(config.@"bell-features".border); return @intFromBool(config.@"bell-features".border);
} }
/// Callback used to determine whether unfocused-split-fill / unfocused-split-opacity
/// should be applied to the surface
fn closureShouldUnfocusedSplitBeShown(
_: *Self,
focused: c_int,
is_split: c_int,
) callconv(.c) c_int {
return @intFromBool(focused == 0 and is_split != 0);
}
pub fn toggleFullscreen(self: *Self) void { pub fn toggleFullscreen(self: *Self) void {
signals.@"toggle-fullscreen".impl.emit( signals.@"toggle-fullscreen".impl.emit(
self, self,
@ -1227,19 +1280,11 @@ pub const Surface = extern struct {
// Unset environment varies set by snaps if we're running in a snap. // Unset environment varies set by snaps if we're running in a snap.
// This allows Ghostty to further launch additional snaps. // This allows Ghostty to further launch additional snaps.
if (env.get("SNAP")) |_| { if (comptime build_config.snap) {
env.remove("SNAP"); if (env.get("SNAP") != null) try filterSnapPaths(
env.remove("DRIRC_CONFIGDIR"); alloc,
env.remove("__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS"); &env,
env.remove("__EGL_VENDOR_LIBRARY_DIRS"); );
env.remove("LD_LIBRARY_PATH");
env.remove("LIBGL_DRIVERS_PATH");
env.remove("LIBVA_DRIVERS_PATH");
env.remove("VK_LAYER_PATH");
env.remove("XLOCALEDIR");
env.remove("GDK_PIXBUF_MODULEDIR");
env.remove("GDK_PIXBUF_MODULE_FILE");
env.remove("GTK_PATH");
} }
// This is a hack because it ties ourselves (optionally) to the // This is a hack because it ties ourselves (optionally) to the
@ -1253,6 +1298,79 @@ pub const Surface = extern struct {
return env; return env;
} }
/// Filter out environment variables that start with forbidden prefixes.
fn filterSnapPaths(gpa: std.mem.Allocator, env_map: *std.process.EnvMap) !void {
comptime assert(build_config.snap);
const snap_vars = [_][]const u8{
"SNAP",
"SNAP_USER_COMMON",
"SNAP_USER_DATA",
"SNAP_DATA",
"SNAP_COMMON",
};
// Use an arena because everything in this function is temporary.
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit();
const alloc = arena.allocator();
var env_to_remove = std.ArrayList([]const u8).init(alloc);
var env_to_update = std.ArrayList(struct {
key: []const u8,
value: []const u8,
}).init(alloc);
var it = env_map.iterator();
while (it.next()) |entry| {
const key = entry.key_ptr.*;
const value = entry.value_ptr.*;
// Ignore fields we set ourself
if (std.mem.eql(u8, key, "TERMINFO")) continue;
if (std.mem.startsWith(u8, key, "GHOSTTY")) continue;
// Any env var starting with SNAP must be removed
if (std.mem.startsWith(u8, key, "SNAP_")) {
try env_to_remove.append(key);
continue;
}
var filtered_paths = std.ArrayList([]const u8).init(alloc);
defer filtered_paths.deinit();
var modified = false;
var paths = std.mem.splitAny(u8, value, ":");
while (paths.next()) |path| {
var include = true;
for (snap_vars) |k| if (env_map.get(k)) |snap_path| {
if (snap_path.len == 0) continue;
if (std.mem.startsWith(u8, path, snap_path)) {
include = false;
modified = true;
break;
}
};
if (include) try filtered_paths.append(path);
}
if (modified) {
if (filtered_paths.items.len > 0) {
const new_value = try std.mem.join(alloc, ":", filtered_paths.items);
try env_to_update.append(.{ .key = key, .value = new_value });
} else {
try env_to_remove.append(key);
}
}
}
for (env_to_update.items) |item| try env_map.put(
item.key,
item.value,
);
for (env_to_remove.items) |key| _ = env_map.remove(key);
}
pub fn clipboardRequest( pub fn clipboardRequest(
self: *Self, self: *Self,
clipboard_type: apprt.Clipboard, clipboard_type: apprt.Clipboard,
@ -1576,6 +1694,16 @@ pub const Surface = extern struct {
} }
pub fn setBellRinging(self: *Self, ringing: bool) void { pub fn setBellRinging(self: *Self, ringing: bool) void {
// Prevent duplicate change notifications if the signals we emit
// in this function cause this state to change again.
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
// Logic around bell reaction happens on every event even if we're
// already in the ringing state.
if (ringing) self.ringBell();
// Property change only happens on actual state change
const priv = self.private(); const priv = self.private();
if (priv.bell_ringing == ringing) return; if (priv.bell_ringing == ringing) return;
priv.bell_ringing = ringing; priv.bell_ringing = ringing;
@ -1760,20 +1888,26 @@ pub const Surface = extern struct {
self.as(gtk.Widget).setCursorFromName(name.ptr); self.as(gtk.Widget).setCursorFromName(name.ptr);
} }
fn propBellRinging( /// Handle bell features that need to happen every time a BEL is received
self: *Self, /// Currently this is audio and system but this could change in the future.
_: *gobject.ParamSpec, fn ringBell(self: *Self) void {
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private(); const priv = self.private();
if (!priv.bell_ringing) return;
// Emit the signal
signals.bell.impl.emit(
self,
null,
.{},
null,
);
// Activate actions if they exist // Activate actions if they exist
_ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null);
_ = self.as(gtk.Widget).activateAction("win.ring-bell", null); _ = self.as(gtk.Widget).activateAction("win.ring-bell", null);
// Do our sound
const config = if (priv.config) |c| c.get() else return; const config = if (priv.config) |c| c.get() else return;
// Do our sound
if (config.@"bell-features".audio) audio: { if (config.@"bell-features".audio) audio: {
const config_path = config.@"bell-audio-path" orelse break :audio; const config_path = config.@"bell-audio-path" orelse break :audio;
const path, const required = switch (config_path) { const path, const required = switch (config_path) {
@ -2761,8 +2895,8 @@ pub const Surface = extern struct {
class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
class.bindTemplateCallback("notify_bell_ringing", &propBellRinging);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown);
// Properties // Properties
gobject.ext.registerProperties(class, &.{ gobject.ext.registerProperties(class, &.{
@ -2781,9 +2915,11 @@ pub const Surface = extern struct {
properties.title.impl, properties.title.impl,
properties.@"title-override".impl, properties.@"title-override".impl,
properties.zoom.impl, properties.zoom.impl,
properties.@"is-split".impl,
}); });
// Signals // Signals
signals.bell.impl.register(.{});
signals.@"close-request".impl.register(.{}); signals.@"close-request".impl.register(.{});
signals.@"clipboard-read".impl.register(.{}); signals.@"clipboard-read".impl.register(.{});
signals.@"clipboard-write".impl.register(.{}); signals.@"clipboard-write".impl.register(.{});

View File

@ -389,8 +389,14 @@ pub const Tab = extern struct {
// the terminal title if it exists, otherwise a default string. // the terminal title if it exists, otherwise a default string.
const plain = plain: { const plain = plain: {
const default = "Ghostty"; const default = "Ghostty";
const config_title: ?[*:0]const u8 = title: {
const config = config_ orelse break :title null;
break :title config.get().title orelse null;
};
const plain = override_ orelse const plain = override_ orelse
terminal_ orelse terminal_ orelse
config_title orelse
break :plain default; break :plain default;
break :plain std.mem.span(plain); break :plain std.mem.span(plain);
}; };

View File

@ -697,6 +697,19 @@ pub const Window = extern struct {
var it = tree.iterator(); var it = tree.iterator();
while (it.next()) |entry| { while (it.next()) |entry| {
const surface = entry.view; const surface = entry.view;
// Before adding any new signal handlers, disconnect any that we may
// have added before. Otherwise we may get multiple handlers for the
// same signal.
_ = gobject.signalHandlersDisconnectMatched(
surface.as(gobject.Object),
.{ .data = true },
0,
0,
null,
null,
self,
);
_ = Surface.signals.@"present-request".connect( _ = Surface.signals.@"present-request".connect(
surface, surface,
*Self, *Self,
@ -1002,6 +1015,15 @@ pub const Window = extern struct {
_: *gobject.ParamSpec, _: *gobject.ParamSpec,
self: *Self, self: *Self,
) callconv(.c) void { ) callconv(.c) void {
// Hide quick-terminal if set to autohide
if (self.isQuickTerminal()) {
if (self.getConfig()) |cfg| {
if (cfg.get().@"quick-terminal-autohide" and self.as(gtk.Window).isActive() == 0) {
self.toggleVisibility();
}
}
}
// Don't change urgency if we're not the active window. // Don't change urgency if we're not the active window.
if (self.as(gtk.Window).isActive() == 0) return; if (self.as(gtk.Window).isActive() == 0) return;
@ -1489,6 +1511,13 @@ pub const Window = extern struct {
const priv = self.private(); const priv = self.private();
if (priv.tab_view.getNPages() == 0) { if (priv.tab_view.getNPages() == 0) {
// If we have no pages left then we want to close window. // If we have no pages left then we want to close window.
// If the tab overview is open, then we don't close the window
// because its a rather abrupt experience. This also fixes an
// issue where dragging out the last tab in the tab overview
// won't cause Ghostty to exit.
if (priv.tab_overview.getOpen() != 0) return;
self.as(gtk.Window).close(); self.as(gtk.Window).close();
} }
} }

29
src/apprt/gtk/flatpak.zig Normal file
View File

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

View File

@ -115,6 +115,20 @@ Overlay terminal_page {
label: bind template.mouse-hover-url; label: bind template.mouse-hover-url;
} }
[overlay]
// Apply unfocused-split-fill and unfocused-split-opacity to current surface
// this is only applied when a tab has more than one surface
Revealer {
reveal-child: bind $should_unfocused_split_be_shown(template.focused, template.is-split) as <bool>;
transition-duration: 0;
DrawingArea {
styles [
"unfocused-split",
]
}
}
// Event controllers for interactivity // Event controllers for interactivity
EventControllerFocus { EventControllerFocus {
enter => $focus_enter(); enter => $focus_enter();
@ -155,7 +169,6 @@ template $GhosttySurface: Adw.Bin {
"surface", "surface",
] ]
notify::bell-ringing => $notify_bell_ringing();
notify::config => $notify_config(); notify::config => $notify_config();
notify::error => $notify_error(); notify::error => $notify_error();
notify::mouse-hover-url => $notify_mouse_hover_url(); notify::mouse-hover-url => $notify_mouse_hover_url();

View File

@ -6,11 +6,14 @@ template $GhosttySurfaceTitleDialog: Adw.AlertDialog {
body: _("Leave blank to restore the default title."); body: _("Leave blank to restore the default title.");
responses [ responses [
cancel: _("Cancel") suggested, cancel: _("Cancel"),
ok: _("OK") destructive, ok: _("OK") suggested,
] ]
default-response: "ok";
focus-widget: entry; focus-widget: entry;
extra-child: Entry entry {}; extra-child: Entry entry {
activates-default: true;
};
} }

View File

@ -20,7 +20,7 @@ const GitVersion = @import("GitVersion.zig");
/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly. /// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly.
/// Until then this MUST match build.zig.zon and should always be the /// Until then this MUST match build.zig.zon and should always be the
/// _next_ version to release. /// _next_ version to release.
const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 0 }; const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 3 };
/// Standard build configuration options. /// Standard build configuration options.
optimize: std.builtin.OptimizeMode, optimize: std.builtin.OptimizeMode,
@ -51,6 +51,7 @@ patch_rpath: ?[]const u8 = null,
/// Artifacts /// Artifacts
flatpak: bool = false, flatpak: bool = false,
snap: bool = false,
emit_bench: bool = false, emit_bench: bool = false,
emit_docs: bool = false, emit_docs: bool = false,
emit_exe: bool = false, emit_exe: bool = false,
@ -59,6 +60,7 @@ emit_macos_app: bool = false,
emit_terminfo: bool = false, emit_terminfo: bool = false,
emit_termcap: bool = false, emit_termcap: bool = false,
emit_test_exe: bool = false, emit_test_exe: bool = false,
emit_themes: bool = false,
emit_xcframework: bool = false, emit_xcframework: bool = false,
emit_webdata: bool = false, emit_webdata: bool = false,
emit_unicode_table_gen: bool = false, emit_unicode_table_gen: bool = false,
@ -152,6 +154,12 @@ pub fn init(b: *std.Build) !Config {
"Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.", "Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.",
) orelse false; ) orelse false;
config.snap = b.option(
bool,
"snap",
"Build for Snap (do specific Snap operations). Only has an effect targeting Linux.",
) orelse false;
config.sentry = b.option( config.sentry = b.option(
bool, bool,
"sentry", "sentry",
@ -359,6 +367,12 @@ pub fn init(b: *std.Build) !Config {
.ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
}; };
config.emit_themes = b.option(
bool,
"emit-themes",
"Install bundled iTerm2-Color-Schemes Ghostty themes",
) orelse true;
config.emit_webdata = b.option( config.emit_webdata = b.option(
bool, bool,
"emit-webdata", "emit-webdata",
@ -442,6 +456,7 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void {
// We need to break these down individual because addOption doesn't // We need to break these down individual because addOption doesn't
// support all types. // support all types.
step.addOption(bool, "flatpak", self.flatpak); step.addOption(bool, "flatpak", self.flatpak);
step.addOption(bool, "snap", self.snap);
step.addOption(bool, "x11", self.x11); step.addOption(bool, "x11", self.x11);
step.addOption(bool, "wayland", self.wayland); step.addOption(bool, "wayland", self.wayland);
step.addOption(bool, "sentry", self.sentry); step.addOption(bool, "sentry", self.sentry);
@ -506,6 +521,7 @@ pub fn fromOptions() Config {
.app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?, .app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?,
.font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?, .font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?,
.renderer = std.meta.stringToEnum(rendererpkg.Impl, @tagName(options.renderer)).?, .renderer = std.meta.stringToEnum(rendererpkg.Impl, @tagName(options.renderer)).?,
.snap = options.snap,
.exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?, .exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?,
.wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?, .wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?,
.wasm_shared = options.wasm_shared, .wasm_shared = options.wasm_shared,

View File

@ -118,6 +118,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
} }
// Themes // Themes
if (cfg.emit_themes) {
if (b.lazyDependency("iterm2_themes", .{})) |upstream| { if (b.lazyDependency("iterm2_themes", .{})) |upstream| {
const install_step = b.addInstallDirectory(.{ const install_step = b.addInstallDirectory(.{
.source_dir = upstream.path(""), .source_dir = upstream.path(""),
@ -127,6 +128,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
}); });
try steps.append(&install_step.step); try steps.append(&install_step.step);
} }
}
// Fish shell completions // Fish shell completions
{ {

View File

@ -38,6 +38,7 @@ pub const artifact = Artifact.detect();
const config = BuildConfig.fromOptions(); const config = BuildConfig.fromOptions();
pub const exe_entrypoint = config.exe_entrypoint; pub const exe_entrypoint = config.exe_entrypoint;
pub const flatpak = options.flatpak; pub const flatpak = options.flatpak;
pub const snap = options.snap;
pub const app_runtime: apprt.Runtime = config.app_runtime; pub const app_runtime: apprt.Runtime = config.app_runtime;
pub const font_backend: font.Backend = config.font_backend; pub const font_backend: font.Backend = config.font_backend;
pub const renderer: rendererpkg.Impl = config.renderer; pub const renderer: rendererpkg.Impl = config.renderer;

424
src/cli/CommaSplitter.zig Normal file
View File

@ -0,0 +1,424 @@
//! Iterator to split a string into fields by commas, taking into account
//! quotes and escapes.
//!
//! Supports the same escapes as in Zig literal strings.
//!
//! Quotes must begin and end with a double quote (`"`). It is an error to not
//! end a quote that was begun. To include a double quote inside a quote (or to
//! not have a double quote start a quoted section) escape it with a backslash.
//!
//! Single quotes (`'`) are not special, they do not begin a quoted block.
//!
//! Zig multiline string literals are NOT supported.
//!
//! Quotes and escapes are not stripped or decoded, that must be handled as a
//! separate step!
const CommaSplitter = @This();
pub const Error = error{
UnclosedQuote,
UnfinishedEscape,
IllegalEscape,
};
/// the string that we are splitting
str: []const u8,
/// how much of the string has been consumed so far
index: usize,
/// initialize a splitter with the given string
pub fn init(str: []const u8) CommaSplitter {
return .{
.str = str,
.index = 0,
};
}
/// return the next field, null if no more fields
pub fn next(self: *CommaSplitter) Error!?[]const u8 {
if (self.index >= self.str.len) return null;
// where the current field starts
const start = self.index;
// state of state machine
const State = enum {
normal,
quoted,
escape,
hexescape,
unicodeescape,
};
// keep track of the state to return to when done processing an escape
// sequence.
var last: State = .normal;
// used to count number of digits seen in a hex escape
var hexescape_digits: usize = 0;
// sub-state of parsing hex escapes
var unicodeescape_state: enum {
start,
digits,
} = .start;
// number of digits in a unicode escape seen so far
var unicodeescape_digits: usize = 0;
// accumulator for value of unicode escape
var unicodeescape_value: usize = 0;
loop: switch (State.normal) {
.normal => {
if (self.index >= self.str.len) return self.str[start..];
switch (self.str[self.index]) {
',' => {
self.index += 1;
return self.str[start .. self.index - 1];
},
'"' => {
self.index += 1;
continue :loop .quoted;
},
'\\' => {
self.index += 1;
last = .normal;
continue :loop .escape;
},
else => {
self.index += 1;
continue :loop .normal;
},
}
},
.quoted => {
if (self.index >= self.str.len) return error.UnclosedQuote;
switch (self.str[self.index]) {
'"' => {
self.index += 1;
continue :loop .normal;
},
'\\' => {
self.index += 1;
last = .quoted;
continue :loop .escape;
},
else => {
self.index += 1;
continue :loop .quoted;
},
}
},
.escape => {
if (self.index >= self.str.len) return error.UnfinishedEscape;
switch (self.str[self.index]) {
'n', 'r', 't', '\\', '\'', '"' => {
self.index += 1;
continue :loop last;
},
'x' => {
self.index += 1;
hexescape_digits = 0;
continue :loop .hexescape;
},
'u' => {
self.index += 1;
unicodeescape_state = .start;
unicodeescape_digits = 0;
unicodeescape_value = 0;
continue :loop .unicodeescape;
},
else => return error.IllegalEscape,
}
},
.hexescape => {
if (self.index >= self.str.len) return error.UnfinishedEscape;
switch (self.str[self.index]) {
'0'...'9', 'a'...'f', 'A'...'F' => {
self.index += 1;
hexescape_digits += 1;
if (hexescape_digits == 2) continue :loop last;
continue :loop .hexescape;
},
else => return error.IllegalEscape,
}
},
.unicodeescape => {
if (self.index >= self.str.len) return error.UnfinishedEscape;
switch (unicodeescape_state) {
.start => {
switch (self.str[self.index]) {
'{' => {
self.index += 1;
unicodeescape_value = 0;
unicodeescape_state = .digits;
continue :loop .unicodeescape;
},
else => return error.IllegalEscape,
}
},
.digits => {
switch (self.str[self.index]) {
'}' => {
self.index += 1;
if (unicodeescape_digits == 0) return error.IllegalEscape;
continue :loop last;
},
'0'...'9' => |d| {
self.index += 1;
unicodeescape_digits += 1;
unicodeescape_value <<= 4;
unicodeescape_value += d - '0';
},
'a'...'f' => |d| {
self.index += 1;
unicodeescape_digits += 1;
unicodeescape_value <<= 4;
unicodeescape_value += d - 'a';
},
'A'...'F' => |d| {
self.index += 1;
unicodeescape_digits += 1;
unicodeescape_value <<= 4;
unicodeescape_value += d - 'A';
},
else => return error.IllegalEscape,
}
if (unicodeescape_value > 0x10ffff) return error.IllegalEscape;
continue :loop .unicodeescape;
},
}
},
}
}
/// Return any remaining string data, whether it has a comma or not.
pub fn rest(self: *CommaSplitter) ?[]const u8 {
if (self.index >= self.str.len) return null;
defer self.index = self.str.len;
return self.str[self.index..];
}
test "splitter 1" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("a,b,c");
try testing.expectEqualStrings("a", (try s.next()).?);
try testing.expectEqualStrings("b", (try s.next()).?);
try testing.expectEqualStrings("c", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 2" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("");
try testing.expect(null == try s.next());
}
test "splitter 3" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("a");
try testing.expectEqualStrings("a", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 4" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\x5a");
try testing.expectEqualStrings("\\x5a", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 5" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("'a',b");
try testing.expectEqualStrings("'a'", (try s.next()).?);
try testing.expectEqualStrings("b", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 6" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("'a,b',c");
try testing.expectEqualStrings("'a", (try s.next()).?);
try testing.expectEqualStrings("b'", (try s.next()).?);
try testing.expectEqualStrings("c", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 7" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\"a,b\",c");
try testing.expectEqualStrings("\"a,b\"", (try s.next()).?);
try testing.expectEqualStrings("c", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 8" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init(" a , b ");
try testing.expectEqualStrings(" a ", (try s.next()).?);
try testing.expectEqualStrings(" b ", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 9" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\x");
try testing.expectError(error.UnfinishedEscape, s.next());
}
test "splitter 10" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\x5");
try testing.expectError(error.UnfinishedEscape, s.next());
}
test "splitter 11" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\u");
try testing.expectError(error.UnfinishedEscape, s.next());
}
test "splitter 12" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\u{");
try testing.expectError(error.UnfinishedEscape, s.next());
}
test "splitter 13" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\u{}");
try testing.expectError(error.IllegalEscape, s.next());
}
test "splitter 14" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\u{h1}");
try testing.expectError(error.IllegalEscape, s.next());
}
test "splitter 15" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\u{10ffff}");
try testing.expectEqualStrings("\\u{10ffff}", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 16" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\u{110000}");
try testing.expectError(error.IllegalEscape, s.next());
}
test "splitter 17" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\d");
try testing.expectError(error.IllegalEscape, s.next());
}
test "splitter 18" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\\n\\r\\t\\\"\\'\\\\");
try testing.expectEqualStrings("\\n\\r\\t\\\"\\'\\\\", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 19" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\"abc'def'ghi\"");
try testing.expectEqualStrings("\"abc'def'ghi\"", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 20" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("\",\",abc");
try testing.expectEqualStrings("\",\"", (try s.next()).?);
try testing.expectEqualStrings("abc", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 21" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("'a','b', 'c'");
try testing.expectEqualStrings("'a'", (try s.next()).?);
try testing.expectEqualStrings("'b'", (try s.next()).?);
try testing.expectEqualStrings(" 'c'", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 22" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("abc\"def");
try testing.expectError(error.UnclosedQuote, s.next());
}
test "splitter 23" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("title:\"Focus Split: Up\",description:\"Focus the split above, if it exists.\",action:goto_split:up");
try testing.expectEqualStrings("title:\"Focus Split: Up\"", (try s.next()).?);
try testing.expectEqualStrings("description:\"Focus the split above, if it exists.\"", (try s.next()).?);
try testing.expectEqualStrings("action:goto_split:up", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter 24" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("a,b,c,def");
try testing.expectEqualStrings("a", (try s.next()).?);
try testing.expectEqualStrings("b", (try s.next()).?);
try testing.expectEqualStrings("c,def", s.rest().?);
try testing.expect(null == try s.next());
}
test "splitter 25" {
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("a,\\u{10,df}");
try testing.expectEqualStrings("a", (try s.next()).?);
try testing.expectError(error.IllegalEscape, s.next());
}

View File

@ -7,6 +7,7 @@ const diags = @import("diagnostics.zig");
const internal_os = @import("../os/main.zig"); const internal_os = @import("../os/main.zig");
const Diagnostic = diags.Diagnostic; const Diagnostic = diags.Diagnostic;
const DiagnosticList = diags.DiagnosticList; const DiagnosticList = diags.DiagnosticList;
const CommaSplitter = @import("CommaSplitter.zig");
const log = std.log.scoped(.cli); const log = std.log.scoped(.cli);
@ -506,13 +507,18 @@ pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
fn parseStruct(comptime T: type, alloc: Allocator, v: []const u8) !T { fn parseStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
return switch (@typeInfo(T).@"struct".layout) { return switch (@typeInfo(T).@"struct".layout) {
.auto => parseAutoStruct(T, alloc, v), .auto => parseAutoStruct(T, alloc, v, null),
.@"packed" => parsePackedStruct(T, v), .@"packed" => parsePackedStruct(T, v),
else => @compileError("unsupported struct layout"), else => @compileError("unsupported struct layout"),
}; };
} }
pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T { pub fn parseAutoStruct(
comptime T: type,
alloc: Allocator,
v: []const u8,
default_: ?T,
) !T {
const info = @typeInfo(T).@"struct"; const info = @typeInfo(T).@"struct";
comptime assert(info.layout == .auto); comptime assert(info.layout == .auto);
@ -527,24 +533,31 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
const FieldSet = std.StaticBitSet(info.fields.len); const FieldSet = std.StaticBitSet(info.fields.len);
var fields_set: FieldSet = .initEmpty(); var fields_set: FieldSet = .initEmpty();
// We split each value by "," // We split each value by "," allowing for quoting and escaping.
var iter = std.mem.splitSequence(u8, v, ","); var iter: CommaSplitter = .init(v);
loop: while (iter.next()) |entry| { loop: while (try iter.next()) |entry| {
// Find the key/value, trimming whitespace. The value may be quoted // Find the key/value, trimming whitespace. The value may be quoted
// which we strip the quotes from. // which we strip the quotes from.
const idx = mem.indexOf(u8, entry, ":") orelse return error.InvalidValue; const idx = mem.indexOf(u8, entry, ":") orelse return error.InvalidValue;
const key = std.mem.trim(u8, entry[0..idx], whitespace); const key = std.mem.trim(u8, entry[0..idx], whitespace);
// used if we need to decode a double-quoted string.
var buf: std.ArrayListUnmanaged(u8) = .empty;
defer buf.deinit(alloc);
const value = value: { const value = value: {
var value = std.mem.trim(u8, entry[idx + 1 ..], whitespace); const value = std.mem.trim(u8, entry[idx + 1 ..], whitespace);
// Detect a quoted string. // Detect a quoted string.
if (value.len >= 2 and if (value.len >= 2 and
value[0] == '"' and value[0] == '"' and
value[value.len - 1] == '"') value[value.len - 1] == '"')
{ {
// Trim quotes since our CLI args processor expects // Decode a double-quoted string as a Zig string literal.
// quotes to already be gone. const writer = buf.writer(alloc);
value = value[1 .. value.len - 1]; const parsed = try std.zig.string_literal.parseWrite(writer, value);
if (parsed == .failure) return error.InvalidValue;
break :value buf.items;
} }
break :value value; break :value value;
@ -565,9 +578,18 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
// Ensure all required fields are set // Ensure all required fields are set
inline for (info.fields, 0..) |field, i| { inline for (info.fields, 0..) |field, i| {
if (!fields_set.isSet(i)) { if (!fields_set.isSet(i)) {
@field(result, field.name) = default: {
// If we're given a default value then we inherit those.
// Otherwise we use the default values as specified by the
// struct.
if (default_) |default| {
break :default @field(default, field.name);
} else {
const default_ptr = field.default_value_ptr orelse return error.InvalidValue; const default_ptr = field.default_value_ptr orelse return error.InvalidValue;
const typed_ptr: *const field.type = @alignCast(@ptrCast(default_ptr)); const typed_ptr: *const field.type = @alignCast(@ptrCast(default_ptr));
@field(result, field.name) = typed_ptr.*; break :default typed_ptr.*;
}
};
} }
} }
@ -1186,6 +1208,17 @@ test "parseIntoField: struct with basic fields" {
try testing.expectEqual(84, data.value.b); try testing.expectEqual(84, data.value.b);
try testing.expectEqual(24, data.value.c); try testing.expectEqual(24, data.value.c);
// Set with explicit default
data.value = try parseAutoStruct(
@TypeOf(data.value),
alloc,
"a:hello",
.{ .a = "oh no", .b = 42 },
);
try testing.expectEqualStrings("hello", data.value.a);
try testing.expectEqual(42, data.value.b);
try testing.expectEqual(12, data.value.c);
// Missing required field // Missing required field
try testing.expectError( try testing.expectError(
error.InvalidValue, error.InvalidValue,

View File

@ -133,13 +133,23 @@ pub fn run(alloc: Allocator) !u8 {
// so this is not a big deal. // so this is not a big deal.
comptime assert(builtin.link_libc); comptime assert(builtin.link_libc);
const editorZ = try alloc.dupeZ(u8, editor); var buf: std.ArrayListUnmanaged(u8) = .empty;
defer alloc.free(editorZ); errdefer buf.deinit(alloc);
const pathZ = try alloc.dupeZ(u8, path);
defer alloc.free(pathZ); const writer = buf.writer(alloc);
var shellescape: internal_os.ShellEscapeWriter(std.ArrayListUnmanaged(u8).Writer) = .init(writer);
var shellescapewriter = shellescape.writer();
try writer.writeAll(editor);
try writer.writeByte(' ');
try shellescapewriter.writeAll(path);
const command = try buf.toOwnedSliceSentinel(alloc, 0);
defer alloc.free(command);
const err = std.posix.execvpeZ( const err = std.posix.execvpeZ(
editorZ, "sh",
&.{ editorZ, pathZ }, &.{ "sh", "-c", command },
std.c.environ, std.c.environ,
); );

View File

@ -895,6 +895,42 @@ const Preview = struct {
config.background.b, config.background.b,
}, },
}; };
const cursor_fg: vaxis.Color = if (config.@"cursor-text") |cursor_text| .{
.rgb = [_]u8{
cursor_text.color.r,
cursor_text.color.g,
cursor_text.color.b,
},
} else bg;
const cursor_bg: vaxis.Color = if (config.@"cursor-color") |cursor_bg| .{
.rgb = [_]u8{
cursor_bg.color.r,
cursor_bg.color.g,
cursor_bg.color.b,
},
} else fg;
const selection_fg: vaxis.Color = if (config.@"selection-foreground") |selection_fg| .{
.rgb = [_]u8{
selection_fg.color.r,
selection_fg.color.g,
selection_fg.color.b,
},
} else bg;
const selection_bg: vaxis.Color = if (config.@"selection-background") |selection_bg| .{
.rgb = [_]u8{
selection_bg.color.r,
selection_bg.color.g,
selection_bg.color.b,
},
} else fg;
const cursor: vaxis.Style = .{
.fg = cursor_fg,
.bg = cursor_bg,
};
const standard_selection: vaxis.Style = .{
.fg = selection_fg,
.bg = selection_bg,
};
const standard: vaxis.Style = .{ const standard: vaxis.Style = .{
.fg = fg, .fg = fg,
.bg = bg, .bg = bg,
@ -1433,11 +1469,8 @@ const Preview = struct {
&.{ &.{
.{ .text = " 14 │ ", .style = color238 }, .{ .text = " 14 │ ", .style = color238 },
.{ .text = "try ", .style = color5 }, .{ .text = "try ", .style = color5 },
.{ .text = "stdout.print(", .style = standard }, .{ .text = "stdout.print(\"{d}\\n\", .{i})", .style = standard_selection },
.{ .text = "\"{d}", .style = color10 }, .{ .text = ";", .style = cursor },
.{ .text = "\\n", .style = color12 },
.{ .text = "\"", .style = color10 },
.{ .text = ", .{i});", .style = standard },
}, },
.{ .{
.row_offset = 17, .row_offset = 17,

View File

@ -7,8 +7,9 @@ const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const assert = std.debug.assert; const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const xdg = @import("../../os/main.zig").xdg; const internal_os = @import("../../os/main.zig");
const TempDir = @import("../../os/main.zig").TempDir; const xdg = internal_os.xdg;
const TempDir = internal_os.TempDir;
const Entry = @import("Entry.zig"); const Entry = @import("Entry.zig");
// 512KB - sufficient for approximately 10k entries // 512KB - sufficient for approximately 10k entries
@ -332,48 +333,28 @@ fn isValidCacheKey(key: []const u8) bool {
if (std.mem.indexOf(u8, key, "@")) |at_pos| { if (std.mem.indexOf(u8, key, "@")) |at_pos| {
const user = key[0..at_pos]; const user = key[0..at_pos];
const hostname = key[at_pos + 1 ..]; const hostname = key[at_pos + 1 ..];
return isValidUser(user) and isValidHostname(hostname); return isValidUser(user) and isValidHost(hostname);
} }
return isValidHostname(key); return isValidHost(key);
} }
// Basic hostname validation - accepts domains and IPs // Checks if a host is a valid hostname or IP address
// (including IPv6 in brackets) fn isValidHost(host: []const u8) bool {
fn isValidHostname(host: []const u8) bool { // First check for valid hostnames because this is assumed to be the more
if (host.len == 0 or host.len > 253) return false; // likely ssh host format.
if (internal_os.hostname.isValid(host)) {
// Handle IPv6 addresses in brackets return true;
if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') {
const ipv6_part = host[1 .. host.len - 1];
if (ipv6_part.len == 0) return false;
var has_colon = false;
for (ipv6_part) |c| {
switch (c) {
'a'...'f', 'A'...'F', '0'...'9' => {},
':' => has_colon = true,
else => return false,
}
}
return has_colon;
} }
// Standard hostname/domain validation // We also accept valid IP addresses. In practice, IPv4 addresses are also
for (host) |c| { // considered valid hostnames due to their overlapping syntax, so we can
switch (c) { // simplify this check to be IPv6-specific.
'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, if (std.net.Address.parseIp6(host, 0)) |_| {
else => return false, return true;
} } else |_| {
}
// No leading/trailing dots or hyphens, no consecutive dots
if (host[0] == '.' or host[0] == '-' or
host[host.len - 1] == '.' or host[host.len - 1] == '-')
{
return false; return false;
} }
return std.mem.indexOf(u8, host, "..") == null;
} }
fn isValidUser(user: []const u8) bool { fn isValidUser(user: []const u8) bool {
@ -467,42 +448,36 @@ test "disk cache operations" {
} }
// Tests // Tests
test "hostname validation - valid cases" {
const testing = std.testing;
try testing.expect(isValidHostname("example.com"));
try testing.expect(isValidHostname("sub.example.com"));
try testing.expect(isValidHostname("host-name.domain.org"));
try testing.expect(isValidHostname("192.168.1.1"));
try testing.expect(isValidHostname("a"));
try testing.expect(isValidHostname("1"));
}
test "hostname validation - IPv6 addresses" { test isValidHost {
const testing = std.testing; const testing = std.testing;
try testing.expect(isValidHostname("[::1]"));
try testing.expect(isValidHostname("[2001:db8::1]"));
try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported
try testing.expect(!isValidHostname("[]")); // Empty IPv6
try testing.expect(!isValidHostname("[invalid]")); // No colons
}
test "hostname validation - invalid cases" { // Valid hostnames
const testing = std.testing; try testing.expect(isValidHost("localhost"));
try testing.expect(!isValidHostname("")); try testing.expect(isValidHost("example.com"));
try testing.expect(!isValidHostname("host\nname")); try testing.expect(isValidHost("sub.example.com"));
try testing.expect(!isValidHostname(".example.com"));
try testing.expect(!isValidHostname("example.com."));
try testing.expect(!isValidHostname("host..domain"));
try testing.expect(!isValidHostname("-hostname"));
try testing.expect(!isValidHostname("hostname-"));
try testing.expect(!isValidHostname("host name"));
try testing.expect(!isValidHostname("host_name"));
try testing.expect(!isValidHostname("host@domain"));
try testing.expect(!isValidHostname("host:port"));
// Too long // IPv4 addresses
const long_host = "a" ** 254; try testing.expect(isValidHost("127.0.0.1"));
try testing.expect(!isValidHostname(long_host)); try testing.expect(isValidHost("192.168.1.1"));
// IPv6 addresses
try testing.expect(isValidHost("::1"));
try testing.expect(isValidHost("2001:db8::1"));
try testing.expect(isValidHost("2001:db8:0:1:1:1:1:1"));
try testing.expect(!isValidHost("fe80::1%eth0")); // scopes not supported
// Invalid hosts
try testing.expect(!isValidHost(""));
try testing.expect(!isValidHost("host\nname"));
try testing.expect(!isValidHost(".example.com"));
try testing.expect(!isValidHost("host..domain"));
try testing.expect(!isValidHost("-hostname"));
try testing.expect(!isValidHost("hostname-"));
try testing.expect(!isValidHost("host name"));
try testing.expect(!isValidHost("host_name"));
try testing.expect(!isValidHost("host@domain"));
try testing.expect(!isValidHost("host:port"));
} }
test "user validation - valid cases" { test "user validation - valid cases" {
@ -543,7 +518,7 @@ test "cache key validation - hostname format" {
try testing.expect(isValidCacheKey("example.com")); try testing.expect(isValidCacheKey("example.com"));
try testing.expect(isValidCacheKey("sub.example.com")); try testing.expect(isValidCacheKey("sub.example.com"));
try testing.expect(isValidCacheKey("192.168.1.1")); try testing.expect(isValidCacheKey("192.168.1.1"));
try testing.expect(isValidCacheKey("[::1]")); try testing.expect(isValidCacheKey("::1"));
try testing.expect(!isValidCacheKey("")); try testing.expect(!isValidCacheKey(""));
try testing.expect(!isValidCacheKey(".invalid.com")); try testing.expect(!isValidCacheKey(".invalid.com"));
} }
@ -555,7 +530,7 @@ test "cache key validation - user@hostname format" {
try testing.expect(isValidCacheKey("test-user@192.168.1.1")); try testing.expect(isValidCacheKey("test-user@192.168.1.1"));
try testing.expect(isValidCacheKey("user_name@host.domain.org")); try testing.expect(isValidCacheKey("user_name@host.domain.org"));
try testing.expect(isValidCacheKey("git@github.com")); try testing.expect(isValidCacheKey("git@github.com"));
try testing.expect(isValidCacheKey("ubuntu@[::1]")); try testing.expect(isValidCacheKey("ubuntu@::1"));
try testing.expect(!isValidCacheKey("@example.com")); try testing.expect(!isValidCacheKey("@example.com"));
try testing.expect(!isValidCacheKey("user@")); try testing.expect(!isValidCacheKey("user@"));
try testing.expect(!isValidCacheKey("user@@host")); try testing.expect(!isValidCacheKey("user@@host"));

View File

@ -27,6 +27,7 @@ pub const FontStyle = Config.FontStyle;
pub const FreetypeLoadFlags = Config.FreetypeLoadFlags; pub const FreetypeLoadFlags = Config.FreetypeLoadFlags;
pub const Keybinds = Config.Keybinds; pub const Keybinds = Config.Keybinds;
pub const MouseShiftCapture = Config.MouseShiftCapture; pub const MouseShiftCapture = Config.MouseShiftCapture;
pub const MouseScrollMultiplier = Config.MouseScrollMultiplier;
pub const NonNativeFullscreen = Config.NonNativeFullscreen; pub const NonNativeFullscreen = Config.NonNativeFullscreen;
pub const OptionAsAlt = Config.OptionAsAlt; pub const OptionAsAlt = Config.OptionAsAlt;
pub const RepeatableCodepointMap = Config.RepeatableCodepointMap; pub const RepeatableCodepointMap = Config.RepeatableCodepointMap;

View File

@ -127,9 +127,6 @@ pub const compatibility = std.StaticStringMap(
/// this within config files if you want to clear previously set values in /// this within config files if you want to clear previously set values in
/// configuration files or on the CLI if you want to clear values set on the /// configuration files or on the CLI if you want to clear values set on the
/// CLI. /// CLI.
///
/// Changing this configuration at runtime will only affect new terminals, i.e.
/// new windows, tabs, etc.
@"font-family": RepeatableString = .{}, @"font-family": RepeatableString = .{},
@"font-family-bold": RepeatableString = .{}, @"font-family-bold": RepeatableString = .{},
@"font-family-italic": RepeatableString = .{}, @"font-family-italic": RepeatableString = .{},
@ -214,11 +211,12 @@ pub const compatibility = std.StaticStringMap(
/// ///
/// For example, 13.5pt @ 2px/pt = 27px /// For example, 13.5pt @ 2px/pt = 27px
/// ///
/// Changing this configuration at runtime will only affect new terminals, /// Changing this configuration at runtime will only affect existing
/// i.e. new windows, tabs, etc. Note that you may still not see the change /// terminals that have NOT manually adjusted their font size in some way
/// depending on your `window-inherit-font-size` setting. If that setting is /// (e.g. increased or decreased the font size). Terminals that have manually
/// true, only the first window will be affected by this change since all /// adjusted their font size will retain their manually adjusted size.
/// subsequent windows will inherit the font size of the previous window. /// Otherwise, the font size of existing terminals will be updated on
/// reload.
/// ///
/// On Linux with GTK, font size is scaled according to both display-wide and /// On Linux with GTK, font size is scaled according to both display-wide and
/// text-specific scaling factors, which are often managed by your desktop /// text-specific scaling factors, which are often managed by your desktop
@ -405,13 +403,13 @@ pub const compatibility = std.StaticStringMap(
@"adjust-box-thickness": ?MetricModifier = null, @"adjust-box-thickness": ?MetricModifier = null,
/// Height in pixels or percentage adjustment of maximum height for nerd font icons. /// Height in pixels or percentage adjustment of maximum height for nerd font icons.
/// ///
/// Increasing this value will allow nerd font icons to be larger, but won't /// A positive (negative) value will increase (decrease) the maximum icon
/// necessarily force them to be. Decreasing this value will make nerd font /// height. This may not affect all icons equally: the effect depends on whether
/// icons smaller. /// the default size of the icon is height-constrained, which in turn depends on
/// the aspect ratio of both the icon and your primary font.
/// ///
/// The default value for the icon height is 1.2 times the height of capital /// Certain icons designed for box drawing and terminal graphics, such as
/// letters in your primary font, so something like -16.6% would make icons /// Powerline symbols, are not affected by this option.
/// roughly the same height as capital letters.
/// ///
/// See the notes about adjustments in `adjust-cell-width`. /// See the notes about adjustments in `adjust-cell-width`.
/// ///
@ -515,7 +513,7 @@ pub const compatibility = std.StaticStringMap(
/// ///
/// To specify a different theme for light and dark mode, use the following /// To specify a different theme for light and dark mode, use the following
/// syntax: `light:theme-name,dark:theme-name`. For example: /// syntax: `light:theme-name,dark:theme-name`. For example:
/// `light:rose-pine-dawn,dark:rose-pine`. Whitespace around all values are /// `light:Rose Pine Dawn,dark:Rose Pine`. Whitespace around all values are
/// trimmed and order of light and dark does not matter. Both light and dark /// trimmed and order of light and dark does not matter. Both light and dark
/// must be specified in this form. In this form, the theme used will be /// must be specified in this form. In this form, the theme used will be
/// based on the current desktop environment theme. /// based on the current desktop environment theme.
@ -826,14 +824,20 @@ palette: Palette = .{},
/// * `never` /// * `never`
@"mouse-shift-capture": MouseShiftCapture = .false, @"mouse-shift-capture": MouseShiftCapture = .false,
/// Multiplier for scrolling distance with the mouse wheel. Any value less /// Multiplier for scrolling distance with the mouse wheel.
/// than 0.01 or greater than 10,000 will be clamped to the nearest valid
/// value.
/// ///
/// A value of "3" (default) scrolls 3 lines per tick. /// A prefix of `precision:` or `discrete:` can be used to set the multiplier
/// only for scrolling with the specific type of devices. These can be
/// comma-separated to set both types of multipliers at the same time, e.g.
/// `precision:0.1,discrete:3`. If no prefix is used, the multiplier applies
/// to all scrolling devices. Specifying a prefix was introduced in Ghostty
/// 1.2.1.
/// ///
/// Available since: 1.2.0 /// The value will be clamped to [0.01, 10,000]. Both of these are extreme
@"mouse-scroll-multiplier": f64 = 3.0, /// and you're likely to have a bad experience if you set either extreme.
///
/// The default value is "3" for discrete devices and "1" for precision devices.
@"mouse-scroll-multiplier": MouseScrollMultiplier = .default,
/// The opacity level (opposite of transparency) of the background. A value of /// The opacity level (opposite of transparency) of the background. A value of
/// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0 /// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0
@ -2146,6 +2150,8 @@ keybind: Keybinds = .{},
/// from the first by a comma (`,`). Percentage and pixel sizes can be mixed /// from the first by a comma (`,`). Percentage and pixel sizes can be mixed
/// together: for instance, a size of `50%,500px` for a top-positioned quick /// together: for instance, a size of `50%,500px` for a top-positioned quick
/// terminal would be half a screen tall, and 500 pixels wide. /// terminal would be half a screen tall, and 500 pixels wide.
///
/// Available since: 1.2.0
@"quick-terminal-size": QuickTerminalSize = .{}, @"quick-terminal-size": QuickTerminalSize = .{},
/// The layer of the quick terminal window. The higher the layer, /// The layer of the quick terminal window. The higher the layer,
@ -2341,6 +2347,11 @@ keybind: Keybinds = .{},
/// cache manually using various arguments. /// cache manually using various arguments.
/// (Available since: 1.2.0) /// (Available since: 1.2.0)
/// ///
/// * `path` - Add Ghostty's binary directory to PATH. This ensures the `ghostty`
/// command is available in the shell even if shell init scripts reset PATH.
/// This is particularly useful on macOS where PATH is often overridden by
/// system scripts. The directory is only added if not already present.
///
/// SSH features work independently and can be combined for optimal experience: /// SSH features work independently and can be combined for optimal experience:
/// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its /// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its
/// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to /// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to
@ -2354,9 +2365,21 @@ keybind: Keybinds = .{},
/// (`:`), and then the specified value. The syntax for actions is identical /// (`:`), and then the specified value. The syntax for actions is identical
/// to the one for keybind actions. Whitespace in between fields is ignored. /// to the one for keybind actions. Whitespace in between fields is ignored.
/// ///
/// If you need to embed commas or any other special characters in the values,
/// enclose the value in double quotes and it will be interpreted as a Zig
/// string literal. This is also useful for including whitespace at the
/// beginning or the end of a value. See the
/// [Zig documentation](https://ziglang.org/documentation/master/#Escape-Sequences)
/// for more information on string literals. Note that multiline string literals
/// are not supported.
///
/// Double quotes can not be used around the field names.
///
/// ```ini /// ```ini
/// command-palette-entry = title:Reset Font Style, action:csi:0m /// command-palette-entry = title:Reset Font Style, action:csi:0m
/// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main /// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main
/// command-palette-entry = title:Focus Split: Right,description:"Focus the split to the right, if it exists.",action:goto_split:right
/// command-palette-entry = title:"Ghostty",description:"Add a little Ghostty to your terminal.",action:"text:\xf0\x9f\x91\xbb"
/// ``` /// ```
/// ///
/// By default, the command palette is preloaded with most actions that might /// By default, the command palette is preloaded with most actions that might
@ -2710,7 +2733,7 @@ keybind: Keybinds = .{},
/// ///
/// * `new-tab` - Create a new tab in the current window, or open /// * `new-tab` - Create a new tab in the current window, or open
/// a new window if none exist. /// a new window if none exist.
/// * `new-window` - Create a new window unconditionally. /// * `window` - Create a new window unconditionally.
/// ///
/// The default value is `new-tab`. /// The default value is `new-tab`.
/// ///
@ -2846,10 +2869,7 @@ keybind: Keybinds = .{},
/// Supported formats include PNG, JPEG, and ICNS. /// Supported formats include PNG, JPEG, and ICNS.
/// ///
/// Defaults to `~/.config/ghostty/Ghostty.icns` /// Defaults to `~/.config/ghostty/Ghostty.icns`
/// @"macos-custom-icon": ?[:0]const u8 = null,
/// Note: This configuration is required when `macos-icon` is set to
/// `custom`
@"macos-custom-icon": ?[]const u8 = null,
/// The material to use for the frame of the macOS app icon. /// The material to use for the frame of the macOS app icon.
/// ///
@ -3342,7 +3362,7 @@ pub fn loadOptionalFile(
fn writeConfigTemplate(path: []const u8) !void { fn writeConfigTemplate(path: []const u8) !void {
log.info("creating template config file: path={s}", .{path}); log.info("creating template config file: path={s}", .{path});
if (std.fs.path.dirname(path)) |dir_path| { if (std.fs.path.dirname(path)) |dir_path| {
try std.fs.makeDirAbsolute(dir_path); try std.fs.cwd().makePath(dir_path);
} }
const file = try std.fs.createFileAbsolute(path, .{}); const file = try std.fs.createFileAbsolute(path, .{});
defer file.close(); defer file.close();
@ -4056,7 +4076,8 @@ pub fn finalize(self: *Config) !void {
} }
// Clamp our mouse scroll multiplier // Clamp our mouse scroll multiplier
self.@"mouse-scroll-multiplier" = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier")); self.@"mouse-scroll-multiplier".precision = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier".precision));
self.@"mouse-scroll-multiplier".discrete = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier".discrete));
// Clamp our split opacity // Clamp our split opacity
self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity")); self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity"));
@ -6487,7 +6508,7 @@ pub const RepeatableCodepointMap = struct {
return .{ .map = try self.map.clone(alloc) }; return .{ .map = try self.map.clone(alloc) };
} }
/// Compare if two of our value are requal. Required by Config. /// Compare if two of our value are equal. Required by Config.
pub fn equal(self: Self, other: Self) bool { pub fn equal(self: Self, other: Self) bool {
const itemsA = self.map.list.slice(); const itemsA = self.map.list.slice();
const itemsB = other.map.list.slice(); const itemsB = other.map.list.slice();
@ -6963,6 +6984,7 @@ pub const ShellIntegrationFeatures = packed struct {
title: bool = true, title: bool = true,
@"ssh-env": bool = false, @"ssh-env": bool = false,
@"ssh-terminfo": bool = false, @"ssh-terminfo": bool = false,
path: bool = true,
}; };
pub const RepeatableCommand = struct { pub const RepeatableCommand = struct {
@ -6989,6 +7011,7 @@ pub const RepeatableCommand = struct {
inputpkg.Command, inputpkg.Command,
alloc, alloc,
input, input,
null,
); );
try self.value.append(alloc, cmd); try self.value.append(alloc, cmd);
} }
@ -7020,18 +7043,24 @@ pub const RepeatableCommand = struct {
return; return;
} }
var buf: [4096]u8 = undefined;
for (self.value.items) |item| { for (self.value.items) |item| {
const str = if (item.description.len > 0) std.fmt.bufPrint( var buf: [4096]u8 = undefined;
&buf, var fbs = std.io.fixedBufferStream(&buf);
"title:{s},description:{s},action:{}", var writer = fbs.writer();
.{ item.title, item.description, item.action },
) else std.fmt.bufPrint( writer.writeAll("title:\"") catch return error.OutOfMemory;
&buf, std.zig.stringEscape(item.title, "", .{}, writer) catch return error.OutOfMemory;
"title:{s},action:{}", writer.writeAll("\"") catch return error.OutOfMemory;
.{ item.title, item.action },
); if (item.description.len > 0) {
try formatter.formatEntry([]const u8, str catch return error.OutOfMemory); writer.writeAll(",description:\"") catch return error.OutOfMemory;
std.zig.stringEscape(item.description, "", .{}, writer) catch return error.OutOfMemory;
writer.writeAll("\"") catch return error.OutOfMemory;
}
writer.print(",action:\"{}\"", .{item.action}) catch return error.OutOfMemory;
try formatter.formatEntry([]const u8, fbs.getWritten());
} }
} }
@ -7097,7 +7126,7 @@ pub const RepeatableCommand = struct {
var list: RepeatableCommand = .{}; var list: RepeatableCommand = .{};
try list.parseCLI(alloc, "title:Bobr, action:text:Bober"); try list.parseCLI(alloc, "title:Bobr, action:text:Bober");
try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:Bober\n", buf.items); try std.testing.expectEqualSlices(u8, "a = title:\"Bobr\",action:\"text:Bober\"\n", buf.items);
} }
test "RepeatableCommand formatConfig multiple items" { test "RepeatableCommand formatConfig multiple items" {
@ -7113,7 +7142,40 @@ pub const RepeatableCommand = struct {
try list.parseCLI(alloc, "title:Bobr, action:text:kurwa"); try list.parseCLI(alloc, "title:Bobr, action:text:kurwa");
try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle"); try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle");
try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:kurwa\na = title:Ja,description:pierdole,action:text:jakie bydle\n", buf.items); try std.testing.expectEqualSlices(u8, "a = title:\"Bobr\",action:\"text:kurwa\"\na = title:\"Ja\",description:\"pierdole\",action:\"text:jakie bydle\"\n", buf.items);
}
test "RepeatableCommand parseCLI commas" {
const testing = std.testing;
var buf = std.ArrayList(u8).init(testing.allocator);
defer buf.deinit();
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
{
var list: RepeatableCommand = .{};
try list.parseCLI(alloc, "title:\"Bo,br\",action:\"text:kur,wa\"");
try testing.expectEqual(@as(usize, 1), list.value.items.len);
const item = list.value.items[0];
try testing.expectEqualStrings("Bo,br", item.title);
try testing.expectEqualStrings("", item.description);
try testing.expect(item.action == .text);
try testing.expectEqualStrings("kur,wa", item.action.text);
}
{
var list: RepeatableCommand = .{};
try list.parseCLI(alloc, "title:\"Bo,br\",description:\"abc,def\",action:text:kurwa");
try testing.expectEqual(@as(usize, 1), list.value.items.len);
const item = list.value.items[0];
try testing.expectEqualStrings("Bo,br", item.title);
try testing.expectEqualStrings("abc,def", item.description);
try testing.expect(item.action == .text);
try testing.expectEqualStrings("kurwa", item.action.text);
}
} }
}; };
@ -7259,6 +7321,108 @@ pub const MouseShiftCapture = enum {
never, never,
}; };
/// See mouse-scroll-multiplier
pub const MouseScrollMultiplier = struct {
const Self = @This();
precision: f64 = 1,
discrete: f64 = 3,
pub const default: MouseScrollMultiplier = .{};
pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void {
const input = input_ orelse return error.ValueRequired;
self.* = cli.args.parseAutoStruct(
MouseScrollMultiplier,
alloc,
input,
self.*,
) catch |err| switch (err) {
error.InvalidValue => bare: {
const v = std.fmt.parseFloat(
f64,
input,
) catch return error.InvalidValue;
break :bare .{
.precision = v,
.discrete = v,
};
},
else => return err,
};
}
/// Deep copy of the struct. Required by Config.
pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self {
_ = alloc;
return self.*;
}
/// Compare if two of our value are equal. Required by Config.
pub fn equal(self: Self, other: Self) bool {
return self.precision == other.precision and self.discrete == other.discrete;
}
/// Used by Formatter
pub fn formatEntry(self: Self, formatter: anytype) !void {
var buf: [32]u8 = undefined;
const formatted = std.fmt.bufPrint(
&buf,
"precision:{d},discrete:{d}",
.{ self.precision, self.discrete },
) catch return error.OutOfMemory;
try formatter.formatEntry([]const u8, formatted);
}
test "parse" {
const testing = std.testing;
const alloc = testing.allocator;
const epsilon = 0.00001;
var args: Self = .{ .precision = 0.1, .discrete = 3 };
try args.parseCLI(alloc, "3");
try testing.expectApproxEqAbs(3, args.precision, epsilon);
try testing.expectApproxEqAbs(3, args.discrete, epsilon);
args = .{ .precision = 0.1, .discrete = 3 };
try args.parseCLI(alloc, "precision:1");
try testing.expectApproxEqAbs(1, args.precision, epsilon);
try testing.expectApproxEqAbs(3, args.discrete, epsilon);
args = .{ .precision = 0.1, .discrete = 3 };
try args.parseCLI(alloc, "discrete:5");
try testing.expectApproxEqAbs(0.1, args.precision, epsilon);
try testing.expectApproxEqAbs(5, args.discrete, epsilon);
args = .{ .precision = 0.1, .discrete = 3 };
try args.parseCLI(alloc, "precision:3,discrete:7");
try testing.expectApproxEqAbs(3, args.precision, epsilon);
try testing.expectApproxEqAbs(7, args.discrete, epsilon);
args = .{ .precision = 0.1, .discrete = 3 };
try args.parseCLI(alloc, "discrete:8,precision:6");
try testing.expectApproxEqAbs(6, args.precision, epsilon);
try testing.expectApproxEqAbs(8, args.discrete, epsilon);
args = .default;
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "foo:1"));
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:bar"));
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:1,discrete:3,foo:5"));
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:1,,discrete:3"));
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, ",precision:1,discrete:3"));
}
test "format entry MouseScrollMultiplier" {
const testing = std.testing;
var buf = std.ArrayList(u8).init(testing.allocator);
defer buf.deinit();
var args: Self = .{ .precision = 1.5, .discrete = 2.5 };
try args.formatEntry(formatterpkg.entryFormatter("mouse-scroll-multiplier", buf.writer()));
try testing.expectEqualSlices(u8, "mouse-scroll-multiplier = precision:1.5,discrete:2.5\n", buf.items);
}
};
/// How to treat requests to write to or read from the clipboard /// How to treat requests to write to or read from the clipboard
pub const ClipboardAccess = enum { pub const ClipboardAccess = enum {
allow, allow,
@ -7873,6 +8037,7 @@ pub const Theme = struct {
Theme, Theme,
alloc, alloc,
input, input,
null,
); );
return; return;
} }

View File

@ -10,7 +10,7 @@ pub const ftdetect =
\\" \\"
\\" THIS FILE IS AUTO-GENERATED \\" THIS FILE IS AUTO-GENERATED
\\ \\
\\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* set ft=ghostty \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* setf ghostty
\\ \\
; ;
pub const ftplugin = pub const ftplugin =

View File

@ -0,0 +1,147 @@
// The contents of this file is largely based on testing.zig from the Zig 0.15.1
// stdlib, distributed under the MIT license, copyright (c) Zig contributors
const std = @import("std");
/// Generic, recursive equality testing utility using approximate comparison for
/// floats and equality for everything else
///
/// Based on `std.testing.expectEqual` and `std.testing.expectEqualSlices`.
///
/// The relative tolerance is currently hardcoded to `sqrt(eps(float_type))`.
pub inline fn expectApproxEqual(expected: anytype, actual: anytype) !void {
const T = @TypeOf(expected, actual);
return expectApproxEqualInner(T, expected, actual);
}
fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void {
switch (@typeInfo(T)) {
// check approximate equality for floats
.float => {
const sqrt_eps = comptime std.math.sqrt(std.math.floatEps(T));
if (!std.math.approxEqRel(T, expected, actual, sqrt_eps)) {
print("expected approximately {any}, found {any}\n", .{ expected, actual });
return error.TestExpectedApproxEqual;
}
},
// recurse into containers
.array => {
const diff_index: usize = diff_index: {
const shortest = @min(expected.len, actual.len);
var index: usize = 0;
while (index < shortest) : (index += 1) {
expectApproxEqual(actual[index], expected[index]) catch break :diff_index index;
}
break :diff_index if (expected.len == actual.len) return else shortest;
};
print("slices not approximately equal. first significant difference occurs at index {d} (0x{X})\n", .{ diff_index, diff_index });
return error.TestExpectedApproxEqual;
},
.vector => |info| {
var i: usize = 0;
while (i < info.len) : (i += 1) {
expectApproxEqual(expected[i], actual[i]) catch {
print("index {d} incorrect. expected approximately {any}, found {any}\n", .{
i, expected[i], actual[i],
});
return error.TestExpectedApproxEqual;
};
}
},
.@"struct" => |structType| {
inline for (structType.fields) |field| {
try expectApproxEqual(@field(expected, field.name), @field(actual, field.name));
}
},
// unwrap unions, optionals, and error unions
.@"union" => |union_info| {
if (union_info.tag_type == null) {
// untagged unions can only be compared bitwise,
// so expectEqual is all we need
std.testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
};
}
const Tag = std.meta.Tag(@TypeOf(expected));
const expectedTag = @as(Tag, expected);
const actualTag = @as(Tag, actual);
std.testing.expectEqual(expectedTag, actualTag) catch {
return error.TestExpectedApproxEqual;
};
// we only reach this switch if the tags are equal
switch (expected) {
inline else => |val, tag| try expectApproxEqual(val, @field(actual, @tagName(tag))),
}
},
.optional, .error_union => {
if (expected) |expected_payload| if (actual) |actual_payload| {
return expectApproxEqual(expected_payload, actual_payload);
};
// we only reach this point if there's at least one null or error,
// in which case expectEqual is all we need
std.testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
};
},
// fall back to expectEqual for everything else
else => std.testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
},
}
}
/// Copy of std.testing.print (not public)
fn print(comptime fmt: []const u8, args: anytype) void {
if (@inComptime()) {
@compileError(std.fmt.comptimePrint(fmt, args));
} else if (std.testing.backend_can_print) {
std.debug.print(fmt, args);
}
}
// Tests based on the `expectEqual` tests in the Zig stdlib
test "expectApproxEqual.union(enum)" {
const T = union(enum) {
a: i32,
b: f32,
};
const b10 = T{ .b = 10.0 };
const b10plus = T{ .b = 10.000001 };
try expectApproxEqual(b10, b10plus);
}
test "expectApproxEqual nested array" {
const a = [2][2]f32{
[_]f32{ 1.0, 0.0 },
[_]f32{ 0.0, 1.0 },
};
const b = [2][2]f32{
[_]f32{ 1.000001, 0.0 },
[_]f32{ 0.0, 0.999999 },
};
try expectApproxEqual(a, b);
}
test "expectApproxEqual vector" {
const a: @Vector(4, f32) = @splat(4.0);
const b: @Vector(4, f32) = @splat(4.000001);
try expectApproxEqual(a, b);
}
test "expectApproxEqual struct" {
const a = .{ 1, @as(f32, 1.0) };
const b = .{ 1, @as(f32, 0.999999) };
try expectApproxEqual(a, b);
}

View File

@ -23,7 +23,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
/// Arguments: /// Arguments:
/// node: Pointer to a node in the list. /// node: Pointer to a node in the list.
/// new_node: Pointer to the new node to insert. /// new_node: Pointer to the new node to insert.
pub fn insertAfter(list: *Self, node: *Node, new_node: *Node) void { pub inline fn insertAfter(list: *Self, node: *Node, new_node: *Node) void {
new_node.prev = node; new_node.prev = node;
if (node.next) |next_node| { if (node.next) |next_node| {
// Intermediate node. // Intermediate node.
@ -42,7 +42,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
/// Arguments: /// Arguments:
/// node: Pointer to a node in the list. /// node: Pointer to a node in the list.
/// new_node: Pointer to the new node to insert. /// new_node: Pointer to the new node to insert.
pub fn insertBefore(list: *Self, node: *Node, new_node: *Node) void { pub inline fn insertBefore(list: *Self, node: *Node, new_node: *Node) void {
new_node.next = node; new_node.next = node;
if (node.prev) |prev_node| { if (node.prev) |prev_node| {
// Intermediate node. // Intermediate node.
@ -60,7 +60,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
/// ///
/// Arguments: /// Arguments:
/// new_node: Pointer to the new node to insert. /// new_node: Pointer to the new node to insert.
pub fn append(list: *Self, new_node: *Node) void { pub inline fn append(list: *Self, new_node: *Node) void {
if (list.last) |last| { if (list.last) |last| {
// Insert after last. // Insert after last.
list.insertAfter(last, new_node); list.insertAfter(last, new_node);
@ -74,7 +74,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
/// ///
/// Arguments: /// Arguments:
/// new_node: Pointer to the new node to insert. /// new_node: Pointer to the new node to insert.
pub fn prepend(list: *Self, new_node: *Node) void { pub inline fn prepend(list: *Self, new_node: *Node) void {
if (list.first) |first| { if (list.first) |first| {
// Insert before first. // Insert before first.
list.insertBefore(first, new_node); list.insertBefore(first, new_node);
@ -91,7 +91,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
/// ///
/// Arguments: /// Arguments:
/// node: Pointer to the node to be removed. /// node: Pointer to the node to be removed.
pub fn remove(list: *Self, node: *Node) void { pub inline fn remove(list: *Self, node: *Node) void {
if (node.prev) |prev_node| { if (node.prev) |prev_node| {
// Intermediate node. // Intermediate node.
prev_node.next = node.next; prev_node.next = node.next;
@ -113,7 +113,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
/// ///
/// Returns: /// Returns:
/// A pointer to the last node in the list. /// A pointer to the last node in the list.
pub fn pop(list: *Self) ?*Node { pub inline fn pop(list: *Self) ?*Node {
const last = list.last orelse return null; const last = list.last orelse return null;
list.remove(last); list.remove(last);
return last; return last;
@ -123,7 +123,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
/// ///
/// Returns: /// Returns:
/// A pointer to the first node in the list. /// A pointer to the first node in the list.
pub fn popFirst(list: *Self) ?*Node { pub inline fn popFirst(list: *Self) ?*Node {
const first = list.first orelse return null; const first = list.first orelse return null;
list.remove(first); list.remove(first);
return first; return first;

View File

@ -19,6 +19,7 @@ const std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const config = @import("../config.zig"); const config = @import("../config.zig");
const comparison = @import("../datastruct/comparison.zig");
const font = @import("main.zig"); const font = @import("main.zig");
const options = font.options; const options = font.options;
const DeferredFace = font.DeferredFace; const DeferredFace = font.DeferredFace;
@ -1199,7 +1200,7 @@ test "metrics" {
try c.updateMetrics(); try c.updateMetrics();
try std.testing.expectEqual(font.Metrics{ try comparison.expectApproxEqual(font.Metrics{
.cell_width = 8, .cell_width = 8,
// The cell height is 17 px because the calculation is // The cell height is 17 px because the calculation is
// //
@ -1213,6 +1214,9 @@ test "metrics" {
// and 1em should be the point size * dpi scale, so 12 * (96/72) // and 1em should be the point size * dpi scale, so 12 * (96/72)
// which is 16, and 16 * 1.049 = 16.784, which finally is rounded // which is 16, and 16 * 1.049 = 16.784, which finally is rounded
// to 17. // to 17.
//
// The icon height is (2 * cap_height + face_height) / 3
// = (2 * 623 + 1049) / 3 = 765, and 16 * 0.765 = 12.24.
.cell_height = 17, .cell_height = 17,
.cell_baseline = 3, .cell_baseline = 3,
.underline_position = 17, .underline_position = 17,
@ -1223,12 +1227,16 @@ test "metrics" {
.overline_thickness = 1, .overline_thickness = 1,
.box_thickness = 1, .box_thickness = 1,
.cursor_height = 17, .cursor_height = 17,
.icon_height = 11, .icon_height = 16.784,
.icon_height_single = 12.24,
.face_width = 8.0,
.face_height = 16.784,
.face_y = -0.04,
}, c.metrics); }, c.metrics);
// Resize should change metrics // Resize should change metrics
try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 }); try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 });
try std.testing.expectEqual(font.Metrics{ try comparison.expectApproxEqual(font.Metrics{
.cell_width = 16, .cell_width = 16,
.cell_height = 34, .cell_height = 34,
.cell_baseline = 6, .cell_baseline = 6,
@ -1240,7 +1248,11 @@ test "metrics" {
.overline_thickness = 2, .overline_thickness = 2,
.box_thickness = 2, .box_thickness = 2,
.cursor_height = 34, .cursor_height = 34,
.icon_height = 23, .icon_height = 33.568,
.icon_height_single = 24.48,
.face_width = 16.0,
.face_height = 33.568,
.face_y = -0.08,
}, c.metrics); }, c.metrics);
} }
@ -1369,3 +1381,133 @@ test "adjusted sizes" {
); );
} }
} }
test "face metrics" {
// The web canvas backend doesn't calculate face metrics, only cell metrics
if (options.backend != .web_canvas) return error.SkipZigTest;
const testing = std.testing;
const alloc = testing.allocator;
const narrowFont = font.embedded.cozette;
const wideFont = font.embedded.geist_mono;
var lib = try Library.init(alloc);
defer lib.deinit();
var c = init();
defer c.deinit(alloc);
const size: DesiredSize = .{ .points = 12, .xdpi = 96, .ydpi = 96 };
c.load_options = .{ .library = lib, .size = size };
const narrowIndex = try c.add(alloc, try .init(
lib,
narrowFont,
.{ .size = size },
), .{
.style = .regular,
.fallback = false,
.size_adjustment = .none,
});
const wideIndex = try c.add(alloc, try .init(
lib,
wideFont,
.{ .size = size },
), .{
.style = .regular,
.fallback = false,
.size_adjustment = .none,
});
const narrowMetrics: font.Metrics.FaceMetrics = (try c.getFace(narrowIndex)).getMetrics();
const wideMetrics: font.Metrics.FaceMetrics = (try c.getFace(wideIndex)).getMetrics();
// Verify provided/measured metrics. Measured
// values are backend-dependent due to hinting.
const narrowMetricsExpected = font.Metrics.FaceMetrics{
.px_per_em = 16.0,
.cell_width = switch (options.backend) {
.freetype,
.fontconfig_freetype,
.coretext_freetype,
=> 8.0,
.coretext,
.coretext_harfbuzz,
.coretext_noshape,
=> 7.3828125,
.web_canvas => unreachable,
},
.ascent = 12.3046875,
.descent = -3.6953125,
.line_gap = 0.0,
.underline_position = -1.2265625,
.underline_thickness = 1.2265625,
.strikethrough_position = 6.15625,
.strikethrough_thickness = 1.234375,
.cap_height = 9.84375,
.ex_height = 7.3828125,
.ascii_height = switch (options.backend) {
.freetype,
.fontconfig_freetype,
.coretext_freetype,
=> 18.0625,
.coretext,
.coretext_harfbuzz,
.coretext_noshape,
=> 16.0,
.web_canvas => unreachable,
},
};
const wideMetricsExpected = font.Metrics.FaceMetrics{
.px_per_em = 16.0,
.cell_width = switch (options.backend) {
.freetype,
.fontconfig_freetype,
.coretext_freetype,
=> 10.0,
.coretext,
.coretext_harfbuzz,
.coretext_noshape,
=> 9.6,
.web_canvas => unreachable,
},
.ascent = 14.72,
.descent = -3.52,
.line_gap = 1.6,
.underline_position = -1.6,
.underline_thickness = 0.8,
.strikethrough_position = 4.24,
.strikethrough_thickness = 0.8,
.cap_height = 11.36,
.ex_height = 8.48,
.ascii_height = switch (options.backend) {
.freetype,
.fontconfig_freetype,
.coretext_freetype,
=> 16.0,
.coretext,
.coretext_harfbuzz,
.coretext_noshape,
=> 15.472000000000001,
.web_canvas => unreachable,
},
};
inline for (
.{ narrowMetricsExpected, wideMetricsExpected },
.{ narrowMetrics, wideMetrics },
) |metricsExpected, metricsActual| {
try comparison.expectApproxEqual(metricsExpected, metricsActual);
}
// Verify estimated metrics. icWidth() should equal the smaller of
// 2 * cell_width and ascii_height. For a narrow (wide) font, the
// smaller quantity is the former (latter).
try std.testing.expectEqual(
2 * narrowMetrics.cell_width,
narrowMetrics.icWidth(),
);
try std.testing.expectEqual(
wideMetrics.ascii_height,
wideMetrics.icWidth(),
);
}

View File

@ -36,11 +36,20 @@ cursor_thickness: u32 = 1,
cursor_height: u32, cursor_height: u32,
/// The constraint height for nerd fonts icons. /// The constraint height for nerd fonts icons.
icon_height: u32, icon_height: f64,
/// Original cell width in pixels. This is used to keep /// The constraint height for nerd fonts icons limited to a single cell width.
/// glyphs centered if the cell width is adjusted wider. icon_height_single: f64,
original_cell_width: ?u32 = null,
/// The unrounded face width, used in scaling calculations.
face_width: f64,
/// The unrounded face height, used in scaling calculations.
face_height: f64,
/// The vertical bearing of face within the pixel-rounded
/// and possibly height-adjusted cell
face_y: f64,
/// Minimum acceptable values for some fields to prevent modifiers /// Minimum acceptable values for some fields to prevent modifiers
/// from being able to, for example, cause 0-thickness underlines. /// from being able to, for example, cause 0-thickness underlines.
@ -53,7 +62,10 @@ const Minimums = struct {
const box_thickness = 1; const box_thickness = 1;
const cursor_thickness = 1; const cursor_thickness = 1;
const cursor_height = 1; const cursor_height = 1;
const icon_height = 1; const icon_height = 1.0;
const icon_height_single = 1.0;
const face_height = 1.0;
const face_width = 1.0;
}; };
/// Metrics extracted from a font face, based on /// Metrics extracted from a font face, based on
@ -117,6 +129,16 @@ pub const FaceMetrics = struct {
/// lowercase x glyph. /// lowercase x glyph.
ex_height: ?f64 = null, ex_height: ?f64 = null,
/// The measured height of the bounding box containing all printable
/// ASCII characters. This can be different from ascent - descent for
/// two reasons: non-letter symbols like @ and $ often exceed the
/// the ascender and descender lines; and fonts often bake the line
/// gap into the ascent and descent metrics (as per, e.g., the Google
/// Fonts guidelines: https://simoncozens.github.io/gf-docs/metrics.html).
///
/// Positive value in px
ascii_height: ?f64 = null,
/// The width of the character "" (CJK water ideograph, U+6C34), /// The width of the character "" (CJK water ideograph, U+6C34),
/// if present. This is used for font size adjustment, to normalize /// if present. This is used for font size adjustment, to normalize
/// the width of CJK fonts mixed with latin fonts. /// the width of CJK fonts mixed with latin fonts.
@ -144,11 +166,20 @@ pub const FaceMetrics = struct {
return 0.75 * self.capHeight(); return 0.75 * self.capHeight();
} }
/// Convenience function for getting the ASCII height. If we
/// couldn't measure this, we use 1.5 * cap_height as our
/// estimator, based on measurements across programming fonts.
pub inline fn asciiHeight(self: FaceMetrics) f64 {
if (self.ascii_height) |value| if (value > 0) return value;
return 1.5 * self.capHeight();
}
/// Convenience function for getting the ideograph width. If this is /// Convenience function for getting the ideograph width. If this is
/// not defined in the font, we estimate it as two cell widths. /// not defined in the font, we estimate it as the minimum of the
/// ascii height and two cell widths.
pub inline fn icWidth(self: FaceMetrics) f64 { pub inline fn icWidth(self: FaceMetrics) f64 {
if (self.ic_width) |value| if (value > 0) return value; if (self.ic_width) |value| if (value > 0) return value;
return 2 * self.cell_width; return @min(self.asciiHeight(), 2 * self.cell_width);
} }
/// Convenience function for getting the underline thickness. If /// Convenience function for getting the underline thickness. If
@ -195,8 +226,10 @@ pub fn calc(face: FaceMetrics) Metrics {
// We use the ceiling of the provided cell width and height to ensure // We use the ceiling of the provided cell width and height to ensure
// that the cell is large enough for the provided size, since we cast // that the cell is large enough for the provided size, since we cast
// it to an integer later. // it to an integer later.
const cell_width = @ceil(face.cell_width); const face_width = face.cell_width;
const cell_height = @ceil(face.lineHeight()); const face_height = face.lineHeight();
const cell_width = @ceil(face_width);
const cell_height = @ceil(face_height);
// We split our line gap in two parts, and put half of it on the top // We split our line gap in two parts, and put half of it on the top
// of the cell and the other half on the bottom, so that our text never // of the cell and the other half on the bottom, so that our text never
@ -205,7 +238,11 @@ pub fn calc(face: FaceMetrics) Metrics {
// Unlike all our other metrics, `cell_baseline` is relative to the // Unlike all our other metrics, `cell_baseline` is relative to the
// BOTTOM of the cell. // BOTTOM of the cell.
const cell_baseline = @round(half_line_gap - face.descent); const face_baseline = half_line_gap - face.descent;
const cell_baseline = @round(face_baseline);
// We keep track of the vertical bearing of the face in the cell
const face_y = cell_baseline - face_baseline;
// We calculate a top_to_baseline to make following calculations simpler. // We calculate a top_to_baseline to make following calculations simpler.
const top_to_baseline = cell_height - cell_baseline; const top_to_baseline = cell_height - cell_baseline;
@ -218,16 +255,11 @@ pub fn calc(face: FaceMetrics) Metrics {
const underline_position = @round(top_to_baseline - face.underlinePosition()); const underline_position = @round(top_to_baseline - face.underlinePosition());
const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition()); const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition());
// The calculation for icon height in the nerd fonts patcher // Same heuristic as the font_patcher script. We store icon_height
// is two thirds cap height to one third line height, but we // separately from face_height such that modifiers can apply to the former
// use an opinionated default of 1.2 * cap height instead. // without affecting the latter.
// const icon_height = face_height;
// Doing this prevents fonts with very large line heights const icon_height_single = (2 * cap_height + face_height) / 3;
// from having excessively oversized icons, and allows fonts
// with very small line heights to still have roomy icons.
//
// We do cap it at `cell_height` though for obvious reasons.
const icon_height = @min(cell_height, cap_height * 1.2);
var result: Metrics = .{ var result: Metrics = .{
.cell_width = @intFromFloat(cell_width), .cell_width = @intFromFloat(cell_width),
@ -241,7 +273,11 @@ pub fn calc(face: FaceMetrics) Metrics {
.overline_thickness = @intFromFloat(underline_thickness), .overline_thickness = @intFromFloat(underline_thickness),
.box_thickness = @intFromFloat(underline_thickness), .box_thickness = @intFromFloat(underline_thickness),
.cursor_height = @intFromFloat(cell_height), .cursor_height = @intFromFloat(cell_height),
.icon_height = @intFromFloat(icon_height), .icon_height = icon_height,
.icon_height_single = icon_height_single,
.face_width = face_width,
.face_height = face_height,
.face_y = face_y,
}; };
// Ensure all metrics are within their allowable range. // Ensure all metrics are within their allowable range.
@ -267,11 +303,6 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
const new = @max(entry.value_ptr.apply(original), 1); const new = @max(entry.value_ptr.apply(original), 1);
if (new == original) continue; if (new == original) continue;
// Preserve the original cell width if not set.
if (self.original_cell_width == null) {
self.original_cell_width = self.cell_width;
}
// Set the new value // Set the new value
@field(self, @tagName(tag)) = new; @field(self, @tagName(tag)) = new;
@ -288,6 +319,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
const diff = new - original; const diff = new - original;
const diff_bottom = diff / 2; const diff_bottom = diff / 2;
const diff_top = diff - diff_bottom; const diff_top = diff - diff_bottom;
self.face_y += @floatFromInt(diff_bottom);
self.cell_baseline +|= diff_bottom; self.cell_baseline +|= diff_bottom;
self.underline_position +|= diff_top; self.underline_position +|= diff_top;
self.strikethrough_position +|= diff_top; self.strikethrough_position +|= diff_top;
@ -296,6 +328,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
const diff = original - new; const diff = original - new;
const diff_bottom = diff / 2; const diff_bottom = diff / 2;
const diff_top = diff - diff_bottom; const diff_top = diff - diff_bottom;
self.face_y -= @floatFromInt(diff_bottom);
self.cell_baseline -|= diff_bottom; self.cell_baseline -|= diff_bottom;
self.underline_position -|= diff_top; self.underline_position -|= diff_top;
self.strikethrough_position -|= diff_top; self.strikethrough_position -|= diff_top;
@ -303,6 +336,10 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
} }
} }
}, },
inline .icon_height => {
self.icon_height = entry.value_ptr.apply(self.icon_height);
self.icon_height_single = entry.value_ptr.apply(self.icon_height_single);
},
inline else => |tag| { inline else => |tag| {
@field(self, @tagName(tag)) = entry.value_ptr.apply(@field(self, @tagName(tag))); @field(self, @tagName(tag)) = entry.value_ptr.apply(@field(self, @tagName(tag)));
@ -398,8 +435,9 @@ pub const Modifier = union(enum) {
/// Apply a modifier to a numeric value. /// Apply a modifier to a numeric value.
pub fn apply(self: Modifier, v: anytype) @TypeOf(v) { pub fn apply(self: Modifier, v: anytype) @TypeOf(v) {
const T = @TypeOf(v); const T = @TypeOf(v);
const signed = @typeInfo(T).int.signedness == .signed; const Tinfo = @typeInfo(T);
return switch (self) { return switch (comptime Tinfo) {
.int, .comptime_int => switch (self) {
.percent => |p| percent: { .percent => |p| percent: {
const p_clamped: f64 = @max(0, p); const p_clamped: f64 = @max(0, p);
const v_f64: f64 = @floatFromInt(v); const v_f64: f64 = @floatFromInt(v);
@ -412,11 +450,20 @@ pub const Modifier = union(enum) {
const v_i64: i64 = @intCast(v); const v_i64: i64 = @intCast(v);
const abs_i64: i64 = @intCast(abs); const abs_i64: i64 = @intCast(abs);
const applied_i64: i64 = v_i64 +| abs_i64; const applied_i64: i64 = v_i64 +| abs_i64;
const clamped_i64: i64 = if (signed) applied_i64 else @max(0, applied_i64); const clamped_i64: i64 = if (Tinfo.int.signedness == .signed)
applied_i64
else
@max(0, applied_i64);
const applied_T: T = std.math.cast(T, clamped_i64) orelse const applied_T: T = std.math.cast(T, clamped_i64) orelse
std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64))); std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64)));
break :absolute applied_T; break :absolute applied_T;
}, },
},
.float, .comptime_float => return switch (self) {
.percent => |p| v * @max(0, p),
.absolute => |abs| v + @as(T, @floatFromInt(abs)),
},
else => {},
}; };
} }
@ -462,7 +509,7 @@ pub const Key = key: {
var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined; var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined;
var count: usize = 0; var count: usize = 0;
for (field_infos, 0..) |field, i| { for (field_infos, 0..) |field, i| {
if (field.type != u32 and field.type != i32) continue; if (field.type != u32 and field.type != i32 and field.type != f64) continue;
enumFields[i] = .{ .name = field.name, .value = i }; enumFields[i] = .{ .name = field.name, .value = i };
count += 1; count += 1;
} }
@ -493,7 +540,11 @@ fn init() Metrics {
.overline_thickness = 0, .overline_thickness = 0,
.box_thickness = 0, .box_thickness = 0,
.cursor_height = 0, .cursor_height = 0,
.icon_height = 0, .icon_height = 0.0,
.icon_height_single = 0.0,
.face_width = 0.0,
.face_height = 0.0,
.face_y = 0.0,
}; };
} }
@ -523,6 +574,7 @@ test "Metrics: adjust cell height smaller" {
try set.put(alloc, .cell_height, .{ .percent = 0.75 }); try set.put(alloc, .cell_height, .{ .percent = 0.75 });
var m: Metrics = init(); var m: Metrics = init();
m.face_y = 0.33;
m.cell_baseline = 50; m.cell_baseline = 50;
m.underline_position = 55; m.underline_position = 55;
m.strikethrough_position = 30; m.strikethrough_position = 30;
@ -530,6 +582,7 @@ test "Metrics: adjust cell height smaller" {
m.cell_height = 100; m.cell_height = 100;
m.cursor_height = 100; m.cursor_height = 100;
m.apply(set); m.apply(set);
try testing.expectEqual(-11.67, m.face_y);
try testing.expectEqual(@as(u32, 75), m.cell_height); try testing.expectEqual(@as(u32, 75), m.cell_height);
try testing.expectEqual(@as(u32, 38), m.cell_baseline); try testing.expectEqual(@as(u32, 38), m.cell_baseline);
try testing.expectEqual(@as(u32, 42), m.underline_position); try testing.expectEqual(@as(u32, 42), m.underline_position);
@ -551,6 +604,7 @@ test "Metrics: adjust cell height larger" {
try set.put(alloc, .cell_height, .{ .percent = 1.75 }); try set.put(alloc, .cell_height, .{ .percent = 1.75 });
var m: Metrics = init(); var m: Metrics = init();
m.face_y = 0.33;
m.cell_baseline = 50; m.cell_baseline = 50;
m.underline_position = 55; m.underline_position = 55;
m.strikethrough_position = 30; m.strikethrough_position = 30;
@ -558,6 +612,7 @@ test "Metrics: adjust cell height larger" {
m.cell_height = 100; m.cell_height = 100;
m.cursor_height = 100; m.cursor_height = 100;
m.apply(set); m.apply(set);
try testing.expectEqual(37.33, m.face_y);
try testing.expectEqual(@as(u32, 175), m.cell_height); try testing.expectEqual(@as(u32, 175), m.cell_height);
try testing.expectEqual(@as(u32, 87), m.cell_baseline); try testing.expectEqual(@as(u32, 87), m.cell_baseline);
try testing.expectEqual(@as(u32, 93), m.underline_position); try testing.expectEqual(@as(u32, 93), m.underline_position);
@ -567,6 +622,48 @@ test "Metrics: adjust cell height larger" {
try testing.expectEqual(@as(u32, 100), m.cursor_height); try testing.expectEqual(@as(u32, 100), m.cursor_height);
} }
test "Metrics: adjust icon height by percentage" {
const testing = std.testing;
const alloc = testing.allocator;
var set: ModifierSet = .{};
defer set.deinit(alloc);
try set.put(alloc, .icon_height, .{ .percent = 0.75 });
var m: Metrics = init();
m.icon_height = 100.0;
m.icon_height_single = 80.0;
m.face_height = 100.0;
m.face_y = 1.0;
m.apply(set);
try testing.expectEqual(75.0, m.icon_height);
try testing.expectEqual(60.0, m.icon_height_single);
// Face metrics not affected
try testing.expectEqual(100.0, m.face_height);
try testing.expectEqual(1.0, m.face_y);
}
test "Metrics: adjust icon height by absolute pixels" {
const testing = std.testing;
const alloc = testing.allocator;
var set: ModifierSet = .{};
defer set.deinit(alloc);
try set.put(alloc, .icon_height, .{ .absolute = -5 });
var m: Metrics = init();
m.icon_height = 100.0;
m.icon_height_single = 80.0;
m.face_height = 100.0;
m.face_y = 1.0;
m.apply(set);
try testing.expectEqual(95.0, m.icon_height);
try testing.expectEqual(75.0, m.icon_height_single);
// Face metrics not affected
try testing.expectEqual(100.0, m.face_height);
try testing.expectEqual(1.0, m.face_y);
}
test "Modifier: parse absolute" { test "Modifier: parse absolute" {
const testing = std.testing; const testing = std.testing;

View File

@ -270,11 +270,9 @@ pub fn renderGlyph(
// Always use these constraints for emoji. // Always use these constraints for emoji.
if (p == .emoji) { if (p == .emoji) {
render_opts.constraint = .{ render_opts.constraint = .{
// Make the emoji as wide as possible, scaling proportionally, // Scale emoji to be as large as possible
// but then scale it down as necessary if its new size exceeds // while preserving their aspect ratio.
// the cell height. .size = .cover,
.size_horizontal = .cover,
.size_vertical = .fit,
// Center the emoji in its cells. // Center the emoji in its cells.
.align_horizontal = .center, .align_horizontal = .center,

View File

@ -93,6 +93,14 @@ pub const Variation = struct {
}; };
}; };
/// The size and position of a glyph.
pub const GlyphSize = struct {
width: f64,
height: f64,
x: f64,
y: f64,
};
/// Additional options for rendering glyphs. /// Additional options for rendering glyphs.
pub const RenderOptions = struct { pub const RenderOptions = struct {
/// The metrics that are defining the grid layout. These are usually /// The metrics that are defining the grid layout. These are usually
@ -136,10 +144,8 @@ pub const RenderOptions = struct {
/// Don't constrain the glyph in any way. /// Don't constrain the glyph in any way.
pub const none: Constraint = .{}; pub const none: Constraint = .{};
/// Vertical sizing rule. /// Sizing rule.
size_vertical: Size = .none, size: Size = .none,
/// Horizontal sizing rule.
size_horizontal: Size = .none,
/// Vertical alignment rule. /// Vertical alignment rule.
align_vertical: Align = .none, align_vertical: Align = .none,
@ -155,42 +161,40 @@ pub const RenderOptions = struct {
/// Bottom padding when resizing. /// Bottom padding when resizing.
pad_bottom: f64 = 0.0, pad_bottom: f64 = 0.0,
// This acts as a multiple of the provided width when applying // Size and bearings of the glyph relative
// constraints, so if this is 1.6 for example, then a width of // to the bounding box of its scale group.
// 10 would be treated as though it were 16. relative_width: f64 = 1.0,
group_width: f64 = 1.0, relative_height: f64 = 1.0,
// This acts as a multiple of the provided height when applying relative_x: f64 = 0.0,
// constraints, so if this is 1.6 for example, then a height of relative_y: f64 = 0.0,
// 10 would be treated as though it were 16.
group_height: f64 = 1.0,
// This is an x offset for the actual width within the group width.
// If this is 0.5 then the glyph will be offset so that its left
// edge sits at the halfway point of the group width.
group_x: f64 = 0.0,
// This is a y offset for the actual height within the group height.
// If this is 0.5 then the glyph will be offset so that its bottom
// edge sits at the halfway point of the group height.
group_y: f64 = 0.0,
/// Maximum ratio of width to height when resizing. /// Maximum aspect ratio (width/height) to allow when stretching.
max_xy_ratio: ?f64 = null, max_xy_ratio: ?f64 = null,
/// Maximum number of cells horizontally to use. /// Maximum number of cells horizontally to use.
max_constraint_width: u2 = 2, max_constraint_width: u2 = 2,
/// What to use as the height metric when constraining the glyph. /// What to use as the height metric when constraining the glyph and
/// the constraint width is 1,
height: Height = .cell, height: Height = .cell,
pub const Size = enum { pub const Size = enum {
/// Don't change the size of this glyph. /// Don't change the size of this glyph.
none, none,
/// Move the glyph and optionally scale it down /// Scale the glyph down if needed to fit within the bounds,
/// proportionally to fit within the given axis. /// preserving aspect ratio.
fit, fit,
/// Move and resize the glyph proportionally to /// Scale the glyph up or down to exactly match the bounds,
/// cover the given axis. /// preserving aspect ratio.
cover, cover,
/// Same as `cover` but not proportional. /// Scale the glyph down if needed to fit within the bounds,
/// preserving aspect ratio. If the glyph doesn't cover a
/// single cell, scale up. If the glyph exceeds a single
/// cell but is within the bounds, do nothing.
/// (Nerd Font specific rule.)
fit_cover1,
/// Stretch the glyph to exactly fit the bounds in both
/// directions, disregarding aspect ratio.
stretch, stretch,
}; };
@ -205,30 +209,29 @@ pub const RenderOptions = struct {
end, end,
/// Move the glyph so that it is centered on this axis. /// Move the glyph so that it is centered on this axis.
center, center,
/// Move the glyph so that it is centered on this axis,
/// but always with respect to the first cell even for
/// multi-cell constraints. (Nerd Font specific rule.)
center1,
}; };
pub const Height = enum { pub const Height = enum {
/// Use the full height of the cell for constraining this glyph. /// Use the full line height of the primary face for
/// constraining this glyph.
cell, cell,
/// Use the "icon height" from the grid metrics as the height. /// Use the icon height from the grid metrics for
/// constraining this glyph. Unlike `cell`, the value of
/// this height depends on both the constraint width and the
/// affected by the `adjust-icon-height` config option.
icon, icon,
}; };
/// The size and position of a glyph.
pub const GlyphSize = struct {
width: f64,
height: f64,
x: f64,
y: f64,
};
/// Returns true if the constraint does anything. If it doesn't, /// Returns true if the constraint does anything. If it doesn't,
/// because it neither sizes nor positions the glyph, then this /// because it neither sizes nor positions the glyph, then this
/// returns false. /// returns false.
pub inline fn doesAnything(self: Constraint) bool { pub inline fn doesAnything(self: Constraint) bool {
return self.size_horizontal != .none or return self.size != .none or
self.align_horizontal != .none or self.align_horizontal != .none or
self.size_vertical != .none or
self.align_vertical != .none; self.align_vertical != .none;
} }
@ -241,156 +244,252 @@ pub const RenderOptions = struct {
/// Number of cells horizontally available for this glyph. /// Number of cells horizontally available for this glyph.
constraint_width: u2, constraint_width: u2,
) GlyphSize { ) GlyphSize {
var g = glyph; if (!self.doesAnything()) return glyph;
const available_width: f64 = @floatFromInt( switch (self.size) {
metrics.cell_width * @min(
self.max_constraint_width,
constraint_width,
),
);
const available_height: f64 = @floatFromInt(switch (self.height) {
.cell => metrics.cell_height,
.icon => metrics.icon_height,
});
const w = available_width -
self.pad_left * available_width -
self.pad_right * available_width;
const h = available_height -
self.pad_top * available_height -
self.pad_bottom * available_height;
// Subtract padding from the bearings so that our
// alignment and sizing code works correctly. We
// re-add before returning.
g.x -= self.pad_left * available_width;
g.y -= self.pad_bottom * available_height;
// Multiply by group width and height for better sizing.
g.width *= self.group_width;
g.height *= self.group_height;
switch (self.size_horizontal) {
.none => {},
.fit => if (g.width > w) {
const orig_height = g.height;
// Adjust our height and width to proportionally
// scale them to fit the glyph to the cell width.
g.height *= w / g.width;
g.width = w;
// Set our x to 0 since anything else would mean
// the glyph extends outside of the cell width.
g.x = 0;
// Compensate our y to keep things vertically
// centered as they're scaled down.
g.y += (orig_height - g.height) / 2;
} else if (g.width + g.x > w) {
// If the width of the glyph can fit in the cell but
// is currently outside due to the left bearing, then
// we reduce the left bearing just enough to fit it
// back in the cell.
g.x = w - g.width;
} else if (g.x < 0) {
g.x = 0;
},
.cover => {
const orig_height = g.height;
g.height *= w / g.width;
g.width = w;
g.x = 0;
g.y += (orig_height - g.height) / 2;
},
.stretch => { .stretch => {
g.width = w; // Stretched glyphs are usually meant to align across cell
g.x = 0; // boundaries, which works best if they're scaled and
// aligned to the grid rather than the face. This is most
// easily done by inserting this little fib in the metrics.
var m = metrics;
m.face_width = @floatFromInt(m.cell_width);
m.face_height = @floatFromInt(m.cell_height);
m.face_y = 0.0;
// Negative padding for stretched glyphs is a band-aid to
// avoid gaps due to pixel rounding, but at the cost of
// unsightly overlap artifacts. Since we scale and align to
// the grid rather than the face, we don't need it.
var c = self;
c.pad_bottom = @max(0, c.pad_bottom);
c.pad_top = @max(0, c.pad_top);
c.pad_left = @max(0, c.pad_left);
c.pad_right = @max(0, c.pad_right);
return c.constrainInner(glyph, m, constraint_width);
}, },
else => return self.constrainInner(glyph, metrics, constraint_width),
}
} }
switch (self.size_vertical) { fn constrainInner(
.none => {}, self: Constraint,
.fit => if (g.height > h) { glyph: GlyphSize,
const orig_width = g.width; metrics: Metrics,
// Adjust our height and width to proportionally constraint_width: u2,
// scale them to fit the glyph to the cell height. ) GlyphSize {
g.width *= h / g.height; // For extra wide font faces, never stretch glyphs across two cells.
g.height = h; // This mirrors font_patcher.
// Set our y to 0 since anything else would mean const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height))
// the glyph extends outside of the cell height. 1
g.y = 0; else
// Compensate our x to keep things horizontally @min(self.max_constraint_width, constraint_width);
// centered as they're scaled down.
g.x += (orig_width - g.width) / 2;
} else if (g.height + g.y > h) {
// If the height of the glyph can fit in the cell but
// is currently outside due to the bottom bearing, then
// we reduce the bottom bearing just enough to fit it
// back in the cell.
g.y = h - g.height;
} else if (g.y < 0) {
g.y = 0;
},
.cover => {
const orig_width = g.width;
g.width *= h / g.height; // The bounding box for the glyph's scale group.
g.height = h; // Scaling and alignment rules are calculated for
// this box and then applied to the glyph.
g.y = 0; var group: GlyphSize = group: {
const group_width = glyph.width / self.relative_width;
g.x += (orig_width - g.width) / 2; const group_height = glyph.height / self.relative_height;
}, break :group .{
.stretch => { .width = group_width,
g.height = h; .height = group_height,
g.y = 0; .x = glyph.x - (group_width * self.relative_x),
}, .y = glyph.y - (group_height * self.relative_y),
} };
// Add group-relative position
g.x += self.group_x * g.width;
g.y += self.group_y * g.height;
// Divide group width and height back out before we align.
g.width /= self.group_width;
g.height /= self.group_height;
if (self.max_xy_ratio) |ratio| if (g.width > g.height * ratio) {
const orig_width = g.width;
g.width = g.height * ratio;
g.x += (orig_width - g.width) / 2;
}; };
switch (self.align_horizontal) { // Apply prescribed scaling, preserving the
.none => {}, // center bearings of the group bounding box
.start => g.x = 0, const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width);
.end => g.x = w - g.width, const center_x = group.x + (group.width / 2);
.center => g.x = (w - g.width) / 2, const center_y = group.y + (group.height / 2);
group.width *= width_factor;
group.height *= height_factor;
group.x = center_x - (group.width / 2);
group.y = center_y - (group.height / 2);
// NOTE: font_patcher jumps through a lot of hoops at this
// point to ensure that the glyph remains within the target
// bounding box after rounding to font definition units.
// This is irrelevant here as we're not rounding, we're
// staying in f64 and heading straight to rendering.
// Apply prescribed alignment
group.y = self.aligned_y(group, metrics);
group.x = self.aligned_x(group, metrics, min_constraint_width);
// Transfer the scaling and alignment back to the glyph and return.
return .{
.width = width_factor * glyph.width,
.height = height_factor * glyph.height,
.x = group.x + (group.width * self.relative_x),
.y = group.y + (group.height * self.relative_y),
};
} }
switch (self.align_vertical) { /// Return width and height scaling factors for this scaling group.
.none => {}, fn scale_factors(
.start => g.y = 0, self: Constraint,
.end => g.y = h - g.height, group: GlyphSize,
.center => g.y = (h - g.height) / 2, metrics: Metrics,
min_constraint_width: u2,
) struct { f64, f64 } {
if (self.size == .none) {
return .{ 1.0, 1.0 };
} }
// Re-add our padding before returning. const multi_cell = (min_constraint_width > 1);
g.x += self.pad_left * available_width;
g.y += self.pad_bottom * available_height;
// If the available height is less than the cell height, we const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right);
// add half of the difference to center it in the full height. const pad_height_factor = 1 - (self.pad_bottom + self.pad_top);
//
// If necessary, in the future, we can adjust this to account
// for alignment, but that isn't necessary with any of the nf
// icons afaict.
const cell_height: f64 = @floatFromInt(metrics.cell_height);
g.y += (cell_height - available_height) / 2;
return g; const target_width = pad_width_factor * metrics.face_width;
const target_height = pad_height_factor * switch (self.height) {
.cell => metrics.face_height,
// Like font-patcher, the icon constraint height depends on the
// constraint width. Unlike font-patcher, the multi-cell
// icon_height may be different from face_height due to the
// `adjust-icon-height` config option.
.icon => if (multi_cell)
metrics.icon_height
else
metrics.icon_height_single,
};
var width_factor = target_width / group.width;
var height_factor = target_height / group.height;
switch (self.size) {
.none => unreachable,
.fit => {
// Scale down to fit if needed
height_factor = @min(1, width_factor, height_factor);
width_factor = height_factor;
},
.cover => {
// Scale to cover
height_factor = @min(width_factor, height_factor);
width_factor = height_factor;
},
.fit_cover1 => {
// Scale down to fit or up to cover at least one cell
// NOTE: This is similar to font_patcher's "pa" mode,
// however, font_patcher will only do the upscaling
// part if the constraint width is 1, resulting in
// some icons becoming smaller when the constraint
// width increases. You'd see icons shrinking when
// opening up a space after them. This makes no
// sense, so we've fixed the rule such that these
// icons are scaled to the same size for multi-cell
// constraints as they would be for single-cell.
height_factor = @min(width_factor, height_factor);
if (multi_cell and (height_factor > 1)) {
// Call back into this function with
// constraint width 1 to get single-cell scale
// factors. We use the height factor as width
// could have been modified by max_xy_ratio.
_, const single_height_factor = self.scale_factors(group, metrics, 1);
height_factor = @max(1, single_height_factor);
}
width_factor = height_factor;
},
.stretch => {},
}
// Reduce aspect ratio if required
if (self.max_xy_ratio) |ratio| {
if (group.width * width_factor > group.height * height_factor * ratio) {
width_factor = group.height * height_factor * ratio / group.width;
}
}
return .{ width_factor, height_factor };
}
/// Return vertical bearing for aligning this group
fn aligned_y(
self: Constraint,
group: GlyphSize,
metrics: Metrics,
) f64 {
if ((self.size == .none) and (self.align_vertical == .none)) {
// If we don't have any constraints affecting the vertical axis,
// we don't touch vertical alignment.
return group.y;
}
// We use face_height and offset by face_y, rather than
// using cell_height directly, to account for the asymmetry
// of the pixel cell around the face (a consequence of
// aligning the baseline with a pixel boundary rather than
// vertically centering the face).
const pad_bottom_dy = self.pad_bottom * metrics.face_height;
const pad_top_dy = self.pad_top * metrics.face_height;
const start_y = metrics.face_y + pad_bottom_dy;
const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy);
const center_y = (start_y + end_y) / 2;
return switch (self.align_vertical) {
// NOTE: Even if there is no prescribed alignment, we ensure
// that the group doesn't protrude outside the padded cell,
// since this is implied by every available size constraint. If
// the group is too high we fall back to centering, though if we
// hit the .none prong we always have self.size != .none, so
// this should never happen.
.none => if (end_y < start_y)
center_y
else
@max(start_y, @min(group.y, end_y)),
.start => start_y,
.end => end_y,
.center, .center1 => center_y,
};
}
/// Return horizontal bearing for aligning this group
fn aligned_x(
self: Constraint,
group: GlyphSize,
metrics: Metrics,
min_constraint_width: u2,
) f64 {
if ((self.size == .none) and (self.align_horizontal == .none)) {
// If we don't have any constraints affecting the horizontal
// axis, we don't touch horizontal alignment.
return group.x;
}
// For multi-cell constraints, we align relative to the span
// from the left edge of the first cell to the right edge of
// the last face cell assuming it's left-aligned within the
// rounded and adjusted pixel cell. Any horizontal offset to
// center the face within the grid cell is the responsibility
// of the backend-specific rendering code, and should be done
// after applying constraints.
const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width));
const pad_left_dx = self.pad_left * metrics.face_width;
const pad_right_dx = self.pad_right * metrics.face_width;
const start_x = pad_left_dx;
const end_x = full_face_span - group.width - pad_right_dx;
return switch (self.align_horizontal) {
// NOTE: Even if there is no prescribed alignment, we ensure
// that the glyph doesn't protrude outside the padded cell,
// since this is implied by every available size constraint. The
// left-side bound has priority if the group is too wide, though
// if we hit the .none prong we always have self.size != .none,
// so this should never happen.
.none => @max(start_x, @min(group.x, end_x)),
.start => start_x,
.end => @max(start_x, end_x),
.center => @max(start_x, (start_x + end_x) / 2),
// NOTE: .center1 implements the font_patcher rule of centering
// in the first cell even for multi-cell constraints. Since glyphs
// are not allowed to protrude to the left, this results in the
// left-alignment like .start when the glyph is wider than a cell.
.center1 => center1: {
const end1_x = metrics.face_width - group.width - pad_right_dx;
break :center1 @max(start_x, (start_x + end1_x) / 2);
},
};
} }
}; };
}; };
@ -412,3 +511,197 @@ test "Variation.Id: slnt should be 1936486004" {
try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id))); try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id)));
try testing.expectEqualStrings("slnt", &(id.str())); try testing.expectEqualStrings("slnt", &(id.str()));
} }
test "Constraints" {
const comparison = @import("../datastruct/comparison.zig");
const getConstraint = @import("nerd_font_attributes.zig").getConstraint;
// Hardcoded data matches metrics from CoreText at size 12 and DPI 96.
// Define grid metrics (matches font-family = JetBrains Mono)
const metrics: Metrics = .{
.cell_width = 10,
.cell_height = 22,
.cell_baseline = 5,
.underline_position = 19,
.underline_thickness = 1,
.strikethrough_position = 12,
.strikethrough_thickness = 1,
.overline_position = 0,
.overline_thickness = 1,
.box_thickness = 1,
.cursor_thickness = 1,
.cursor_height = 22,
.icon_height = 21.12,
.icon_height_single = 44.48 / 3.0,
.face_width = 9.6,
.face_height = 21.12,
.face_y = 0.2,
};
// ASCII (no constraint).
{
const constraint: RenderOptions.Constraint = .none;
// BBox of 'x' from JetBrains Mono.
const glyph_x: GlyphSize = .{
.width = 6.784,
.height = 15.28,
.x = 1.408,
.y = 4.84,
};
// Any constraint width: do nothing.
inline for (.{ 1, 2 }) |constraint_width| {
try comparison.expectApproxEqual(
glyph_x,
constraint.constrain(glyph_x, metrics, constraint_width),
);
}
}
// Symbol (same constraint as hardcoded in Renderer.addGlyph).
{
const constraint: RenderOptions.Constraint = .{ .size = .fit };
// BBox of '■' (0x25A0 black square) from Iosevka.
// NOTE: This glyph is designed to span two cells.
const glyph_25A0: GlyphSize = .{
.width = 10.272,
.height = 10.272,
.x = 2.864,
.y = 5.304,
};
// Constraint width 1: scale down and shift to fit a single cell.
try comparison.expectApproxEqual(
GlyphSize{
.width = metrics.face_width,
.height = metrics.face_width,
.x = 0,
.y = 5.64,
},
constraint.constrain(glyph_25A0, metrics, 1),
);
// Constraint width 2: do nothing.
try comparison.expectApproxEqual(
glyph_25A0,
constraint.constrain(glyph_25A0, metrics, 2),
);
}
// Emoji (same constraint as hardcoded in SharedGrid.renderGlyph).
{
const constraint: RenderOptions.Constraint = .{
.size = .cover,
.align_horizontal = .center,
.align_vertical = .center,
.pad_left = 0.025,
.pad_right = 0.025,
};
// BBox of '🥸' (0x1F978) from Apple Color Emoji.
const glyph_1F978: GlyphSize = .{
.width = 20,
.height = 20,
.x = 0.46,
.y = 1,
};
// Constraint width 2: scale to cover two cells with padding, center;
try comparison.expectApproxEqual(
GlyphSize{
.width = 18.72,
.height = 18.72,
.x = 0.44,
.y = 1.4,
},
constraint.constrain(glyph_1F978, metrics, 2),
);
}
// Nerd Font default.
{
const constraint = getConstraint(0xea61).?;
// Verify that this is the constraint we expect.
try std.testing.expectEqual(.fit_cover1, constraint.size);
try std.testing.expectEqual(.icon, constraint.height);
try std.testing.expectEqual(.center1, constraint.align_horizontal);
try std.testing.expectEqual(.center1, constraint.align_vertical);
// BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only.
// NOTE: This icon is part of a group, so the
// constraint applies to a larger bounding box.
const glyph_EA61: GlyphSize = .{
.width = 9.015625,
.height = 13.015625,
.x = 3.015625,
.y = 3.76525,
};
// Constraint width 1: scale and shift group to fit a single cell.
try comparison.expectApproxEqual(
GlyphSize{
.width = 7.2125,
.height = 10.4125,
.x = 0.8125,
.y = 5.950695224719102,
},
constraint.constrain(glyph_EA61, metrics, 1),
);
// Constraint width 2: no scaling; left-align and vertically center group.
try comparison.expectApproxEqual(
GlyphSize{
.width = glyph_EA61.width,
.height = glyph_EA61.height,
.x = 1.015625,
.y = 4.7483690308988775,
},
constraint.constrain(glyph_EA61, metrics, 2),
);
}
// Nerd Font stretch.
{
const constraint = getConstraint(0xe0c0).?;
// Verify that this is the constraint we expect.
try std.testing.expectEqual(.stretch, constraint.size);
try std.testing.expectEqual(.cell, constraint.height);
try std.testing.expectEqual(.start, constraint.align_horizontal);
try std.testing.expectEqual(.center1, constraint.align_vertical);
// BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only.
const glyph_E0C0: GlyphSize = .{
.width = 16.796875,
.height = 16.46875,
.x = -0.796875,
.y = 1.7109375,
};
// Constraint width 1: stretch and position to exactly cover one cell.
try comparison.expectApproxEqual(
GlyphSize{
.width = @floatFromInt(metrics.cell_width),
.height = @floatFromInt(metrics.cell_height),
.x = 0,
.y = 0,
},
constraint.constrain(glyph_E0C0, metrics, 1),
);
// Constraint width 1: stretch and position to exactly cover two cells.
try comparison.expectApproxEqual(
GlyphSize{
.width = @floatFromInt(2 * metrics.cell_width),
.height = @floatFromInt(metrics.cell_height),
.x = 0,
.y = 0,
},
constraint.constrain(glyph_E0C0, metrics, 2),
);
}
}

View File

@ -319,17 +319,6 @@ pub const Face = struct {
rect.origin.y -= line_width / 2; rect.origin.y -= line_width / 2;
}; };
// We make an assumption that font smoothing ("thicken")
// adds no more than 1 extra pixel to any edge. We don't
// add extra size if it's a sbix color font though, since
// bitmaps aren't affected by smoothing.
if (opts.thicken and !sbix) {
rect.size.width += 2.0;
rect.size.height += 2.0;
rect.origin.x -= 1.0;
rect.origin.y -= 1.0;
}
// If our rect is smaller than a quarter pixel in either axis // If our rect is smaller than a quarter pixel in either axis
// then it has no outlines or they're too small to render. // then it has no outlines or they're too small to render.
// //
@ -349,14 +338,7 @@ pub const Face = struct {
const cell_height: f64 = @floatFromInt(metrics.cell_height); const cell_height: f64 = @floatFromInt(metrics.cell_height);
// Next we apply any constraints to get the final size of the glyph. // Next we apply any constraints to get the final size of the glyph.
var constraint = opts.constraint; const constraint = opts.constraint;
// We eliminate any negative vertical padding since these overlap
// values aren't needed with how precisely we apply constraints,
// and they can lead to extra height that looks bad for things like
// powerline glyphs.
constraint.pad_top = @max(0.0, constraint.pad_top);
constraint.pad_bottom = @max(0.0, constraint.pad_bottom);
// We need to add the baseline position before passing to the constrain // We need to add the baseline position before passing to the constrain
// function since it operates on cell-relative positions, not baseline. // function since it operates on cell-relative positions, not baseline.
@ -378,6 +360,18 @@ pub const Face = struct {
var width = glyph_size.width; var width = glyph_size.width;
var height = glyph_size.height; var height = glyph_size.height;
// We center all glyphs within the pixel-rounded and adjusted
// cell width if it's larger than the face width, so that they
// aren't weirdly off to the left.
//
// We don't do this if the glyph has a stretch constraint,
// since in that case the position was already calculated with the
// new cell width in mind.
if ((constraint.size != .stretch) and (metrics.face_width < cell_width)) {
// We add half the difference to re-center.
x += (cell_width - metrics.face_width) / 2;
}
// If this is a bitmap glyph, it will always render as full pixels, // If this is a bitmap glyph, it will always render as full pixels,
// not fractional pixels, so we need to quantize its position and // not fractional pixels, so we need to quantize its position and
// size accordingly to align to full pixels so we get good results. // size accordingly to align to full pixels so we get good results.
@ -388,25 +382,16 @@ pub const Face = struct {
y = @round(y); y = @round(y);
} }
// If the cell width was adjusted wider, we re-center all glyphs // We make an assumption that font smoothing ("thicken")
// in the new width, so that they aren't weirdly off to the left. // adds no more than 1 extra pixel to any edge. We don't
if (metrics.original_cell_width) |original| recenter: { // add extra size if it's a sbix color font though, since
// We don't do this if the constraint has a horizontal alignment, // bitmaps aren't affected by smoothing.
// since in that case the position was already calculated with the const canvas_padding: u32 = if (opts.thicken and !sbix) 1 else 0;
// new cell width in mind.
if (opts.constraint.align_horizontal != .none) break :recenter;
// If the original width was wider then we don't do anything.
if (original >= metrics.cell_width) break :recenter;
// We add half the difference to re-center.
x += (cell_width - @as(f64, @floatFromInt(original))) / 2;
}
// Our whole-pixel bearings for the final glyph. // Our whole-pixel bearings for the final glyph.
// The fractional portion will be included in the rasterized position. // The fractional portion will be included in the rasterized position.
const px_x: i32 = @intFromFloat(@floor(x)); const px_x = @as(i32, @intFromFloat(@floor(x))) - @as(i32, @intCast(canvas_padding));
const px_y: i32 = @intFromFloat(@floor(y)); const px_y = @as(i32, @intFromFloat(@floor(y))) - @as(i32, @intCast(canvas_padding));
// We keep track of the fractional part of the pixel bearings, which // We keep track of the fractional part of the pixel bearings, which
// we will add as an offset when rasterizing to make sure we get the // we will add as an offset when rasterizing to make sure we get the
@ -416,9 +401,9 @@ pub const Face = struct {
// Add the fractional pixel to the width and height and take // Add the fractional pixel to the width and height and take
// the ceiling to get a canvas size that will definitely fit // the ceiling to get a canvas size that will definitely fit
// our drawn glyph, including the fractional offset. // our drawn glyph, including the fractional offset and font smoothing.
const px_width: u32 = @intFromFloat(@ceil(width + frac_x)); const px_width = @as(u32, @intFromFloat(@ceil(width + frac_x))) + (2 * canvas_padding);
const px_height: u32 = @intFromFloat(@ceil(height + frac_y)); const px_height = @as(u32, @intFromFloat(@ceil(height + frac_y))) + (2 * canvas_padding);
// Settings that are specific to if we are rendering text or emoji. // Settings that are specific to if we are rendering text or emoji.
const color: struct { const color: struct {
@ -529,8 +514,8 @@ pub const Face = struct {
// `drawGlyphs`, we pass the negated bearings. // `drawGlyphs`, we pass the negated bearings.
context.translateCTM( context.translateCTM(
ctx, ctx,
frac_x, frac_x + @as(f64, @floatFromInt(canvas_padding)),
frac_y, frac_y + @as(f64, @floatFromInt(canvas_padding)),
); );
// Scale the drawing context so that when we draw // Scale the drawing context so that when we draw
@ -775,7 +760,10 @@ pub const Face = struct {
// Cell width is calculated by calculating the widest width of the // Cell width is calculated by calculating the widest width of the
// visible ASCII characters. Usually 'M' is widest but we just take // visible ASCII characters. Usually 'M' is widest but we just take
// whatever is widest. // whatever is widest.
const cell_width: f64 = cell_width: { //
// ASCII height is calculated as the height of the overall bounding
// box of the same characters.
const cell_width: f64, const ascii_height: f64 = measurements: {
// Build a comptime array of all the ASCII chars // Build a comptime array of all the ASCII chars
const unichars = comptime unichars: { const unichars = comptime unichars: {
const len = 127 - 32; const len = 127 - 32;
@ -803,7 +791,10 @@ pub const Face = struct {
max = @max(advances[i].width, max); max = @max(advances[i].width, max);
} }
break :cell_width max; // Get the overall bounding rect for the glyphs
const rect = ct_font.getBoundingRectsForGlyphs(.horizontal, &glyphs, null);
break :measurements .{ max, rect.size.height };
}; };
// Measure "" (CJK water ideograph, U+6C34) for our ic width. // Measure "" (CJK water ideograph, U+6C34) for our ic width.
@ -864,6 +855,7 @@ pub const Face = struct {
.cap_height = cap_height, .cap_height = cap_height,
.ex_height = ex_height, .ex_height = ex_height,
.ascii_height = ascii_height,
.ic_width = ic_width, .ic_width = ic_width,
}; };
} }

View File

@ -170,7 +170,7 @@ pub const Face = struct {
if (string.len > 1024) break :skip; if (string.len > 1024) break :skip;
var tmp: [512]u16 = undefined; var tmp: [512]u16 = undefined;
const max = string.len / 2; const max = string.len / 2;
for (@as([]const u16, @alignCast(@ptrCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c); for (@as([]const u16, @ptrCast(@alignCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c);
const len = std.unicode.utf16LeToUtf8(buf, tmp[0..max]) catch return string; const len = std.unicode.utf16LeToUtf8(buf, tmp[0..max]) catch return string;
return buf[0..len]; return buf[0..len];
} }
@ -351,26 +351,16 @@ pub const Face = struct {
return glyph.*.bitmap.pixel_mode == freetype.c.FT_PIXEL_MODE_BGRA; return glyph.*.bitmap.pixel_mode == freetype.c.FT_PIXEL_MODE_BGRA;
} }
/// Render a glyph using the glyph index. The rendered glyph is stored in the /// Set the load flags to use when loading a glyph for measurement or
/// given texture atlas. /// rendering.
pub fn renderGlyph( fn glyphLoadFlags(self: Face, constrained: bool) freetype.LoadFlags {
self: Face,
alloc: Allocator,
atlas: *font.Atlas,
glyph_index: u32,
opts: font.face.RenderOptions,
) !Glyph {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
// Hinting should only be enabled if the configured load flags specify // Hinting should only be enabled if the configured load flags specify
// it and the provided constraint doesn't actually do anything, since // it and the provided constraint doesn't actually do anything, since
// if it does, then it'll mess up the hinting anyway when it moves or // if it does, then it'll mess up the hinting anyway when it moves or
// resizes the glyph. // resizes the glyph.
const do_hinting = self.load_flags.hinting and !opts.constraint.doesAnything(); const do_hinting = self.load_flags.hinting and !constrained;
// Load the glyph. return .{
try self.face.loadGlyph(glyph_index, .{
// If our glyph has color, we want to render the color // If our glyph has color, we want to render the color
.color = self.face.hasColor(), .color = self.face.hasColor(),
@ -392,17 +382,11 @@ pub const Face = struct {
// SVG glyphs under FreeType, since that requires bundling another // SVG glyphs under FreeType, since that requires bundling another
// dependency to handle rendering the SVG. // dependency to handle rendering the SVG.
.no_svg = true, .no_svg = true,
}); };
const glyph = self.face.handle.*.glyph; }
// We get a rect that represents the position /// Get a rect that represents the position and size of the loaded glyph.
// and size of the glyph before any changes. fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.GlyphSize {
const rect: struct {
x: f64,
y: f64,
width: f64,
height: f64,
} = metrics: {
// If we're dealing with an outline glyph then we get the // If we're dealing with an outline glyph then we get the
// outline's bounding box instead of using the built-in // outline's bounding box instead of using the built-in
// metrics, since that's more precise and allows better // metrics, since that's more precise and allows better
@ -413,7 +397,7 @@ pub const Face = struct {
var bbox: freetype.c.FT_BBox = undefined; var bbox: freetype.c.FT_BBox = undefined;
_ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox);
break :metrics .{ return .{
.x = f26dot6ToF64(bbox.xMin), .x = f26dot6ToF64(bbox.xMin),
.y = f26dot6ToF64(bbox.yMin), .y = f26dot6ToF64(bbox.yMin),
.width = f26dot6ToF64(bbox.xMax - bbox.xMin), .width = f26dot6ToF64(bbox.xMax - bbox.xMin),
@ -421,13 +405,44 @@ pub const Face = struct {
}; };
} }
break :metrics .{ return .{
.x = f26dot6ToF64(glyph.*.metrics.horiBearingX), .x = f26dot6ToF64(glyph.*.metrics.horiBearingX),
.y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height), .y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height),
.width = f26dot6ToF64(glyph.*.metrics.width), .width = f26dot6ToF64(glyph.*.metrics.width),
.height = f26dot6ToF64(glyph.*.metrics.height), .height = f26dot6ToF64(glyph.*.metrics.height),
}; };
}; }
/// Render a glyph using the glyph index. The rendered glyph is stored in the
/// given texture atlas.
pub fn renderGlyph(
self: Face,
alloc: Allocator,
atlas: *font.Atlas,
glyph_index: u32,
opts: font.face.RenderOptions,
) !Glyph {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
// Load the glyph.
try self.face.loadGlyph(glyph_index, self.glyphLoadFlags(opts.constraint.doesAnything()));
const glyph = self.face.handle.*.glyph;
// For synthetic bold, we embolden the glyph.
if (self.synthetic.bold) {
// We need to scale the embolden amount based on the font size.
// This is a heuristic I found worked well across a variety of
// founts: 1 pixel per 64 units of height.
const font_height: f64 = @floatFromInt(self.face.handle.*.size.*.metrics.height);
const ratio: f64 = 64.0 / 2048.0;
const amount = @ceil(font_height * ratio);
_ = freetype.c.FT_Outline_Embolden(&glyph.*.outline, @intFromFloat(amount));
}
// We get a rect that represents the position
// and size of the glyph before constraints.
const rect = getGlyphSize(glyph);
// If our glyph is smaller than a quarter pixel in either axis // If our glyph is smaller than a quarter pixel in either axis
// then it has no outlines or they're too small to render. // then it has no outlines or they're too small to render.
@ -443,30 +458,12 @@ pub const Face = struct {
.atlas_y = 0, .atlas_y = 0,
}; };
// For synthetic bold, we embolden the glyph.
if (self.synthetic.bold) {
// We need to scale the embolden amount based on the font size.
// This is a heuristic I found worked well across a variety of
// founts: 1 pixel per 64 units of height.
const font_height: f64 = @floatFromInt(self.face.handle.*.size.*.metrics.height);
const ratio: f64 = 64.0 / 2048.0;
const amount = @ceil(font_height * ratio);
_ = freetype.c.FT_Outline_Embolden(&glyph.*.outline, @intFromFloat(amount));
}
const metrics = opts.grid_metrics; const metrics = opts.grid_metrics;
const cell_width: f64 = @floatFromInt(metrics.cell_width); const cell_width: f64 = @floatFromInt(metrics.cell_width);
const cell_height: f64 = @floatFromInt(metrics.cell_height); const cell_height: f64 = @floatFromInt(metrics.cell_height);
// Next we apply any constraints to get the final size of the glyph. // Next we apply any constraints to get the final size of the glyph.
var constraint = opts.constraint; const constraint = opts.constraint;
// We eliminate any negative vertical padding since these overlap
// values aren't needed with how precisely we apply constraints,
// and they can lead to extra height that looks bad for things like
// powerline glyphs.
constraint.pad_top = @max(0.0, constraint.pad_top);
constraint.pad_bottom = @max(0.0, constraint.pad_bottom);
// We need to add the baseline position before passing to the constrain // We need to add the baseline position before passing to the constrain
// function since it operates on cell-relative positions, not baseline. // function since it operates on cell-relative positions, not baseline.
@ -488,6 +485,24 @@ pub const Face = struct {
var x = glyph_size.x; var x = glyph_size.x;
var y = glyph_size.y; var y = glyph_size.y;
// We center all glyphs within the pixel-rounded and adjusted
// cell width if it's larger than the face width, so that they
// aren't weirdly off to the left.
//
// We don't do this if the glyph has a stretch constraint,
// since in that case the position was already calculated with the
// new cell width in mind.
if ((constraint.size != .stretch) and (metrics.face_width < cell_width)) {
// We add half the difference to re-center.
//
// NOTE: We round this to a whole-pixel amount because under
// FreeType, the outlines will be hinted, which isn't
// the case under CoreText. If we move the outlines by
// a non-whole-pixel amount, it completely ruins the
// hinting.
x += @round((cell_width - metrics.face_width) / 2);
}
// If this is a bitmap glyph, it will always render as full pixels, // If this is a bitmap glyph, it will always render as full pixels,
// not fractional pixels, so we need to quantize its position and // not fractional pixels, so we need to quantize its position and
// size accordingly to align to full pixels so we get good results. // size accordingly to align to full pixels so we get good results.
@ -498,27 +513,6 @@ pub const Face = struct {
y = @round(y); y = @round(y);
} }
// If the cell width was adjusted wider, we re-center all glyphs
// in the new width, so that they aren't weirdly off to the left.
if (metrics.original_cell_width) |original| recenter: {
// We don't do this if the constraint has a horizontal alignment,
// since in that case the position was already calculated with the
// new cell width in mind.
if (opts.constraint.align_horizontal != .none) break :recenter;
// If the original width was wider then we don't do anything.
if (original >= metrics.cell_width) break :recenter;
// We add half the difference to re-center.
//
// NOTE: We round this to a whole-pixel amount because under
// FreeType, the outlines will be hinted, which isn't
// the case under CoreText. If we move the outlines by
// a non-whole-pixel amount, it completely ruins the
// hinting.
x += @round((cell_width - @as(f64, @floatFromInt(original))) / 2);
}
// Now we can render the glyph. // Now we can render the glyph.
var bitmap: freetype.c.FT_Bitmap = undefined; var bitmap: freetype.c.FT_Bitmap = undefined;
_ = freetype.c.FT_Bitmap_Init(&bitmap); _ = freetype.c.FT_Bitmap_Init(&bitmap);
@ -960,34 +954,49 @@ pub const Face = struct {
// visible ASCII characters. Usually 'M' is widest but we just take // visible ASCII characters. Usually 'M' is widest but we just take
// whatever is widest. // whatever is widest.
// //
// ASCII height is calculated as the height of the overall bounding
// box of the same characters.
//
// If we fail to load any visible ASCII we just use max_advance from // If we fail to load any visible ASCII we just use max_advance from
// the metrics provided by FreeType. // the metrics provided by FreeType, and set ascii_height to null as
const cell_width: f64 = cell_width: { // it's optional.
const cell_width: f64, const ascii_height: ?f64 = measurements: {
self.ft_mutex.lock(); self.ft_mutex.lock();
defer self.ft_mutex.unlock(); defer self.ft_mutex.unlock();
var max: f64 = 0.0; var max: f64 = 0.0;
var top: f64 = 0.0;
var bottom: f64 = 0.0;
var c: u8 = ' '; var c: u8 = ' ';
while (c < 127) : (c += 1) { while (c < 127) : (c += 1) {
if (face.getCharIndex(c)) |glyph_index| { if (face.getCharIndex(c)) |glyph_index| {
if (face.loadGlyph(glyph_index, .{ if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
.render = false, const glyph = face.handle.*.glyph;
.no_svg = true,
})) {
max = @max( max = @max(
f26dot6ToF64(face.handle.*.glyph.*.advance.x), f26dot6ToF64(glyph.*.advance.x),
max, max,
); );
const rect = getGlyphSize(glyph);
top = @max(rect.y + rect.height, top);
bottom = @min(rect.y, bottom);
} else |_| {} } else |_| {}
} }
} }
// If we couldn't get any widths, just use FreeType's max_advance. // If we couldn't get valid measurements, just use
// FreeType's max_advance and null, respectively.
if (max == 0.0) { if (max == 0.0) {
break :cell_width f26dot6ToF64(size_metrics.max_advance); max = f26dot6ToF64(size_metrics.max_advance);
} }
const rect_height: ?f64 = rect_height: {
const estimate = top - bottom;
if (estimate <= 0.0) {
break :rect_height null;
}
break :rect_height estimate;
};
break :cell_width max; break :measurements .{ max, rect_height };
}; };
// We use the cap and ex heights specified by the font if they're // We use the cap and ex heights specified by the font if they're
@ -1008,11 +1017,8 @@ pub const Face = struct {
self.ft_mutex.lock(); self.ft_mutex.lock();
defer self.ft_mutex.unlock(); defer self.ft_mutex.unlock();
if (face.getCharIndex('H')) |glyph_index| { if (face.getCharIndex('H')) |glyph_index| {
if (face.loadGlyph(glyph_index, .{ if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
.render = false, break :cap getGlyphSize(face.handle.*.glyph).height;
.no_svg = true,
})) {
break :cap f26dot6ToF64(face.handle.*.glyph.*.metrics.height);
} else |_| {} } else |_| {}
} }
break :cap null; break :cap null;
@ -1021,11 +1027,8 @@ pub const Face = struct {
self.ft_mutex.lock(); self.ft_mutex.lock();
defer self.ft_mutex.unlock(); defer self.ft_mutex.unlock();
if (face.getCharIndex('x')) |glyph_index| { if (face.getCharIndex('x')) |glyph_index| {
if (face.loadGlyph(glyph_index, .{ if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
.render = false, break :ex getGlyphSize(face.handle.*.glyph).height;
.no_svg = true,
})) {
break :ex f26dot6ToF64(face.handle.*.glyph.*.metrics.height);
} else |_| {} } else |_| {}
} }
break :ex null; break :ex null;
@ -1040,10 +1043,7 @@ pub const Face = struct {
const glyph = face.getCharIndex('水') orelse break :ic_width null; const glyph = face.getCharIndex('水') orelse break :ic_width null;
face.loadGlyph(glyph, .{ face.loadGlyph(glyph, self.glyphLoadFlags(false)) catch break :ic_width null;
.render = false,
.no_svg = true,
}) catch break :ic_width null;
const ft_glyph = face.handle.*.glyph; const ft_glyph = face.handle.*.glyph;
@ -1055,21 +1055,19 @@ pub const Face = struct {
// This can sometimes happen if there's a CJK font that has been // This can sometimes happen if there's a CJK font that has been
// patched with the nerd fonts patcher and it butchers the advance // patched with the nerd fonts patcher and it butchers the advance
// values so the advance ends up half the width of the actual glyph. // values so the advance ends up half the width of the actual glyph.
if (ft_glyph.*.metrics.width > ft_glyph.*.advance.x) { const ft_glyph_width = getGlyphSize(ft_glyph).width;
const advance = f26dot6ToF64(ft_glyph.*.advance.x);
if (ft_glyph_width > advance) {
var buf: [1024]u8 = undefined; var buf: [1024]u8 = undefined;
const font_name = self.name(&buf) catch "<Error getting font name>"; const font_name = self.name(&buf) catch "<Error getting font name>";
log.warn( log.warn(
"(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.", "(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.",
.{ .{ font_name, ft_glyph_width, advance },
font_name,
f26dot6ToF64(ft_glyph.*.metrics.width),
f26dot6ToF64(ft_glyph.*.advance.x),
},
); );
break :ic_width null; break :ic_width null;
} }
break :ic_width f26dot6ToF64(ft_glyph.*.advance.x); break :ic_width advance;
}; };
return .{ return .{
@ -1089,6 +1087,7 @@ pub const Face = struct {
.cap_height = cap_height, .cap_height = cap_height,
.ex_height = ex_height, .ex_height = ex_height,
.ascii_height = ascii_height,
.ic_width = ic_width, .ic_width = ic_width,
}; };
} }
@ -1178,37 +1177,6 @@ test "color emoji" {
const glyph_id = ft_font.glyphIndex('🥸').?; const glyph_id = ft_font.glyphIndex('🥸').?;
try testing.expect(ft_font.isColorGlyph(glyph_id)); try testing.expect(ft_font.isColorGlyph(glyph_id));
} }
// resize
// TODO: Comprehensive tests for constraints,
// this is just an adapted legacy test.
{
const glyph = try ft_font.renderGlyph(
alloc,
&atlas,
ft_font.glyphIndex('🥸').?,
.{ .grid_metrics = .{
.cell_width = 13,
.cell_height = 24,
.cell_baseline = 0,
.underline_position = 0,
.underline_thickness = 0,
.strikethrough_position = 0,
.strikethrough_thickness = 0,
.overline_position = 0,
.overline_thickness = 0,
.box_thickness = 0,
.cursor_height = 0,
.icon_height = 0,
}, .constraint_width = 2, .constraint = .{
.size_horizontal = .cover,
.size_vertical = .cover,
.align_horizontal = .center,
.align_vertical = .center,
} },
);
try testing.expectEqual(@as(u32, 24), glyph.height);
}
} }
test "mono to bgra" { test "mono to bgra" {

File diff suppressed because it is too large Load Diff

View File

@ -15,13 +15,14 @@ SymbolsNerdFont (not Mono!) font is passed as the first argument to it.
import ast import ast
import sys import sys
import math import math
from fontTools.ttLib import TTFont from fontTools.ttLib import TTFont, TTLibError
from fontTools.pens.boundsPen import BoundsPen from fontTools.pens.boundsPen import BoundsPen
from collections import defaultdict from collections import defaultdict
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Literal, TypedDict, cast from typing import Literal, TypedDict, cast
from urllib.request import urlretrieve
type PatchSetAttributes = dict[Literal["default"] | int, PatchSetAttributeEntry] type PatchSetAttributes = dict[Literal["default"] | int, PatchSetAttributeEntry]
type AttributeHash = tuple[ type AttributeHash = tuple[
@ -50,13 +51,16 @@ class PatchSetAttributeEntry(TypedDict):
stretch: str stretch: str
params: dict[str, float | bool] params: dict[str, float | bool]
group_x: float relative_x: float
group_y: float relative_y: float
group_width: float relative_width: float
group_height: float relative_height: float
class PatchSet(TypedDict): class PatchSet(TypedDict):
Name: str
Filename: str
Exact: bool
SymStart: int SymStart: int
SymEnd: int SymEnd: int
SrcStart: int | None SrcStart: int | None
@ -68,6 +72,18 @@ class PatchSetExtractor(ast.NodeVisitor):
def __init__(self) -> None: def __init__(self) -> None:
self.symbol_table: dict[str, ast.expr] = {} self.symbol_table: dict[str, ast.expr] = {}
self.patch_set_values: list[PatchSet] = [] self.patch_set_values: list[PatchSet] = []
self.nf_version: str = ""
def visit_Assign(self, node):
if (
node.col_offset == 0 # top-level assignment
and len(node.targets) == 1 # no funny destructuring business
and isinstance(node.targets[0], ast.Name) # no setitem et cetera
and node.targets[0].id == "version" # it's the version string!
):
self.nf_version = ast.literal_eval(node.value)
else:
return self.generic_visit(node)
def visit_ClassDef(self, node: ast.ClassDef) -> None: def visit_ClassDef(self, node: ast.ClassDef) -> None:
if node.name != "font_patcher": if node.name != "font_patcher":
@ -113,37 +129,56 @@ class PatchSetExtractor(ast.NodeVisitor):
if hasattr(ast, "unparse"): if hasattr(ast, "unparse"):
return eval( return eval(
ast.unparse(node), ast.unparse(node),
{"box_keep": True}, {"box_enabled": False, "box_keep": False},
{"self": SimpleNamespace(args=SimpleNamespace(careful=True))}, {
"self": SimpleNamespace(
args=SimpleNamespace(
careful=False,
custom=False,
fontawesome=True,
fontawesomeextension=True,
fontlogos=True,
octicons=True,
codicons=True,
powersymbols=True,
pomicons=True,
powerline=True,
powerlineextra=True,
material=True,
weather=True,
)
),
},
) )
msg = f"<cannot eval: {type(node).__name__}>" msg = f"<cannot eval: {type(node).__name__}>"
raise ValueError(msg) from None raise ValueError(msg) from None
def process_patch_entry(self, dict_node: ast.Dict) -> None: def process_patch_entry(self, dict_node: ast.Dict) -> None:
entry = {} entry = {}
disallowed_key_nodes = frozenset({"Enabled", "Name", "Filename", "Exact"})
for key_node, value_node in zip(dict_node.keys, dict_node.values): for key_node, value_node in zip(dict_node.keys, dict_node.values):
if ( if isinstance(key_node, ast.Constant):
isinstance(key_node, ast.Constant) if key_node.value == "Enabled":
and key_node.value not in disallowed_key_nodes if self.safe_literal_eval(value_node):
): continue # This patch set is enabled, continue to next key
else:
return # This patch set is disabled, skip
key = ast.literal_eval(cast("ast.Constant", key_node)) key = ast.literal_eval(cast("ast.Constant", key_node))
entry[key] = self.resolve_symbol(value_node) entry[key] = self.resolve_symbol(value_node)
self.patch_set_values.append(cast("PatchSet", entry)) self.patch_set_values.append(cast("PatchSet", entry))
def extract_patch_set_values(source_code: str) -> list[PatchSet]: def extract_patch_set_values(source_code: str) -> tuple[list[PatchSet], str]:
tree = ast.parse(source_code) tree = ast.parse(source_code)
extractor = PatchSetExtractor() extractor = PatchSetExtractor()
extractor.visit(tree) extractor.visit(tree)
return extractor.patch_set_values return extractor.patch_set_values, extractor.nf_version
def parse_alignment(val: str) -> str | None: def parse_alignment(val: str) -> str | None:
return { return {
"l": ".start", "l": ".start",
"r": ".end", "r": ".end",
"c": ".center", "c": ".center1", # font-patcher specific centering rule, see face.zig
"": None, "": None,
}.get(val, ".none") }.get(val, ".none")
@ -158,10 +193,10 @@ def attr_key(attr: PatchSetAttributeEntry) -> AttributeHash:
float(params.get("overlap", 0.0)), float(params.get("overlap", 0.0)),
float(params.get("xy-ratio", -1.0)), float(params.get("xy-ratio", -1.0)),
float(params.get("ypadding", 0.0)), float(params.get("ypadding", 0.0)),
float(attr.get("group_x", 0.0)), float(attr.get("relative_x", 0.0)),
float(attr.get("group_y", 0.0)), float(attr.get("relative_y", 0.0)),
float(attr.get("group_width", 1.0)), float(attr.get("relative_width", 1.0)),
float(attr.get("group_height", 1.0)), float(attr.get("relative_height", 1.0)),
) )
@ -187,10 +222,10 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
stretch = attr.get("stretch", "") stretch = attr.get("stretch", "")
params = attr.get("params", {}) params = attr.get("params", {})
group_x = attr.get("group_x", 0.0) relative_x = attr.get("relative_x", 0.0)
group_y = attr.get("group_y", 0.0) relative_y = attr.get("relative_y", 0.0)
group_width = attr.get("group_width", 1.0) relative_width = attr.get("relative_width", 1.0)
group_height = attr.get("group_height", 1.0) relative_height = attr.get("relative_height", 1.0)
overlap = params.get("overlap", 0.0) overlap = params.get("overlap", 0.0)
xy_ratio = params.get("xy-ratio", -1.0) xy_ratio = params.get("xy-ratio", -1.0)
@ -204,28 +239,30 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
s = f"{keys}\n => .{{\n" s = f"{keys}\n => .{{\n"
# These translations don't quite capture the way # This maps the font_patcher stretch rules to a Constrain instance
# the actual patcher does scaling, but they're a # NOTE: some comments in font_patcher indicate that only x or y
# good enough compromise. # would also be a valid spec, but no icons use it, so we won't
if "xy" in stretch: # support it until we have to.
s += " .size_horizontal = .stretch,\n" if "pa" in stretch:
s += " .size_vertical = .stretch,\n" if "!" in stretch or overlap:
elif "!" in stretch or "^" in stretch: s += " .size = .cover,\n"
s += " .size_horizontal = .cover,\n"
s += " .size_vertical = .fit,\n"
else: else:
s += " .size_horizontal = .fit,\n" s += " .size = .fit_cover1,\n"
s += " .size_vertical = .fit,\n" elif "xy" in stretch:
s += " .size = .stretch,\n"
else:
print(f"Warning: Unknown stretch rule {stretch}")
# `^` indicates that scaling should fill # `^` indicates that scaling should use the
# the whole cell, not just the icon height. # full cell height, not just the icon height,
# even when the constraint width is 1
if "^" not in stretch: if "^" not in stretch:
s += " .height = .icon,\n" s += " .height = .icon,\n"
# There are two cases where we want to limit the constraint width to 1: # There are two cases where we want to limit the constraint width to 1:
# - If there's a `1` in the stretch mode string. # - If there's a `1` in the stretch mode string.
# - If the stretch mode is `xy` and there's not an explicit `2`. # - If the stretch mode is not `pa` and there's not an explicit `2`.
if "1" in stretch or ("xy" in stretch and "2" not in stretch): if "1" in stretch or ("pa" not in stretch and "2" not in stretch):
s += " .max_constraint_width = 1,\n" s += " .max_constraint_width = 1,\n"
if align is not None: if align is not None:
@ -233,24 +270,24 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
if valign is not None: if valign is not None:
s += f" .align_vertical = {valign},\n" s += f" .align_vertical = {valign},\n"
if group_width != 1.0: if relative_width != 1.0:
s += f" .group_width = {group_width:.16f},\n" s += f" .relative_width = {relative_width:.16f},\n"
if group_height != 1.0: if relative_height != 1.0:
s += f" .group_height = {group_height:.16f},\n" s += f" .relative_height = {relative_height:.16f},\n"
if group_x != 0.0: if relative_x != 0.0:
s += f" .group_x = {group_x:.16f},\n" s += f" .relative_x = {relative_x:.16f},\n"
if group_y != 0.0: if relative_y != 0.0:
s += f" .group_y = {group_y:.16f},\n" s += f" .relative_y = {relative_y:.16f},\n"
# `overlap` and `ypadding` are mutually exclusive, # `overlap` and `ypadding` are mutually exclusive,
# this is asserted in the nerd fonts patcher itself. # this is asserted in the nerd fonts patcher itself.
if overlap: if overlap:
pad = -overlap pad = -overlap / 2
s += f" .pad_left = {pad},\n" s += f" .pad_left = {pad},\n"
s += f" .pad_right = {pad},\n" s += f" .pad_right = {pad},\n"
# In the nerd fonts patcher, overlap values # In the nerd fonts patcher, overlap values
# are capped at 0.01 in the vertical direction. # are capped at 0.01 in the vertical direction.
v_pad = -min(0.01, overlap) v_pad = -min(0.01, overlap) / 2
s += f" .pad_top = {v_pad},\n" s += f" .pad_top = {v_pad},\n"
s += f" .pad_bottom = {v_pad},\n" s += f" .pad_bottom = {v_pad},\n"
elif y_padding: elif y_padding:
@ -264,54 +301,236 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
return s return s
def generate_codepoint_tables(
patch_sets: list[PatchSet],
nerd_font: TTFont,
nf_version: str,
) -> dict[str, dict[int, int]]:
# We may already have the table saved from a previous run.
if Path("nerd_font_codepoint_tables.py").exists():
import nerd_font_codepoint_tables
if nerd_font_codepoint_tables.version == nf_version:
return nerd_font_codepoint_tables.cp_tables
cp_tables: dict[str, dict[int, int]] = {}
cp_nerdfont_used: set[int] = set()
cmap = nerd_font.getBestCmap()
for entry in patch_sets:
patch_set_name = entry["Name"]
print(f"Info: Extracting codepoint table from patch set '{patch_set_name}'")
# Extract codepoint map from original font file; download if needed
source_filename = entry["Filename"]
target_folder = Path("nerd_font_symbol_fonts")
target_folder.mkdir(exist_ok=True)
target_file = target_folder / Path(source_filename).name
if not target_file.exists():
print(f"Info: Downloading '{source_filename}'")
urlretrieve(
f"https://github.com/ryanoasis/nerd-fonts/raw/refs/tags/v{nf_version}/src/glyphs/{source_filename}",
target_file,
)
try:
with TTFont(target_file) as patchfont:
patch_cmap = patchfont.getBestCmap()
except TTLibError:
# Not a TTF/OTF font. This is OK if this patch set is exact, so we
# let if pass. If there's a problem, later checks will catch it.
patch_cmap = None
# A glyph's scale rules are specified using its codepoint in
# the original font, which is sometimes different from its
# Nerd Font codepoint. If entry["Exact"] is False, the codepoints are
# mapped according to the following rules:
# * entry["SymStart"] and entry["SymEnd"] denote the patch set's codepoint
# range in the original font.
# * entry["SrcStart"] is the starting point of the patch set's mapped
# codepoint range. It must not be None if entry["Exact"] is False.
# * The destination codepoint range is packed; that is, while there may be
# gaps without glyphs in the original font's codepoint range, there are
# none in the Nerd Font range. Hence there is no constant codepoint
# offset; instead we must iterate through the range and increment the
# destination codepoint every time we encounter a glyph in the original
# font.
# If entry["Exact"] is True, the origin and Nerd Font codepoints are the
# same, gaps included, and entry["SrcStart"] must be None.
if entry["Exact"]:
assert entry["SrcStart"] is None
cp_nerdfont = 0
else:
assert entry["SrcStart"]
assert patch_cmap is not None
cp_nerdfont = entry["SrcStart"] - 1
if patch_set_name not in cp_tables:
# There are several patch sets with the same name, representing
# different codepoint ranges within the same original font. Merging
# these into a single table is OK. However, we need to keep separate
# tables for the different fonts to correctly deal with cases where
# they fill in each other's gaps.
cp_tables[patch_set_name] = {}
for cp_original in range(entry["SymStart"], entry["SymEnd"] + 1):
if patch_cmap and cp_original not in patch_cmap:
continue
if not entry["Exact"]:
cp_nerdfont += 1
else:
cp_nerdfont = cp_original
if cp_nerdfont not in cmap:
raise ValueError(
f"Missing codepoint in Symbols Only Font: {hex(cp_nerdfont)} in patch set '{patch_set_name}'"
)
elif cp_nerdfont in cp_nerdfont_used:
raise ValueError(
f"Overlap for codepoint {hex(cp_nerdfont)} in patch set '{patch_set_name}'"
)
cp_tables[patch_set_name][cp_original] = cp_nerdfont
cp_nerdfont_used.add(cp_nerdfont)
# Store the table and corresponding Nerd Fonts version together in a module.
with open("nerd_font_codepoint_tables.py", "w") as f:
print(
"""#! This is a generated file, produced by nerd_font_codegen.py
#! DO NOT EDIT BY HAND!
#!
#! This file specifies the mapping of codepoints in the original symbol
#! fonts to codepoints in a patched Nerd Font. This is extracted from
#! the nerd fonts patcher script and the symbol font files.""",
file=f,
)
print(f'version = "{nf_version}"', file=f)
print("cp_tables = {", file=f)
for name, table in cp_tables.items():
print(f' "{name}": {{', file=f)
for key, value in table.items():
print(f" {hex(key)}: {hex(value)},", file=f)
print(" },", file=f)
print("}", file=f)
return cp_tables
def generate_zig_switch_arms( def generate_zig_switch_arms(
patch_sets: list[PatchSet], patch_sets: list[PatchSet],
nerd_font: TTFont, nerd_font: TTFont,
nf_version: str,
) -> str: ) -> str:
cmap = nerd_font.getBestCmap() cmap = nerd_font.getBestCmap()
glyphs = nerd_font.getGlyphSet() glyphs = nerd_font.getGlyphSet()
cp_tables = generate_codepoint_tables(patch_sets, nerd_font, nf_version)
entries: dict[int, PatchSetAttributeEntry] = {} entries: dict[int, PatchSetAttributeEntry] = {}
for entry in patch_sets: for entry in patch_sets:
patch_set_name = entry["Name"]
print(f"Info: Extracting rules from patch set '{patch_set_name}'")
attributes = entry["Attributes"] attributes = entry["Attributes"]
patch_set_entries: dict[int, PatchSetAttributeEntry] = {}
for cp in range(entry["SymStart"], entry["SymEnd"] + 1): cp_table = cp_tables[patch_set_name]
entries[cp] = attributes["default"].copy() for cp_original in range(entry["SymStart"], entry["SymEnd"] + 1):
if cp_original not in cp_table:
continue
cp_nerdfont = cp_table[cp_original]
if cp_nerdfont in entries:
raise ValueError(
f"Overlap for codepoint {hex(cp_nerdfont)} in patch set '{patch_set_name}'"
)
if cp_original in attributes:
patch_set_entries[cp_nerdfont] = attributes[cp_original].copy()
else:
patch_set_entries[cp_nerdfont] = attributes["default"].copy()
entries |= {k: v for k, v in attributes.items() if isinstance(k, int)} if entry["ScaleRules"] is not None:
if entry["ScaleRules"] is not None and "ScaleGroups" in entry["ScaleRules"]:
for group in entry["ScaleRules"]["ScaleGroups"]: for group in entry["ScaleRules"]["ScaleGroups"]:
xMin = math.inf xMin = math.inf
yMin = math.inf yMin = math.inf
xMax = -math.inf xMax = -math.inf
yMax = -math.inf yMax = -math.inf
individual_bounds: dict[int, tuple[int, int, int, int]] = {} individual_bounds: dict[int, tuple[int, int, int, int]] = {}
for cp in group: individual_advances: set[float] = set()
if cp not in cmap: for cp_original in group:
continue if cp_original not in cp_table:
glyph = glyphs[cmap[cp]] # There is one special case where a scale group includes
# a glyph from the original font that's not in any patch
# set, and hence not in the Symbols Only font. The point
# of this glyph is to add extra vertical padding to a
# stretched (^xy) scale group, which means that its
# scaled and aligned position would span the line height
# plus overlap. Thus, we can use any other stretched
# glyph with overlap as stand-in to get the vertical
# bounds, such as as 0xE0B0 (powerline left hard
# divider). We don't worry about the horizontal bounds,
# as they by design should not affect the group's
# bounding box.
if (
patch_set_name == "Progress Indicators"
and cp_original == 0xEDFF
):
glyph = glyphs[cmap[0xE0B0]]
bounds = BoundsPen(glyphSet=glyphs) bounds = BoundsPen(glyphSet=glyphs)
glyph.draw(bounds) glyph.draw(bounds)
individual_bounds[cp] = bounds.bounds yMin = min(bounds.bounds[1], yMin)
yMax = max(bounds.bounds[3], yMax)
else:
# Other cases are due to lazily specified scale
# groups with gaps in the codepoint range.
print(
f"Info: Skipping scale group codepoint {hex(cp_original)}, which does not exist in patch set '{patch_set_name}'"
)
continue
cp_nerdfont = cp_table[cp_original]
glyph = glyphs[cmap[cp_nerdfont]]
individual_advances.add(glyph.width)
bounds = BoundsPen(glyphSet=glyphs)
glyph.draw(bounds)
individual_bounds[cp_nerdfont] = bounds.bounds
xMin = min(bounds.bounds[0], xMin) xMin = min(bounds.bounds[0], xMin)
yMin = min(bounds.bounds[1], yMin) yMin = min(bounds.bounds[1], yMin)
xMax = max(bounds.bounds[2], xMax) xMax = max(bounds.bounds[2], xMax)
yMax = max(bounds.bounds[3], yMax) yMax = max(bounds.bounds[3], yMax)
group_width = xMax - xMin group_width = xMax - xMin
group_height = yMax - yMin group_height = yMax - yMin
for cp in group: group_is_monospace = (len(individual_bounds) > 1) and (
if cp not in cmap or cp not in entries: len(individual_advances) == 1
)
for cp_original in group:
if cp_original not in cp_table:
continue continue
this_bounds = individual_bounds[cp] cp_nerdfont = cp_table[cp_original]
this_width = this_bounds[2] - this_bounds[0] if (
# Scale groups may cut across patch sets, but we're only
# updating a single patch set at a time, so we skip
# codepoints not in it.
cp_nerdfont not in patch_set_entries
# Codepoints may contribute to the bounding box of multiple groups,
# but should be scaled according to the first group they are found
# in. Hence, to avoid overwriting, we need to skip codepoints that
# have already been assigned a scale group.
or "relative_height" in patch_set_entries[cp_nerdfont]
):
continue
this_bounds = individual_bounds[cp_nerdfont]
this_height = this_bounds[3] - this_bounds[1] this_height = this_bounds[3] - this_bounds[1]
entries[cp]["group_width"] = group_width / this_width patch_set_entries[cp_nerdfont]["relative_height"] = (
entries[cp]["group_height"] = group_height / this_height this_height / group_height
entries[cp]["group_x"] = (this_bounds[0] - xMin) / group_width )
entries[cp]["group_y"] = (this_bounds[1] - yMin) / group_height patch_set_entries[cp_nerdfont]["relative_y"] = (
this_bounds[1] - yMin
del entries[0] ) / group_height
# Horizontal alignment should only be grouped if the group is monospace,
# that is, if all glyphs in the group have the same advance width.
if group_is_monospace:
this_width = this_bounds[2] - this_bounds[0]
patch_set_entries[cp_nerdfont]["relative_width"] = (
this_width / group_width
)
patch_set_entries[cp_nerdfont]["relative_x"] = (
this_bounds[0] - xMin
) / group_width
entries |= patch_set_entries
# Group codepoints by attribute key # Group codepoints by attribute key
grouped = defaultdict[AttributeHash, list[int]](list) grouped = defaultdict[AttributeHash, list[int]](list)
@ -337,7 +556,7 @@ if __name__ == "__main__":
patcher_path = project_root / "vendor" / "nerd-fonts" / "font-patcher.py" patcher_path = project_root / "vendor" / "nerd-fonts" / "font-patcher.py"
source = patcher_path.read_text(encoding="utf-8") source = patcher_path.read_text(encoding="utf-8")
patch_set = extract_patch_set_values(source) patch_set, nf_version = extract_patch_set_values(source)
out_path = project_root / "src" / "font" / "nerd_font_attributes.zig" out_path = project_root / "src" / "font" / "nerd_font_attributes.zig"
@ -350,9 +569,9 @@ if __name__ == "__main__":
const Constraint = @import("face.zig").RenderOptions.Constraint; const Constraint = @import("face.zig").RenderOptions.Constraint;
/// Get the a constraints for the provided codepoint. /// Get the constraints for the provided codepoint.
pub fn getConstraint(cp: u21) ?Constraint { pub fn getConstraint(cp: u21) ?Constraint {
return switch (cp) { return switch (cp) {
""") """)
f.write(generate_zig_switch_arms(patch_set, nerd_font)) f.write(generate_zig_switch_arms(patch_set, nerd_font, nf_version))
f.write("\n else => null,\n };\n}\n") f.write("\n else => null,\n };\n}\n")

File diff suppressed because it is too large Load Diff

View File

@ -52,10 +52,10 @@ pub const Shaper = struct {
/// The shared memory used for shaping results. /// The shared memory used for shaping results.
cell_buf: CellBuf, cell_buf: CellBuf,
/// The cached writing direction value for shaping. This isn't /// Cached attributes dict for creating CTTypesetter objects.
/// configurable we just use this as a cache to avoid creating /// The values in this never change so we can avoid overhead
/// and releasing many objects when shaping. /// by just creating it once and saving it for re-use.
writing_direction: *macos.foundation.Array, typesetter_attr_dict: *macos.foundation.Dictionary,
/// List where we cache fonts, so we don't have to remake them for /// List where we cache fonts, so we don't have to remake them for
/// every single shaping operation. /// every single shaping operation.
@ -174,21 +174,28 @@ pub const Shaper = struct {
// //
// See: https://github.com/mitchellh/ghostty/issues/1737 // See: https://github.com/mitchellh/ghostty/issues/1737
// See: https://github.com/mitchellh/ghostty/issues/1442 // See: https://github.com/mitchellh/ghostty/issues/1442
const writing_direction = array: { //
const dir: macos.text.WritingDirection = .lro; // We used to do this by setting the writing direction attribute
const num = try macos.foundation.Number.create( // on the attributed string we used, but it seems like that will
.int, // still allow some weird results, for example a single space at
&@intFromEnum(dir), // the end of a line composed of RTL characters will be cause it
); // to output a run containing just that space, BEFORE it outputs
// the rest of the line as a separate run, very weirdly with the
// "right to left" flag set in the single space run's run status...
//
// So instead what we do is use a CTTypesetter to create our line,
// using the kCTTypesetterOptionForcedEmbeddingLevel attribute to
// force CoreText not to try doing any sort of BiDi, instead just
// treat all text as embedding level 0 (left to right).
const typesetter_attr_dict = dict: {
const num = try macos.foundation.Number.create(.int, &0);
defer num.release(); defer num.release();
break :dict try macos.foundation.Dictionary.create(
var arr_init = [_]*const macos.foundation.Number{num}; &.{macos.c.kCTTypesetterOptionForcedEmbeddingLevel},
break :array try macos.foundation.Array.create( &.{num},
macos.foundation.Number,
&arr_init,
); );
}; };
errdefer writing_direction.release(); errdefer typesetter_attr_dict.release();
// Create the CF release thread. // Create the CF release thread.
var cf_release_thread = try alloc.create(CFReleaseThread); var cf_release_thread = try alloc.create(CFReleaseThread);
@ -210,7 +217,7 @@ pub const Shaper = struct {
.run_state = run_state, .run_state = run_state,
.features = features, .features = features,
.features_no_default = features_no_default, .features_no_default = features_no_default,
.writing_direction = writing_direction, .typesetter_attr_dict = typesetter_attr_dict,
.cached_fonts = .{}, .cached_fonts = .{},
.cached_font_grid = 0, .cached_font_grid = 0,
.cf_release_pool = .{}, .cf_release_pool = .{},
@ -224,7 +231,7 @@ pub const Shaper = struct {
self.run_state.deinit(self.alloc); self.run_state.deinit(self.alloc);
self.features.release(); self.features.release();
self.features_no_default.release(); self.features_no_default.release();
self.writing_direction.release(); self.typesetter_attr_dict.release();
{ {
for (self.cached_fonts.items) |ft| { for (self.cached_fonts.items) |ft| {
@ -346,8 +353,8 @@ pub const Shaper = struct {
run.font_index, run.font_index,
); );
// Make room for the attributed string and the CTLine. // Make room for the attributed string, CTTypesetter, and CTLine.
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3); try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 4);
const str = macos.foundation.String.createWithCharactersNoCopy(state.unichars.items); const str = macos.foundation.String.createWithCharactersNoCopy(state.unichars.items);
self.cf_release_pool.appendAssumeCapacity(str); self.cf_release_pool.appendAssumeCapacity(str);
@ -359,8 +366,17 @@ pub const Shaper = struct {
); );
self.cf_release_pool.appendAssumeCapacity(attr_str); self.cf_release_pool.appendAssumeCapacity(attr_str);
// We should always have one run because we do our own run splitting. // Create a typesetter from the attributed string and the cached
const line = try macos.text.Line.createWithAttributedString(attr_str); // attr dict. (See comment in init for more info on the attr dict.)
const typesetter =
try macos.text.Typesetter.createWithAttributedStringAndOptions(
attr_str,
self.typesetter_attr_dict,
);
self.cf_release_pool.appendAssumeCapacity(typesetter);
// Create a line from the typesetter
const line = typesetter.createLine(.{ .location = 0, .length = 0 });
self.cf_release_pool.appendAssumeCapacity(line); self.cf_release_pool.appendAssumeCapacity(line);
// This keeps track of the current offsets within a single cell. // This keeps track of the current offsets within a single cell.
@ -369,7 +385,12 @@ pub const Shaper = struct {
x: f64 = 0, x: f64 = 0,
y: f64 = 0, y: f64 = 0,
} = .{}; } = .{};
// Clear our cell buf and make sure we have enough room for the whole
// line of glyphs, so that we can just assume capacity when appending
// instead of maybe allocating.
self.cell_buf.clearRetainingCapacity(); self.cell_buf.clearRetainingCapacity();
try self.cell_buf.ensureTotalCapacity(self.alloc, line.getGlyphCount());
// CoreText may generate multiple runs even though our input to // CoreText may generate multiple runs even though our input to
// CoreText is already split into runs by our own run iterator. // CoreText is already split into runs by our own run iterator.
@ -381,9 +402,9 @@ pub const Shaper = struct {
const ctrun = runs.getValueAtIndex(macos.text.Run, i); const ctrun = runs.getValueAtIndex(macos.text.Run, i);
// Get our glyphs and positions // Get our glyphs and positions
const glyphs = try ctrun.getGlyphs(alloc); const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc);
const advances = try ctrun.getAdvances(alloc); const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc);
const indices = try ctrun.getStringIndices(alloc); const indices = ctrun.getStringIndicesPtr() orelse try ctrun.getStringIndices(alloc);
assert(glyphs.len == advances.len); assert(glyphs.len == advances.len);
assert(glyphs.len == indices.len); assert(glyphs.len == indices.len);
@ -406,7 +427,7 @@ pub const Shaper = struct {
cell_offset = .{ .cluster = cluster }; cell_offset = .{ .cluster = cluster };
} }
try self.cell_buf.append(self.alloc, .{ self.cell_buf.appendAssumeCapacity(.{
.x = @intCast(cluster), .x = @intCast(cluster),
.x_offset = @intFromFloat(@round(cell_offset.x)), .x_offset = @intFromFloat(@round(cell_offset.x)),
.y_offset = @intFromFloat(@round(cell_offset.y)), .y_offset = @intFromFloat(@round(cell_offset.y)),
@ -511,15 +532,10 @@ pub const Shaper = struct {
// Get our font and use that get the attributes to set for the // Get our font and use that get the attributes to set for the
// attributed string so the whole string uses the same font. // attributed string so the whole string uses the same font.
const attr_dict = dict: { const attr_dict = dict: {
var keys = [_]?*const anyopaque{ break :dict try macos.foundation.Dictionary.create(
macos.text.StringAttribute.font.key(), &.{macos.text.StringAttribute.font.key()},
macos.text.StringAttribute.writing_direction.key(), &.{run_font},
}; );
var values = [_]?*const anyopaque{
run_font,
self.writing_direction,
};
break :dict try macos.foundation.Dictionary.create(&keys, &values);
}; };
self.cached_fonts.items[index_int] = attr_dict; self.cached_fonts.items[index_int] = attr_dict;

View File

@ -64,11 +64,35 @@ pub const Parser = struct {
const flags, const start_idx = try parseFlags(raw_input); const flags, const start_idx = try parseFlags(raw_input);
const input = raw_input[start_idx..]; const input = raw_input[start_idx..];
// Find the last = which splits are mapping into the trigger // Find the equal sign. This is more complicated than it seems on
// and action, respectively. // the surface because we need to ignore equal signs that are
// We use the last = because the keybind itself could contain // part of the trigger.
// raw equal signs (for the = codepoint) const eql_idx: usize = eql: {
const eql_idx = std.mem.lastIndexOf(u8, input, "=") orelse return Error.InvalidFormat; // TODO: We should change this parser into a real state machine
// based parser that parses the trigger fully, then yields the
// action after. The loop below is a total mess.
var offset: usize = 0;
while (std.mem.indexOfScalar(
u8,
input[offset..],
'=',
)) |offset_idx| {
// Find: '=+ctrl' or '==action'
const idx = offset + offset_idx;
if (idx < input.len - 1 and
(input[idx + 1] == '+' or
input[idx + 1] == '='))
{
offset += offset_idx + 1;
continue;
}
// Looks like the real equal sign.
break :eql idx;
}
return Error.InvalidFormat;
};
// Sequence iterator goes up to the equal, action is after. We can // Sequence iterator goes up to the equal, action is after. We can
// parse the action now. // parse the action now.
@ -698,7 +722,7 @@ pub const Action = union(enum) {
/// All actions are only undoable/redoable for a limited time. /// All actions are only undoable/redoable for a limited time.
/// For example, restoring a closed split can only be done for /// For example, restoring a closed split can only be done for
/// some number of seconds since the split was closed. The exact /// some number of seconds since the split was closed. The exact
/// amount is configured with `TODO`. /// amount is configured with the `undo-timeout` configuration settings.
/// ///
/// The undo/redo actions being limited ensures that there is /// The undo/redo actions being limited ensures that there is
/// bounded memory usage over time, closed surfaces don't continue running /// bounded memory usage over time, closed surfaces don't continue running
@ -1189,7 +1213,7 @@ pub const Action = union(enum) {
const value_info = @typeInfo(Value); const value_info = @typeInfo(Value);
switch (Value) { switch (Value) {
void => {}, void => {},
[]const u8 => try writer.print("{s}", .{value}), []const u8 => try std.zig.stringEscape(value, "", .{}, writer),
else => switch (value_info) { else => switch (value_info) {
.@"enum" => try writer.print("{s}", .{@tagName(value)}), .@"enum" => try writer.print("{s}", .{@tagName(value)}),
.float => try writer.print("{d}", .{value}), .float => try writer.print("{d}", .{value}),
@ -2298,6 +2322,39 @@ test "parse: equals sign" {
try testing.expectError(Error.InvalidFormat, parseSingle("=ignore")); try testing.expectError(Error.InvalidFormat, parseSingle("=ignore"));
} }
test "parse: text action equals sign" {
const testing = std.testing;
{
const binding = try parseSingle("==text:=");
try testing.expectEqual(Trigger{ .key = .{ .unicode = '=' } }, binding.trigger);
try testing.expectEqualStrings("=", binding.action.text);
}
{
const binding = try parseSingle("==text:=hello");
try testing.expectEqual(Trigger{ .key = .{ .unicode = '=' } }, binding.trigger);
try testing.expectEqualStrings("=hello", binding.action.text);
}
{
const binding = try parseSingle("ctrl+==text:=hello");
try testing.expectEqual(Trigger{
.key = .{ .unicode = '=' },
.mods = .{ .ctrl = true },
}, binding.trigger);
try testing.expectEqualStrings("=hello", binding.action.text);
}
{
const binding = try parseSingle("=+ctrl=text:=hello");
try testing.expectEqual(Trigger{
.key = .{ .unicode = '=' },
.mods = .{ .ctrl = true },
}, binding.trigger);
try testing.expectEqualStrings("=hello", binding.action.text);
}
}
// For Ghostty 1.2+ we changed our key names to match the W3C and removed // For Ghostty 1.2+ we changed our key names to match the W3C and removed
// `physical:`. This tests the backwards compatibility with the old format. // `physical:`. This tests the backwards compatibility with the old format.
// Note that our backwards compatibility isn't 100% perfect since triggers // Note that our backwards compatibility isn't 100% perfect since triggers
@ -3166,3 +3223,18 @@ test "parse: set_font_size" {
try testing.expectEqual(13.5, binding.action.set_font_size); try testing.expectEqual(13.5, binding.action.set_font_size);
} }
} }
test "action: format" {
const testing = std.testing;
const alloc = testing.allocator;
const a: Action = .{ .text = "👻" };
var buf: std.ArrayListUnmanaged(u8) = .empty;
defer buf.deinit(alloc);
const writer = buf.writer(alloc);
try a.format("", .{}, writer);
try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.items);
}

View File

@ -346,6 +346,10 @@ fn legacy(
// ever be a multi-codepoint sequence that triggers this. // ever be a multi-codepoint sequence that triggers this.
if (it.nextCodepoint() != null) break :modify_other; if (it.nextCodepoint() != null) break :modify_other;
// The mods we encode for this are just the binding mods (shift, ctrl,
// super, alt).
const mods = self.event.mods.binding();
// This copies xterm's `ModifyOtherKeys` function that returns // This copies xterm's `ModifyOtherKeys` function that returns
// whether modify other keys should be encoded for the given // whether modify other keys should be encoded for the given
// input. // input.
@ -355,7 +359,7 @@ fn legacy(
break :should_modify true; break :should_modify true;
// If we have anything other than shift pressed, encode. // If we have anything other than shift pressed, encode.
var mods_no_shift = binding_mods; var mods_no_shift = mods;
mods_no_shift.shift = false; mods_no_shift.shift = false;
if (!mods_no_shift.empty()) break :should_modify true; if (!mods_no_shift.empty()) break :should_modify true;
@ -370,7 +374,7 @@ fn legacy(
if (should_modify) { if (should_modify) {
for (function_keys.modifiers, 2..) |modset, code| { for (function_keys.modifiers, 2..) |modset, code| {
if (!binding_mods.equal(modset)) continue; if (!mods.equal(modset)) continue;
return try std.fmt.bufPrint( return try std.fmt.bufPrint(
buf, buf,
"\x1B[27;{};{}~", "\x1B[27;{};{}~",
@ -1984,6 +1988,22 @@ test "legacy: ctrl+shift+char with modify other state 2" {
try testing.expectEqualStrings("\x1b[27;6;72~", actual); try testing.expectEqualStrings("\x1b[27;6;72~", actual);
} }
test "legacy: ctrl+shift+char with modify other state 2 and consumed mods" {
var buf: [128]u8 = undefined;
var enc: KeyEncoder = .{
.event = .{
.key = .key_h,
.mods = .{ .ctrl = true, .shift = true },
.consumed_mods = .{ .shift = true },
.utf8 = "H",
},
.modify_other_keys_state_2 = true,
};
const actual = try enc.legacy(&buf);
try testing.expectEqualStrings("\x1b[27;6;72~", actual);
}
test "legacy: fixterm awkward letters" { test "legacy: fixterm awkward letters" {
var buf: [128]u8 = undefined; var buf: [128]u8 = undefined;
{ {

View File

@ -472,13 +472,18 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Quit the application.", .description = "Quit the application.",
}}, }},
.text => comptime &.{.{
.action = .{ .text = "👻" },
.title = "Ghostty",
.description = "Put a little Ghostty in your terminal.",
}},
// No commands because they're parameterized and there // No commands because they're parameterized and there
// aren't obvious values users would use. It is possible that // aren't obvious values users would use. It is possible that
// these may have commands in the future if there are very // these may have commands in the future if there are very
// common values that users tend to use. // common values that users tend to use.
.csi, .csi,
.esc, .esc,
.text,
.cursor_key, .cursor_key,
.set_font_size, .set_font_size,
.scroll_page_fractional, .scroll_page_fractional,

View File

@ -63,18 +63,42 @@ const Info = extern struct {
pub const String = extern struct { pub const String = extern struct {
ptr: ?[*]const u8, ptr: ?[*]const u8,
len: usize, len: usize,
sentinel: bool,
pub const empty: String = .{ pub const empty: String = .{
.ptr = null, .ptr = null,
.len = 0, .len = 0,
.sentinel = false,
}; };
pub fn fromSlice(slice: []const u8) String { pub fn fromSlice(slice: anytype) String {
return .{ return .{
.ptr = slice.ptr, .ptr = slice.ptr,
.len = slice.len, .len = slice.len,
.sentinel = sentinel: {
const info = @typeInfo(@TypeOf(slice));
switch (info) {
.pointer => |p| {
if (p.size != .slice) @compileError("only slices supported");
if (p.child != u8) @compileError("only u8 slices supported");
const sentinel_ = p.sentinel();
if (sentinel_) |sentinel| if (sentinel != 0) @compileError("only 0 is supported for sentinels");
break :sentinel sentinel_ != null;
},
else => @compileError("only []const u8 and [:0]const u8"),
}
},
}; };
} }
pub fn deinit(self: *const String) void {
const ptr = self.ptr orelse return;
if (self.sentinel) {
state.alloc.free(ptr[0..self.len :0]);
} else {
state.alloc.free(ptr[0..self.len]);
}
}
}; };
/// Initialize ghostty global state. /// Initialize ghostty global state.
@ -129,5 +153,45 @@ pub export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 {
/// Free a string allocated by Ghostty. /// Free a string allocated by Ghostty.
pub export fn ghostty_string_free(str: String) void { pub export fn ghostty_string_free(str: String) void {
state.alloc.free(str.ptr.?[0..str.len]); str.deinit();
}
test "ghostty_string_s empty string" {
const testing = std.testing;
const empty_string = String.empty;
defer empty_string.deinit();
try testing.expect(empty_string.len == 0);
try testing.expect(empty_string.sentinel == false);
}
test "ghostty_string_s c string" {
const testing = std.testing;
state.alloc = testing.allocator;
const slice: [:0]const u8 = "hello";
const allocated_slice = try testing.allocator.dupeZ(u8, slice);
const c_null_string = String.fromSlice(allocated_slice);
defer c_null_string.deinit();
try testing.expect(allocated_slice[5] == 0);
try testing.expect(@TypeOf(slice) == [:0]const u8);
try testing.expect(@TypeOf(allocated_slice) == [:0]u8);
try testing.expect(c_null_string.len == 5);
try testing.expect(c_null_string.sentinel == true);
}
test "ghostty_string_s zig string" {
const testing = std.testing;
state.alloc = testing.allocator;
const slice: []const u8 = "hello";
const allocated_slice = try testing.allocator.dupe(u8, slice);
const zig_string = String.fromSlice(allocated_slice);
defer zig_string.deinit();
try testing.expect(@TypeOf(slice) == []const u8);
try testing.expect(@TypeOf(allocated_slice) == []u8);
try testing.expect(zig_string.len == 5);
try testing.expect(zig_string.sentinel == false);
} }

View File

@ -95,6 +95,21 @@ pub fn getenv(alloc: Allocator, key: []const u8) Error!?GetEnvResult {
}; };
} }
/// Gets the value of an environment variable. Returns null if not found or the
/// value is empty. This will allocate on Windows but not on other platforms.
/// The returned value should have deinit called to do the proper cleanup no
/// matter what platform you are on.
pub fn getenvNotEmpty(alloc: Allocator, key: []const u8) !?GetEnvResult {
const result_ = try getenv(alloc, key);
if (result_) |result| {
if (result.value.len == 0) {
result.deinit(alloc);
return null;
}
}
return result_;
}
pub fn setenv(key: [:0]const u8, value: [:0]const u8) c_int { pub fn setenv(key: [:0]const u8, value: [:0]const u8) c_int {
return switch (builtin.os.tag) { return switch (builtin.os.tag) {
.windows => c._putenv_s(key.ptr, value.ptr), .windows => c._putenv_s(key.ptr, value.ptr),

View File

@ -46,10 +46,6 @@ fn isValidMacAddress(mac_address: []const u8) bool {
/// correctly. /// correctly.
pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri { pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri {
return std.Uri.parse(url) catch |e| { return std.Uri.parse(url) catch |e| {
// The mac-address-as-hostname issue is specific to macOS so we just return an error if we
// hit it on other platforms.
if (comptime builtin.os.tag != .macos) return e;
// It's possible this is a mac address on macOS where the last 2 characters in the // It's possible this is a mac address on macOS where the last 2 characters in the
// address are non-digits, e.g. 'ff', and thus an invalid port. // address are non-digits, e.g. 'ff', and thus an invalid port.
// //
@ -148,6 +144,91 @@ pub const LocalHostnameValidationError = error{
Unexpected, Unexpected,
}; };
/// Validates a hostname according to [RFC 1123](https://www.rfc-editor.org/rfc/rfc1123)
///
/// std.net.isValidHostname is (currently) too generous. It considers strings like
/// ".example.com", "exa..mple.com", and "-example.com" to be valid hostnames, which
/// is incorrect.
pub fn isValid(hostname: []const u8) bool {
if (hostname.len == 0) return false;
if (hostname[0] == '.') return false;
// Ignore trailing dot (FQDN). It doesn't count toward our length.
const end = if (hostname[hostname.len - 1] == '.') end: {
if (hostname.len == 1) return false;
break :end hostname.len - 1;
} else hostname.len;
if (end > 253) return false;
// Hostnames are divided into dot-separated "labels", which:
//
// - Start with a letter or digit
// - Can contain letters, digits, or hyphens
// - Must end with a letter or digit
// - Have a minimum of 1 character and a maximum of 63
var label_start: usize = 0;
var label_len: usize = 0;
for (hostname[0..end], 0..) |c, i| {
switch (c) {
'.' => {
if (label_len == 0 or label_len > 63) return false;
if (!std.ascii.isAlphanumeric(hostname[label_start])) return false;
if (!std.ascii.isAlphanumeric(hostname[i - 1])) return false;
label_start = i + 1;
label_len = 0;
},
'-' => {
label_len += 1;
},
else => {
if (!std.ascii.isAlphanumeric(c)) return false;
label_len += 1;
},
}
}
// Validate the final label
if (label_len == 0 or label_len > 63) return false;
if (!std.ascii.isAlphanumeric(hostname[label_start])) return false;
if (!std.ascii.isAlphanumeric(hostname[end - 1])) return false;
return true;
}
test isValid {
const testing = std.testing;
// Valid hostnames
try testing.expect(isValid("example"));
try testing.expect(isValid("example.com"));
try testing.expect(isValid("www.example.com"));
try testing.expect(isValid("sub.domain.example.com"));
try testing.expect(isValid("example.com."));
try testing.expect(isValid("host-name.example.com."));
try testing.expect(isValid("123.example.com."));
try testing.expect(isValid("a-b.com"));
try testing.expect(isValid("a.b.c.d.e.f.g"));
try testing.expect(isValid("127.0.0.1")); // Also a valid hostname
try testing.expect(isValid("a" ** 63 ++ ".com")); // Label exactly 63 chars (valid)
try testing.expect(isValid("a." ** 126 ++ "a")); // Total length 253 (valid)
// Invalid hostnames
try testing.expect(!isValid(""));
try testing.expect(!isValid(".example.com"));
try testing.expect(!isValid("example.com.."));
try testing.expect(!isValid("host..domain"));
try testing.expect(!isValid("-hostname"));
try testing.expect(!isValid("hostname-"));
try testing.expect(!isValid("a.-.b"));
try testing.expect(!isValid("host_name.com"));
try testing.expect(!isValid("."));
try testing.expect(!isValid(".."));
try testing.expect(!isValid("a" ** 64 ++ ".com")); // Label length 64 (too long)
try testing.expect(!isValid("a." ** 126 ++ "ab")); // Total length 254 (too long)
}
/// Checks if a hostname is local to the current machine. This matches /// Checks if a hostname is local to the current machine. This matches
/// both "localhost" and the current hostname of the machine (as returned /// both "localhost" and the current hostname of the machine (as returned
/// by `gethostname`). /// by `gethostname`).
@ -197,7 +278,6 @@ test parseUrl {
try std.testing.expect(uri.port == 12); try std.testing.expect(uri.port == 12);
// Alphabetical mac addresses. // Alphabetical mac addresses.
uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/"); uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/");
try std.testing.expectEqualStrings("file", uri.scheme); try std.testing.expectEqualStrings("file", uri.scheme);

View File

@ -52,6 +52,8 @@ pub const locales = [_][:0]const u8{
"ga_IE.UTF-8", "ga_IE.UTF-8",
"hu_HU.UTF-8", "hu_HU.UTF-8",
"he_IL.UTF-8", "he_IL.UTF-8",
"zh_TW.UTF-8",
"hr_HR.UTF-8",
}; };
/// Set for faster membership lookup of locales. /// Set for faster membership lookup of locales.

View File

@ -83,6 +83,11 @@ fn setLangFromCocoa() void {
const lang = locale.getProperty(objc.Object, "languageCode"); const lang = locale.getProperty(objc.Object, "languageCode");
const country = locale.getProperty(objc.Object, "countryCode"); const country = locale.getProperty(objc.Object, "countryCode");
if (lang.value == null or country.value == null) {
log.warn("languageCode or countryCode not found. Locale may be incorrect.", .{});
return;
}
// Get our UTF8 string values // Get our UTF8 string values
const c_lang = lang.getProperty([*:0]const u8, "UTF8String"); const c_lang = lang.getProperty([*:0]const u8, "UTF8String");
const c_country = country.getProperty([*:0]const u8, "UTF8String"); const c_country = country.getProperty([*:0]const u8, "UTF8String");

View File

@ -36,6 +36,10 @@ pub fn ShellEscapeWriter(comptime T: type) type {
const Writer = std.io.Writer(*ShellEscapeWriter(T), error{Error}, write); const Writer = std.io.Writer(*ShellEscapeWriter(T), error{Error}, write);
pub fn init(child_writer: T) ShellEscapeWriter(T) {
return .{ .child_writer = child_writer };
}
pub fn writer(self: *ShellEscapeWriter(T)) Writer { pub fn writer(self: *ShellEscapeWriter(T)) Writer {
return .{ .context = self }; return .{ .context = self };
} }

View File

@ -7,6 +7,7 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const posix = std.posix; const posix = std.posix;
const homedir = @import("homedir.zig"); const homedir = @import("homedir.zig");
const env_os = @import("env.zig");
pub const Options = struct { pub const Options = struct {
/// Subdirectories to join to the base. This avoids extra allocations /// Subdirectories to join to the base. This avoids extra allocations
@ -70,36 +71,22 @@ fn dir(
// First check the env var. On Windows we have to allocate so this tracks // First check the env var. On Windows we have to allocate so this tracks
// both whether we have the env var and whether we own it. // both whether we have the env var and whether we own it.
// on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME` // on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME`
const env_, const owned = switch (builtin.os.tag) { const env_ = try env_os.getenvNotEmpty(alloc, internal_opts.env) orelse switch (builtin.os.tag) {
else => .{ posix.getenv(internal_opts.env), false }, else => null,
.windows => windows: { .windows => try env_os.getenvNotEmpty(alloc, internal_opts.windows_env),
if (std.process.getEnvVarOwned(alloc, internal_opts.env)) |env| {
break :windows .{ env, true };
} else |err| switch (err) {
error.EnvironmentVariableNotFound => {
if (std.process.getEnvVarOwned(alloc, internal_opts.windows_env)) |env| {
break :windows .{ env, true };
} else |err2| switch (err2) {
error.EnvironmentVariableNotFound => break :windows .{ null, false },
else => return err,
}
},
else => return err,
}
},
}; };
defer if (owned) if (env_) |v| alloc.free(v); defer if (env_) |env| env.deinit(alloc);
if (env_) |env| { if (env_) |env| {
// If we have a subdir, then we use the env as-is to avoid a copy. // If we have a subdir, then we use the env as-is to avoid a copy.
if (opts.subdir) |subdir| { if (opts.subdir) |subdir| {
return try std.fs.path.join(alloc, &[_][]const u8{ return try std.fs.path.join(alloc, &[_][]const u8{
env, env.value,
subdir, subdir,
}); });
} }
return try alloc.dupe(u8, env); return try alloc.dupe(u8, env.value);
} }
// Get our home dir // Get our home dir
@ -169,6 +156,133 @@ test "cache directory paths" {
} }
} }
test "fallback when xdg env empty" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
const alloc = std.testing.allocator;
const saved_home = home: {
const home = std.posix.getenv("HOME") orelse break :home null;
break :home try alloc.dupeZ(u8, home);
};
defer env: {
const home = saved_home orelse {
_ = env_os.unsetenv("HOME");
break :env;
};
_ = env_os.setenv("HOME", home);
std.testing.allocator.free(home);
}
const temp_home = "/tmp/ghostty-test-home";
_ = env_os.setenv("HOME", temp_home);
const DirCase = struct {
name: [:0]const u8,
func: fn (Allocator, Options) anyerror![]u8,
default_subdir: []const u8,
};
const cases = [_]DirCase{
.{ .name = "XDG_CONFIG_HOME", .func = config, .default_subdir = ".config" },
.{ .name = "XDG_CACHE_HOME", .func = cache, .default_subdir = ".cache" },
.{ .name = "XDG_STATE_HOME", .func = state, .default_subdir = ".local/state" },
};
inline for (cases) |case| {
// Save and restore each environment variable
const saved_env = blk: {
const value = std.posix.getenv(case.name) orelse break :blk null;
break :blk try alloc.dupeZ(u8, value);
};
defer env: {
const value = saved_env orelse {
_ = env_os.unsetenv(case.name);
break :env;
};
_ = env_os.setenv(case.name, value);
alloc.free(value);
}
const expected = try std.fs.path.join(alloc, &[_][]const u8{
temp_home,
case.default_subdir,
});
defer alloc.free(expected);
// Test with empty string - should fallback to home
_ = env_os.setenv(case.name, "");
const actual = try case.func(alloc, .{});
defer alloc.free(actual);
try std.testing.expectEqualStrings(expected, actual);
}
}
test "fallback when xdg env empty and subdir" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
const env = @import("env.zig");
const alloc = std.testing.allocator;
const saved_home = home: {
const home = std.posix.getenv("HOME") orelse break :home null;
break :home try alloc.dupeZ(u8, home);
};
defer env: {
const home = saved_home orelse {
_ = env.unsetenv("HOME");
break :env;
};
_ = env.setenv("HOME", home);
std.testing.allocator.free(home);
}
const temp_home = "/tmp/ghostty-test-home";
_ = env.setenv("HOME", temp_home);
const DirCase = struct {
name: [:0]const u8,
func: fn (Allocator, Options) anyerror![]u8,
default_subdir: []const u8,
};
const cases = [_]DirCase{
.{ .name = "XDG_CONFIG_HOME", .func = config, .default_subdir = ".config" },
.{ .name = "XDG_CACHE_HOME", .func = cache, .default_subdir = ".cache" },
.{ .name = "XDG_STATE_HOME", .func = state, .default_subdir = ".local/state" },
};
inline for (cases) |case| {
// Save and restore each environment variable
const saved_env = blk: {
const value = std.posix.getenv(case.name) orelse break :blk null;
break :blk try alloc.dupeZ(u8, value);
};
defer env: {
const value = saved_env orelse {
_ = env.unsetenv(case.name);
break :env;
};
_ = env.setenv(case.name, value);
alloc.free(value);
}
const expected = try std.fs.path.join(alloc, &[_][]const u8{
temp_home,
case.default_subdir,
"ghostty",
});
defer alloc.free(expected);
// Test with empty string - should fallback to home
_ = env.setenv(case.name, "");
const actual = try case.func(alloc, .{ .subdir = "ghostty" });
defer alloc.free(actual);
try std.testing.expectEqualStrings(expected, actual);
}
}
test parseTerminalExec { test parseTerminalExec {
const testing = std.testing; const testing = std.testing;

View File

@ -25,6 +25,7 @@ pub const RenderPass = @import("metal/RenderPass.zig");
pub const Pipeline = @import("metal/Pipeline.zig"); pub const Pipeline = @import("metal/Pipeline.zig");
const bufferpkg = @import("metal/buffer.zig"); const bufferpkg = @import("metal/buffer.zig");
pub const Buffer = bufferpkg.Buffer; pub const Buffer = bufferpkg.Buffer;
pub const Sampler = @import("metal/Sampler.zig");
pub const Texture = @import("metal/Texture.zig"); pub const Texture = @import("metal/Texture.zig");
pub const shaders = @import("metal/shaders.zig"); pub const shaders = @import("metal/shaders.zig");
@ -273,6 +274,27 @@ pub inline fn textureOptions(self: Metal) Texture.Options {
.cpu_cache_mode = .write_combined, .cpu_cache_mode = .write_combined,
.storage_mode = self.default_storage_mode, .storage_mode = self.default_storage_mode,
}, },
.usage = .{
// textureOptions is currently only used for custom shaders,
// which require both the shader read (for when multiple shaders
// are chained) and render target (for the final output) usage.
// Disabling either of these will lead to metal validation
// errors in Xcode.
.shader_read = true,
.render_target = true,
},
};
}
pub inline fn samplerOptions(self: Metal) Sampler.Options {
return .{
.device = self.device,
// These parameters match Shadertoy behaviors.
.min_filter = .linear,
.mag_filter = .linear,
.s_address_mode = .clamp_to_edge,
.t_address_mode = .clamp_to_edge,
}; };
} }
@ -311,6 +333,10 @@ pub inline fn imageTextureOptions(
.cpu_cache_mode = .write_combined, .cpu_cache_mode = .write_combined,
.storage_mode = self.default_storage_mode, .storage_mode = self.default_storage_mode,
}, },
.usage = .{
// We only need to read from this texture from a shader.
.shader_read = true,
},
}; };
} }
@ -334,6 +360,10 @@ pub fn initAtlasTexture(
.cpu_cache_mode = .write_combined, .cpu_cache_mode = .write_combined,
.storage_mode = self.default_storage_mode, .storage_mode = self.default_storage_mode,
}, },
.usage = .{
// We only need to read from this texture from a shader.
.shader_read = true,
},
}, },
atlas.size, atlas.size,
atlas.size, atlas.size,

View File

@ -20,6 +20,7 @@ pub const RenderPass = @import("opengl/RenderPass.zig");
pub const Pipeline = @import("opengl/Pipeline.zig"); pub const Pipeline = @import("opengl/Pipeline.zig");
const bufferpkg = @import("opengl/buffer.zig"); const bufferpkg = @import("opengl/buffer.zig");
pub const Buffer = bufferpkg.Buffer; pub const Buffer = bufferpkg.Buffer;
pub const Sampler = @import("opengl/Sampler.zig");
pub const Texture = @import("opengl/Texture.zig"); pub const Texture = @import("opengl/Texture.zig");
pub const shaders = @import("opengl/shaders.zig"); pub const shaders = @import("opengl/shaders.zig");
@ -364,6 +365,17 @@ pub inline fn textureOptions(self: OpenGL) Texture.Options {
}; };
} }
/// Returns the options to use when constructing samplers.
pub inline fn samplerOptions(self: OpenGL) Sampler.Options {
_ = self;
return .{
.min_filter = .linear,
.mag_filter = .linear,
.wrap_s = .clamp_to_edge,
.wrap_t = .clamp_to_edge,
};
}
/// Pixel format for image texture options. /// Pixel format for image texture options.
pub const ImageTextureFormat = enum { pub const ImageTextureFormat = enum {
/// 1 byte per pixel grayscale. /// 1 byte per pixel grayscale.

View File

@ -236,7 +236,7 @@ pub fn isCovering(cp: u21) bool {
} }
/// Returns true of the codepoint is a "symbol-like" character, which /// Returns true of the codepoint is a "symbol-like" character, which
/// for now we define as anything in a private use area and anything /// for now we define as anything in a private use area, and anything
/// in several unicode blocks: /// in several unicode blocks:
/// - Dingbats /// - Dingbats
/// - Emoticons /// - Emoticons
@ -274,9 +274,9 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
// If we have a previous cell and it was a symbol then we need // If we have a previous cell and it was a symbol then we need
// to also constrain. This is so that multiple PUA glyphs align. // to also constrain. This is so that multiple PUA glyphs align.
// As an exception, we ignore powerline glyphs since they are // This does not apply if the previous symbol is a graphics
// used for box drawing and we consider them whitespace. // element such as a block element or Powerline glyph.
if (cell_pin.x > 0) prev: { if (cell_pin.x > 0) {
const prev_cp = prev_cp: { const prev_cp = prev_cp: {
var copy = cell_pin; var copy = cell_pin;
copy.x -= 1; copy.x -= 1;
@ -284,10 +284,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
break :prev_cp prev_cell.codepoint(); break :prev_cp prev_cell.codepoint();
}; };
// We consider powerline glyphs whitespace. if (isSymbol(prev_cp) and !isGraphicsElement(prev_cp)) {
if (isPowerline(prev_cp)) break :prev;
if (isSymbol(prev_cp)) {
return 1; return 1;
} }
} }
@ -300,10 +297,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
const next_cell = copy.rowAndCell().cell; const next_cell = copy.rowAndCell().cell;
break :next_cp next_cell.codepoint(); break :next_cp next_cell.codepoint();
}; };
if (next_cp == 0 or if (next_cp == 0 or isSpace(next_cp)) {
isSpace(next_cp) or
isPowerline(next_cp))
{
return 2; return 2;
} }
@ -311,10 +305,10 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
return 1; return 1;
} }
/// Whether min contrast should be disabled for a given glyph. /// Whether min contrast should be disabled for a given glyph. True
/// for graphics elements such as blocks and Powerline glyphs.
pub fn noMinContrast(cp: u21) bool { pub fn noMinContrast(cp: u21) bool {
// TODO: We should disable for all box drawing type characters. return isGraphicsElement(cp);
return isPowerline(cp);
} }
// Some general spaces, others intentionally kept // Some general spaces, others intentionally kept
@ -328,10 +322,42 @@ fn isSpace(char: u21) bool {
}; };
} }
/// Returns true if the codepoint is used for terminal graphics, such
/// as box drawing characters, block elements, and Powerline glyphs.
fn isGraphicsElement(char: u21) bool {
return isBoxDrawing(char) or isBlockElement(char) or isLegacyComputing(char) or isPowerline(char);
}
// Returns true if the codepoint is a box drawing character.
fn isBoxDrawing(char: u21) bool {
return switch (char) {
0x2500...0x257F => true,
else => false,
};
}
// Returns true if the codepoint is a block element.
fn isBlockElement(char: u21) bool {
return switch (char) {
0x2580...0x259F => true,
else => false,
};
}
// Returns true if the codepoint is in a Symbols for Legacy
// Computing block, including supplements.
fn isLegacyComputing(char: u21) bool {
return switch (char) {
0x1FB00...0x1FBFF => true,
0x1CC00...0x1CEBF => true, // Supplement introduced in Unicode 16.0
else => false,
};
}
// Returns true if the codepoint is a part of the Powerline range. // Returns true if the codepoint is a part of the Powerline range.
fn isPowerline(char: u21) bool { fn isPowerline(char: u21) bool {
return switch (char) { return switch (char) {
0xE0B0...0xE0C8, 0xE0CA, 0xE0CC...0xE0D2, 0xE0D4 => true, 0xE0B0...0xE0D7 => true,
else => false, else => false,
}; };
} }
@ -492,3 +518,113 @@ test "Contents with zero-sized screen" {
c.setCursor(null, null); c.setCursor(null, null);
try testing.expect(c.getCursorGlyph() == null); try testing.expect(c.getCursorGlyph() == null);
} }
test "Cell constraint widths" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try terminal.Screen.init(alloc, 4, 1, 0);
defer s.deinit();
// for each case, the numbers in the comment denote expected
// constraint widths for the symbol-containing cells
// symbol->nothing: 2
{
try s.testWriteString("");
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
try testing.expectEqual(2, constraintWidth(p0));
s.reset();
}
// symbol->character: 1
{
try s.testWriteString("z");
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
try testing.expectEqual(1, constraintWidth(p0));
s.reset();
}
// symbol->space: 2
{
try s.testWriteString(" z");
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
try testing.expectEqual(2, constraintWidth(p0));
s.reset();
}
// symbol->no-break space: 1
{
try s.testWriteString("\u{00a0}z");
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
try testing.expectEqual(1, constraintWidth(p0));
s.reset();
}
// symbol->end of row: 1
{
try s.testWriteString("");
const p3 = s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?;
try testing.expectEqual(1, constraintWidth(p3));
s.reset();
}
// character->symbol: 2
{
try s.testWriteString("z");
const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?;
try testing.expectEqual(2, constraintWidth(p1));
s.reset();
}
// symbol->symbol: 1,1
{
try s.testWriteString("");
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?;
try testing.expectEqual(1, constraintWidth(p0));
try testing.expectEqual(1, constraintWidth(p1));
s.reset();
}
// symbol->space->symbol: 2,2
{
try s.testWriteString(" ");
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const p2 = s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?;
try testing.expectEqual(2, constraintWidth(p0));
try testing.expectEqual(2, constraintWidth(p2));
s.reset();
}
// symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg)
{
try s.testWriteString("");
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
try testing.expectEqual(1, constraintWidth(p0));
s.reset();
}
// powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg)
{
try s.testWriteString("");
const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?;
try testing.expectEqual(2, constraintWidth(p1));
s.reset();
}
// powerline->nothing: 2 (dedicated test because powerline is special-cased in cellpkg)
{
try s.testWriteString("");
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
try testing.expectEqual(2, constraintWidth(p0));
s.reset();
}
// powerline->space: 2 (dedicated test because powerline is special-cased in cellpkg)
{
try s.testWriteString(" z");
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
try testing.expectEqual(2, constraintWidth(p0));
s.reset();
}
}

View File

@ -85,6 +85,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const Target = GraphicsAPI.Target; const Target = GraphicsAPI.Target;
const Buffer = GraphicsAPI.Buffer; const Buffer = GraphicsAPI.Buffer;
const Sampler = GraphicsAPI.Sampler;
const Texture = GraphicsAPI.Texture; const Texture = GraphicsAPI.Texture;
const RenderPass = GraphicsAPI.RenderPass; const RenderPass = GraphicsAPI.RenderPass;
@ -428,6 +429,19 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
front_texture: Texture, front_texture: Texture,
back_texture: Texture, back_texture: Texture,
/// Shadertoy uses a sampler for accessing the various channel
/// textures. In Metal, we need to explicitly create these since
/// the glslang-to-msl compiler doesn't do it for us (as we
/// normally would in hand-written MSL). To keep it clean and
/// consistent, we just force all rendering APIs to provide an
/// explicit sampler.
///
/// Samplers are immutable and describe sampling properties so
/// we can share the sampler across front/back textures (although
/// we only need it for the source texture at a time, we don't
/// need to "swap" it).
sampler: Sampler,
uniforms: UniformBuffer, uniforms: UniformBuffer,
const UniformBuffer = Buffer(shadertoy.Uniforms); const UniformBuffer = Buffer(shadertoy.Uniforms);
@ -459,9 +473,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
); );
errdefer back_texture.deinit(); errdefer back_texture.deinit();
const sampler = try Sampler.init(api.samplerOptions());
errdefer sampler.deinit();
return .{ return .{
.front_texture = front_texture, .front_texture = front_texture,
.back_texture = back_texture, .back_texture = back_texture,
.sampler = sampler,
.uniforms = uniforms, .uniforms = uniforms,
}; };
} }
@ -469,6 +487,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
pub fn deinit(self: *CustomShaderState) void { pub fn deinit(self: *CustomShaderState) void {
self.front_texture.deinit(); self.front_texture.deinit();
self.back_texture.deinit(); self.back_texture.deinit();
self.sampler.deinit();
self.uniforms.deinit(); self.uniforms.deinit();
} }
@ -1041,6 +1060,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Update relevant uniforms // Update relevant uniforms
self.updateFontGridUniforms(); self.updateFontGridUniforms();
// Force a full rebuild, because cached rows may still reference
// an outdated atlas from the old grid and this can cause garbage
// to be rendered.
self.cells_viewport = null;
} }
/// Update uniforms that are based on the font grid. /// Update uniforms that are based on the font grid.
@ -1509,6 +1533,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.pipeline = pipeline, .pipeline = pipeline,
.uniforms = state.uniforms.buffer, .uniforms = state.uniforms.buffer,
.textures = &.{state.back_texture}, .textures = &.{state.back_texture},
.samplers = &.{state.sampler},
.draw = .{ .draw = .{
.type = .triangle, .type = .triangle,
.vertex_count = 3, .vertex_count = 3,
@ -3073,8 +3098,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// its cell(s), we don't modify the alignment at all. // its cell(s), we don't modify the alignment at all.
.constraint = getConstraint(cp) orelse .constraint = getConstraint(cp) orelse
if (cellpkg.isSymbol(cp)) .{ if (cellpkg.isSymbol(cp)) .{
.size_horizontal = .fit, .size = .fit,
.size_vertical = .fit,
} else .none, } else .none,
.constraint_width = constraintWidth(cell_pin), .constraint_width = constraintWidth(cell_pin),
}, },

View File

@ -9,6 +9,7 @@ const objc = @import("objc");
const mtl = @import("api.zig"); const mtl = @import("api.zig");
const Pipeline = @import("Pipeline.zig"); const Pipeline = @import("Pipeline.zig");
const Sampler = @import("Sampler.zig");
const Texture = @import("Texture.zig"); const Texture = @import("Texture.zig");
const Target = @import("Target.zig"); const Target = @import("Target.zig");
const Metal = @import("../Metal.zig"); const Metal = @import("../Metal.zig");
@ -41,6 +42,9 @@ pub const Step = struct {
/// MTLBuffer /// MTLBuffer
buffers: []const ?objc.Object = &.{}, buffers: []const ?objc.Object = &.{},
textures: []const ?Texture = &.{}, textures: []const ?Texture = &.{},
/// Set of samplers to use for this step. The index maps to an index
/// of a fragment texture, set via setFragmentSamplerState(_:index:).
samplers: []const ?Sampler = &.{},
draw: Draw, draw: Draw,
/// Describes the draw call for this step. /// Describes the draw call for this step.
@ -200,6 +204,15 @@ pub fn step(self: *const Self, s: Step) void {
); );
}; };
// Set samplers.
for (s.samplers, 0..) |samp, i| if (samp) |sampler| {
self.encoder.msgSend(
void,
objc.sel("setFragmentSamplerState:atIndex:"),
.{ sampler.sampler.value, @as(c_ulong, i) },
);
};
// Draw! // Draw!
self.encoder.msgSend( self.encoder.msgSend(
void, void,

View File

@ -0,0 +1,66 @@
//! Wrapper for handling samplers.
const Self = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const builtin = @import("builtin");
const objc = @import("objc");
const mtl = @import("api.zig");
const Metal = @import("../Metal.zig");
const log = std.log.scoped(.metal);
/// Options for initializing a sampler.
pub const Options = struct {
/// MTLDevice
device: objc.Object,
min_filter: mtl.MTLSamplerMinMagFilter,
mag_filter: mtl.MTLSamplerMinMagFilter,
s_address_mode: mtl.MTLSamplerAddressMode,
t_address_mode: mtl.MTLSamplerAddressMode,
};
/// The underlying MTLSamplerState Object.
sampler: objc.Object,
pub const Error = error{
/// A Metal API call failed.
MetalFailed,
};
/// Initialize a sampler
pub fn init(
opts: Options,
) Error!Self {
// Create our descriptor
const desc = init: {
const Class = objc.getClass("MTLSamplerDescriptor").?;
const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init;
};
defer desc.release();
// Properties
desc.setProperty("minFilter", opts.min_filter);
desc.setProperty("magFilter", opts.mag_filter);
desc.setProperty("sAddressMode", opts.s_address_mode);
desc.setProperty("tAddressMode", opts.t_address_mode);
// Create the sampler state
const id = opts.device.msgSend(
?*anyopaque,
objc.sel("newSamplerStateWithDescriptor:"),
.{desc},
) orelse return error.MetalFailed;
return .{
.sampler = objc.Object.fromId(id),
};
}
pub fn deinit(self: Self) void {
self.sampler.release();
}

View File

@ -18,6 +18,7 @@ pub const Options = struct {
device: objc.Object, device: objc.Object,
pixel_format: mtl.MTLPixelFormat, pixel_format: mtl.MTLPixelFormat,
resource_options: mtl.MTLResourceOptions, resource_options: mtl.MTLResourceOptions,
usage: mtl.MTLTextureUsage,
}; };
/// The underlying MTLTexture Object. /// The underlying MTLTexture Object.
@ -57,6 +58,7 @@ pub fn init(
desc.setProperty("width", @as(c_ulong, width)); desc.setProperty("width", @as(c_ulong, width));
desc.setProperty("height", @as(c_ulong, height)); desc.setProperty("height", @as(c_ulong, height));
desc.setProperty("resourceOptions", opts.resource_options); desc.setProperty("resourceOptions", opts.resource_options);
desc.setProperty("usage", opts.usage);
// Initialize // Initialize
const id = opts.device.msgSend( const id = opts.device.msgSend(

View File

@ -8,6 +8,7 @@ const builtin = @import("builtin");
const gl = @import("opengl"); const gl = @import("opengl");
const OpenGL = @import("../OpenGL.zig"); const OpenGL = @import("../OpenGL.zig");
const Sampler = @import("Sampler.zig");
const Target = @import("Target.zig"); const Target = @import("Target.zig");
const Texture = @import("Texture.zig"); const Texture = @import("Texture.zig");
const Pipeline = @import("Pipeline.zig"); const Pipeline = @import("Pipeline.zig");
@ -35,6 +36,7 @@ pub const Step = struct {
uniforms: ?gl.Buffer = null, uniforms: ?gl.Buffer = null,
buffers: []const ?gl.Buffer = &.{}, buffers: []const ?gl.Buffer = &.{},
textures: []const ?Texture = &.{}, textures: []const ?Texture = &.{},
samplers: []const ?Sampler = &.{},
draw: Draw, draw: Draw,
/// Describes the draw call for this step. /// Describes the draw call for this step.
@ -103,6 +105,11 @@ pub fn step(self: *Self, s: Step) void {
_ = tex.texture.bind(tex.target) catch return; _ = tex.texture.bind(tex.target) catch return;
}; };
// Bind relevant samplers.
for (s.samplers, 0..) |s_, i| if (s_) |sampler| {
_ = sampler.sampler.bind(@intCast(i)) catch return;
};
// Bind 0th buffer as the vertex buffer, // Bind 0th buffer as the vertex buffer,
// and bind the rest as storage buffers. // and bind the rest as storage buffers.
if (s.buffers.len > 0) { if (s.buffers.len > 0) {

View File

@ -0,0 +1,47 @@
//! Wrapper for handling samplers.
const Self = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const builtin = @import("builtin");
const gl = @import("opengl");
const OpenGL = @import("../OpenGL.zig");
const log = std.log.scoped(.opengl);
/// Options for initializing a sampler.
pub const Options = struct {
min_filter: gl.Texture.MinFilter,
mag_filter: gl.Texture.MagFilter,
wrap_s: gl.Texture.Wrap,
wrap_t: gl.Texture.Wrap,
};
sampler: gl.Sampler,
pub const Error = error{
/// An OpenGL API call failed.
OpenGLFailed,
};
/// Initialize a sampler
pub fn init(
opts: Options,
) Error!Self {
const sampler = gl.Sampler.create() catch return error.OpenGLFailed;
errdefer sampler.destroy();
sampler.parameter(.WrapS, @intFromEnum(opts.wrap_s)) catch return error.OpenGLFailed;
sampler.parameter(.WrapT, @intFromEnum(opts.wrap_t)) catch return error.OpenGLFailed;
sampler.parameter(.MinFilter, @intFromEnum(opts.min_filter)) catch return error.OpenGLFailed;
sampler.parameter(.MagFilter, @intFromEnum(opts.mag_filter)) catch return error.OpenGLFailed;
return .{
.sampler = sampler,
};
}
pub fn deinit(self: Self) void {
self.sampler.destroy();
}

View File

@ -102,7 +102,7 @@ vec4 contrasted_color(float min_ratio, vec4 fg, vec4 bg) {
if (white_ratio > black_ratio) { if (white_ratio > black_ratio) {
return vec4(1.0); return vec4(1.0);
} else { } else {
return vec4(0.0); return vec4(0.0, 0.0, 0.0, 1.0);
} }
} }

View File

@ -73,6 +73,13 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
builtin unset GHOSTTY_BASH_RCFILE builtin unset GHOSTTY_BASH_RCFILE
fi fi
# Add Ghostty binary to PATH if the path feature is enabled
if [[ "$GHOSTTY_SHELL_FEATURES" == *"path"* && -n "$GHOSTTY_BIN_DIR" ]]; then
if [[ ":$PATH:" != *":$GHOSTTY_BIN_DIR:"* ]]; then
export PATH="$PATH:$GHOSTTY_BIN_DIR"
fi
fi
# Sudo # Sudo
if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
# Wrap `sudo` command to ensure Ghostty terminfo is preserved. # Wrap `sudo` command to ensure Ghostty terminfo is preserved.
@ -103,7 +110,7 @@ fi
# SSH Integration # SSH Integration
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
ssh() { function ssh() {
builtin local ssh_term ssh_opts builtin local ssh_term ssh_opts
ssh_term="xterm-256color" ssh_term="xterm-256color"
ssh_opts=() ssh_opts=()

View File

@ -196,6 +196,11 @@
set edit:before-readline = (conj $edit:before-readline $beam~) set edit:before-readline = (conj $edit:before-readline $beam~)
set edit:after-readline = (conj $edit:after-readline {|_| block }) set edit:after-readline = (conj $edit:after-readline {|_| block })
} }
if (and (has-value $features path) (has-env GHOSTTY_BIN_DIR)) {
if (not (has-value $paths $E:GHOSTTY_BIN_DIR)) {
set paths = [$@paths $E:GHOSTTY_BIN_DIR]
}
}
if (and (has-value $features sudo) (not-eq "" $E:TERMINFO) (has-external sudo)) { if (and (has-value $features sudo) (not-eq "" $E:TERMINFO) (has-external sudo)) {
edit:add-var sudo~ $sudo-with-terminfo~ edit:add-var sudo~ $sudo-with-terminfo~
} }

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