68 Commits
tip ... v1.2.2

Author SHA1 Message Date
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
98 changed files with 6673 additions and 1584 deletions

View File

@@ -19,6 +19,7 @@ jobs:
- build-nix
- build-macos
- build-macos-matrix
- build-snap
- build-windows
- test
- test-gtk
@@ -118,7 +119,41 @@ jobs:
run: |
nix develop -c \
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:
strategy:
@@ -275,7 +310,7 @@ jobs:
trigger-snap:
if: github.event_name != 'pull_request'
runs-on: namespace-profile-ghostty-xsm
needs: build-dist
needs: [build-dist, build-snap]
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

View File

@@ -184,6 +184,8 @@
/po/ko_KR.UTF-8.po @ghostty-org/ko_KR
/po/he_IL.UTF-8.po @ghostty-org/he_IL
/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
/snap/ @ghostty-org/snap

View File

@@ -8,7 +8,13 @@ comptime {
}
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 test_filter = b.option(
[]const u8,
@@ -258,3 +264,13 @@ pub fn build(b: *std.Build) !void {
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,
.version = "1.2.0",
.version = "1.2.2",
.paths = .{""},
.fingerprint = 0x64407a2a0b4147e5,
.minimum_zig_version = "0.14.1",
.dependencies = .{
// Zig libs
@@ -112,8 +113,8 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz",
.hash = "N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B",
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz",
.hash = "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y",
.lazy = true,
},
},

6
build.zig.zon.json generated
View File

@@ -49,10 +49,10 @@
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
},
"N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B": {
"N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y": {
"name": "iterm2_themes",
"url": "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz",
"hash": "sha256-6rKNFpaUvSbvNZ0/+u0h4I/RRaV5V7xIPQ9y7eNVbCA="
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz",
"hash": "sha256-GsEWVt4wMzp6+7N5I+QVuhCVJ70cFrdADwUds59AKnw="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"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 {
name = "iterm2_themes";
url = "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz";
hash = "sha256-6rKNFpaUvSbvNZ0/+u0h4I/RRaV5V7xIPQ9y7eNVbCA=";
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz";
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/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.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/gtk4-layer-shell-1.1.0.tar.gz
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/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/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/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz
https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz

View File

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

View File

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

View File

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

View File

@@ -143,6 +143,8 @@
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; };
A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.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 */; };
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 */; };
@@ -293,6 +295,7 @@
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>"; };
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>"; };
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>"; };
@@ -492,6 +495,7 @@
isa = PBXGroup;
children = (
A55B7BB729B6F53A0055DE60 /* Package.swift */,
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */,
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */,
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
@@ -892,6 +896,7 @@
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */,
A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
@@ -986,6 +991,7 @@
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
A5FB3A882E942A1B00A919E5 /* SurfaceProgressBar.swift in Sources */,
A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */,
A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */,

View File

@@ -860,7 +860,12 @@ class AppDelegate: NSObject,
} else {
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) {
case .official:
self.appIcon = nil
@@ -909,11 +914,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
/// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways.

View File

@@ -43,11 +43,13 @@ struct NewTerminalIntent: AppIntent {
)
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, *)
static var supportedModes: IntentModes = .foreground(.immediate)
static var supportedModes: IntentModes = .background
@available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes")
static var openAppWhenRun = true
static var openAppWhenRun = false
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> {
@@ -96,6 +98,11 @@ struct NewTerminalIntent: AppIntent {
parent = nil
}
defer {
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
}
}
switch location {
case .window:
let newController = TerminalController.newWindow(
@@ -123,7 +130,8 @@ struct NewTerminalIntent: AppIntent {
if let view = controller.newSplit(
at: parent,
direction: location.splitDirection!
direction: location.splitDirection!,
baseConfig: config
) {
return .result(value: TerminalEntity(view))
}

View File

@@ -21,13 +21,13 @@ class QuickTerminalController: BaseTerminalController {
// The active space when the quick terminal was last shown.
private var previousActiveSpace: CGSSpace? = nil
/// The window frame saved when the quick terminal's surface tree becomes empty.
/// The saved state when the quick terminal's surface tree becomes empty.
///
/// 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
private var lastClosedFrames: NSMapTable<NSScreen, LastClosedState>
/// Non-nil if we have hidden dock state.
private var hiddenDock: HiddenDock? = nil
@@ -45,6 +45,10 @@ class QuickTerminalController: BaseTerminalController {
) {
self.position = position
self.derivedConfig = DerivedConfig(ghostty.config)
// This is a weak to strong mapping, so that our keys being NSScreens
// can remove themselves when they disappear.
self.lastClosedFrames = .weakToStrongObjects()
// Important detail here: we initialize with an empty surface tree so
// that we don't start a terminal process. This gets started when the
@@ -360,8 +364,9 @@ class QuickTerminalController: BaseTerminalController {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
// Grab our last closed frame to use, and clear our state since we're animating in.
let lastClosedFrame = self.lastClosedFrame
self.lastClosedFrame = nil
// We only use the last closed frame if we're opening on the same screen.
let lastClosedFrame: NSRect? = lastClosedFrames.object(forKey: screen)?.frame
lastClosedFrames.removeObject(forKey: screen)
// Move our window off screen to the initial animation position.
position.setInitial(
@@ -491,8 +496,8 @@ class QuickTerminalController: BaseTerminalController {
// the user's preferred window size and position for when the quick
// terminal is reactivated with a new surface. Without this, SwiftUI
// would reset the window to its minimum content size.
if window.frame.width > 0 && window.frame.height > 0 {
lastClosedFrame = window.frame
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
lastClosedFrames.setObject(.init(frame: window.frame), forKey: screen)
}
// If we hid the dock then we unhide it.
@@ -715,6 +720,14 @@ class QuickTerminalController: BaseTerminalController {
hidden = false
}
}
private class LastClosedState {
let frame: NSRect
init(frame: NSRect) {
self.frame = frame
}
}
}
extension Notification.Name {

View File

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

View File

@@ -688,6 +688,8 @@ class BaseTerminalController: NSWindowController,
surfaceTree.contains(titleSurface) {
// If we have a surface, we want to listen for title changes.
titleSurface.$title
.combineLatest(titleSurface.$bell)
.map { [weak self] in self?.computeTitle(title: $0, bell: $1) ?? "" }
.sink { [weak self] in self?.titleDidChange(to: $0) }
.store(in: &focusedSurfaceCancellables)
} else {
@@ -695,8 +697,17 @@ class BaseTerminalController: NSWindowController,
titleDidChange(to: "👻")
}
}
private func computeTitle(title: String, bell: Bool) -> String {
var result = title
if (bell && ghostty.config.bellFeatures.contains(.title)) {
result = "🔔 \(result)"
}
func titleDidChange(to: String) {
return result
}
private func titleDidChange(to: String) {
guard let window else { return }
// Set the main window title
@@ -717,6 +728,10 @@ class BaseTerminalController: NSWindowController,
func cellSizeDidChange(to: NSSize) {
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
}
@@ -863,14 +878,6 @@ class BaseTerminalController: NSWindowController,
// Everything beyond here is setting up the window
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
// initialization sets up some state (i.e. observers). If its set already
// somehow we don't do this.
@@ -1072,20 +1079,17 @@ class BaseTerminalController: NSWindowController,
}
private struct DerivedConfig {
let title: String?
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
let windowStepResize: Bool
let focusFollowsMouse: Bool
init() {
self.title = nil
self.macosTitlebarProxyIcon = .visible
self.windowStepResize = false
self.focusFollowsMouse = false
}
init(_ config: Ghostty.Config) {
self.title = config.title
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
self.windowStepResize = config.windowStepResize
self.focusFollowsMouse = config.focusFollowsMouse

View File

@@ -184,8 +184,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
static var preferredParent: TerminalController? {
all.first {
$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.
static func newWindow(
@@ -1036,6 +1042,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
if let 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

View File

@@ -49,6 +49,14 @@ class TerminalWindow: NSWindow {
// Setup our initial 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 (!config.windowDecorations) { styleMask.remove(.titled) }
@@ -408,11 +416,19 @@ class TerminalWindow: NSWindow {
return
}
// Orient based on the top left of the primary monitor
let frame = screen.visibleFrame
setFrameOrigin(.init(
x: frame.minX + CGFloat(x),
y: frame.maxY - (CGFloat(y) + frame.height)))
// 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)
setFrameOrigin(safeOrigin)
}
private func hideWindowButtons() {
@@ -424,17 +440,20 @@ class TerminalWindow: NSWindow {
// MARK: Config
struct DerivedConfig {
let title: String?
let backgroundColor: NSColor
let backgroundOpacity: Double
let macosWindowButtons: Ghostty.MacOSWindowButtons
init() {
self.title = nil
self.backgroundColor = NSColor.windowBackgroundColor
self.backgroundOpacity = 1
self.macosWindowButtons = .visible
}
init(_ config: Ghostty.Config) {
self.title = config.title
self.backgroundColor = NSColor(config.backgroundColor)
self.backgroundOpacity = config.backgroundOpacity
self.macosWindowButtons = config.macosWindowButtons

View File

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

View File

@@ -624,10 +624,15 @@ extension Ghostty {
) -> Bool {
let action = Ghostty.Action.OpenURL(c: v)
// Convert the URL string to a URL object
guard let url = URL(string: action.url) else {
Ghostty.logger.warning("invalid URL for open URL action: \(action.url)")
return false
// If the URL doesn't have a valid scheme we assume its a file path. The URL
// initializer will gladly take invalid URLs (e.g. plain file paths) and turn
// them into schema-less URLs, but these won't open properly in text editors.
// 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 {

View File

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

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
var title: String {
var result = surfaceView.title
if (surfaceView.bell && ghostty.config.bellFeatures.contains(.title)) {
result = "🔔 \(result)"
}
return result
}
var body: some View {
let center = NotificationCenter.default
@@ -114,11 +105,17 @@ extension Ghostty {
}
.ghosttySurfaceView(surfaceView)
// Progress report overlay
if let progressReport = surfaceView.progressReport {
ProgressReportOverlay(report: progressReport)
// Progress report
if let progressReport = surfaceView.progressReport, progressReport.state != .remove {
VStack(spacing: 0) {
SurfaceProgressBar(report: progressReport)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.allowsHitTesting(false)
.transition(.opacity)
}
#if canImport(AppKit)
// If we are in the middle of a key sequence, then we show a visual element. We only
// support this on macOS currently although in theory we can support mobile with keyboards!
@@ -201,6 +198,11 @@ extension Ghostty {
SecureInputOverlay()
}
#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 (!surfaceView.healthy) {
@@ -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
// 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)
/// 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

View File

@@ -1815,18 +1815,39 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
forSendType sendType: NSPasteboard.PasteboardType?,
returnType: NSPasteboard.PasteboardType?
) -> Any? {
// Types that we accept sent to us
let accepted: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
// This function confused me a bit so I'm going to add my own commentary on
// 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.
// Types we can receive
let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
// 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
// We can always receive the accepted types
if (returnType == nil || accepted.contains(returnType!)) {
return self
}
// 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!))
) {
// 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)
}
}
return self
}

View File

@@ -41,4 +41,20 @@ extension NSScreen {
// know any other situation this is true.
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,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
stdenv.mkDerivation (finalAttrs: {
pname = "ghostty";
version = "1.2.0";
version = "1.2.2";
# 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

View File

@@ -9,6 +9,7 @@ pub const Library = @import("Library.zig");
pub const Error = errors.Error;
pub const Face = face.Face;
pub const LoadFlags = face.LoadFlags;
pub const Tag = tag.Tag;
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 frame = @import("text/frame.zig");
const framesetter = @import("text/framesetter.zig");
const typesetter = @import("text/typesetter.zig");
const line = @import("text/line.zig");
const paragraph_style = @import("text/paragraph_style.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 Frame = frame.Frame;
pub const Framesetter = framesetter.Framesetter;
pub const Typesetter = typesetter.Typesetter;
pub const Line = line.Line;
pub const ParagraphStyle = paragraph_style.ParagraphStyle;
pub const ParagraphStyleSetting = paragraph_style.ParagraphStyleSetting;

View File

@@ -15,10 +15,13 @@ pub const Run = opaque {
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();
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];
}
@@ -34,10 +37,13 @@ pub const Run = opaque {
return ptr;
}
pub fn getPositionsPtr(self: *Run) []const graphics.Point {
pub fn getPositionsPtr(self: *Run) ?[]const graphics.Point {
const len = self.getGlyphCount();
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];
}
@@ -53,10 +59,13 @@ pub const Run = opaque {
return ptr;
}
pub fn getAdvancesPtr(self: *Run) []const graphics.Size {
pub fn getAdvancesPtr(self: *Run) ?[]const graphics.Size {
const len = self.getGlyphCount();
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];
}
@@ -72,10 +81,13 @@ pub const Run = opaque {
return ptr;
}
pub fn getStringIndicesPtr(self: *Run) []const usize {
pub fn getStringIndicesPtr(self: *Run) ?[]const usize {
const len = self.getGlyphCount();
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];
}
@@ -90,4 +102,16 @@ pub const Run = opaque {
);
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 Renderbuffer = @import("Renderbuffer.zig");
pub const Program = @import("Program.zig");
pub const Sampler = @import("Sampler.zig");
pub const Shader = @import("Shader.zig");
pub const Texture = @import("Texture.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.
# Gustavo Peres <gsodevel@gmail.com>, 2025.
# Guilherme Tiscoski <github@guilhermetiscoski.com>, 2025.
# Nilton Perim Neto <niltonperimneto@gmail.com>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2025-07-22 17:18+0000\n"
"PO-Revision-Date: 2025-08-25 11:46-0500\n"
"Last-Translator: Guilherme Tiscoski <github@guihermetiscoski.com>\n"
"PO-Revision-Date: 2025-09-15 13:57-0300\n"
"Last-Translator: Nilton Perim Neto <niltonperimneto@gmail.com>\n"
"Language-Team: Brazilian Portuguese <ldpbr-translation@lists.sourceforge."
"net>\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
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/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
msgid "Ghostty Developers"
msgstr "Desenvolvedores Ghostty"
msgstr "Desenvolvedores do Ghostty"
#: src/apprt/gtk/inspector.zig:144
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"
# 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 "$@"

View File

@@ -20,7 +20,7 @@ platforms:
apps:
ghostty:
command: bin/ghostty
command-chain: [bin/launcher]
command-chain: [app/launcher]
completer: share/bash-completion/completions/ghostty.bash
desktop: share/applications/com.mitchellh.ghostty.desktop
#refresh-mode: ignore-running # Store rejects this, needs fix in review-tools
@@ -35,7 +35,7 @@ parts:
source: snap/local
source-type: local
organize:
launcher: bin/
launcher: app/
zig:
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+
override-build: |
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/
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_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.
renderer: Renderer,
@@ -254,7 +260,7 @@ const DerivedConfig = struct {
font: font.SharedGridSet.DerivedConfig,
mouse_interval: u64,
mouse_hide_while_typing: bool,
mouse_scroll_multiplier: f64,
mouse_scroll_multiplier: configpkg.MouseScrollMultiplier,
mouse_shift_capture: configpkg.MouseShiftCapture,
macos_non_native_fullscreen: configpkg.NonNativeFullscreen,
macos_option_as_alt: ?configpkg.OptionAsAlt,
@@ -514,6 +520,7 @@ pub fn init(
.rt_surface = rt_surface,
.font_grid_key = font_grid_key,
.font_size = font_size,
.font_size_adjusted = false,
.font_metrics = font_grid.metrics,
.renderer = renderer_impl,
.renderer_thread = render_thread,
@@ -1446,7 +1453,21 @@ pub fn updateConfig(
// 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.)
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
// our messages aren't huge.
@@ -2808,7 +2829,7 @@ pub fn scrollCallback(
// 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.
const yoff_adjusted: f64 = if (scroll_mods.precision)
yoff
yoff * self.config.mouse_scroll_multiplier.precision
else yoff_adjusted: {
// Round out the yoff to an absolute minimum of 1. macos tries to
// simulate precision scrolling with non precision events by
@@ -2822,7 +2843,7 @@ pub fn scrollCallback(
else
@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
@@ -3991,7 +4012,7 @@ pub fn cursorPosCallback(
// Stop selection scrolling when inside the viewport within a 1px buffer
// 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(
.{ .selection_scroll = false },
.locked,
@@ -4637,10 +4658,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
log.debug("increase font size={}", .{clamped_delta});
var size = self.font_size;
// Max point size is somewhat arbitrary.
var size = self.font_size;
size.points = @min(size.points + clamped_delta, 255);
try self.setFontSize(size);
// Mark that we manually adjusted the font size
self.font_size_adjusted = true;
},
.decrease_font_size => |delta| {
@@ -4652,6 +4676,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
var size = self.font_size;
size.points = @max(1, size.points - clamped_delta);
try self.setFontSize(size);
// Mark that we manually adjusted the font size
self.font_size_adjusted = true;
},
.reset_font_size => {
@@ -4660,6 +4687,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
var size = self.font_size;
size.points = self.config.original_font_size;
try self.setFontSize(size);
// Reset font size also resets the manual adjustment state
self.font_size_adjusted = false;
},
.set_font_size => |points| {
@@ -4668,6 +4698,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
var size = self.font_size;
size.points = std.math.clamp(points, 1.0, 255.0);
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(

View File

@@ -569,6 +569,15 @@ pub const SetTitle = struct {
.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 {
@@ -584,6 +593,15 @@ pub const Pwd = struct {
.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.
@@ -603,6 +621,19 @@ pub const DesktopNotification = struct {
.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) {

View File

@@ -3,7 +3,7 @@ const internal_os = @import("../os/main.zig");
// The required comptime API for any apprt.
pub const App = @import("gtk/App.zig");
pub const Surface = @import("gtk/Surface.zig");
pub const resourcesDir = internal_os.resourcesDir;
pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir;
// The exported API, custom for the apprt.
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 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 (priv.requested_window and @as(
?*glib.List,
self.as(gtk.Application).getWindows(),
) == null) break :q true;
// 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(
?*glib.List,
self.as(gtk.Application).getWindows(),
) == null) {
log.debug("must_quit due to no app windows", .{});
break :q true;
}
}
// No quit conditions met
break :q false;
@@ -741,6 +751,10 @@ pub const Application = extern struct {
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;
try writer.print(
@@ -779,9 +793,6 @@ pub const Application = extern struct {
, .{ .font_family = font_family });
}
try loadRuntimeCss414(config, &writer);
try loadRuntimeCss416(config, &writer);
// ensure that we have a sentinel
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 {
@@ -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
var single_tree = try Surface.Tree.init(alloc, surface);
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
@@ -816,6 +855,9 @@ pub const SplitTree = extern struct {
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
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.@"is-zoomed".impl,
properties.tree.impl,
properties.@"is-split".impl,
});
// Bindings

View File

@@ -9,6 +9,7 @@ const gobject = @import("gobject");
const gtk = @import("gtk");
const apprt = @import("../../../apprt.zig");
const build_config = @import("../../../build_config.zig");
const datastruct = @import("../../../datastruct/main.zig");
const font = @import("../../../font/main.zig");
const input = @import("../../../input.zig");
@@ -50,6 +51,13 @@ pub const Surface = extern struct {
pub const Tree = datastruct.SplitTree(Self);
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 name = "bell-ringing";
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 {
/// 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
/// reason.
///
@@ -502,6 +541,10 @@ pub const Surface = extern struct {
/// A weak reference to an inspector window.
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
child_exited_overlay: *ChildExited,
context_menu: *gtk.PopoverMenu,
@@ -600,6 +643,16 @@ pub const Surface = extern struct {
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 {
signals.@"toggle-fullscreen".impl.emit(
self,
@@ -1227,19 +1280,11 @@ pub const Surface = extern struct {
// Unset environment varies set by snaps if we're running in a snap.
// This allows Ghostty to further launch additional snaps.
if (env.get("SNAP")) |_| {
env.remove("SNAP");
env.remove("DRIRC_CONFIGDIR");
env.remove("__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS");
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");
if (comptime build_config.snap) {
if (env.get("SNAP") != null) try filterSnapPaths(
alloc,
&env,
);
}
// This is a hack because it ties ourselves (optionally) to the
@@ -1253,6 +1298,79 @@ pub const Surface = extern struct {
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(
self: *Self,
clipboard_type: apprt.Clipboard,
@@ -1576,6 +1694,16 @@ pub const Surface = extern struct {
}
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();
if (priv.bell_ringing == ringing) return;
priv.bell_ringing = ringing;
@@ -1760,20 +1888,26 @@ pub const Surface = extern struct {
self.as(gtk.Widget).setCursorFromName(name.ptr);
}
fn propBellRinging(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
/// Handle bell features that need to happen every time a BEL is received
/// Currently this is audio and system but this could change in the future.
fn ringBell(self: *Self) void {
const priv = self.private();
if (!priv.bell_ringing) return;
// Emit the signal
signals.bell.impl.emit(
self,
null,
.{},
null,
);
// Activate actions if they exist
_ = self.as(gtk.Widget).activateAction("tab.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;
// Do our sound
if (config.@"bell-features".audio) audio: {
const config_path = config.@"bell-audio-path" orelse break :audio;
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_hidden", &propMouseHidden);
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
class.bindTemplateCallback("notify_bell_ringing", &propBellRinging);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown);
// Properties
gobject.ext.registerProperties(class, &.{
@@ -2781,9 +2915,11 @@ pub const Surface = extern struct {
properties.title.impl,
properties.@"title-override".impl,
properties.zoom.impl,
properties.@"is-split".impl,
});
// Signals
signals.bell.impl.register(.{});
signals.@"close-request".impl.register(.{});
signals.@"clipboard-read".impl.register(.{});
signals.@"clipboard-write".impl.register(.{});

View File

@@ -697,6 +697,19 @@ pub const Window = extern struct {
var it = tree.iterator();
while (it.next()) |entry| {
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,
*Self,
@@ -1489,6 +1502,13 @@ pub const Window = extern struct {
const priv = self.private();
if (priv.tab_view.getNPages() == 0) {
// 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();
}
}

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;
}
[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
EventControllerFocus {
enter => $focus_enter();
@@ -155,7 +169,6 @@ template $GhosttySurface: Adw.Bin {
"surface",
]
notify::bell-ringing => $notify_bell_ringing();
notify::config => $notify_config();
notify::error => $notify_error();
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.");
responses [
cancel: _("Cancel") suggested,
ok: _("OK") destructive,
cancel: _("Cancel"),
ok: _("OK") suggested,
]
default-response: "ok";
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.
/// Until then this MUST match build.zig.zon and should always be the
/// _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 = 2 };
/// Standard build configuration options.
optimize: std.builtin.OptimizeMode,
@@ -51,6 +51,7 @@ patch_rpath: ?[]const u8 = null,
/// Artifacts
flatpak: bool = false,
snap: bool = false,
emit_bench: bool = false,
emit_docs: bool = false,
emit_exe: bool = false,
@@ -152,6 +153,12 @@ pub fn init(b: *std.Build) !Config {
"Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.",
) 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(
bool,
"sentry",
@@ -442,6 +449,7 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void {
// We need to break these down individual because addOption doesn't
// support all types.
step.addOption(bool, "flatpak", self.flatpak);
step.addOption(bool, "snap", self.snap);
step.addOption(bool, "x11", self.x11);
step.addOption(bool, "wayland", self.wayland);
step.addOption(bool, "sentry", self.sentry);
@@ -506,6 +514,7 @@ pub fn fromOptions() Config {
.app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?,
.font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?,
.renderer = std.meta.stringToEnum(rendererpkg.Impl, @tagName(options.renderer)).?,
.snap = options.snap,
.exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?,
.wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?,
.wasm_shared = options.wasm_shared,

View File

@@ -38,6 +38,7 @@ pub const artifact = Artifact.detect();
const config = BuildConfig.fromOptions();
pub const exe_entrypoint = config.exe_entrypoint;
pub const flatpak = options.flatpak;
pub const snap = options.snap;
pub const app_runtime: apprt.Runtime = config.app_runtime;
pub const font_backend: font.Backend = config.font_backend;
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 Diagnostic = diags.Diagnostic;
const DiagnosticList = diags.DiagnosticList;
const CommaSplitter = @import("CommaSplitter.zig");
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 {
return switch (@typeInfo(T).@"struct".layout) {
.auto => parseAutoStruct(T, alloc, v),
.auto => parseAutoStruct(T, alloc, v, null),
.@"packed" => parsePackedStruct(T, v),
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";
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);
var fields_set: FieldSet = .initEmpty();
// We split each value by ","
var iter = std.mem.splitSequence(u8, v, ",");
loop: while (iter.next()) |entry| {
// We split each value by "," allowing for quoting and escaping.
var iter: CommaSplitter = .init(v);
loop: while (try iter.next()) |entry| {
// Find the key/value, trimming whitespace. The value may be quoted
// which we strip the quotes from.
const idx = mem.indexOf(u8, entry, ":") orelse return error.InvalidValue;
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: {
var value = std.mem.trim(u8, entry[idx + 1 ..], whitespace);
const value = std.mem.trim(u8, entry[idx + 1 ..], whitespace);
// Detect a quoted string.
if (value.len >= 2 and
value[0] == '"' and
value[value.len - 1] == '"')
{
// Trim quotes since our CLI args processor expects
// quotes to already be gone.
value = value[1 .. value.len - 1];
// Decode a double-quoted string as a Zig string literal.
const writer = buf.writer(alloc);
const parsed = try std.zig.string_literal.parseWrite(writer, value);
if (parsed == .failure) return error.InvalidValue;
break :value buf.items;
}
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
inline for (info.fields, 0..) |field, i| {
if (!fields_set.isSet(i)) {
const default_ptr = field.default_value_ptr orelse return error.InvalidValue;
const typed_ptr: *const field.type = @alignCast(@ptrCast(default_ptr));
@field(result, field.name) = typed_ptr.*;
@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 typed_ptr: *const field.type = @alignCast(@ptrCast(default_ptr));
break :default typed_ptr.*;
}
};
}
}
@@ -1186,7 +1208,18 @@ test "parseIntoField: struct with basic fields" {
try testing.expectEqual(84, data.value.b);
try testing.expectEqual(24, data.value.c);
// Missing require dfield
// 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
try testing.expectError(
error.InvalidValue,
parseIntoField(@TypeOf(data), alloc, &data, "value", "a:hello"),

View File

@@ -133,13 +133,23 @@ pub fn run(alloc: Allocator) !u8 {
// so this is not a big deal.
comptime assert(builtin.link_libc);
const editorZ = try alloc.dupeZ(u8, editor);
defer alloc.free(editorZ);
const pathZ = try alloc.dupeZ(u8, path);
defer alloc.free(pathZ);
var buf: std.ArrayListUnmanaged(u8) = .empty;
errdefer buf.deinit(alloc);
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(
editorZ,
&.{ editorZ, pathZ },
"sh",
&.{ "sh", "-c", command },
std.c.environ,
);

View File

@@ -895,6 +895,42 @@ const Preview = struct {
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 = .{
.fg = fg,
.bg = bg,
@@ -1433,11 +1469,8 @@ const Preview = struct {
&.{
.{ .text = " 14 │ ", .style = color238 },
.{ .text = "try ", .style = color5 },
.{ .text = "stdout.print(", .style = standard },
.{ .text = "\"{d}", .style = color10 },
.{ .text = "\\n", .style = color12 },
.{ .text = "\"", .style = color10 },
.{ .text = ", .{i});", .style = standard },
.{ .text = "stdout.print(\"{d}\\n\", .{i})", .style = standard_selection },
.{ .text = ";", .style = cursor },
},
.{
.row_offset = 17,

View File

@@ -27,6 +27,7 @@ pub const FontStyle = Config.FontStyle;
pub const FreetypeLoadFlags = Config.FreetypeLoadFlags;
pub const Keybinds = Config.Keybinds;
pub const MouseShiftCapture = Config.MouseShiftCapture;
pub const MouseScrollMultiplier = Config.MouseScrollMultiplier;
pub const NonNativeFullscreen = Config.NonNativeFullscreen;
pub const OptionAsAlt = Config.OptionAsAlt;
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
/// configuration files or on the CLI if you want to clear values set on the
/// CLI.
///
/// Changing this configuration at runtime will only affect new terminals, i.e.
/// new windows, tabs, etc.
@"font-family": RepeatableString = .{},
@"font-family-bold": RepeatableString = .{},
@"font-family-italic": RepeatableString = .{},
@@ -214,11 +211,12 @@ pub const compatibility = std.StaticStringMap(
///
/// For example, 13.5pt @ 2px/pt = 27px
///
/// Changing this configuration at runtime will only affect new terminals,
/// i.e. new windows, tabs, etc. Note that you may still not see the change
/// depending on your `window-inherit-font-size` setting. If that setting is
/// true, only the first window will be affected by this change since all
/// subsequent windows will inherit the font size of the previous window.
/// Changing this configuration at runtime will only affect existing
/// terminals that have NOT manually adjusted their font size in some way
/// (e.g. increased or decreased the font size). Terminals that have manually
/// adjusted their font size will retain their manually adjusted size.
/// 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
/// text-specific scaling factors, which are often managed by your desktop
@@ -409,9 +407,12 @@ pub const compatibility = std.StaticStringMap(
/// necessarily force them to be. Decreasing this value will make nerd font
/// icons smaller.
///
/// The default value for the icon height is 1.2 times the height of capital
/// letters in your primary font, so something like -16.6% would make icons
/// roughly the same height as capital letters.
/// This value only applies to icons that are constrained to a single cell by
/// neighboring characters. An icon that is free to spread across two cells
/// can always use up to the full line height of the primary font.
///
/// The default value is 2/3 times the height of capital letters in your primary
/// font plus 1/3 times the font's line height.
///
/// See the notes about adjustments in `adjust-cell-width`.
///
@@ -515,7 +516,7 @@ pub const compatibility = std.StaticStringMap(
///
/// To specify a different theme for light and dark mode, use the following
/// 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
/// must be specified in this form. In this form, the theme used will be
/// based on the current desktop environment theme.
@@ -826,14 +827,20 @@ palette: Palette = .{},
/// * `never`
@"mouse-shift-capture": MouseShiftCapture = .false,
/// Multiplier for scrolling distance with the mouse wheel. Any value less
/// than 0.01 or greater than 10,000 will be clamped to the nearest valid
/// value.
/// Multiplier for scrolling distance with the mouse wheel.
///
/// 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
@"mouse-scroll-multiplier": f64 = 3.0,
/// The value will be clamped to [0.01, 10,000]. Both of these are extreme
/// 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
/// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0
@@ -2146,6 +2153,8 @@ keybind: Keybinds = .{},
/// 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
/// terminal would be half a screen tall, and 500 pixels wide.
///
/// Available since: 1.2.0
@"quick-terminal-size": QuickTerminalSize = .{},
/// The layer of the quick terminal window. The higher the layer,
@@ -2341,6 +2350,11 @@ keybind: Keybinds = .{},
/// cache manually using various arguments.
/// (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:
/// 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
@@ -2354,9 +2368,21 @@ keybind: Keybinds = .{},
/// (`:`), and then the specified value. The syntax for actions is identical
/// 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
/// 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: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
@@ -2710,7 +2736,7 @@ keybind: Keybinds = .{},
///
/// * `new-tab` - Create a new tab in the current window, or open
/// 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`.
///
@@ -2846,10 +2872,7 @@ keybind: Keybinds = .{},
/// Supported formats include PNG, JPEG, and ICNS.
///
/// Defaults to `~/.config/ghostty/Ghostty.icns`
///
/// Note: This configuration is required when `macos-icon` is set to
/// `custom`
@"macos-custom-icon": ?[]const u8 = null,
@"macos-custom-icon": ?[:0]const u8 = null,
/// The material to use for the frame of the macOS app icon.
///
@@ -3342,7 +3365,7 @@ pub fn loadOptionalFile(
fn writeConfigTemplate(path: []const u8) !void {
log.info("creating template config file: path={s}", .{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, .{});
defer file.close();
@@ -4056,7 +4079,8 @@ pub fn finalize(self: *Config) !void {
}
// 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
self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity"));
@@ -6487,7 +6511,7 @@ pub const RepeatableCodepointMap = struct {
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 {
const itemsA = self.map.list.slice();
const itemsB = other.map.list.slice();
@@ -6963,6 +6987,7 @@ pub const ShellIntegrationFeatures = packed struct {
title: bool = true,
@"ssh-env": bool = false,
@"ssh-terminfo": bool = false,
path: bool = true,
};
pub const RepeatableCommand = struct {
@@ -6989,6 +7014,7 @@ pub const RepeatableCommand = struct {
inputpkg.Command,
alloc,
input,
null,
);
try self.value.append(alloc, cmd);
}
@@ -7020,18 +7046,24 @@ pub const RepeatableCommand = struct {
return;
}
var buf: [4096]u8 = undefined;
for (self.value.items) |item| {
const str = if (item.description.len > 0) std.fmt.bufPrint(
&buf,
"title:{s},description:{s},action:{}",
.{ item.title, item.description, item.action },
) else std.fmt.bufPrint(
&buf,
"title:{s},action:{}",
.{ item.title, item.action },
);
try formatter.formatEntry([]const u8, str catch return error.OutOfMemory);
var buf: [4096]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buf);
var writer = fbs.writer();
writer.writeAll("title:\"") catch return error.OutOfMemory;
std.zig.stringEscape(item.title, "", .{}, writer) catch return error.OutOfMemory;
writer.writeAll("\"") catch return error.OutOfMemory;
if (item.description.len > 0) {
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 +7129,7 @@ pub const RepeatableCommand = struct {
var list: RepeatableCommand = .{};
try list.parseCLI(alloc, "title:Bobr, action:text:Bober");
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" {
@@ -7113,7 +7145,40 @@ pub const RepeatableCommand = struct {
try list.parseCLI(alloc, "title:Bobr, action:text:kurwa");
try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle");
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 +7324,108 @@ pub const MouseShiftCapture = enum {
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
pub const ClipboardAccess = enum {
allow,
@@ -7873,6 +8040,7 @@ pub const Theme = struct {
Theme,
alloc,
input,
null,
);
return;
}

View File

@@ -10,7 +10,7 @@ pub const ftdetect =
\\"
\\" 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 =

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:
/// node: Pointer to a node in the list.
/// 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;
if (node.next) |next_node| {
// Intermediate node.
@@ -42,7 +42,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
/// Arguments:
/// node: Pointer to a node in the list.
/// 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;
if (node.prev) |prev_node| {
// Intermediate node.
@@ -60,7 +60,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
///
/// Arguments:
/// 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| {
// Insert after last.
list.insertAfter(last, new_node);
@@ -74,7 +74,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
///
/// Arguments:
/// 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| {
// Insert before first.
list.insertBefore(first, new_node);
@@ -91,7 +91,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
///
/// Arguments:
/// 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| {
// Intermediate node.
prev_node.next = node.next;
@@ -113,7 +113,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
///
/// Returns:
/// 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;
list.remove(last);
return last;
@@ -123,7 +123,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
///
/// Returns:
/// 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;
list.remove(first);
return first;

View File

@@ -19,6 +19,7 @@ const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const config = @import("../config.zig");
const comparison = @import("../datastruct/comparison.zig");
const font = @import("main.zig");
const options = font.options;
const DeferredFace = font.DeferredFace;
@@ -1199,7 +1200,7 @@ test "metrics" {
try c.updateMetrics();
try std.testing.expectEqual(font.Metrics{
try comparison.expectApproxEqual(font.Metrics{
.cell_width = 8,
// 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)
// which is 16, and 16 * 1.049 = 16.784, which finally is rounded
// 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_baseline = 3,
.underline_position = 17,
@@ -1223,12 +1227,15 @@ test "metrics" {
.overline_thickness = 1,
.box_thickness = 1,
.cursor_height = 17,
.icon_height = 11,
.icon_height = 12.24,
.face_width = 8.0,
.face_height = 16.784,
.face_y = -0.04,
}, c.metrics);
// Resize should change metrics
try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 });
try std.testing.expectEqual(font.Metrics{
try comparison.expectApproxEqual(font.Metrics{
.cell_width = 16,
.cell_height = 34,
.cell_baseline = 6,
@@ -1240,7 +1247,10 @@ test "metrics" {
.overline_thickness = 2,
.box_thickness = 2,
.cursor_height = 34,
.icon_height = 23,
.icon_height = 24.48,
.face_width = 16.0,
.face_height = 33.568,
.face_y = -0.08,
}, c.metrics);
}
@@ -1369,3 +1379,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,17 @@ cursor_thickness: u32 = 1,
cursor_height: u32,
/// The constraint height for nerd fonts icons.
icon_height: u32,
icon_height: f64,
/// Original cell width in pixels. This is used to keep
/// glyphs centered if the cell width is adjusted wider.
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
/// from being able to, for example, cause 0-thickness underlines.
@@ -53,7 +59,9 @@ const Minimums = struct {
const box_thickness = 1;
const cursor_thickness = 1;
const cursor_height = 1;
const icon_height = 1;
const icon_height = 1.0;
const face_height = 1.0;
const face_width = 1.0;
};
/// Metrics extracted from a font face, based on
@@ -117,6 +125,16 @@ pub const FaceMetrics = struct {
/// lowercase x glyph.
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),
/// if present. This is used for font size adjustment, to normalize
/// the width of CJK fonts mixed with latin fonts.
@@ -144,11 +162,20 @@ pub const FaceMetrics = struct {
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
/// 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 {
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
@@ -195,8 +222,10 @@ pub fn calc(face: FaceMetrics) Metrics {
// 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
// it to an integer later.
const cell_width = @ceil(face.cell_width);
const cell_height = @ceil(face.lineHeight());
const face_width = face.cell_width;
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
// of the cell and the other half on the bottom, so that our text never
@@ -205,7 +234,11 @@ pub fn calc(face: FaceMetrics) Metrics {
// Unlike all our other metrics, `cell_baseline` is relative to the
// 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.
const top_to_baseline = cell_height - cell_baseline;
@@ -218,16 +251,8 @@ pub fn calc(face: FaceMetrics) Metrics {
const underline_position = @round(top_to_baseline - face.underlinePosition());
const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition());
// The calculation for icon height in the nerd fonts patcher
// is two thirds cap height to one third line height, but we
// use an opinionated default of 1.2 * cap height instead.
//
// Doing this prevents fonts with very large line heights
// 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);
// Same heuristic as the font_patcher script
const icon_height = (2 * cap_height + face_height) / 3;
var result: Metrics = .{
.cell_width = @intFromFloat(cell_width),
@@ -241,7 +266,10 @@ pub fn calc(face: FaceMetrics) Metrics {
.overline_thickness = @intFromFloat(underline_thickness),
.box_thickness = @intFromFloat(underline_thickness),
.cursor_height = @intFromFloat(cell_height),
.icon_height = @intFromFloat(icon_height),
.icon_height = icon_height,
.face_width = face_width,
.face_height = face_height,
.face_y = face_y,
};
// Ensure all metrics are within their allowable range.
@@ -267,11 +295,6 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
const new = @max(entry.value_ptr.apply(original), 1);
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
@field(self, @tagName(tag)) = new;
@@ -288,6 +311,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
const diff = new - original;
const diff_bottom = diff / 2;
const diff_top = diff - diff_bottom;
self.face_y += @floatFromInt(diff_bottom);
self.cell_baseline +|= diff_bottom;
self.underline_position +|= diff_top;
self.strikethrough_position +|= diff_top;
@@ -296,6 +320,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
const diff = original - new;
const diff_bottom = diff / 2;
const diff_top = diff - diff_bottom;
self.face_y -= @floatFromInt(diff_bottom);
self.cell_baseline -|= diff_bottom;
self.underline_position -|= diff_top;
self.strikethrough_position -|= diff_top;
@@ -398,25 +423,35 @@ pub const Modifier = union(enum) {
/// Apply a modifier to a numeric value.
pub fn apply(self: Modifier, v: anytype) @TypeOf(v) {
const T = @TypeOf(v);
const signed = @typeInfo(T).int.signedness == .signed;
return switch (self) {
.percent => |p| percent: {
const p_clamped: f64 = @max(0, p);
const v_f64: f64 = @floatFromInt(v);
const applied_f64: f64 = @round(v_f64 * p_clamped);
const applied_T: T = @intFromFloat(applied_f64);
break :percent applied_T;
},
const Tinfo = @typeInfo(T);
return switch (comptime Tinfo) {
.int, .comptime_int => switch (self) {
.percent => |p| percent: {
const p_clamped: f64 = @max(0, p);
const v_f64: f64 = @floatFromInt(v);
const applied_f64: f64 = @round(v_f64 * p_clamped);
const applied_T: T = @intFromFloat(applied_f64);
break :percent applied_T;
},
.absolute => |abs| absolute: {
const v_i64: i64 = @intCast(v);
const abs_i64: i64 = @intCast(abs);
const applied_i64: i64 = v_i64 +| abs_i64;
const clamped_i64: i64 = if (signed) applied_i64 else @max(0, applied_i64);
const applied_T: T = std.math.cast(T, clamped_i64) orelse
std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64)));
break :absolute applied_T;
.absolute => |abs| absolute: {
const v_i64: i64 = @intCast(v);
const abs_i64: i64 = @intCast(abs);
const applied_i64: i64 = v_i64 +| abs_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
std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64)));
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 +497,7 @@ pub const Key = key: {
var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined;
var count: usize = 0;
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 };
count += 1;
}
@@ -493,7 +528,10 @@ fn init() Metrics {
.overline_thickness = 0,
.box_thickness = 0,
.cursor_height = 0,
.icon_height = 0,
.icon_height = 0.0,
.face_width = 0.0,
.face_height = 0.0,
.face_y = 0.0,
};
}
@@ -523,6 +561,7 @@ test "Metrics: adjust cell height smaller" {
try set.put(alloc, .cell_height, .{ .percent = 0.75 });
var m: Metrics = init();
m.face_y = 0.33;
m.cell_baseline = 50;
m.underline_position = 55;
m.strikethrough_position = 30;
@@ -530,6 +569,7 @@ test "Metrics: adjust cell height smaller" {
m.cell_height = 100;
m.cursor_height = 100;
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, 38), m.cell_baseline);
try testing.expectEqual(@as(u32, 42), m.underline_position);
@@ -551,6 +591,7 @@ test "Metrics: adjust cell height larger" {
try set.put(alloc, .cell_height, .{ .percent = 1.75 });
var m: Metrics = init();
m.face_y = 0.33;
m.cell_baseline = 50;
m.underline_position = 55;
m.strikethrough_position = 30;
@@ -558,6 +599,7 @@ test "Metrics: adjust cell height larger" {
m.cell_height = 100;
m.cursor_height = 100;
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, 87), m.cell_baseline);
try testing.expectEqual(@as(u32, 93), m.underline_position);

View File

@@ -270,11 +270,9 @@ pub fn renderGlyph(
// Always use these constraints for emoji.
if (p == .emoji) {
render_opts.constraint = .{
// Make the emoji as wide as possible, scaling proportionally,
// but then scale it down as necessary if its new size exceeds
// the cell height.
.size_horizontal = .cover,
.size_vertical = .fit,
// Scale emoji to be as large as possible
// while preserving their aspect ratio.
.size = .cover,
// Center the emoji in its cells.
.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.
pub const RenderOptions = struct {
/// 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.
pub const none: Constraint = .{};
/// Vertical sizing rule.
size_vertical: Size = .none,
/// Horizontal sizing rule.
size_horizontal: Size = .none,
/// Sizing rule.
size: Size = .none,
/// Vertical alignment rule.
align_vertical: Align = .none,
@@ -155,42 +161,40 @@ pub const RenderOptions = struct {
/// Bottom padding when resizing.
pad_bottom: f64 = 0.0,
// This acts as a multiple of the provided width when applying
// constraints, so if this is 1.6 for example, then a width of
// 10 would be treated as though it were 16.
group_width: f64 = 1.0,
// This acts as a multiple of the provided height when applying
// constraints, so if this is 1.6 for example, then a height of
// 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,
// Size and bearings of the glyph relative
// to the bounding box of its scale group.
relative_width: f64 = 1.0,
relative_height: f64 = 1.0,
relative_x: f64 = 0.0,
relative_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,
/// Maximum number of cells horizontally to use.
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,
pub const Size = enum {
/// Don't change the size of this glyph.
none,
/// Move the glyph and optionally scale it down
/// proportionally to fit within the given axis.
/// Scale the glyph down if needed to fit within the bounds,
/// preserving aspect ratio.
fit,
/// Move and resize the glyph proportionally to
/// cover the given axis.
/// Scale the glyph up or down to exactly match the bounds,
/// preserving aspect ratio.
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,
};
@@ -205,30 +209,27 @@ pub const RenderOptions = struct {
end,
/// Move the glyph so that it is centered on this axis.
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 {
/// Use the full height of the cell for constraining this glyph.
/// Always use the full height of the cell for constraining this glyph.
cell,
/// Use the "icon height" from the grid metrics as the height.
/// When the constraint width is 1, use the "icon height" from the grid
/// metrics as the height. (When the constraint width is >1, the
/// constraint height is always the full cell height.)
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,
/// because it neither sizes nor positions the glyph, then this
/// returns false.
pub inline fn doesAnything(self: Constraint) bool {
return self.size_horizontal != .none or
return self.size != .none or
self.align_horizontal != .none or
self.size_vertical != .none or
self.align_vertical != .none;
}
@@ -241,156 +242,250 @@ pub const RenderOptions = struct {
/// Number of cells horizontally available for this glyph.
constraint_width: u2,
) GlyphSize {
var g = glyph;
if (!self.doesAnything()) return glyph;
const available_width: f64 = @floatFromInt(
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;
},
switch (self.size) {
.stretch => {
g.width = w;
g.x = 0;
// Stretched glyphs are usually meant to align across cell
// 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) {
.none => {},
.fit => if (g.height > h) {
const orig_width = g.width;
// Adjust our height and width to proportionally
// scale them to fit the glyph to the cell height.
g.width *= h / g.height;
g.height = h;
// Set our y to 0 since anything else would mean
// the glyph extends outside of the cell height.
g.y = 0;
// Compensate our x to keep things horizontally
// 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;
fn constrainInner(
self: Constraint,
glyph: GlyphSize,
metrics: Metrics,
constraint_width: u2,
) GlyphSize {
// For extra wide font faces, never stretch glyphs across two cells.
// This mirrors font_patcher.
const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height))
1
else
@min(self.max_constraint_width, constraint_width);
g.width *= h / g.height;
g.height = h;
g.y = 0;
g.x += (orig_width - g.width) / 2;
},
.stretch => {
g.height = h;
g.y = 0;
},
}
// 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;
// The bounding box for the glyph's scale group.
// Scaling and alignment rules are calculated for
// this box and then applied to the glyph.
var group: GlyphSize = group: {
const group_width = glyph.width / self.relative_width;
const group_height = glyph.height / self.relative_height;
break :group .{
.width = group_width,
.height = group_height,
.x = glyph.x - (group_width * self.relative_x),
.y = glyph.y - (group_height * self.relative_y),
};
};
switch (self.align_horizontal) {
.none => {},
.start => g.x = 0,
.end => g.x = w - g.width,
.center => g.x = (w - g.width) / 2,
// Apply prescribed scaling, preserving the
// center bearings of the group bounding box
const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width);
const center_x = group.x + (group.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),
};
}
/// Return width and height scaling factors for this scaling group.
fn scale_factors(
self: Constraint,
group: GlyphSize,
metrics: Metrics,
min_constraint_width: u2,
) struct { f64, f64 } {
if (self.size == .none) {
return .{ 1.0, 1.0 };
}
switch (self.align_vertical) {
.none => {},
.start => g.y = 0,
.end => g.y = h - g.height,
.center => g.y = (h - g.height) / 2,
const multi_cell = (min_constraint_width > 1);
const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right);
const pad_height_factor = 1 - (self.pad_bottom + self.pad_top);
const target_width = pad_width_factor * metrics.face_width;
const target_height = pad_height_factor * switch (self.height) {
.cell => metrics.face_height,
// icon_height only applies with single-cell constraints.
// This mirrors font_patcher.
.icon => if (multi_cell)
metrics.face_height
else
metrics.icon_height,
};
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 => {},
}
// Re-add our padding before returning.
g.x += self.pad_left * available_width;
g.y += self.pad_bottom * available_height;
// 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;
}
}
// If the available height is less than the cell height, we
// add half of the difference to center it in the full height.
//
// 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 .{ width_factor, height_factor };
}
return g;
/// 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 +507,196 @@ test "Variation.Id: slnt should be 1936486004" {
try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id)));
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 = 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;
};
// 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
// 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);
// Next we apply any constraints to get the final size of the glyph.
var 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);
const constraint = opts.constraint;
// We need to add the baseline position before passing to the constrain
// function since it operates on cell-relative positions, not baseline.
@@ -378,6 +360,18 @@ pub const Face = struct {
var width = glyph_size.width;
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,
// not fractional pixels, so we need to quantize its position and
// size accordingly to align to full pixels so we get good results.
@@ -388,25 +382,16 @@ pub const Face = struct {
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.
x += (cell_width - @as(f64, @floatFromInt(original))) / 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.
const canvas_padding: u32 = if (opts.thicken and !sbix) 1 else 0;
// Our whole-pixel bearings for the final glyph.
// The fractional portion will be included in the rasterized position.
const px_x: i32 = @intFromFloat(@floor(x));
const px_y: i32 = @intFromFloat(@floor(y));
const px_x = @as(i32, @intFromFloat(@floor(x))) - @as(i32, @intCast(canvas_padding));
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 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
// the ceiling to get a canvas size that will definitely fit
// our drawn glyph, including the fractional offset.
const px_width: u32 = @intFromFloat(@ceil(width + frac_x));
const px_height: u32 = @intFromFloat(@ceil(height + frac_y));
// our drawn glyph, including the fractional offset and font smoothing.
const px_width = @as(u32, @intFromFloat(@ceil(width + frac_x))) + (2 * canvas_padding);
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.
const color: struct {
@@ -529,8 +514,8 @@ pub const Face = struct {
// `drawGlyphs`, we pass the negated bearings.
context.translateCTM(
ctx,
frac_x,
frac_y,
frac_x + @as(f64, @floatFromInt(canvas_padding)),
frac_y + @as(f64, @floatFromInt(canvas_padding)),
);
// 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
// visible ASCII characters. Usually 'M' is widest but we just take
// 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
const unichars = comptime unichars: {
const len = 127 - 32;
@@ -803,7 +791,10 @@ pub const Face = struct {
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.
@@ -864,6 +855,7 @@ pub const Face = struct {
.cap_height = cap_height,
.ex_height = ex_height,
.ascii_height = ascii_height,
.ic_width = ic_width,
};
}

View File

@@ -170,7 +170,7 @@ pub const Face = struct {
if (string.len > 1024) break :skip;
var tmp: [512]u16 = undefined;
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;
return buf[0..len];
}
@@ -351,26 +351,16 @@ pub const Face = struct {
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
/// 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();
/// Set the load flags to use when loading a glyph for measurement or
/// rendering.
fn glyphLoadFlags(self: Face, constrained: bool) freetype.LoadFlags {
// Hinting should only be enabled if the configured load flags specify
// 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
// 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.
try self.face.loadGlyph(glyph_index, .{
return .{
// If our glyph has color, we want to render the color
.color = self.face.hasColor(),
@@ -392,42 +382,67 @@ pub const Face = struct {
// SVG glyphs under FreeType, since that requires bundling another
// dependency to handle rendering the SVG.
.no_svg = true,
});
};
}
/// Get a rect that represents the position and size of the loaded glyph.
fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.GlyphSize {
// If we're dealing with an outline glyph then we get the
// outline's bounding box instead of using the built-in
// metrics, since that's more precise and allows better
// cell-fitting.
if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) {
// Get the glyph's bounding box before we transform it at all.
// We use this rather than the metrics, since it's more precise.
var bbox: freetype.c.FT_BBox = undefined;
_ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox);
return .{
.x = f26dot6ToF64(bbox.xMin),
.y = f26dot6ToF64(bbox.yMin),
.width = f26dot6ToF64(bbox.xMax - bbox.xMin),
.height = f26dot6ToF64(bbox.yMax - bbox.yMin),
};
}
return .{
.x = f26dot6ToF64(glyph.*.metrics.horiBearingX),
.y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height),
.width = f26dot6ToF64(glyph.*.metrics.width),
.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 any changes.
const rect: struct {
x: f64,
y: f64,
width: f64,
height: f64,
} = metrics: {
// If we're dealing with an outline glyph then we get the
// outline's bounding box instead of using the built-in
// metrics, since that's more precise and allows better
// cell-fitting.
if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) {
// Get the glyph's bounding box before we transform it at all.
// We use this rather than the metrics, since it's more precise.
var bbox: freetype.c.FT_BBox = undefined;
_ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox);
break :metrics .{
.x = f26dot6ToF64(bbox.xMin),
.y = f26dot6ToF64(bbox.yMin),
.width = f26dot6ToF64(bbox.xMax - bbox.xMin),
.height = f26dot6ToF64(bbox.yMax - bbox.yMin),
};
}
break :metrics .{
.x = f26dot6ToF64(glyph.*.metrics.horiBearingX),
.y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height),
.width = f26dot6ToF64(glyph.*.metrics.width),
.height = f26dot6ToF64(glyph.*.metrics.height),
};
};
// and size of the glyph before constraints.
const rect = getGlyphSize(glyph);
// If our glyph is smaller than a quarter pixel in either axis
// then it has no outlines or they're too small to render.
@@ -443,30 +458,12 @@ pub const Face = struct {
.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 cell_width: f64 = @floatFromInt(metrics.cell_width);
const cell_height: f64 = @floatFromInt(metrics.cell_height);
// Next we apply any constraints to get the final size of the glyph.
var 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);
const constraint = opts.constraint;
// We need to add the baseline position before passing to the constrain
// function since it operates on cell-relative positions, not baseline.
@@ -488,6 +485,24 @@ pub const Face = struct {
var x = glyph_size.x;
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,
// not fractional pixels, so we need to quantize its position and
// size accordingly to align to full pixels so we get good results.
@@ -498,27 +513,6 @@ pub const Face = struct {
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.
var bitmap: freetype.c.FT_Bitmap = undefined;
_ = 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
// 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
// the metrics provided by FreeType.
const cell_width: f64 = cell_width: {
// the metrics provided by FreeType, and set ascii_height to null as
// it's optional.
const cell_width: f64, const ascii_height: ?f64 = measurements: {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
var max: f64 = 0.0;
var top: f64 = 0.0;
var bottom: f64 = 0.0;
var c: u8 = ' ';
while (c < 127) : (c += 1) {
if (face.getCharIndex(c)) |glyph_index| {
if (face.loadGlyph(glyph_index, .{
.render = false,
.no_svg = true,
})) {
if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
const glyph = face.handle.*.glyph;
max = @max(
f26dot6ToF64(face.handle.*.glyph.*.advance.x),
f26dot6ToF64(glyph.*.advance.x),
max,
);
const rect = getGlyphSize(glyph);
top = @max(rect.y + rect.height, top);
bottom = @min(rect.y, bottom);
} 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) {
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
@@ -1008,11 +1017,8 @@ pub const Face = struct {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
if (face.getCharIndex('H')) |glyph_index| {
if (face.loadGlyph(glyph_index, .{
.render = false,
.no_svg = true,
})) {
break :cap f26dot6ToF64(face.handle.*.glyph.*.metrics.height);
if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
break :cap getGlyphSize(face.handle.*.glyph).height;
} else |_| {}
}
break :cap null;
@@ -1021,11 +1027,8 @@ pub const Face = struct {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
if (face.getCharIndex('x')) |glyph_index| {
if (face.loadGlyph(glyph_index, .{
.render = false,
.no_svg = true,
})) {
break :ex f26dot6ToF64(face.handle.*.glyph.*.metrics.height);
if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
break :ex getGlyphSize(face.handle.*.glyph).height;
} else |_| {}
}
break :ex null;
@@ -1040,10 +1043,7 @@ pub const Face = struct {
const glyph = face.getCharIndex('水') orelse break :ic_width null;
face.loadGlyph(glyph, .{
.render = false,
.no_svg = true,
}) catch break :ic_width null;
face.loadGlyph(glyph, self.glyphLoadFlags(false)) catch break :ic_width null;
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
// patched with the nerd fonts patcher and it butchers the advance
// 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;
const font_name = self.name(&buf) catch "<Error getting font name>";
log.warn(
"(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.",
.{
font_name,
f26dot6ToF64(ft_glyph.*.metrics.width),
f26dot6ToF64(ft_glyph.*.advance.x),
},
.{ font_name, ft_glyph_width, advance },
);
break :ic_width null;
}
break :ic_width f26dot6ToF64(ft_glyph.*.advance.x);
break :ic_width advance;
};
return .{
@@ -1089,6 +1087,7 @@ pub const Face = struct {
.cap_height = cap_height,
.ex_height = ex_height,
.ascii_height = ascii_height,
.ic_width = ic_width,
};
}
@@ -1178,37 +1177,6 @@ test "color emoji" {
const glyph_id = ft_font.glyphIndex('🥸').?;
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" {

File diff suppressed because it is too large Load Diff

View File

@@ -50,13 +50,14 @@ class PatchSetAttributeEntry(TypedDict):
stretch: str
params: dict[str, float | bool]
group_x: float
group_y: float
group_width: float
group_height: float
relative_x: float
relative_y: float
relative_width: float
relative_height: float
class PatchSet(TypedDict):
Name: str
SymStart: int
SymEnd: int
SrcStart: int | None
@@ -113,20 +114,43 @@ class PatchSetExtractor(ast.NodeVisitor):
if hasattr(ast, "unparse"):
return eval(
ast.unparse(node),
{"box_keep": True},
{"self": SimpleNamespace(args=SimpleNamespace(careful=True))},
{"box_enabled": False, "box_keep": False},
{
"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__}>"
raise ValueError(msg) from None
def process_patch_entry(self, dict_node: ast.Dict) -> None:
entry = {}
disallowed_key_nodes = frozenset({"Enabled", "Name", "Filename", "Exact"})
disallowed_key_nodes = frozenset({"Filename", "Exact"})
for key_node, value_node in zip(dict_node.keys, dict_node.values):
if (
isinstance(key_node, ast.Constant)
and key_node.value not in disallowed_key_nodes
):
if key_node.value == "Enabled":
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))
entry[key] = self.resolve_symbol(value_node)
self.patch_set_values.append(cast("PatchSet", entry))
@@ -143,7 +167,7 @@ def parse_alignment(val: str) -> str | None:
return {
"l": ".start",
"r": ".end",
"c": ".center",
"c": ".center1", # font-patcher specific centering rule, see face.zig
"": None,
}.get(val, ".none")
@@ -158,10 +182,10 @@ def attr_key(attr: PatchSetAttributeEntry) -> AttributeHash:
float(params.get("overlap", 0.0)),
float(params.get("xy-ratio", -1.0)),
float(params.get("ypadding", 0.0)),
float(attr.get("group_x", 0.0)),
float(attr.get("group_y", 0.0)),
float(attr.get("group_width", 1.0)),
float(attr.get("group_height", 1.0)),
float(attr.get("relative_x", 0.0)),
float(attr.get("relative_y", 0.0)),
float(attr.get("relative_width", 1.0)),
float(attr.get("relative_height", 1.0)),
)
@@ -187,10 +211,10 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
stretch = attr.get("stretch", "")
params = attr.get("params", {})
group_x = attr.get("group_x", 0.0)
group_y = attr.get("group_y", 0.0)
group_width = attr.get("group_width", 1.0)
group_height = attr.get("group_height", 1.0)
relative_x = attr.get("relative_x", 0.0)
relative_y = attr.get("relative_y", 0.0)
relative_width = attr.get("relative_width", 1.0)
relative_height = attr.get("relative_height", 1.0)
overlap = params.get("overlap", 0.0)
xy_ratio = params.get("xy-ratio", -1.0)
@@ -204,28 +228,30 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
s = f"{keys}\n => .{{\n"
# These translations don't quite capture the way
# the actual patcher does scaling, but they're a
# good enough compromise.
if "xy" in stretch:
s += " .size_horizontal = .stretch,\n"
s += " .size_vertical = .stretch,\n"
elif "!" in stretch or "^" in stretch:
s += " .size_horizontal = .cover,\n"
s += " .size_vertical = .fit,\n"
# This maps the font_patcher stretch rules to a Constrain instance
# NOTE: some comments in font_patcher indicate that only x or y
# would also be a valid spec, but no icons use it, so we won't
# support it until we have to.
if "pa" in stretch:
if "!" in stretch or overlap:
s += " .size = .cover,\n"
else:
s += " .size = .fit_cover1,\n"
elif "xy" in stretch:
s += " .size = .stretch,\n"
else:
s += " .size_horizontal = .fit,\n"
s += " .size_vertical = .fit,\n"
print(f"Warning: Unknown stretch rule {stretch}")
# `^` indicates that scaling should fill
# the whole cell, not just the icon height.
# `^` indicates that scaling should use the
# full cell height, not just the icon height,
# even when the constraint width is 1
if "^" not in stretch:
s += " .height = .icon,\n"
# 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 the stretch mode is `xy` and there's not an explicit `2`.
if "1" in stretch or ("xy" in stretch and "2" not in stretch):
# - If the stretch mode is not `pa` and there's not an explicit `2`.
if "1" in stretch or ("pa" not in stretch and "2" not in stretch):
s += " .max_constraint_width = 1,\n"
if align is not None:
@@ -233,14 +259,14 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
if valign is not None:
s += f" .align_vertical = {valign},\n"
if group_width != 1.0:
s += f" .group_width = {group_width:.16f},\n"
if group_height != 1.0:
s += f" .group_height = {group_height:.16f},\n"
if group_x != 0.0:
s += f" .group_x = {group_x:.16f},\n"
if group_y != 0.0:
s += f" .group_y = {group_y:.16f},\n"
if relative_width != 1.0:
s += f" .relative_width = {relative_width:.16f},\n"
if relative_height != 1.0:
s += f" .relative_height = {relative_height:.16f},\n"
if relative_x != 0.0:
s += f" .relative_x = {relative_x:.16f},\n"
if relative_y != 0.0:
s += f" .relative_y = {relative_y:.16f},\n"
# `overlap` and `ypadding` are mutually exclusive,
# this is asserted in the nerd fonts patcher itself.
@@ -273,45 +299,109 @@ def generate_zig_switch_arms(
entries: dict[int, PatchSetAttributeEntry] = {}
for entry in patch_sets:
patch_set_name = entry["Name"]
print(f"Info: Extracting rules from patch set '{patch_set_name}'")
attributes = entry["Attributes"]
patch_set_entries: dict[int, PatchSetAttributeEntry] = {}
for cp in range(entry["SymStart"], entry["SymEnd"] + 1):
entries[cp] = attributes["default"].copy()
# A glyph's scale rules are specified using its codepoint in
# the original font, which is sometimes different from its
# Nerd Font codepoint. In font_patcher, the font to be patched
# (including the Symbols Only font embedded in Ghostty) is
# termed the sourceFont, while the original font is the
# symbolFont. Thus, the offset that maps the scale rule
# codepoint to the Nerd Font codepoint is SrcStart - SymStart.
cp_offset = entry["SrcStart"] - entry["SymStart"] if entry["SrcStart"] else 0
for cp_rule in range(entry["SymStart"], entry["SymEnd"] + 1):
cp_font = cp_rule + cp_offset
if cp_font not in cmap:
print(f"Info: Skipping missing codepoint {hex(cp_font)}")
continue
elif cp_font in entries:
# Patch sets sometimes have overlapping codepoint ranges.
# Sometimes a later set is a smaller set filling in a gap
# in the range of a larger, preceding set. Sometimes it's
# the other way around. The best thing we can do is hardcode
# each case.
if patch_set_name == "Font Awesome":
# The Font Awesome range has a gap matching the
# prededing Progress Indicators range.
print(f"Info: Not overwriting existing codepoint {hex(cp_font)}")
continue
elif patch_set_name == "Octicons":
# The fourth Octicons range overlaps with the first.
print(f"Info: Overwriting existing codepoint {hex(cp_font)}")
else:
raise ValueError(
f"Unknown case of overlap for codepoint {hex(cp_font)} in patch set '{patch_set_name}'"
)
if cp_rule in attributes:
patch_set_entries[cp_font] = attributes[cp_rule].copy()
else:
patch_set_entries[cp_font] = attributes["default"].copy()
entries |= {k: v for k, v in attributes.items() if isinstance(k, int)}
if entry["ScaleRules"] is not None and "ScaleGroups" in entry["ScaleRules"]:
if entry["ScaleRules"] is not None:
if "ScaleGroups" not in entry["ScaleRules"]:
raise ValueError(
f"Scale rule format {entry['ScaleRules']} not implemented."
)
for group in entry["ScaleRules"]["ScaleGroups"]:
xMin = math.inf
yMin = math.inf
xMax = -math.inf
yMax = -math.inf
individual_bounds: dict[int, tuple[int, int, int ,int]] = {}
for cp in group:
if cp not in cmap:
individual_bounds: dict[int, tuple[int, int, int, int]] = {}
individual_advances: set[float] = set()
for cp_rule in group:
cp_font = cp_rule + cp_offset
if cp_font not in cmap:
continue
glyph = glyphs[cmap[cp]]
glyph = glyphs[cmap[cp_font]]
individual_advances.add(glyph.width)
bounds = BoundsPen(glyphSet=glyphs)
glyph.draw(bounds)
individual_bounds[cp] = bounds.bounds
individual_bounds[cp_font] = bounds.bounds
xMin = min(bounds.bounds[0], xMin)
yMin = min(bounds.bounds[1], yMin)
xMax = max(bounds.bounds[2], xMax)
yMax = max(bounds.bounds[3], yMax)
group_width = xMax - xMin
group_height = yMax - yMin
for cp in group:
if cp not in cmap or cp not in entries:
group_is_monospace = (len(individual_bounds) > 1) and (
len(individual_advances) == 1
)
for cp_rule in group:
cp_font = cp_rule + cp_offset
if (
cp_font not in cmap
or cp_font 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_font]
):
continue
this_bounds = individual_bounds[cp]
this_width = this_bounds[2] - this_bounds[0]
this_bounds = individual_bounds[cp_font]
this_height = this_bounds[3] - this_bounds[1]
entries[cp]["group_width"] = group_width / this_width
entries[cp]["group_height"] = group_height / this_height
entries[cp]["group_x"] = (this_bounds[0] - xMin) / group_width
entries[cp]["group_y"] = (this_bounds[1] - yMin) / group_height
del entries[0]
patch_set_entries[cp_font]["relative_height"] = (
this_height / group_height
)
patch_set_entries[cp_font]["relative_y"] = (
this_bounds[1] - yMin
) / 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_font]["relative_width"] = (
this_width / group_width
)
patch_set_entries[cp_font]["relative_x"] = (
this_bounds[0] - xMin
) / group_width
entries |= patch_set_entries
# Group codepoints by attribute key
grouped = defaultdict[AttributeHash, list[int]](list)
@@ -350,7 +440,7 @@ if __name__ == "__main__":
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 {
return switch (cp) {
""")

View File

@@ -52,10 +52,10 @@ pub const Shaper = struct {
/// The shared memory used for shaping results.
cell_buf: CellBuf,
/// The cached writing direction value for shaping. This isn't
/// configurable we just use this as a cache to avoid creating
/// and releasing many objects when shaping.
writing_direction: *macos.foundation.Array,
/// Cached attributes dict for creating CTTypesetter objects.
/// The values in this never change so we can avoid overhead
/// by just creating it once and saving it for re-use.
typesetter_attr_dict: *macos.foundation.Dictionary,
/// List where we cache fonts, so we don't have to remake them for
/// 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/1442
const writing_direction = array: {
const dir: macos.text.WritingDirection = .lro;
const num = try macos.foundation.Number.create(
.int,
&@intFromEnum(dir),
);
//
// We used to do this by setting the writing direction attribute
// on the attributed string we used, but it seems like that will
// still allow some weird results, for example a single space at
// 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();
var arr_init = [_]*const macos.foundation.Number{num};
break :array try macos.foundation.Array.create(
macos.foundation.Number,
&arr_init,
break :dict try macos.foundation.Dictionary.create(
&.{macos.c.kCTTypesetterOptionForcedEmbeddingLevel},
&.{num},
);
};
errdefer writing_direction.release();
errdefer typesetter_attr_dict.release();
// Create the CF release thread.
var cf_release_thread = try alloc.create(CFReleaseThread);
@@ -210,7 +217,7 @@ pub const Shaper = struct {
.run_state = run_state,
.features = features,
.features_no_default = features_no_default,
.writing_direction = writing_direction,
.typesetter_attr_dict = typesetter_attr_dict,
.cached_fonts = .{},
.cached_font_grid = 0,
.cf_release_pool = .{},
@@ -224,7 +231,7 @@ pub const Shaper = struct {
self.run_state.deinit(self.alloc);
self.features.release();
self.features_no_default.release();
self.writing_direction.release();
self.typesetter_attr_dict.release();
{
for (self.cached_fonts.items) |ft| {
@@ -346,8 +353,8 @@ pub const Shaper = struct {
run.font_index,
);
// Make room for the attributed string and the CTLine.
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3);
// Make room for the attributed string, CTTypesetter, and CTLine.
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 4);
const str = macos.foundation.String.createWithCharactersNoCopy(state.unichars.items);
self.cf_release_pool.appendAssumeCapacity(str);
@@ -359,8 +366,17 @@ pub const Shaper = struct {
);
self.cf_release_pool.appendAssumeCapacity(attr_str);
// We should always have one run because we do our own run splitting.
const line = try macos.text.Line.createWithAttributedString(attr_str);
// Create a typesetter from the attributed string and the cached
// 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);
// This keeps track of the current offsets within a single cell.
@@ -369,7 +385,12 @@ pub const Shaper = struct {
x: 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();
try self.cell_buf.ensureTotalCapacity(self.alloc, line.getGlyphCount());
// CoreText may generate multiple runs even though our input to
// 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);
// Get our glyphs and positions
const glyphs = try ctrun.getGlyphs(alloc);
const advances = try ctrun.getAdvances(alloc);
const indices = try ctrun.getStringIndices(alloc);
const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc);
const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc);
const indices = ctrun.getStringIndicesPtr() orelse try ctrun.getStringIndices(alloc);
assert(glyphs.len == advances.len);
assert(glyphs.len == indices.len);
@@ -406,7 +427,7 @@ pub const Shaper = struct {
cell_offset = .{ .cluster = cluster };
}
try self.cell_buf.append(self.alloc, .{
self.cell_buf.appendAssumeCapacity(.{
.x = @intCast(cluster),
.x_offset = @intFromFloat(@round(cell_offset.x)),
.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
// attributed string so the whole string uses the same font.
const attr_dict = dict: {
var keys = [_]?*const anyopaque{
macos.text.StringAttribute.font.key(),
macos.text.StringAttribute.writing_direction.key(),
};
var values = [_]?*const anyopaque{
run_font,
self.writing_direction,
};
break :dict try macos.foundation.Dictionary.create(&keys, &values);
break :dict try macos.foundation.Dictionary.create(
&.{macos.text.StringAttribute.font.key()},
&.{run_font},
);
};
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 input = raw_input[start_idx..];
// Find the last = which splits are mapping into the trigger
// and action, respectively.
// We use the last = because the keybind itself could contain
// raw equal signs (for the = codepoint)
const eql_idx = std.mem.lastIndexOf(u8, input, "=") orelse return Error.InvalidFormat;
// Find the equal sign. This is more complicated than it seems on
// the surface because we need to ignore equal signs that are
// part of the trigger.
const eql_idx: usize = eql: {
// 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
// parse the action now.
@@ -698,7 +722,7 @@ pub const Action = union(enum) {
/// All actions are only undoable/redoable for a limited time.
/// For example, restoring a closed split can only be done for
/// 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
/// 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);
switch (Value) {
void => {},
[]const u8 => try writer.print("{s}", .{value}),
[]const u8 => try std.zig.stringEscape(value, "", .{}, writer),
else => switch (value_info) {
.@"enum" => try writer.print("{s}", .{@tagName(value)}),
.float => try writer.print("{d}", .{value}),
@@ -2298,6 +2322,39 @@ test "parse: equals sign" {
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
// `physical:`. This tests the backwards compatibility with the old format.
// 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);
}
}
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

@@ -472,13 +472,18 @@ fn actionCommands(action: Action.Key) []const Command {
.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
// aren't obvious values users would use. It is possible that
// these may have commands in the future if there are very
// common values that users tend to use.
.csi,
.esc,
.text,
.cursor_key,
.set_font_size,
.scroll_page_fractional,

View File

@@ -63,18 +63,42 @@ const Info = extern struct {
pub const String = extern struct {
ptr: ?[*]const u8,
len: usize,
sentinel: bool,
pub const empty: String = .{
.ptr = null,
.len = 0,
.sentinel = false,
};
pub fn fromSlice(slice: []const u8) String {
pub fn fromSlice(slice: anytype) String {
return .{
.ptr = slice.ptr,
.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.
@@ -129,5 +153,45 @@ pub export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 {
/// Free a string allocated by Ghostty.
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 {
return switch (builtin.os.tag) {
.windows => c._putenv_s(key.ptr, value.ptr),

View File

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

View File

@@ -36,6 +36,10 @@ pub fn ShellEscapeWriter(comptime T: type) type {
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 {
return .{ .context = self };
}

View File

@@ -7,6 +7,7 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const posix = std.posix;
const homedir = @import("homedir.zig");
const env_os = @import("env.zig");
pub const Options = struct {
/// 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
// both whether we have the env var and whether we own it.
// on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME`
const env_, const owned = switch (builtin.os.tag) {
else => .{ posix.getenv(internal_opts.env), false },
.windows => windows: {
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,
}
},
const env_ = try env_os.getenvNotEmpty(alloc, internal_opts.env) orelse switch (builtin.os.tag) {
else => null,
.windows => try env_os.getenvNotEmpty(alloc, internal_opts.windows_env),
};
defer if (owned) if (env_) |v| alloc.free(v);
defer if (env_) |env| env.deinit(alloc);
if (env_) |env| {
// If we have a subdir, then we use the env as-is to avoid a copy.
if (opts.subdir) |subdir| {
return try std.fs.path.join(alloc, &[_][]const u8{
env,
env.value,
subdir,
});
}
return try alloc.dupe(u8, env);
return try alloc.dupe(u8, env.value);
}
// 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 {
const testing = std.testing;

View File

@@ -25,6 +25,7 @@ pub const RenderPass = @import("metal/RenderPass.zig");
pub const Pipeline = @import("metal/Pipeline.zig");
const bufferpkg = @import("metal/buffer.zig");
pub const Buffer = bufferpkg.Buffer;
pub const Sampler = @import("metal/Sampler.zig");
pub const Texture = @import("metal/Texture.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,
.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,
.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,
.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,

View File

@@ -20,6 +20,7 @@ pub const RenderPass = @import("opengl/RenderPass.zig");
pub const Pipeline = @import("opengl/Pipeline.zig");
const bufferpkg = @import("opengl/buffer.zig");
pub const Buffer = bufferpkg.Buffer;
pub const Sampler = @import("opengl/Sampler.zig");
pub const Texture = @import("opengl/Texture.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.
pub const ImageTextureFormat = enum {
/// 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
/// 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:
/// - Dingbats
/// - 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
// to also constrain. This is so that multiple PUA glyphs align.
// As an exception, we ignore powerline glyphs since they are
// used for box drawing and we consider them whitespace.
if (cell_pin.x > 0) prev: {
// This does not apply if the previous symbol is a graphics
// element such as a block element or Powerline glyph.
if (cell_pin.x > 0) {
const prev_cp = prev_cp: {
var copy = cell_pin;
copy.x -= 1;
@@ -284,10 +284,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
break :prev_cp prev_cell.codepoint();
};
// We consider powerline glyphs whitespace.
if (isPowerline(prev_cp)) break :prev;
if (isSymbol(prev_cp)) {
if (isSymbol(prev_cp) and !isGraphicsElement(prev_cp)) {
return 1;
}
}
@@ -300,10 +297,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
const next_cell = copy.rowAndCell().cell;
break :next_cp next_cell.codepoint();
};
if (next_cp == 0 or
isSpace(next_cp) or
isPowerline(next_cp))
{
if (next_cp == 0 or isSpace(next_cp)) {
return 2;
}
@@ -311,10 +305,10 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
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 {
// TODO: We should disable for all box drawing type characters.
return isPowerline(cp);
return isGraphicsElement(cp);
}
// 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.
fn isPowerline(char: u21) bool {
return switch (char) {
0xE0B0...0xE0C8, 0xE0CA, 0xE0CC...0xE0D2, 0xE0D4 => true,
0xE0B0...0xE0D7 => true,
else => false,
};
}
@@ -492,3 +518,113 @@ test "Contents with zero-sized screen" {
c.setCursor(null, 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 Buffer = GraphicsAPI.Buffer;
const Sampler = GraphicsAPI.Sampler;
const Texture = GraphicsAPI.Texture;
const RenderPass = GraphicsAPI.RenderPass;
@@ -428,6 +429,19 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
front_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,
const UniformBuffer = Buffer(shadertoy.Uniforms);
@@ -459,9 +473,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
);
errdefer back_texture.deinit();
const sampler = try Sampler.init(api.samplerOptions());
errdefer sampler.deinit();
return .{
.front_texture = front_texture,
.back_texture = back_texture,
.sampler = sampler,
.uniforms = uniforms,
};
}
@@ -469,6 +487,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
pub fn deinit(self: *CustomShaderState) void {
self.front_texture.deinit();
self.back_texture.deinit();
self.sampler.deinit();
self.uniforms.deinit();
}
@@ -1509,6 +1528,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.pipeline = pipeline,
.uniforms = state.uniforms.buffer,
.textures = &.{state.back_texture},
.samplers = &.{state.sampler},
.draw = .{
.type = .triangle,
.vertex_count = 3,
@@ -3073,8 +3093,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// its cell(s), we don't modify the alignment at all.
.constraint = getConstraint(cp) orelse
if (cellpkg.isSymbol(cp)) .{
.size_horizontal = .fit,
.size_vertical = .fit,
.size = .fit,
} else .none,
.constraint_width = constraintWidth(cell_pin),
},

View File

@@ -9,6 +9,7 @@ const objc = @import("objc");
const mtl = @import("api.zig");
const Pipeline = @import("Pipeline.zig");
const Sampler = @import("Sampler.zig");
const Texture = @import("Texture.zig");
const Target = @import("Target.zig");
const Metal = @import("../Metal.zig");
@@ -41,6 +42,9 @@ pub const Step = struct {
/// MTLBuffer
buffers: []const ?objc.Object = &.{},
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,
/// 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!
self.encoder.msgSend(
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,
pixel_format: mtl.MTLPixelFormat,
resource_options: mtl.MTLResourceOptions,
usage: mtl.MTLTextureUsage,
};
/// The underlying MTLTexture Object.
@@ -57,6 +58,7 @@ pub fn init(
desc.setProperty("width", @as(c_ulong, width));
desc.setProperty("height", @as(c_ulong, height));
desc.setProperty("resourceOptions", opts.resource_options);
desc.setProperty("usage", opts.usage);
// Initialize
const id = opts.device.msgSend(

View File

@@ -8,6 +8,7 @@ const builtin = @import("builtin");
const gl = @import("opengl");
const OpenGL = @import("../OpenGL.zig");
const Sampler = @import("Sampler.zig");
const Target = @import("Target.zig");
const Texture = @import("Texture.zig");
const Pipeline = @import("Pipeline.zig");
@@ -35,6 +36,7 @@ pub const Step = struct {
uniforms: ?gl.Buffer = null,
buffers: []const ?gl.Buffer = &.{},
textures: []const ?Texture = &.{},
samplers: []const ?Sampler = &.{},
draw: Draw,
/// 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;
};
// 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,
// and bind the rest as storage buffers.
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) {
return vec4(1.0);
} 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
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
if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
# Wrap `sudo` command to ensure Ghostty terminfo is preserved.
@@ -103,7 +110,7 @@ fi
# SSH Integration
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
ssh() {
function ssh() {
builtin local ssh_term ssh_opts
ssh_term="xterm-256color"
ssh_opts=()

View File

@@ -196,6 +196,11 @@
set edit:before-readline = (conj $edit:before-readline $beam~)
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)) {
edit:add-var sudo~ $sudo-with-terminfo~
}

View File

@@ -61,6 +61,11 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
end
end
# Add Ghostty binary to PATH if the path feature is enabled
if contains path $features; and test -n "$GHOSTTY_BIN_DIR"
fish_add_path --append "$GHOSTTY_BIN_DIR"
end
# When using sudo shell integration feature, ensure $TERMINFO is set
# and `sudo` is not already a function or alias
if contains sudo $features; and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x")

View File

@@ -220,6 +220,13 @@ _ghostty_deferred_init() {
builtin print -rnu $_ghostty_fd \$'\\e[0 q'"
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
builtin export PATH="$PATH:$GHOSTTY_BIN_DIR"
fi
fi
# Sudo
if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* ]] && [[ -n "$TERMINFO" ]]; then
# Wrap `sudo` command to ensure Ghostty terminfo is preserved

View File

@@ -1859,7 +1859,7 @@ pub fn maxSize(self: *const PageList) usize {
}
/// Returns true if we need to grow into our active area.
fn growRequiredForActive(self: *const PageList) bool {
inline fn growRequiredForActive(self: *const PageList) bool {
var rows: usize = 0;
var page = self.pages.last;
while (page) |p| : (page = p.prev) {
@@ -2045,7 +2045,7 @@ pub fn adjustCapacity(
/// Create a new page node. This does not add it to the list and this
/// does not do any memory size accounting with max_size/page_size.
fn createPage(
inline fn createPage(
self: *PageList,
cap: Capacity,
) Allocator.Error!*List.Node {
@@ -2053,7 +2053,7 @@ fn createPage(
return try createPageExt(&self.pool, cap, &self.page_size);
}
fn createPageExt(
inline fn createPageExt(
pool: *MemoryPool,
cap: Capacity,
total_size: ?*usize,
@@ -3392,7 +3392,7 @@ pub const Pin = struct {
y: size.CellCountInt = 0,
x: size.CellCountInt = 0,
pub fn rowAndCell(self: Pin) struct {
pub inline fn rowAndCell(self: Pin) struct {
row: *pagepkg.Row,
cell: *pagepkg.Cell,
} {
@@ -3405,7 +3405,7 @@ pub const Pin = struct {
/// Returns the cells for the row that this pin is on. The subset determines
/// what subset of the cells are returned. The "left/right" subsets are
/// inclusive of the x coordinate of the pin.
pub fn cells(self: Pin, subset: CellSubset) []pagepkg.Cell {
pub inline fn cells(self: Pin, subset: CellSubset) []pagepkg.Cell {
const rac = self.rowAndCell();
const all = self.node.data.getCells(rac.row);
return switch (subset) {
@@ -3417,12 +3417,12 @@ pub const Pin = struct {
/// Returns the grapheme codepoints for the given cell. These are only
/// the EXTRA codepoints and not the first codepoint.
pub fn grapheme(self: Pin, cell: *const pagepkg.Cell) ?[]u21 {
pub inline fn grapheme(self: Pin, cell: *const pagepkg.Cell) ?[]u21 {
return self.node.data.lookupGrapheme(cell);
}
/// Returns the style for the given cell in this pin.
pub fn style(self: Pin, cell: *const pagepkg.Cell) stylepkg.Style {
pub inline fn style(self: Pin, cell: *const pagepkg.Cell) stylepkg.Style {
if (cell.style_id == stylepkg.default_id) return .{};
return self.node.data.styles.get(
self.node.data.memory,
@@ -3431,12 +3431,12 @@ pub const Pin = struct {
}
/// Check if this pin is dirty.
pub fn isDirty(self: Pin) bool {
pub inline fn isDirty(self: Pin) bool {
return self.node.data.isRowDirty(self.y);
}
/// Mark this pin location as dirty.
pub fn markDirty(self: Pin) void {
pub inline fn markDirty(self: Pin) void {
var set = self.node.data.dirtyBitSet();
set.set(self.y);
}
@@ -3505,7 +3505,7 @@ pub const Pin = struct {
/// pointFromPin and building up the iterator from points.
///
/// The limit pin is inclusive.
pub fn pageIterator(
pub inline fn pageIterator(
self: Pin,
direction: Direction,
limit: ?Pin,
@@ -3527,7 +3527,7 @@ pub const Pin = struct {
};
}
pub fn rowIterator(
pub inline fn rowIterator(
self: Pin,
direction: Direction,
limit: ?Pin,
@@ -3544,7 +3544,7 @@ pub const Pin = struct {
};
}
pub fn cellIterator(
pub inline fn cellIterator(
self: Pin,
direction: Direction,
limit: ?Pin,
@@ -3645,14 +3645,14 @@ pub const Pin = struct {
return false;
}
pub fn eql(self: Pin, other: Pin) bool {
pub inline fn eql(self: Pin, other: Pin) bool {
return self.node == other.node and
self.y == other.y and
self.x == other.x;
}
/// Move the pin left n columns. n must fit within the size.
pub fn left(self: Pin, n: usize) Pin {
pub inline fn left(self: Pin, n: usize) Pin {
assert(n <= self.x);
var result = self;
result.x -= std.math.cast(size.CellCountInt, n) orelse result.x;
@@ -3660,7 +3660,7 @@ pub const Pin = struct {
}
/// Move the pin right n columns. n must fit within the size.
pub fn right(self: Pin, n: usize) Pin {
pub inline fn right(self: Pin, n: usize) Pin {
assert(self.x + n < self.node.data.size.cols);
var result = self;
result.x +|= std.math.cast(size.CellCountInt, n) orelse
@@ -3669,14 +3669,14 @@ pub const Pin = struct {
}
/// Move the pin left n columns, stopping at the start of the row.
pub fn leftClamp(self: Pin, n: size.CellCountInt) Pin {
pub inline fn leftClamp(self: Pin, n: size.CellCountInt) Pin {
var result = self;
result.x -|= n;
return result;
}
/// Move the pin right n columns, stopping at the end of the row.
pub fn rightClamp(self: Pin, n: size.CellCountInt) Pin {
pub inline fn rightClamp(self: Pin, n: size.CellCountInt) Pin {
var result = self;
result.x = @min(self.x +| n, self.node.data.size.cols - 1);
return result;
@@ -3738,7 +3738,7 @@ pub const Pin = struct {
/// Move the pin down a certain number of rows, or return null if
/// the pin goes beyond the end of the screen.
pub fn down(self: Pin, n: usize) ?Pin {
pub inline fn down(self: Pin, n: usize) ?Pin {
return switch (self.downOverflow(n)) {
.offset => |v| v,
.overflow => null,
@@ -3747,7 +3747,7 @@ pub const Pin = struct {
/// Move the pin up a certain number of rows, or return null if
/// the pin goes beyond the start of the screen.
pub fn up(self: Pin, n: usize) ?Pin {
pub inline fn up(self: Pin, n: usize) ?Pin {
return switch (self.upOverflow(n)) {
.offset => |v| v,
.overflow => null,

View File

@@ -314,7 +314,7 @@ pub fn next(self: *Parser, c: u8) [3]?Action {
};
}
pub fn collect(self: *Parser, c: u8) void {
pub inline fn collect(self: *Parser, c: u8) void {
if (self.intermediates_idx >= MAX_INTERMEDIATE) {
log.warn("invalid intermediates count", .{});
return;
@@ -324,7 +324,7 @@ pub fn collect(self: *Parser, c: u8) void {
self.intermediates_idx += 1;
}
fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
return switch (action) {
.none, .ignore => null,
.print => Action{ .print = c },
@@ -410,7 +410,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
};
}
pub fn clear(self: *Parser) void {
pub inline fn clear(self: *Parser) void {
self.intermediates_idx = 0;
self.params_idx = 0;
self.params_sep = .initEmpty();

View File

@@ -14,6 +14,7 @@ const Selection = @import("Selection.zig");
const PageList = @import("PageList.zig");
const StringMap = @import("StringMap.zig");
const pagepkg = @import("page.zig");
const cellpkg = @import("../renderer/cell.zig");
const point = @import("point.zig");
const size = @import("size.zig");
const style = @import("style.zig");
@@ -540,13 +541,13 @@ pub fn adjustCapacity(
return new_node;
}
pub fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell {
pub inline fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell {
assert(self.cursor.x + n < self.pages.cols);
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
return @ptrCast(cell + n);
}
pub fn cursorCellLeft(self: *Screen, n: size.CellCountInt) *pagepkg.Cell {
pub inline fn cursorCellLeft(self: *Screen, n: size.CellCountInt) *pagepkg.Cell {
assert(self.cursor.x >= n);
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
return @ptrCast(cell - n);
@@ -964,7 +965,7 @@ fn cursorScrollAboveRotate(self: *Screen) !void {
/// Move the cursor down if we're not at the bottom of the screen. Otherwise
/// scroll. Currently only used for testing.
fn cursorDownOrScroll(self: *Screen) !void {
inline fn cursorDownOrScroll(self: *Screen) !void {
if (self.cursor.y + 1 < self.pages.rows) {
self.cursorDown(1);
} else {
@@ -1039,7 +1040,7 @@ pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct {
/// page than the old AND we have a style or hyperlink set. In that case,
/// we must release our old one and insert the new one, since styles are
/// stored per-page.
fn cursorChangePin(self: *Screen, new: Pin) void {
inline fn cursorChangePin(self: *Screen, new: Pin) void {
// Moving the cursor affects text run splitting (ligatures) so
// we must mark the old and new page dirty. We do this as long
// as the pins are not equal
@@ -1113,7 +1114,7 @@ fn cursorChangePin(self: *Screen, new: Pin) void {
/// Mark the cursor position as dirty.
/// TODO: test
pub fn cursorMarkDirty(self: *Screen) void {
pub inline fn cursorMarkDirty(self: *Screen) void {
self.cursor.page_pin.markDirty();
}
@@ -1165,7 +1166,7 @@ pub const Scroll = union(enum) {
};
/// Scroll the viewport of the terminal grid.
pub fn scroll(self: *Screen, behavior: Scroll) void {
pub inline fn scroll(self: *Screen, behavior: Scroll) void {
defer self.assertIntegrity();
// No matter what, scrolling marks our image state as dirty since
@@ -1184,7 +1185,7 @@ pub fn scroll(self: *Screen, behavior: Scroll) void {
/// See PageList.scrollClear. In addition to that, we reset the cursor
/// to be on top.
pub fn scrollClear(self: *Screen) !void {
pub inline fn scrollClear(self: *Screen) !void {
defer self.assertIntegrity();
try self.pages.scrollClear();
@@ -1197,14 +1198,14 @@ pub fn scrollClear(self: *Screen) !void {
}
/// Returns true if the viewport is scrolled to the bottom of the screen.
pub fn viewportIsBottom(self: Screen) bool {
pub inline fn viewportIsBottom(self: Screen) bool {
return self.pages.viewport == .active;
}
/// Erase the region specified by tl and br, inclusive. This will physically
/// erase the rows meaning the memory will be reclaimed (if the underlying
/// page is empty) and other rows will be shifted up.
pub fn eraseRows(
pub inline fn eraseRows(
self: *Screen,
tl: point.Point,
bl: ?point.Point,
@@ -1538,7 +1539,7 @@ pub fn splitCellBoundary(
/// Returns the blank cell to use when doing terminal operations that
/// require preserving the bg color.
pub fn blankCell(self: *const Screen) Cell {
pub inline fn blankCell(self: *const Screen) Cell {
if (self.cursor.style_id == style.default_id) return .{};
return self.cursor.style.bgCell() orelse .{};
}
@@ -1556,7 +1557,7 @@ pub fn blankCell(self: *const Screen) Cell {
/// probably means the system is in trouble anyways. I'd like to improve this
/// in the future but it is not a priority particularly because this scenario
/// (resize) is difficult.
pub fn resize(
pub inline fn resize(
self: *Screen,
cols: size.CellCountInt,
rows: size.CellCountInt,
@@ -1567,7 +1568,7 @@ pub fn resize(
/// Resize the screen without any reflow. In this mode, columns/rows will
/// be truncated as they are shrunk. If they are grown, the new space is filled
/// with zeros.
pub fn resizeWithoutReflow(
pub inline fn resizeWithoutReflow(
self: *Screen,
cols: size.CellCountInt,
rows: size.CellCountInt,
@@ -9094,3 +9095,97 @@ test "Screen UTF8 cell map with blank prefix" {
.y = 1,
}, cell_map.items[3]);
}
test "Screen cell constraint widths" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try 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, cellpkg.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, cellpkg.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, cellpkg.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, cellpkg.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, cellpkg.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, cellpkg.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, cellpkg.constraintWidth(p0));
try testing.expectEqual(1, cellpkg.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, cellpkg.constraintWidth(p0));
try testing.expectEqual(2, cellpkg.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, cellpkg.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, cellpkg.constraintWidth(p1));
s.reset();
}
}

View File

@@ -191,7 +191,7 @@ pub const Page = struct {
/// The backing memory is always allocated using mmap directly.
/// You cannot use custom allocators with this structure because
/// it is critical to performance that we use mmap.
pub fn init(cap: Capacity) !Page {
pub inline fn init(cap: Capacity) !Page {
const l = layout(cap);
// We use mmap directly to avoid Zig allocator overhead
@@ -215,7 +215,7 @@ pub const Page = struct {
/// Initialize a new page using the given backing memory.
/// It is up to the caller to not call deinit on these pages.
pub fn initBuf(buf: OffsetBuf, l: Layout) Page {
pub inline fn initBuf(buf: OffsetBuf, l: Layout) Page {
const cap = l.capacity;
const rows = buf.member(Row, l.rows_start);
const cells = buf.member(Cell, l.cells_start);
@@ -270,13 +270,13 @@ pub const Page = struct {
/// Deinitialize the page, freeing any backing memory. Do NOT call
/// this if you allocated the backing memory yourself (i.e. you used
/// initBuf).
pub fn deinit(self: *Page) void {
pub inline fn deinit(self: *Page) void {
posix.munmap(self.memory);
self.* = undefined;
}
/// Reinitialize the page with the same capacity.
pub fn reinit(self: *Page) void {
pub inline fn reinit(self: *Page) void {
// We zero the page memory as u64 instead of u8 because
// we can and it's empirically quite a bit faster.
@memset(@as([*]u64, @ptrCast(self.memory))[0 .. self.memory.len / 8], 0);
@@ -600,7 +600,7 @@ pub const Page = struct {
/// Clone the contents of this page. This will allocate new memory
/// using the page allocator. If you want to manage memory manually,
/// use cloneBuf.
pub fn clone(self: *const Page) !Page {
pub inline fn clone(self: *const Page) !Page {
const backing = try posix.mmap(
null,
self.memory.len,
@@ -616,7 +616,7 @@ pub const Page = struct {
/// Clone the entire contents of this page.
///
/// The buffer must be at least the size of self.memory.
pub fn cloneBuf(self: *const Page, buf: []align(std.heap.page_size_min) u8) Page {
pub inline fn cloneBuf(self: *const Page, buf: []align(std.heap.page_size_min) u8) Page {
assert(buf.len >= self.memory.len);
// The entire concept behind a page is that everything is stored
@@ -668,7 +668,7 @@ pub const Page = struct {
/// If the other page has more columns, the extra columns will be
/// truncated. If the other page has fewer columns, the extra columns
/// will be zeroed.
pub fn cloneFrom(
pub inline fn cloneFrom(
self: *Page,
other: *const Page,
y_start: usize,
@@ -692,7 +692,7 @@ pub const Page = struct {
}
/// Clone a single row from another page into this page.
pub fn cloneRowFrom(
pub inline fn cloneRowFrom(
self: *Page,
other: *const Page,
dst_row: *Row,
@@ -907,7 +907,7 @@ pub const Page = struct {
}
/// Get a single row. y must be valid.
pub fn getRow(self: *const Page, y: usize) *Row {
pub inline fn getRow(self: *const Page, y: usize) *Row {
assert(y < self.size.rows);
return &self.rows.ptr(self.memory)[y];
}
@@ -926,7 +926,7 @@ pub const Page = struct {
}
/// Get the row and cell for the given X/Y within this page.
pub fn getRowAndCell(self: *const Page, x: usize, y: usize) struct {
pub inline fn getRowAndCell(self: *const Page, x: usize, y: usize) struct {
row: *Row,
cell: *Cell,
} {
@@ -1007,7 +1007,7 @@ pub const Page = struct {
}
/// Swap two cells within the same row as quickly as possible.
pub fn swapCells(
pub inline fn swapCells(
self: *Page,
src: *Cell,
dst: *Cell,
@@ -1068,7 +1068,7 @@ pub const Page = struct {
/// active, Page cannot know this and it will still be ref counted down.
/// The best solution for this is to artificially increment the ref count
/// prior to calling this function.
pub fn clearCells(
pub inline fn clearCells(
self: *Page,
row: *Row,
left: usize,
@@ -1116,14 +1116,14 @@ pub const Page = struct {
}
/// Returns the hyperlink ID for the given cell.
pub fn lookupHyperlink(self: *const Page, cell: *const Cell) ?hyperlink.Id {
pub inline fn lookupHyperlink(self: *const Page, cell: *const Cell) ?hyperlink.Id {
const cell_offset = getOffset(Cell, self.memory, cell);
const map = self.hyperlink_map.map(self.memory);
return map.get(cell_offset);
}
/// Clear the hyperlink from the given cell.
pub fn clearHyperlink(self: *Page, row: *Row, cell: *Cell) void {
pub inline fn clearHyperlink(self: *Page, row: *Row, cell: *Cell) void {
defer self.assertIntegrity();
// Get our ID
@@ -1247,7 +1247,7 @@ pub const Page = struct {
/// Caller is responsible for updating the refcount in the hyperlink
/// set as necessary by calling `use` if the id was not acquired with
/// `add`.
pub fn setHyperlink(self: *Page, row: *Row, cell: *Cell, id: hyperlink.Id) error{HyperlinkMapOutOfMemory}!void {
pub inline fn setHyperlink(self: *Page, row: *Row, cell: *Cell, id: hyperlink.Id) error{HyperlinkMapOutOfMemory}!void {
defer self.assertIntegrity();
const cell_offset = getOffset(Cell, self.memory, cell);
@@ -1289,7 +1289,7 @@ pub const Page = struct {
/// Move the hyperlink from one cell to another. This can't fail
/// because we avoid any allocations since we're just moving data.
/// Destination must NOT have a hyperlink.
fn moveHyperlink(self: *Page, src: *Cell, dst: *Cell) void {
inline fn moveHyperlink(self: *Page, src: *Cell, dst: *Cell) void {
assert(src.hyperlink);
assert(!dst.hyperlink);
@@ -1309,19 +1309,19 @@ pub const Page = struct {
/// Returns the number of hyperlinks in the page. This isn't the byte
/// size but the total number of unique cells that have hyperlink data.
pub fn hyperlinkCount(self: *const Page) usize {
pub inline fn hyperlinkCount(self: *const Page) usize {
return self.hyperlink_map.map(self.memory).count();
}
/// Returns the hyperlink capacity for the page. This isn't the byte
/// size but the number of unique cells that can have hyperlink data.
pub fn hyperlinkCapacity(self: *const Page) usize {
pub inline fn hyperlinkCapacity(self: *const Page) usize {
return self.hyperlink_map.map(self.memory).capacity();
}
/// Set the graphemes for the given cell. This asserts that the cell
/// has no graphemes set, and only contains a single codepoint.
pub fn setGraphemes(
pub inline fn setGraphemes(
self: *Page,
row: *Row,
cell: *Cell,
@@ -1422,7 +1422,7 @@ pub const Page = struct {
/// Returns the codepoints for the given cell. These are the codepoints
/// in addition to the first codepoint. The first codepoint is NOT
/// included since it is on the cell itself.
pub fn lookupGrapheme(self: *const Page, cell: *const Cell) ?[]u21 {
pub inline fn lookupGrapheme(self: *const Page, cell: *const Cell) ?[]u21 {
const cell_offset = getOffset(Cell, self.memory, cell);
const map = self.grapheme_map.map(self.memory);
const slice = map.get(cell_offset) orelse return null;
@@ -1451,7 +1451,7 @@ pub const Page = struct {
}
/// Clear the graphemes for a given cell.
pub fn clearGrapheme(self: *Page, row: *Row, cell: *Cell) void {
pub inline fn clearGrapheme(self: *Page, row: *Row, cell: *Cell) void {
defer self.assertIntegrity();
if (build_config.slow_runtime_safety) assert(cell.hasGrapheme());
@@ -1477,13 +1477,13 @@ pub const Page = struct {
/// Returns the number of graphemes in the page. This isn't the byte
/// size but the total number of unique cells that have grapheme data.
pub fn graphemeCount(self: *const Page) usize {
pub inline fn graphemeCount(self: *const Page) usize {
return self.grapheme_map.map(self.memory).count();
}
/// Returns the grapheme capacity for the page. This isn't the byte
/// size but the number of unique cells that can have grapheme data.
pub fn graphemeCapacity(self: *const Page) usize {
pub inline fn graphemeCapacity(self: *const Page) usize {
return self.grapheme_map.map(self.memory).capacity();
}
@@ -1665,7 +1665,7 @@ pub const Page = struct {
/// The returned value is a DynamicBitSetUnmanaged but it is NOT
/// actually dynamic; do NOT call resize on this. It is safe to
/// read and write but do not resize it.
pub fn dirtyBitSet(self: *const Page) std.DynamicBitSetUnmanaged {
pub inline fn dirtyBitSet(self: *const Page) std.DynamicBitSetUnmanaged {
return .{
.bit_length = self.capacity.rows,
.masks = self.dirty.ptr(self.memory),
@@ -1675,14 +1675,14 @@ pub const Page = struct {
/// Returns true if the given row is dirty. This is NOT very
/// efficient if you're checking many rows and you should use
/// dirtyBitSet directly instead.
pub fn isRowDirty(self: *const Page, y: usize) bool {
pub inline fn isRowDirty(self: *const Page, y: usize) bool {
return self.dirtyBitSet().isSet(y);
}
/// Returns true if this page is dirty at all. If you plan on
/// checking any additional rows, you should use dirtyBitSet and
/// check this on your own so you have the set available.
pub fn isDirty(self: *const Page) bool {
pub inline fn isDirty(self: *const Page) bool {
return self.dirtyBitSet().findFirstSet() != null;
}
@@ -1711,7 +1711,7 @@ pub const Page = struct {
/// The memory layout for a page given a desired minimum cols
/// and rows size.
pub fn layout(cap: Capacity) Layout {
pub inline fn layout(cap: Capacity) Layout {
const rows_count: usize = @intCast(cap.rows);
const rows_start = 0;
const rows_end: usize = rows_start + (rows_count * @sizeOf(Row));

View File

@@ -56,7 +56,7 @@ pub const Point = union(Tag) {
screen: Coordinate,
history: Coordinate,
pub fn coord(self: Point) Coordinate {
pub inline fn coord(self: Point) Coordinate {
return switch (self) {
.active,
.viewport,

View File

@@ -31,7 +31,7 @@ pub fn Offset(comptime T: type) type {
};
/// Returns a pointer to the start of the data, properly typed.
pub fn ptr(self: Self, base: anytype) [*]T {
pub inline fn ptr(self: Self, base: anytype) [*]T {
// The offset must be properly aligned for the type since
// our return type is naturally aligned. We COULD modify this
// to return arbitrary alignment, but its not something we need.

View File

@@ -85,7 +85,7 @@ pub fn Stream(comptime Handler: type) type {
}
}
fn nextSliceCapped(self: *Self, input: []const u8, cp_buf: []u32) !void {
inline fn nextSliceCapped(self: *Self, input: []const u8, cp_buf: []u32) !void {
assert(input.len <= cp_buf.len);
var offset: usize = 0;
@@ -142,7 +142,7 @@ pub fn Stream(comptime Handler: type) type {
///
/// Expects input to start with 0x1B, use consumeUntilGround first
/// if the stream may be in the middle of an escape sequence.
fn consumeAllEscapes(self: *Self, input: []const u8) !usize {
inline fn consumeAllEscapes(self: *Self, input: []const u8) !usize {
var offset: usize = 0;
while (input[offset] == 0x1B) {
self.parser.state = .escape;
@@ -156,7 +156,7 @@ pub fn Stream(comptime Handler: type) type {
/// Parses escape sequences until the parser reaches the ground state.
/// Returns the number of bytes consumed from the provided input.
fn consumeUntilGround(self: *Self, input: []const u8) !usize {
inline fn consumeUntilGround(self: *Self, input: []const u8) !usize {
var offset: usize = 0;
while (self.parser.state != .ground) {
if (offset >= input.len) return input.len;
@@ -169,7 +169,7 @@ pub fn Stream(comptime Handler: type) type {
/// Like nextSlice but takes one byte and is necessarily a scalar
/// operation that can't use SIMD. Prefer nextSlice if you can and
/// try to get multiple bytes at once.
pub fn next(self: *Self, c: u8) !void {
pub inline fn next(self: *Self, c: u8) !void {
// The scalar path can be responsible for decoding UTF-8.
if (self.parser.state == .ground) {
try self.nextUtf8(c);
@@ -183,7 +183,7 @@ pub fn Stream(comptime Handler: type) type {
///
/// This assumes we're in the UTF-8 decoding state. If we may not
/// be in the UTF-8 decoding state call nextSlice or next.
fn nextUtf8(self: *Self, c: u8) !void {
inline fn nextUtf8(self: *Self, c: u8) !void {
assert(self.parser.state == .ground);
const res = self.utf8decoder.next(c);
@@ -276,7 +276,14 @@ pub fn Stream(comptime Handler: type) type {
return;
}
const actions = self.parser.next(c);
// We explicitly inline this call here for performance reasons.
//
// We do this rather than mark Parser.next as inline because doing
// that causes weird behavior in some tests- I'm not sure if they
// miscompile or it's just very counter-intuitive comptime stuff,
// but regardless, this is the easy solution.
const actions = @call(.always_inline, Parser.next, .{ &self.parser, c });
for (actions) |action_opt| {
const action = action_opt orelse continue;
if (comptime debug) log.info("action: {}", .{action});
@@ -324,13 +331,13 @@ pub fn Stream(comptime Handler: type) type {
}
}
pub fn print(self: *Self, c: u21) !void {
pub inline fn print(self: *Self, c: u21) !void {
if (@hasDecl(T, "print")) {
try self.handler.print(c);
}
}
pub fn execute(self: *Self, c: u8) !void {
pub inline fn execute(self: *Self, c: u8) !void {
const c0: ansi.C0 = @enumFromInt(c);
if (comptime debug) log.info("execute: {}", .{c0});
switch (c0) {
@@ -381,7 +388,7 @@ pub fn Stream(comptime Handler: type) type {
}
}
fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void {
inline fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void {
switch (input.final) {
// CUU - Cursor Up
'A', 'k' => switch (input.intermediates.len) {
@@ -1488,7 +1495,7 @@ pub fn Stream(comptime Handler: type) type {
}
}
fn oscDispatch(self: *Self, cmd: osc.Command) !void {
inline fn oscDispatch(self: *Self, cmd: osc.Command) !void {
switch (cmd) {
.change_window_title => |title| {
if (@hasDecl(T, "changeWindowTitle")) {
@@ -1633,7 +1640,7 @@ pub fn Stream(comptime Handler: type) type {
}
}
fn configureCharset(
inline fn configureCharset(
self: *Self,
intermediates: []const u8,
set: charsets.Charset,
@@ -1667,7 +1674,7 @@ pub fn Stream(comptime Handler: type) type {
});
}
fn escDispatch(
inline fn escDispatch(
self: *Self,
action: Parser.Action.ESC,
) !void {

View File

@@ -219,8 +219,8 @@ test "setup features" {
var env = EnvMap.init(alloc);
defer env.deinit();
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true });
try testing.expectEqualStrings("cursor,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true, .path = true });
try testing.expectEqualStrings("cursor,path,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
}
// Test: all features disabled
@@ -228,7 +228,7 @@ test "setup features" {
var env = EnvMap.init(alloc);
defer env.deinit();
try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false });
try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false });
try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null);
}
@@ -237,7 +237,7 @@ test "setup features" {
var env = EnvMap.init(alloc);
defer env.deinit();
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false });
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false, .path = false });
try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?);
}
}

View File

@@ -186,19 +186,19 @@ pub const StreamHandler = struct {
_ = self.renderer_mailbox.push(msg, .{ .forever = {} });
}
pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void {
pub inline fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void {
var cmd = self.dcs.hook(self.alloc, dcs) orelse return;
defer cmd.deinit();
try self.dcsCommand(&cmd);
}
pub fn dcsPut(self: *StreamHandler, byte: u8) !void {
pub inline fn dcsPut(self: *StreamHandler, byte: u8) !void {
var cmd = self.dcs.put(byte) orelse return;
defer cmd.deinit();
try self.dcsCommand(&cmd);
}
pub fn dcsUnhook(self: *StreamHandler) !void {
pub inline fn dcsUnhook(self: *StreamHandler) !void {
var cmd = self.dcs.unhook() orelse return;
defer cmd.deinit();
try self.dcsCommand(&cmd);
@@ -293,11 +293,11 @@ pub const StreamHandler = struct {
}
}
pub fn apcStart(self: *StreamHandler) !void {
pub inline fn apcStart(self: *StreamHandler) !void {
self.apc.start();
}
pub fn apcPut(self: *StreamHandler, byte: u8) !void {
pub inline fn apcPut(self: *StreamHandler, byte: u8) !void {
self.apc.feed(self.alloc, byte);
}
@@ -322,23 +322,23 @@ pub const StreamHandler = struct {
}
}
pub fn print(self: *StreamHandler, ch: u21) !void {
pub inline fn print(self: *StreamHandler, ch: u21) !void {
try self.terminal.print(ch);
}
pub fn printRepeat(self: *StreamHandler, count: usize) !void {
pub inline fn printRepeat(self: *StreamHandler, count: usize) !void {
try self.terminal.printRepeat(count);
}
pub fn bell(self: *StreamHandler) !void {
pub inline fn bell(self: *StreamHandler) !void {
self.surfaceMessageWriter(.ring_bell);
}
pub fn backspace(self: *StreamHandler) !void {
pub inline fn backspace(self: *StreamHandler) !void {
self.terminal.backspace();
}
pub fn horizontalTab(self: *StreamHandler, count: u16) !void {
pub inline fn horizontalTab(self: *StreamHandler, count: u16) !void {
for (0..count) |_| {
const x = self.terminal.screen.cursor.x;
try self.terminal.horizontalTab();
@@ -346,7 +346,7 @@ pub const StreamHandler = struct {
}
}
pub fn horizontalTabBack(self: *StreamHandler, count: u16) !void {
pub inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void {
for (0..count) |_| {
const x = self.terminal.screen.cursor.x;
try self.terminal.horizontalTabBack();
@@ -354,61 +354,61 @@ pub const StreamHandler = struct {
}
}
pub fn linefeed(self: *StreamHandler) !void {
pub inline fn linefeed(self: *StreamHandler) !void {
// Small optimization: call index instead of linefeed because they're
// identical and this avoids one layer of function call overhead.
try self.terminal.index();
}
pub fn carriageReturn(self: *StreamHandler) !void {
pub inline fn carriageReturn(self: *StreamHandler) !void {
self.terminal.carriageReturn();
}
pub fn setCursorLeft(self: *StreamHandler, amount: u16) !void {
pub inline fn setCursorLeft(self: *StreamHandler, amount: u16) !void {
self.terminal.cursorLeft(amount);
}
pub fn setCursorRight(self: *StreamHandler, amount: u16) !void {
pub inline fn setCursorRight(self: *StreamHandler, amount: u16) !void {
self.terminal.cursorRight(amount);
}
pub fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void {
pub inline fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void {
self.terminal.cursorDown(amount);
if (carriage) self.terminal.carriageReturn();
}
pub fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void {
pub inline fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void {
self.terminal.cursorUp(amount);
if (carriage) self.terminal.carriageReturn();
}
pub fn setCursorCol(self: *StreamHandler, col: u16) !void {
pub inline fn setCursorCol(self: *StreamHandler, col: u16) !void {
self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col);
}
pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void {
pub inline fn setCursorColRelative(self: *StreamHandler, offset: u16) !void {
self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1,
self.terminal.screen.cursor.x + 1 +| offset,
);
}
pub fn setCursorRow(self: *StreamHandler, row: u16) !void {
pub inline fn setCursorRow(self: *StreamHandler, row: u16) !void {
self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1);
}
pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void {
pub inline fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void {
self.terminal.setCursorPos(
self.terminal.screen.cursor.y + 1 +| offset,
self.terminal.screen.cursor.x + 1,
);
}
pub fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void {
pub inline fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void {
self.terminal.setCursorPos(row, col);
}
pub fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void {
pub inline fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void {
if (mode == .complete) {
// Whenever we erase the full display, scroll to bottom.
try self.terminal.scrollViewport(.{ .bottom = {} });
@@ -418,48 +418,48 @@ pub const StreamHandler = struct {
self.terminal.eraseDisplay(mode, protected);
}
pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void {
pub inline fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void {
self.terminal.eraseLine(mode, protected);
}
pub fn deleteChars(self: *StreamHandler, count: usize) !void {
pub inline fn deleteChars(self: *StreamHandler, count: usize) !void {
self.terminal.deleteChars(count);
}
pub fn eraseChars(self: *StreamHandler, count: usize) !void {
pub inline fn eraseChars(self: *StreamHandler, count: usize) !void {
self.terminal.eraseChars(count);
}
pub fn insertLines(self: *StreamHandler, count: usize) !void {
pub inline fn insertLines(self: *StreamHandler, count: usize) !void {
self.terminal.insertLines(count);
}
pub fn insertBlanks(self: *StreamHandler, count: usize) !void {
pub inline fn insertBlanks(self: *StreamHandler, count: usize) !void {
self.terminal.insertBlanks(count);
}
pub fn deleteLines(self: *StreamHandler, count: usize) !void {
pub inline fn deleteLines(self: *StreamHandler, count: usize) !void {
self.terminal.deleteLines(count);
}
pub fn reverseIndex(self: *StreamHandler) !void {
pub inline fn reverseIndex(self: *StreamHandler) !void {
self.terminal.reverseIndex();
}
pub fn index(self: *StreamHandler) !void {
pub inline fn index(self: *StreamHandler) !void {
try self.terminal.index();
}
pub fn nextLine(self: *StreamHandler) !void {
pub inline fn nextLine(self: *StreamHandler) !void {
try self.terminal.index();
self.terminal.carriageReturn();
}
pub fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void {
pub inline fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void {
self.terminal.setTopAndBottomMargin(top, bot);
}
pub fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void {
pub inline fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void {
if (self.terminal.modes.get(.enable_left_and_right_margin)) {
try self.setLeftAndRightMargin(0, 0);
} else {
@@ -467,7 +467,7 @@ pub const StreamHandler = struct {
}
}
pub fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void {
pub inline fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void {
self.terminal.setLeftAndRightMargin(left, right);
}
@@ -504,12 +504,12 @@ pub const StreamHandler = struct {
self.messageWriter(msg);
}
pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void {
pub inline fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void {
// log.debug("save mode={}", .{mode});
self.terminal.modes.save(mode);
}
pub fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void {
pub inline fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void {
// For restore mode we have to restore but if we set it, we
// always have to call setMode because setting some modes have
// side effects and we want to make sure we process those.
@@ -696,11 +696,11 @@ pub const StreamHandler = struct {
}
}
pub fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void {
pub inline fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void {
self.terminal.flags.mouse_shift_capture = if (v) .true else .false;
}
pub fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void {
pub inline fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void {
switch (attr) {
.unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}),
@@ -709,11 +709,11 @@ pub const StreamHandler = struct {
}
}
pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void {
pub inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void {
try self.terminal.screen.startHyperlink(uri, id);
}
pub fn endHyperlink(self: *StreamHandler) !void {
pub inline fn endHyperlink(self: *StreamHandler) !void {
self.terminal.screen.endHyperlink();
}
@@ -832,31 +832,31 @@ pub const StreamHandler = struct {
}
}
pub fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void {
pub inline fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void {
self.terminal.setProtectedMode(mode);
}
pub fn decaln(self: *StreamHandler) !void {
pub inline fn decaln(self: *StreamHandler) !void {
try self.terminal.decaln();
}
pub fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void {
pub inline fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void {
self.terminal.tabClear(cmd);
}
pub fn tabSet(self: *StreamHandler) !void {
pub inline fn tabSet(self: *StreamHandler) !void {
self.terminal.tabSet();
}
pub fn tabReset(self: *StreamHandler) !void {
pub inline fn tabReset(self: *StreamHandler) !void {
self.terminal.tabReset();
}
pub fn saveCursor(self: *StreamHandler) !void {
pub inline fn saveCursor(self: *StreamHandler) !void {
self.terminal.saveCursor();
}
pub fn restoreCursor(self: *StreamHandler) !void {
pub inline fn restoreCursor(self: *StreamHandler) !void {
try self.terminal.restoreCursor();
}
@@ -865,11 +865,11 @@ pub const StreamHandler = struct {
self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response));
}
pub fn scrollDown(self: *StreamHandler, count: usize) !void {
pub inline fn scrollDown(self: *StreamHandler, count: usize) !void {
self.terminal.scrollDown(count);
}
pub fn scrollUp(self: *StreamHandler, count: usize) !void {
pub inline fn scrollUp(self: *StreamHandler, count: usize) !void {
self.terminal.scrollUp(count);
}
@@ -995,7 +995,7 @@ pub const StreamHandler = struct {
self.surfaceMessageWriter(.{ .set_title = buf });
}
pub fn setMouseShape(
pub inline fn setMouseShape(
self: *StreamHandler,
shape: terminal.MouseShape,
) !void {
@@ -1037,22 +1037,22 @@ pub const StreamHandler = struct {
});
}
pub fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void {
pub inline fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void {
_ = aid;
self.terminal.markSemanticPrompt(.prompt);
self.terminal.flags.shell_redraws_prompt = redraw;
}
pub fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void {
pub inline fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void {
_ = aid;
self.terminal.markSemanticPrompt(.prompt_continuation);
}
pub fn promptEnd(self: *StreamHandler) !void {
pub inline fn promptEnd(self: *StreamHandler) !void {
self.terminal.markSemanticPrompt(.input);
}
pub fn endOfInput(self: *StreamHandler) !void {
pub inline fn endOfInput(self: *StreamHandler) !void {
self.terminal.markSemanticPrompt(.command);
}