48 Commits

Author SHA1 Message Date
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
75 changed files with 3910 additions and 1177 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

@@ -3,6 +3,7 @@
.version = "1.2.0",
.paths = .{""},
.fingerprint = 0x64407a2a0b4147e5,
.minimum_zig_version = "0.14.1",
.dependencies = .{
// Zig libs

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,7 @@
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 */; };
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 +294,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 +494,7 @@
isa = PBXGroup;
children = (
A55B7BB729B6F53A0055DE60 /* Package.swift */,
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */,
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */,
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
@@ -892,6 +895,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 */,

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
@@ -863,14 +874,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 +1075,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

@@ -625,6 +625,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

@@ -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;

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,19 @@ 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;
) == null) {
log.debug("must_quit due to no app windows", .{});
break :q true;
}
// No quit conditions met
break :q false;
@@ -741,6 +747,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 +789,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");
@@ -274,6 +275,24 @@ 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 {
@@ -502,6 +521,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 +623,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 +1260,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 +1278,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,
@@ -2763,6 +2861,7 @@ pub const Surface = extern struct {
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
class.bindTemplateCallback("notify_bell_ringing", &propBellRinging);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown);
// Properties
gobject.ext.registerProperties(class, &.{
@@ -2781,6 +2880,7 @@ pub const Surface = extern struct {
properties.title.impl,
properties.@"title-override".impl,
properties.zoom.impl,
properties.@"is-split".impl,
});
// Signals

View File

@@ -1489,6 +1489,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();

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

@@ -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,
@@ -2354,9 +2363,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 +2731,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`.
///
@@ -3342,7 +3363,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 +4077,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 +6509,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();
@@ -6989,6 +7011,7 @@ pub const RepeatableCommand = struct {
inputpkg.Command,
alloc,
input,
null,
);
try self.value.append(alloc, cmd);
}
@@ -7020,18 +7043,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 +7126,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 +7142,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 +7321,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 +8037,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

@@ -1213,6 +1213,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,7 +1226,10 @@ 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 = @round(3.04) - @as(f64, 3.04), // use f64, not comptime float, for exact match with runtime value
}, c.metrics);
// Resize should change metrics
@@ -1240,7 +1246,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 = @round(6.08) - @as(f64, 6.08), // use f64, not comptime float, for exact match with runtime value
}, c.metrics);
}
@@ -1369,3 +1378,155 @@ 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| {
inline for (@typeInfo(font.Metrics.FaceMetrics).@"struct".fields) |field| {
const expected = @field(metricsExpected, field.name);
const actual = @field(metricsActual, field.name);
// Unwrap optional fields
const expectedValue, const actualValue = unwrap: switch (@typeInfo(field.type)) {
.optional => {
if (expected) |expectedValue| if (actual) |actualValue| {
break :unwrap .{ expectedValue, actualValue };
};
// Null values can be compared directly
try std.testing.expectEqual(expected, actual);
continue;
},
else => break :unwrap .{ expected, actual },
};
// All non-null values are floats
const eps = std.math.floatEps(@TypeOf(actualValue - expectedValue));
try std.testing.expectApproxEqRel(
expectedValue,
actualValue,
std.math.sqrt(eps),
);
}
}
// 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,202 @@ 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,
});
// 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);
const w = available_width -
self.pad_left * available_width -
self.pad_right * available_width;
const h = available_height -
self.pad_top * available_height -
self.pad_bottom * available_height;
// Subtract padding from the bearings so that our
// alignment and sizing code works correctly. We
// re-add before returning.
g.x -= self.pad_left * available_width;
g.y -= self.pad_bottom * available_height;
// Multiply by group width and height for better sizing.
g.width *= self.group_width;
g.height *= self.group_height;
switch (self.size_horizontal) {
.none => {},
.fit => if (g.width > w) {
const orig_height = g.height;
// Adjust our height and width to proportionally
// scale them to fit the glyph to the cell width.
g.height *= w / g.width;
g.width = w;
// Set our x to 0 since anything else would mean
// the glyph extends outside of the cell width.
g.x = 0;
// Compensate our y to keep things vertically
// centered as they're scaled down.
g.y += (orig_height - g.height) / 2;
} else if (g.width + g.x > w) {
// If the width of the glyph can fit in the cell but
// is currently outside due to the left bearing, then
// we reduce the left bearing just enough to fit it
// back in the cell.
g.x = w - g.width;
} else if (g.x < 0) {
g.x = 0;
},
.cover => {
const orig_height = g.height;
g.height *= w / g.width;
g.width = w;
g.x = 0;
g.y += (orig_height - g.height) / 2;
},
.stretch => {
g.width = w;
g.x = 0;
},
}
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;
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,
// The new, constrained glyph size
var constrained_glyph = glyph;
// Apply prescribed scaling
const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width);
constrained_glyph.width *= width_factor;
constrained_glyph.x *= width_factor;
constrained_glyph.height *= height_factor;
constrained_glyph.y *= height_factor;
// 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.
// Align vertically
if (self.align_vertical != .none) {
// Vertically scale group bounding box.
group.height *= height_factor;
group.y *= height_factor;
// Calculate offset and shift the glyph
constrained_glyph.y += self.offset_vertical(group, metrics);
}
switch (self.align_vertical) {
.none => {},
.start => g.y = 0,
.end => g.y = h - g.height,
.center => g.y = (h - g.height) / 2,
// Align horizontally
if (self.align_horizontal != .none) {
// Horizontally scale group bounding box.
group.width *= width_factor;
group.x *= width_factor;
// Calculate offset and shift the glyph
constrained_glyph.x += self.offset_horizontal(group, metrics, min_constraint_width);
}
// Re-add our padding before returning.
g.x += self.pad_left * available_width;
g.y += self.pad_bottom * available_height;
return constrained_glyph;
}
// 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 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 };
}
return g;
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 => {},
}
// Reduce aspect ratio if required
if (self.max_xy_ratio) |ratio| {
if (group.width * width_factor > group.height * height_factor * ratio) {
width_factor = group.height * height_factor * ratio / group.width;
}
}
return .{ width_factor, height_factor };
}
/// Return vertical offset needed to align this group
fn offset_vertical(
self: Constraint,
group: GlyphSize,
metrics: Metrics,
) f64 {
// 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 new_group_y = metrics.face_y + switch (self.align_vertical) {
.none => return 0.0,
.start => self.pad_bottom * metrics.face_height,
.end => end: {
const pad_top_dy = self.pad_top * metrics.face_height;
break :end metrics.face_height - pad_top_dy - group.height;
},
.center, .center1 => (metrics.face_height - group.height) / 2,
};
return new_group_y - group.y;
}
/// Return horizontal offset needed to align this group
fn offset_horizontal(
self: Constraint,
group: GlyphSize,
metrics: Metrics,
min_constraint_width: u2,
) f64 {
// For multi-cell constraints, we align relative to the span
// from the left edge of the first face cell to the right
// edge of the last face cell as they sit within the rounded
// and adjusted pixel cell (centered if narrower than the
// pixel cell, left-aligned if wider).
const face_x, const full_face_span = facecalcs: {
const cell_width: f64 = @floatFromInt(metrics.cell_width);
const full_width: f64 = @floatFromInt(min_constraint_width * metrics.cell_width);
const cell_margin = cell_width - metrics.face_width;
break :facecalcs .{ @max(0, cell_margin / 2), full_width - cell_margin };
};
const pad_left_x = self.pad_left * metrics.face_width;
const new_group_x = face_x + switch (self.align_horizontal) {
.none => return 0.0,
.start => pad_left_x,
.end => end: {
const pad_right_dx = self.pad_right * metrics.face_width;
break :end @max(pad_left_x, full_face_span - pad_right_dx - group.width);
},
.center => @max(pad_left_x, (full_face_span - group.width) / 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 => @max(pad_left_x, (metrics.face_width - group.width) / 2),
};
return new_group_x - group.x;
}
};
};

View File

@@ -388,19 +388,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 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 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) and (metrics.face_width < cell_width)) {
// We add half the difference to re-center.
x += (cell_width - @as(f64, @floatFromInt(original))) / 2;
x += (cell_width - metrics.face_width) / 2;
}
// Our whole-pixel bearings for the final glyph.
@@ -775,7 +772,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 +803,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 +867,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,56 @@ 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;
// 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),
};
};
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.
@@ -498,17 +502,14 @@ 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 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 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) 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
@@ -516,7 +517,7 @@ pub const Face = struct {
// 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);
x += @round((cell_width - metrics.face_width) / 2);
}
// Now we can render the glyph.
@@ -960,34 +961,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 +1024,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 +1034,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 +1050,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 +1062,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 +1094,7 @@ pub const Face = struct {
.cap_height = cap_height,
.ex_height = ex_height,
.ascii_height = ascii_height,
.ic_width = ic_width,
};
}
@@ -1187,25 +1193,31 @@ test "color emoji" {
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,
} },
.{
.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,
.face_width = 13,
.face_height = 24,
.face_y = 0,
},
.constraint_width = 2,
.constraint = .{
.size = .fit,
.align_horizontal = .center,
.align_vertical = .center,
},
},
);
try testing.expectEqual(@as(u32, 24), glyph.height);
}

File diff suppressed because it is too large Load Diff

View File

@@ -50,10 +50,10 @@ 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):
@@ -143,7 +143,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 +158,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 +187,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 +204,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 +235,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.
@@ -286,7 +288,7 @@ def generate_zig_switch_arms(
yMin = math.inf
xMax = -math.inf
yMax = -math.inf
individual_bounds: dict[int, tuple[int, int, int ,int]] = {}
individual_bounds: dict[int, tuple[int, int, int, int]] = {}
for cp in group:
if cp not in cmap:
continue
@@ -306,10 +308,10 @@ def generate_zig_switch_arms(
this_bounds = individual_bounds[cp]
this_width = this_bounds[2] - this_bounds[0]
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
entries[cp]["relative_width"] = this_width / group_width
entries[cp]["relative_height"] = this_height / group_height
entries[cp]["relative_x"] = (this_bounds[0] - xMin) / group_width
entries[cp]["relative_y"] = (this_bounds[1] - yMin) / group_height
del entries[0]
@@ -350,7 +352,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

@@ -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,8 +236,8 @@ 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
/// in several unicode blocks:
/// for now we define as anything in a private use area, except
/// the Powerline range, and anything in several unicode blocks:
/// - Dingbats
/// - Emoticons
/// - Miscellaneous Symbols
@@ -249,11 +249,13 @@ pub fn isCovering(cp: u21) bool {
/// In the future it may be prudent to expand this to encompass more
/// symbol-like characters, and/or exclude some PUA sections.
pub fn isSymbol(cp: u21) bool {
return symbols.get(cp);
return symbols.get(cp) and !isPowerline(cp);
}
/// Returns the appropriate `constraint_width` for
/// the provided cell when rendering its glyph(s).
///
/// Tested as part of the Screen tests.
pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
const cell = cell_pin.rowAndCell().cell;
const cp = cell.codepoint();
@@ -274,9 +276,7 @@ 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: {
if (cell_pin.x > 0) {
const prev_cp = prev_cp: {
var copy = cell_pin;
copy.x -= 1;
@@ -284,9 +284,6 @@ 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)) {
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;
}
@@ -312,9 +306,10 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
}
/// Whether min contrast should be disabled for a given glyph.
/// True for glyphs used for terminal graphics, such as box
/// drawing characters, block elements, and Powerline glyphs.
pub fn noMinContrast(cp: u21) bool {
// TODO: We should disable for all box drawing type characters.
return isPowerline(cp);
return isBoxDrawing(cp) or isBlockElement(cp) or isLegacyComputing(cp) or isPowerline(cp);
}
// Some general spaces, others intentionally kept
@@ -328,10 +323,36 @@ fn isSpace(char: u21) bool {
};
}
// 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,
};
}

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

@@ -103,7 +103,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

@@ -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");
@@ -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();
}
}